[번역] Parcel을 이용한 React Server Components

I
Inkyu Oh

Front-End2025.12.30

Gildas Garcia - 2025년 11월 14일


React Server Components(RSC)는 개발자가 서버에서 실행될 수 있는 React 컴포넌트로 서버 사이드 렌더링 애플리케이션을 구축할 수 있게 해주는 React의 새로운 기능입니다. 서버가 데이터 페칭(Data fetching)과 렌더링을 처리하는 동안 클라이언트는 상호작용에 집중할 수 있으므로, 더 나은 성능과 향상된 사용자 경험을 제공할 수 있다는 약속을 담고 있습니다. 또한 애플리케이션 구조를 잡는 방식을 변화시켜, 데이터 페칭과 렌더링 로직을 동일한 컴포넌트에 함께 배치(Colocate)할 수 있게 해줍니다.
한동안 RSC는 Next.js에서만 사용할 수 있었지만, 이제 다른 번들러와 프레임워크들도 이를 지원하기 시작했습니다. 반가운 소식은 Parcel이 버전 2.9.0에서 RSC 지원을 추가했다는 점입니다.
이 글에서는 간단한 작업 관리(Task management) 애플리케이션을 구축하며 Parcel과 함께 RSC를 사용하는 방법을 살펴보겠습니다.

서버 설정 (The Server Setup)

Parcel에서 새 페이지를 선언하려면 서버 프레임워크에서 새 라우트(Route)를 선언해야 합니다. 예를 들어 Express를 사용하는 경우는 다음과 같습니다.
// src/server.tsx 파일
import { renderRequest } from "@parcel/rsc/node";
import express from "express";
import { AboutPage } from "./pages/about/AboutPage";

const app = express();

app.get("/about", addDelay, async (req, res) => {
await renderRequest(req, res, <AboutPage />, { component: AboutPage });
});

app.listen(3000, () => {
console.log("Server listening on port http://localhost:3000");
});
About 페이지

여기서 특별히 새로운 것은 없습니다. 예상하시다시피 React 컴포넌트가 서버 측에서 렌더링되므로, 브라우저는 대부분 HTML 마크업(HTML 1.5kB, CSS 10kB)과 작은 런타임(일반 파일 5.0kB 및 페이지 전용 파일 약 65kB 등 두 개의 파일)을 수신하여 렌더링 프로세스 속도를 높입니다.
위에서 언급한 파일들을 보여주는 브라우저 개발자 도구 네트워크 탭


서버 컴포넌트와 데이터 페칭 (Server Components And Data Fetching)

RSC는 데이터베이스나 API에서 데이터를 가져와야 할 때 흥미로워집니다. 서버 컴포넌트는 이제 서버 측 함수를 호출하여 데이터를 기다릴(await) 수 있습니다. 다음은 데이터베이스에서 작업을 가져오는 작업 목록 페이지의 예시입니다.
// src/pages/tasks/TasksPage.tsx 파일
import { Layout } from "../../layout/Layout";
import { TaskList } from "./TaskList";
import { getTasks } from "./tasks";

export async function TasksPage({
filter,
}: {
filter?: "active" | "completed" | undefined;
}) {
const { tasks, totalActiveTasks } = await getTasks(filter);

return (
<Layout>
<TaskList
filter={filter}
tasks={tasks}
totalActiveTasks={totalActiveTasks}
/>
</Layout>
);
}
보시다시피 <TasksPage>getTasks 함수를 호출하고 이를 직접 기다립니다(useEffect가 필요 없습니다). getTasks 함수는 "use server"; 지시문이 있는 파일에 선언되어 있습니다.
// src/pages/tasks/tasks.ts 파일
"use server";
import { db } from "../../db";

export const getTasks = async (filter?: string) => {
const query = db
.from("tasks")
.select("*")
.order("created_at", { ascending: false });
if (filter === "active") {
query.is("completed_at", null);
} else if (filter === "completed") {
query.not("completed_at", "is", null);
}
const { data, error } = await query;
const { count: totalActiveTasks, error: errorActiveTask } = await db
.from("tasks")
.select("id", { count: "exact", head: true })
.is("completed_at", null);

if (error || errorActiveTask) {
throw new Error(
`Error fetching tasks: ${
error ? error.message : errorActiveTask?.message
}`,
);
}

return { tasks: data, totalActiveTasks: totalActiveTasks ?? 0 };
};

서버 컴포넌트에 URL 파라미터 전달하기 (Passing URL Parameters To Server Components)

이 작업 목록을 클라이언트에 노출해 보겠습니다. 이를 위해 새로운 Express 라우트가 필요합니다. <TasksPage> 컴포넌트는 선택적(optional) filter 프롭(prop)을 받으므로, 라우트는 URL에서 해당 필터를 읽어 컴포넌트에 전달합니다.
// src/server.tsx 파일
import { callAction, renderRequest } from "@parcel/rsc/node";
import express from "express";
import { TasksPage } from "./pages/tasks/TasksPage";

const app = express();

app.get("{/:filter}", addDelay, async (req, res) => {
const filter = getFilter(req.params.filter as string | undefined);
if (filter !== undefined && filter !== "active" && filter !== "completed") {
res.status(404).send("Not found");
return;
}
await renderRequest(req, res, <TasksPage filter={filter} />, {
component: TasksPage,
});
});

const getFilter = (filter: string | undefined) => {
if (filter === "active" || filter === "completed") {
return filter;
}
return undefined;
};
이를 통해 /active 라우트에 접속하는 사용자는 필요한 작업만 로드하게 됩니다.
RSC 덕분에 About 페이지와 마찬가지로 작업 목록 페이지는 서버에서 작업 목록을 보여주는 HTML 코드로 전송됩니다. 결과적으로 네트워크 페이로드가 매우 작아져 콘텐츠의 빠른 파싱과 로딩이 보장됩니다.
위에서 언급한 파일들을 보여주는 브라우저 개발자 도구 네트워크 탭


서버 사이드 렌더링 페이지 간의 탐색 (Navigating Between Server-Side Rendered Pages)

클라이언트 측에서 <TaskList>는 작업을 필터링하기 위해 몇 가지 Tailwind 클래스가 적용된 단순한 HTML 링크를 렌더링합니다.
// src/pages/tasks/TaskFilter.tsx 파일
const TaskFilter = ({
currentFilter,
filter,
children,
}: {
currentFilter: "active" | "completed" | undefined;
filter?: "active" | "completed";
children: React.ReactNode;
}) => (
<a
href={`/${filter}`}
data-active={currentFilter === filter ? true : undefined}
className="basis-1/3 group btn btn-sm join-item data-active:btn-primary"
>
<span className="hidden group-data-loading:block group-data-loading:loading group-data-loading:loading-spinner group-data-loading:loading-xs" />
{children}
</a>
);
이 링크를 클릭하면 보통 전체 페이지가 새로고침되지만, Parcel 문서에서는 링크 클릭을 가로채고 대신 RSC 탐색을 수행하는 클라이언트 측 스크립트를 통해 이를 방지하는 방법을 설명합니다.
"use client-entry";

import { hydrate, fetchRSC } from "@parcel/rsc/client";

let updateRoot = hydrate();

async function navigate(pathname, push = false) {
let root = await fetchRSC(pathname);
updateRoot(root, () => {
if (push) {
history.pushState(null, "", pathname);
}
});
}

// 링크 클릭을 가로채서 RSC 탐색을 수행합니다.
document.addEventListener("click", (e) => {
let link = e.target.closest("a");
if (link) {
link.dataset.loading = "true"; // 로딩 상태 표시
e.preventDefault();
navigate(link.pathname, true)
.then(() => {
delete link.dataset.loading; // 로딩 상태 숨김
});
}
});

// 사용자가 뒤로 가기 버튼을 클릭하면 RSC로 탐색합니다.
window.addEventListener("popstate", (e) => {
navigate(location.pathname);
}
기존 Parcel 코드를 수정하여 페이지를 로드하는 동안 링크에 data-loading 속성을 추가했습니다. 이를 통해 사용자에게 자신의 동작이 처리되고 있음을 즉시 알릴 수 있습니다.
All 링크는 활성 상태이고 Completed 링크는 로딩 상태인 작업 페이지

Parcel 문서에서 설명하듯이, 이 방식은 여전히 전체 페이지 콘텐츠를 교체하므로 페이지 콘텐츠를 더 세밀하게 업데이트하려면 라우팅 라이브러리에 의존해야 합니다. 이 글을 쓰는 시점에 React Router가 RSC에 대한 실험적 지원을 막 발표했지만, 아직 시도해 보지는 않았습니다.

서버 함수를 이용한 데이터 변경 (Mutating Data With Server Functions)

데이터를 변경(Mutation)할 때 상황이 흥미로워집니다. RSC를 사용하면 "use server;" 지시문이 있는 파일에서 서버 함수를 가져와 직접 호출할 수 있습니다. 내부적으로는 실제로 fetch 요청이 발생하며, 이로 인해 클라이언트 코드와 서버 코드 사이의 경계가 모호해집니다.
예를 들어, TaskList에 새 작업을 추가하는 폼(form)을 포함할 수 있습니다.
// src/pages/tasks/TaskList.tsx 파일
import { addTask } from "./tasks";

export const TaskList = ({
filter,
tasks,
totalActiveTasks,
}: {
filter?: "active" | "completed";
tasks: Task[];
totalActiveTasks: number;
}) => {
const handleAddTask = async (formData: FormData) => {
await addTask(formData);
};

return (
<>
<form action={handleAddTask}>
{/* inputs */}
</form>
<ul>
{tasks.map(task => (
<li key={task.id}>{task.description}</li>
))}
</ul>
<p>
Total tasks: {totalActiveTasks}
</p>
</>
);
}
addTask 함수는 데이터베이스에 새 작업을 삽입하는 서버 함수(Server Function)입니다.
// src/pages/tasks/tasks.ts 파일
"use server";
import { db } from "../../db";

export const addTask = async (formData: FormData) => {
const description = formData.get("description");
if (!description || typeof description !== "string") {
throw new Error("Invalid task description");
}

const { error } = await db.from("tasks").insert([{ description }]);

if (error) {
throw new Error(`Error adding task: ${error.message}`);
}
};
이것으로도 잘 작동하지만, 사용자 경험은 더 개선될 여지가 있습니다. 사용자는 새로 삽입된 작업을 보기 위해 서버 응답을 기다려야 합니다. 하지만 낙관적 업데이트(Optimistic updates)를 사용하면 더 나은 경험을 제공할 수 있습니다!

낙관적 업데이트 추가하기 (Adding Optimistic Updates)

이 용어가 생소한 분들을 위해 설명하자면, 데이터 변경(새 작업을 생성하는 서버로의 POST 요청)이 호출되는 동안 UI에서 그 결과를 즉시 가짜로 보여주는 것을 의미합니다. 대부분의 경우 서버 호출은 성공할 것이며, 사용자는 업데이트된 UI를 더 빨리 보게 됩니다. 서버 호출이 실패할 경우 UI 변경 사항을 롤백(Rollback)하면 됩니다.
React는 이제 이를 위해 useOptimistic 훅을 제공합니다.
import { addTask } from "./tasks";
import { getTaskFromFormData } from "./getTaskFromFormData";

export const TaskList = ({
filter,
tasks,
totalActiveTasks,
}: {
filter?: "active" | "completed";
tasks: Task[];
totalActiveTasks: number;
}) => {
+ const initialOptimisticData = { tasks, totalActiveTasks };
+ const [optimisticData, setOptimisticTasks] = useOptimistic<
+ { tasks: Task[]; totalActiveTasks: number },
+ Task
+ >(initialOptimisticData, (currentOptimisticDataValue, newTasks) => {
+ const { totalActiveTasks } = currentOptimisticDataValue;
+ return {
+ tasks: newTasks,
+ totalActiveTasks: totalActiveTasks + 1,
+ };
+ });
const handleAddTask = async (formData: FormData) => {
- await addTask(formData);
+ setOptimisticTasks([...tasks, getTaskFromFormData(formData)]);
+ startTransitionNewTask(async () => {
+ await addTask(formData).catch((error) => {
+ setOptimisticTasks(tasks); // 작업을 초기 값으로 롤백
+ alert(error.message);
+ });
+ });
};

return (
<>
<form action={handleAddTask}>
{/* inputs */}
</form>
<ul>
- {tasks.map(task => (
+ {optimisticData.tasks.map(task => (
<li key={task.id}>{task.description}</li>
))}
</ul>
<p>
- Total tasks: {totalActiveTasks}
+ Total tasks: {optimisticData.totalActiveTasks}
</p>
</>
);
}
이제 사용자가 새 작업을 추가할 때 UI가 즉시 업데이트되어 더욱 매끄러운 경험을 제공합니다.

풀스택 컴포넌트의 부상 (The Rise of Full-Stack Components)

React Server Components는 프론트엔드와 백엔드 로직을 모두 포함하는 새로운 유형의 재사용 가능한 컴포넌트를 가능하게 합니다. 좋아요 수를 표시하고 사용자가 아이템에 좋아요를 누를 수 있게 하며, 서버에서 좋아요 수를 증가시키는 데이터 변경 로직까지 포함된 <LikeButton> 컴포넌트를 생각해 보십시오.
이러한 컴포넌트는 데이터 페칭 및 변경 로직을 중복해서 작성하지 않고도 애플리케이션의 여러 곳에서 사용될 수 있습니다. 이를 때때로 *풀스택 컴포넌트(Full-Stack Components)*라고 부릅니다. 저는 이 용어를 Remix(및 react-router에도 적용됨)의 맥락에서 이 패턴을 설명하는 Kent C. Dodds의 글에서 빌려왔지만, 아이디어는 RSC에서도 거의 동일합니다.
실제 예시를 살펴보겠습니다. 작업 완료 상태를 토글하는 버튼 컴포넌트입니다.
// src/pages/tasks/TaskCheckbox.tsx 파일
"use client";
import { useOptimistic, useTransition } from "react";
import type { Task } from "../../types";
import { updateTask } from "./api";
import { CircleCheckIcon, CircleIcon } from "./icons";

export const TaskCheckbox = ({
task,
}: {
task: Task;
}) => {
const [isPending, startTransition] = useTransition();
const [optimisticCompleted, setOptimisticCompleted] = useOptimistic<
boolean,
boolean
>(
// 초기 값
task.completed_at ? true : false,
// 낙관적 업데이트 함수
(current, next) => next
);

return (
<form
className="group inline-flex w-auto"
data-loading={isPending ? "true" : undefined}
action={(formData) => {
const completed_at = task.completed_at
? "" // 작업이 완료된 상태라면 완료되지 않음으로 표시
: new Date().toISOString(); // 작업이 완료되지 않은 상태라면 완료됨으로 표시
formData.append("completed_at", completed_at);
setOptimisticCompleted(!optimisticCompleted);
startTransition(async () => {
await updateTask(formData);
});
}}
>
<input type="hidden" name="id" value={task.id} />
<div
className="tooltip"
data-tip={optimisticCompleted ? "Undo" : "Complete"}
>
<button
type="submit"
className="btn btn-square btn-ghost hover:text-primary group-data-loading:text-base-content/75"
>
<span className="sr-only">
{optimisticCompleted ? "Undo" : "Complete"}
</span>
{optimisticCompleted ? <CircleCheckIcon /> : <CircleIcon />}
</button>
</div>
</form>
);
};
우리는 <TaskCheckbox><TaskListItem> 컴포넌트와 단일 작업을 수정하는 다른 페이지 모두에서 사용할 수 있습니다.

결론 (Conclusion)

명확성을 위해 스타일링과 필수적이지 않은 코드 대부분을 제거했지만, 이 예제 애플리케이션의 전체 소스 코드는 GitHub에서 확인할 수 있습니다: marmelab/parcel-rsc-app.
React Server Components는 React로 서버 렌더링 애플리케이션을 구축하는 강력한 방법입니다. 컴포넌트에서 직접 데이터를 가져오고 백엔드 함수를 더 자연스러운 방식으로 호출할 수 있게 해주어, 데이터 흐름에 대한 멘탈 모델(mental model)을 더 쉽게 세울 수 있게 하고 복잡한 상태 관리의 필요성을 줄여줍니다. 이는 애플리케이션이 빠르고 반응성이 좋으면서도 훌륭한 사용자 경험을 제공하도록 보장합니다.
반가운 소식은 RSC가 더 이상 Next.js와 같은 특정 프레임워크에 종속되지 않으며, Parcel과 같은 다른 번들러와도 함께 사용될 수 있다는 점입니다. 하지만 프로덕션 수준의 애플리케이션을 구축하려면 라우팅, 낙관적 업데이트, 캐싱과 같은 다른 고려 사항들도 해결해야 할 것입니다.
0
6

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글