최근 저는 Next Beats, Drop, Flow, Huddle을 통해 Next.js에서 SPA와 유사한 경험을 구축하는 방법을 공유해왔습니다. Huddle과 Flow에서 사용하고 있지만 아직 다루지 않은 패턴 중 하나는 인터렉션이 겹칠 때 낙관적 쓰기(optimistic writes)를 조정하는 방법입니다. 쓰기 작업이 겹치는 상황은 웹에서 흔히 다뤄야 하는 문제이며, 프레임워크마다 이를 처리하는 방식이 다릅니다. 예를 들어 React Router는 중단된 요청과 오래된 재검증을 취소하고, Solid Router는 대기 중인 제출을 추적합니다. 리액트에서는 useActionState와 useOptimistic을 함께 사용할 수 있습니다.
이 글에서는 먼저 Huddle에서 이 두 훅이 어떻게 함께 동작하는지 살펴본 다음, Flow에서는 이 패턴을 컴포넌트 트리 전체로 확장하는 방법을 알아보겠습니다.
Huddle에서 낙관적 채널 사이드바 구축하기
Huddle은 워크스페이스 레일, 채널 사이드바, 현재 채널로 구성된 Slack과 유사한 팀 채팅 애플리케이션입니다. 다음은 워크스페이스 셸입니다.
// app/(workspace)/layout.tsx
<WorkspaceRail />
<ChannelSidebar>
<WorkspaceNav />
<SearchButton />
<ChannelList />
</ChannelSidebar>
<main>{children}</main>
Huddle의 사이드바는 ChannelList로 시작하며, 서버 컴포넌트에서 저장된 그룹을 불러옵니다.
// features/channel/components/channel-list.tsx
export async function ChannelList() {
const { groups, userId } = await getCurrentChannelLayout();
return <ChannelNav groups={groups} key={userId} />;
}
이 그룹들은 ChannelNav 클라이언트 컴포넌트의 초기 상태가 되며, 목록과 관련 인터렉션은 이곳에서 처리됩니다.
// features/channel/components/channel-nav.tsx
"use client";
export function ChannelNav({ groups }: { groups: LayoutGroup[] }) {
// ...채널을 드래그하고 그룹을 생성, 이름 변경, 삭제 또는 재정렬합니다...
return (
<nav aria-label="Channels">
{groups.map((group) => (
<div key={group.name}>
<p>{group.name}</p>
{group.channels.map((channel) => (
<ChannelLink channel={channel} key={channel.id} />
))}
</div>
))}
</nav>
);
}
채널을 이동하거나 그룹을 수정했을 때 채널 사이드바가 즉시 업데이트되는 동시에, 레이아웃 변경 사항은 순서대로 저장되도록 만들고 싶습니다. 이미 여러 글과 문서에서 이를 위해 useActionState와 useOptimistic을 함께 사용하는 방법을 다루고 있습니다.
- The True Nature of useActionState에서는 이 훅을 비동기 리듀서(async reducer)처럼 사용하고 useOptimistic과 함께 사용하는 방법을 초기에 살펴봅니다.
- 리액트 useActionState 문서에서는 이제 큐에 들어간 액션과 두 훅을 함께 사용하는 방법을 다룹니다.
- Next.js SPA 가이드에서는 서버 함수를 통해 저장되는 할 일 목록에 이 패턴을 적용합니다.
이제 이 패턴을 Huddle에 적용해보겠습니다.
Transition 안에서 채널 레이아웃 저장하기
전체 레이아웃을 저장하기 위해 서버 함수를 추가할 수 있습니다. 이 함수는 그룹의 위치와 각 그룹 안에 있는 채널의 위치를 저장합니다. 아래 코드는 Huddle의 트랜잭션을 단순화한 버전입니다.
// features/channel/channel-actions.ts
"use server";
export async function saveChannelLayout(groups: LayoutGroup[]) {
const user = await verifyAuth();
for (const [position, group] of groups.entries()) {
await prisma.channelGroup.upsert({
create: { name: group.name, position, userId: user.id },
update: { position },
where: { userId_name: { name: group.name, userId: user.id } },
});
// ...그룹과 그 안에 있는 채널의 위치를 저장합니다...
}
// ...캐시를 무효화합니다...
}
서버 함수를 준비했다면 ChannelNav에서 Transition 안에서 saveChannelLayout을 호출할 수 있습니다. useTransition으로 호출을 감싸면 저장이 진행되는 동안 isPending이 true가 되고, 사이드바는 계속해서 변경 사항을 받을 수 있습니다.
// features/channel/components/channel-nav.tsx
export function ChannelNav({ groups }: { groups: LayoutGroup[] }) {
const [isPending, startTransition] = useTransition();
function saveChange(nextGroups: LayoutGroup[]) {
startTransition(async () => {
await saveChannelLayout(nextGroups);
});
}
// ...그룹을 렌더링합니다...
}
현재 Next.js는 서버 액션을 한 번에 하나씩 디스패치하고 완료될 때까지 기다리기 때문에 Huddle에서는 이러한 쓰기 작업끼리 경합하지 않습니다. 하지만 이 큐 바깥에서 Transition 내부에 직접 시작한 비동기 작업은 여전히 순서가 뒤바뀐 채 완료될 수 있습니다. 리액트 문서에서는 이를 순서가 뒤바뀐 Transition 업데이트라고 설명하며, 일반적인 경우에는 useActionState를 사용하도록 안내합니다.
하지만 ChannelNav는 저장 요청이 큐에 들어가기 전에 nextGroups를 계산합니다. 첫 번째 저장이 끝나기 전에 사용자가 또 다른 변경을 하면, 두 번째 nextGroups는 두 변경 사항이 모두 반영되기 전의 레이아웃을 기준으로 만들어집니다. Next.js는 여전히 요청을 순서대로 저장하지만, 더 나중의 요청이 마지막에 반영되면서 앞선 변경 사항이 저장된 레이아웃에서 사라집니다.
isPending이 true인 동안 컨트롤을 비활성화할 수도 있습니다. 하지만 그렇게 하면 사이드바에서 드래그하거나 편집할 때 반응이 느리게 느껴질 수 있습니다. 대신 사이드바는 계속 인터렉티브하게 유지하고, 이후 변경 사항이 그보다 앞선 저장 결과를 기반으로 만들어지도록 할 수 있습니다.
이전 레이아웃을 기반으로 useActionState 사용하기
useActionState 훅은 액션의 결과를 상태로 저장하고, 디스패처를 통해 호출된 작업을 큐에 넣습니다.
const [state, dispatchAction, isPending] = useActionState(async (previousState, actionPayload) => {
// ...사이드 이펙트를 실행합니다...
return nextState;
}, initialState);
여러 변경 사항을 디스패치하면 리액트는 하나의 콜백이 끝날 때까지 기다린 뒤, 그 결과를 다음 콜백에서 사용합니다. 콜백 안에서 저장 작업을 await할 수 있으며, 모든 저장이 끝날 때까지 isPending은 true로 유지됩니다.
이전 저장 결과를 기반으로 다음 변경 사항을 적용하려면 saveChannelLayout에 이전 그룹과 어떤 일이 발생했는지를 나타내는 LayoutChange가 필요합니다. 레이아웃 업데이트 로직을 리듀서로 분리할 수 있습니다.
// features/channel/utils/channel-layout-reducer.ts
export function channelLayoutReducer(groups: LayoutGroup[], change: LayoutChange): LayoutGroup[] {
switch (change.type) {
case "move": {
const next = groups.map((group) => ({
...group,
channels: group.channels.filter((channel) => channel.id !== change.channelId),
}));
const moved = groups.flatMap((group) => group.channels).find((channel) => channel.id === change.channelId);
const target = next.find((group) => group.name === change.toGroup);
if (!moved || !target) return groups;
const index = Math.max(0, Math.min(change.toIndex, target.channels.length));
target.channels.splice(index, 0, moved);
return next;
}
case "addGroup": {
// ...새 그룹이 추가된 레이아웃을 반환하되 Channels는 마지막에 유지합니다...
}
case "renameGroup": {
// ...이름을 검증하고 이름이 변경된 그룹을 반환합니다...
}
case "deleteGroup": {
// ...해당 그룹의 채널을 Channels로 이동한 레이아웃을 반환합니다...
}
case "moveGroup": {
// ...그룹을 위나 아래로 이동한 레이아웃을 반환합니다...
}
default:
return groups;
}
}
리듀서의 move 케이스에서는 입력값을 변경하지 않고 채널을 현재 그룹에서 제거한 뒤 대상 그룹에 삽입합니다. 나머지 케이스에서는 그룹 자체에 대한 변경을 처리합니다. 이제 saveChannelLayout 안에서 channelLayoutReducer를 사용해 실제로 저장할 레이아웃을 계산할 수 있습니다.
// features/channel/channel-actions.ts
"use server";
import {
channelLayoutReducer,
type LayoutChange,
type LayoutGroup,
} from "@/features/channel/utils/channel-layout-reducer";
// ...데이터베이스, 캐시, 인증 관련 import...
export async function saveChannelLayout(groups: LayoutGroup[], change: LayoutChange): Promise<LayoutGroup[]> {
await verifyAuth();
const next = channelLayoutReducer(groups, change);
// ...위와 동일한 upsert 방식으로 next를 저장합니다...
return next;
}
쓰기 작업이 성공하면 반환된 레이아웃이 다음 큐 업데이트의 상태가 됩니다. 다시 ChannelNav로 돌아가 saveChannelLayout을 useActionState에 전달해보겠습니다.
// features/channel/components/channel-nav.tsx
"use client";
import { startTransition, useActionState } from "react";
// ...애플리케이션 import...
export function ChannelNav({ groups: initialGroups }: { groups: LayoutGroup[] }) {
const [groups, dispatch] = useActionState(saveChannelLayout, initialGroups);
function runChange(change: LayoutChange) {
startTransition(() => {
dispatch(change);
});
}
// ...그룹을 렌더링합니다...
}
리액트는 <form action> 과 같은 Action 프로퍼티에 대해서는 자동으로 Transition을 시작합니다. Huddle에서는 드래그나 메뉴 핸들러에서 변경 사항을 디스패치하므로, runChange 안에서 startTransition을 명시적으로 호출합니다. 사용자가 또 다른 변경을 수행하면 리액트는 이전 저장이 끝날 때까지 기다린 뒤, 그 저장 결과로 반환된 레이아웃을 saveChannelLayout에 전달합니다. 그런 다음 channelLayoutReducer가 해당 레이아웃에 새로운 LayoutChange를 적용합니다.
이제 저장 작업의 순서는 보장되지만, 사이드바는 groups를 렌더링하고 있으며 이 값은 서버 함수가 끝난 뒤에야 업데이트됩니다. 따라서 채널을 이동하면 데이터베이스 쓰기가 끝날 때까지 사이드바에 변경 사항이 나타나지 않습니다.
useOptimistic으로 레이아웃 변경 사항 보여주기
액션이 대기 중일 때 임시 상태를 보여주기 위해 useOptimistic을 사용할 수 있습니다.
const [optimisticState, addOptimistic] = useOptimistic(state, (currentState, optimisticValue) => {
return nextState;
});
첫 번째 인자는 확정된 상태입니다. addOptimistic을 호출하면 리액트는 현재의 낙관적 상태와 새로운 값을 업데이트 함수에 전달합니다. 업데이트 함수가 반환한 값은 액션이 대기 중인 동안 화면에 표시됩니다. 그사이에 확정된 상태가 변경되면 리액트는 그 변경된 상태를 기준으로 업데이트 함수를 다시 적용합니다.
Huddle에서 확정된 상태는 groups이고, 낙관적으로 반영할 값은 LayoutChange입니다. channelLayoutReducer는 이미 이 두 값을 인자로 받으므로, ChannelNav의 useOptimistic에 그대로 전달해보겠습니다.
// features/channel/components/channel-nav.tsx
"use client";
import { startTransition, useActionState, useOptimistic } from "react";
// ...애플리케이션 import...
export function ChannelNav({ groups: initialGroups }: { groups: LayoutGroup[] }) {
const [groups, dispatch] = useActionState(saveChannelLayout, initialGroups);
const [optimisticGroups, addOptimistic] = useOptimistic(groups, channelLayoutReducer);
function runChange(change: LayoutChange) {
startTransition(() => {
addOptimistic(change);
dispatch(change);
});
}
// ...optimisticGroups를 렌더링합니다...
}
runChange가 실행되면 dispatch가 변경 사항을 저장하는 동안 사이드바는 즉시 optimisticGroups를 렌더링합니다. 저장이 완료되면 groups가 이미 화면에 표시된 레이아웃과 동일한 상태로 따라잡습니다.
저장에 실패한 레이아웃 변경 사항 롤백하기
낙관적으로 적용한 변경 사항도 저장에 실패할 수 있습니다. 이 경우 사용자에게 실패 사실을 알려주고, 사이드바를 마지막으로 저장에 성공한 레이아웃으로 되돌리고 싶습니다.
두 작업 모두 useActionState에 전달하는 콜백 안에서 처리할 수 있습니다. saveChannelLayout에서 에러가 발생하면 토스트를 표시하고, 콜백에 전달된 이전 groups를 반환해보겠습니다.
// features/channel/components/channel-nav.tsx
"use client";
import { toast } from "sonner";
// ...애플리케이션 import...
export function ChannelNav({ groups: initialGroups }: { groups: LayoutGroup[] }) {
const [groups, dispatch] = useActionState(async (previousGroups: LayoutGroup[], change: LayoutChange) => {
try {
return await saveChannelLayout(previousGroups, change);
} catch {
toast.error("Could not save channel layout. Try again.");
return previousGroups;
}
}, initialGroups);
// ...위와 같이 useOptimistic을 추가하고 optimisticGroups를 렌더링합니다...
}
액션이 대기 중인 동안 useOptimistic은 변경된 레이아웃을 계속 화면에 표시합니다. 액션이 완료되면 리액트는 해당 임시 상태를 버리고 useActionState의 확정된 groups를 렌더링합니다. 에러가 발생한 경우 이 groups는 여전히 마지막으로 저장에 성공한 상태이므로, 반대 방향의 LayoutChange를 따로 만들지 않아도 사이드바가 이전 상태로 되돌아갑니다.
전체 ChannelNav
다음은 큐, 낙관적 레이아웃, 롤백을 모두 연결한 사이드바입니다. 드래그와 메뉴 핸들러는 runChange를 거치고, 렌더링할 때는 optimisticGroups를 사용합니다.
// features/channel/components/channel-nav.tsx
"use client";
import { startTransition, useActionState, useOptimistic } from "react";
import { toast } from "sonner";
import { channelLayoutReducer } from "@/features/channel/utils/channel-layout-reducer";
// ...애플리케이션 import...
export function ChannelNav({ groups: initialGroups }: { groups: LayoutGroup[] }) {
const [groups, dispatch] = useActionState(async (previousGroups: LayoutGroup[], change: LayoutChange) => {
try {
return await saveChannelLayout(previousGroups, change);
} catch {
toast.error("Could not save channel layout. Try again.");
return previousGroups;
}
}, initialGroups);
const [optimisticGroups, addOptimistic] = useOptimistic(groups, channelLayoutReducer);
function runChange(change: LayoutChange) {
startTransition(() => {
addOptimistic(change);
dispatch(change);
});
}
return (
<nav aria-label="Channels">
{optimisticGroups.map((group) => (
<div key={group.name}>
<p>{group.name}</p>
{/* ...드래그 핸들러와 그룹 메뉴에서 runChange를 호출합니다... */}
{group.channels.map((channel) => (
<ChannelLink channel={channel} key={channel.id} />
))}
</div>
))}
</nav>
);
}
이렇게 하면 사용자가 채널을 드롭하는 순간 사이드바가 바로 변경되고, 저장 작업은 여전히 변경이 발생한 순서대로 실행됩니다. 그중 하나가 실패하면 사이드바는 서버가 마지막으로 확인한 레이아웃으로 되돌아갑니다.
직접 사용해보세요. Huddle에서 채널을 그룹 사이로 이동한 다음, 첫 번째 저장이 끝나기 전에 다시 한번 이동해보세요. 코드: channel-nav.tsx.
Flow에서 낙관적 이벤트 보드 구축하기
Flow는 주간 보기와 월간 보기를 제공하는 캘린더 및 예약 링크 애플리케이션입니다. 페이지에서는 헤더와 선택된 보기를 조합합니다.
// app/(workspace)/calendar/[date]/page.tsx
<main>
<CalendarHeader date={date} view={calendarView} />
{calendarView === "month" ? <CalendarMonth date={date} /> : <CalendarWeek date={date} />}
</main>
주간 보기에서는 CalendarWeek 서버 컴포넌트가 이벤트와 캘린더를 가져옵니다.
// features/calendar/components/calendar-week.tsx
export async function CalendarWeek({ date }: { date: string }) {
const [week, calendars] = await Promise.all([getCalendarWeek(date), getCalendars()]);
return <CalendarBoard calendars={calendars} days={week.days} events={week.events} />;
}
그다음 CalendarBoard 클라이언트 컴포넌트가 주간 그리드를 렌더링합니다. 이 그리드에서 생성, 업데이트, 삭제, 이동, 크기 조절이 즉시 화면에 반영되는 동시에 쓰기 작업은 순서대로 저장되도록 만들고 싶습니다.
CalendarBoard에 액션 큐 추가하기
사용자는 이벤트를 이동한 뒤 첫 번째 저장이 끝나기 전에 크기를 조절할 수 있습니다. 두 쓰기 작업의 순서를 보장하기 위해 인터렉션을 EventChange 값으로 표현하고, 하나의 서버 함수가 변경 유형에 맞는 쓰기 작업을 실행하도록 해보겠습니다.
// features/calendar/calendar-actions.ts
"use server";
export async function saveEventChange(change: EventChange) {
switch (change.type) {
case "move":
return moveEvent({
day: change.day,
sourceId: change.sourceId,
start: change.start,
});
// ...생성, 업데이트, 크기 조절, 삭제...
}
}
async function moveEvent({ day, sourceId, start }: MoveEventInput) {
const user = await verifyAuth();
const event = await findEvent(sourceId);
if (event?.demo) {
return { error: "Create your own calendar to make changes." };
}
// ...이벤트가 존재하고 사용자에게 속해 있는지 확인합니다...
const updated = await prisma.calendarEvent.update({
data: { day: new Date(`${day}T00:00:00.000Z`), start },
where: { id: sourceId },
});
// ...캐시를 무효화합니다...
return { data: updated };
}
각 케이스는 에러 또는 업데이트된 행을 반환하며, 이후 롤백 과정에서 이 에러를 확인합니다. Huddle과 달리 다음 변경 사항을 처리할 때 이전 저장 결과가 필요하지 않으므로 액션 상태는 void여도 됩니다.
// features/calendar/components/calendar-board.tsx
"use client";
import { startTransition, useActionState } from "react";
// ...애플리케이션 import...
export function CalendarBoard({ days, events }: { days: string[]; events: CalendarEvent[] }) {
const [, dispatch, isPending] = useActionState(async (_: void, change: EventChange) => {
await saveEventChange(change);
}, undefined);
function mutate(change: EventChange) {
startTransition(() => {
dispatch(change);
});
}
// ...이벤트를 렌더링하고 인터렉션에 mutate를 전달합니다...
}
하지만 CalendarBoard는 여전히 CalendarWeek에서 전달받은 events를 렌더링합니다. 이벤트를 이동하거나 크기를 조절해도 events가 업데이트되기 전까지는 화면에 변경 사항이 나타나지 않습니다. 저장이 진행되는 동안에도 변경 사항이 바로 보이도록 만들고 싶습니다.
useOptimistic으로 이벤트 변경 사항 적용하기
저장이 끝나기 전에 변경 사항을 보여주려면 useOptimistic에 다음 이벤트 상태를 계산하는 업데이트 함수가 필요합니다. Huddle의 사이드바를 저장할 때는 전체 레이아웃을 쓰기 때문에 서버가 이미 channelLayoutReducer를 사용해 다음 상태를 계산했고, 이를 그대로 사용할 수 있었습니다. 하지만 캘린더 이벤트는 Flow가 개별적으로 업데이트하는 하나의 행이므로 재사용할 로직이 없습니다. 대신 서버에서 받은 이벤트와 하나의 EventChange를 받아 다음 이벤트 목록을 반환하는 리듀서를 클라이언트용으로 작성해보겠습니다.
// features/calendar/utils/event-change-reducer.ts
export function eventChangeReducer(events: CalendarEvent[], change: EventChange) {
switch (change.type) {
case "create":
return [change.event, ...events.filter((event) => event.id !== change.event.id)];
case "delete":
return events.filter((event) => event.sourceId !== change.sourceId);
case "resize":
return events.map((event) =>
event.sourceId === change.sourceId ? { ...event, duration: change.duration } : event,
);
case "update":
return events.map((event) => (event.sourceId === change.event.sourceId ? { ...event, ...change.event } : event));
case "move":
return events.map((event) =>
event.id === change.id ? { ...event, day: change.day, start: change.start } : event,
);
}
}
리듀서를 준비했으니 CalendarBoard의 useOptimistic에 전달하고, 디스패치와 함께 변경 사항도 추가할 수 있습니다.
// features/calendar/components/calendar-board.tsx
export function CalendarBoard({ days, events }: CalendarBoardProps) {
// ...앞서 만든 액션 큐...
const [optimisticEvents, addOptimisticChange] = useOptimistic(events, eventChangeReducer);
function mutate(change: EventChange) {
startTransition(() => {
addOptimisticChange(change);
dispatch(change);
});
}
// ...optimisticEvents를 렌더링합니다...
}
이제 이벤트를 빠르게 여러 번 이동하거나 크기를 조절해도 현재 화면에 표시된 상태를 기반으로 다음 변경 사항이 적용됩니다. 주간 보기만 고려한다면 CalendarBoard가 이 상태를 직접 소유해도 충분합니다.
하지만 월간 보기에서는 별도의 CalendarMonthBoard를 렌더링하고, 헤더에는 NewEventButton이 있습니다. 월간 보드도 동일한 낙관적 상태를 읽어야 하며, 버튼 뒤에 있는 생성 다이얼로그에서도 이 상태에 새로운 이벤트를 추가할 수 있어야 합니다.
Context로 이벤트 변경 사항 공유하기
헤더와 보드 사이에는 서버 컴포넌트가 있으므로, mutate를 props로 전달하려면 이들을 클라이언트 컴포넌트로 전환해야 합니다.
캘린더 전체를 하나의 큰 클라이언트 컴포넌트로 끌어올리는 대신, 헤더와 선택된 보기 주변을 CalendarEventsProvider로 감쌀 수 있습니다. 저는 리액트 19가 아직 Canary 단계였을 때, 리액트 쿼리에서 익숙하게 사용하던 낙관적 업데이트를 애플리케이션 전반에 적용하는 방법을 고민하면서 이 Provider 접근 방식에 대해 글을 쓴 적이 있습니다. Provider 아래에 있는 클라이언트 컴포넌트는 그 사이에 서버 컴포넌트가 있더라도 Context를 읽을 수 있습니다.
// app/(workspace)/calendar/[date]/page.tsx
<CalendarEventsProvider>
<CalendarHeader date={date} view={calendarView} />
{calendarView === "month" ? <CalendarMonth date={date} /> : <CalendarWeek date={date} />}
</CalendarEventsProvider>
액션 큐는 saveEventChange가 EventChange만 필요로 하므로 그대로 Provider로 옮길 수 있습니다. 낙관적 상태는 조금 더 까다롭습니다. 업데이트 함수는 항상 기준이 되는 상태를 바탕으로 실행되며, eventChangeReducer는 이미 목록에 있는 이벤트를 이동하거나 크기를 조절하고 삭제합니다. 따라서 기준 상태는 이벤트 목록 자체여야 합니다. 그런데 이 이벤트들은 Provider 아래에 있는 CalendarWeek와 CalendarMonth에서 전달되고, 두 보기는 서로 다른 범위의 이벤트를 가져옵니다.
따라서 이벤트 자체 대신 변경 사항을 보관해보겠습니다. 변경 사항 목록은 빈 배열에서 시작해 계속 추가되기만 하므로, Provider는 서버 데이터 없이도 이를 보관할 수 있습니다. 각 보드는 자신이 전달받은 이벤트에 이 변경 사항 목록을 적용하면 됩니다.
// providers/calendar-events-provider.tsx
"use client";
export function CalendarEventsProvider({ children }: { children: ReactNode }) {
const [, dispatch, isPending] = useActionState(async (_: void, change: EventChange) => {
await saveEventChange(change);
}, undefined);
const [pendingChanges, addOptimisticChange] = useOptimistic<EventChange[], EventChange>([], (changes, change) => [
...changes,
change,
]);
function mutate(change: EventChange) {
startTransition(() => {
addOptimisticChange(change);
dispatch(change);
});
}
// ...pendingChanges, isPending, mutate를 Context를 통해 제공합니다...
}
액션 큐와 mutate는 CalendarBoard에서 사용했던 것과 동일합니다. 사용자가 이벤트를 이동한 다음 크기를 조절하면 pendingChanges에는 이동 변경 사항이 먼저 들어가고 그다음 크기 조절 변경 사항이 들어갑니다. 그러면 훅에서 두 변경 사항을 서버에서 받은 이벤트 위에 순서대로 다시 적용할 수 있습니다.
// features/calendar/hooks/use-optimistic-events.ts
"use client";
import { useCalendarEvents } from "@/providers/calendar-events-provider";
import { eventChangeReducer } from "../utils/event-change-reducer";
// 반복 이벤트를 제외하고 단순화한 버전입니다
export function useOptimisticEvents(events: CalendarEvent[]) {
const { pendingChanges } = useCalendarEvents();
return pendingChanges.reduce(eventChangeReducer, events);
}
Flow의 실제 구현에서는 보이는 날짜들도 함께 전달하므로, reduce를 실행하기 전에 반복 이벤트를 해당 날짜 범위에 맞게 확장할 수 있습니다.
실패한 이벤트 변경 사항 롤백하기
서버가 쓰기 작업을 거부할 때쯤이면 이벤트는 이미 낙관적으로 변경된 위치로 이동한 상태입니다. Flow의 데모 이벤트는 사용자가 변경하려고 하면 에러를 반환합니다. 이 에러를 사용자에게 보여주고 이벤트를 저장된 위치로 되돌리고 싶습니다.
큐 콜백 안에서 saveEventChange의 결과를 확인하고, 에러가 있으면 토스트로 표시해보겠습니다.
// providers/calendar-events-provider.tsx
export function CalendarEventsProvider({ children }: { children: ReactNode }) {
const [, dispatch, isPending] = useActionState(async (_: void, change: EventChange) => {
const result = await saveEventChange(change);
if (result.error) {
toast.error(result.error);
}
}, undefined);
// ...대기 중인 변경 사항, mutate, Context...
}
쓰기 작업이 실패하면 서버의 이벤트 데이터는 변경되지 않은 상태로 남아 있습니다. 임시로 변경된 위치는 Transition이 끝날 때까지 화면에 유지되며, 이후 보드는 다시 서버에서 받은 이벤트를 렌더링하고 이벤트도 저장된 위치로 돌아갑니다. 반대 방향의 변경 사항을 따로 계산할 필요는 없습니다.
전체 CalendarEventsProvider
Context 자체는 리액트의 리듀서와 Context로 확장하기 가이드를 따라 상태와 디스패치를 분리해보겠습니다. 이렇게 하면 변경 사항만 전송하는 컴포넌트는 대기 중인 변경 사항이 업데이트되어도 리렌더링되지 않습니다.
다음은 두 Context와 이를 읽는 훅까지 포함한 Provider 전체 코드입니다.
// providers/calendar-events-provider.tsx
"use client";
import { createContext, startTransition, useActionState, useContext, useOptimistic, type ReactNode } from "react";
import { toast } from "sonner";
// ...애플리케이션 import...
type CalendarEventsStateContextValue = {
isPending: boolean;
pendingChanges: EventChange[];
};
type CalendarEventsDispatchContextValue = (change: EventChange) => void;
const CalendarEventsStateContext = createContext<CalendarEventsStateContextValue | null>(null);
const CalendarEventsDispatchContext = createContext<CalendarEventsDispatchContextValue | null>(null);
export function CalendarEventsProvider({ children }: { children: ReactNode }) {
const [, dispatch, isPending] = useActionState(async (_: void, change: EventChange) => {
const result = await saveEventChange(change);
if (result.error) {
toast.error(result.error);
}
}, undefined);
const [pendingChanges, addOptimisticChange] = useOptimistic<EventChange[], EventChange>([], (changes, change) => [
...changes,
change,
]);
function mutate(change: EventChange) {
startTransition(() => {
addOptimisticChange(change);
dispatch(change);
});
}
const contextValue = { isPending, pendingChanges };
return (
<CalendarEventsStateContext.Provider value={contextValue}>
<CalendarEventsDispatchContext.Provider value={mutate}>{children}</CalendarEventsDispatchContext.Provider>
</CalendarEventsStateContext.Provider>
);
}
export function useCalendarEvents() {
const context = useContext(CalendarEventsStateContext);
if (!context) {
throw new Error("useCalendarEvents must be used within CalendarEventsProvider");
}
return context;
}
export function useCalendarEventsDispatch() {
const context = useContext(CalendarEventsDispatchContext);
if (!context) {
throw new Error("useCalendarEventsDispatch must be used within CalendarEventsProvider");
}
return context;
}
이제 두 Context가 모두 준비되었으므로 Provider 아래에 있는 클라이언트 컴포넌트는 useOptimisticEvents를 호출해 이벤트를 렌더링하고, mutate를 사용해 이벤트를 변경할 수 있습니다.
캘린더 보드에 대기 중인 변경 사항 적용하기
CalendarWeek에서 받은 이벤트를 그리드의 드래그, 크기 조절, 선택 상태를 관리하는 훅인 useCalendarBoard에 전달하기 전에 useOptimisticEvents를 호출해보겠습니다.
// features/calendar/components/calendar-board.tsx
"use client";
import { useOptimisticEvents } from "../hooks/use-optimistic-events";
// ...애플리케이션 import...
export function CalendarBoard({
calendars,
days,
events,
}: {
calendars: Calendar[];
days: string[];
events: CalendarEvent[];
}) {
const optimisticEvents = useOptimisticEvents(events);
const { interactions, visibleEvents } = useCalendarBoard({
calendars,
days,
events: optimisticEvents,
});
// ...캘린더 그리드에서 interactions와 함께 visibleEvents를 렌더링합니다...
}
보드 자체는 pendingChanges를 직접 다루지 않습니다. 대신 변경 사항이 이미 적용된 이벤트를 요청해 사용합니다. 월간 보기의 CalendarMonthBoard도 이벤트를 날짜별로 그룹화하기 전에 같은 훅을 호출합니다.
팝오버에서 이벤트 업데이트 및 삭제하기
보드는 선택한 이벤트에 대해 EventPopover도 렌더링하므로, 팝오버 역시 Provider 아래에 위치하게 됩니다. mutate를 보드를 거쳐 props로 전달하는 대신, 팝오버가 디스패치 Context를 직접 읽도록 할 수 있습니다.
// features/calendar/components/event-popover.tsx
"use client";
import { useCalendarEventsDispatch } from "@/providers/calendar-events-provider";
// ...애플리케이션 import...
export function EventPopover({ event, onClose }: EventPopoverProps) {
const mutate = useCalendarEventsDispatch();
function remove() {
mutate({ sourceId: event.sourceId, type: "delete" });
}
// ...상세 정보, 수정 폼, 삭제 버튼을 렌더링합니다...
}
remove를 호출하면 saveEventChange가 실행되는 동안 이벤트가 화면에서 숨겨집니다. 수정 폼 역시 같은 mutate 함수를 통해 update 변경 사항을 전달합니다.
이렇게 하면 Provider 한 곳에서 임시 변경 사항과 저장 큐를 함께 관리할 수 있습니다. 보드들은 계속해서 서버 컴포넌트로부터 확정된 이벤트를 전달받고, 쓰기 작업이 실패하면 토스트를 보여준 뒤 해당 이벤트 상태로 돌아갑니다.
직접 사용해보세요. Flow에서 데모 캘린더 이벤트를 이동한 뒤 에러 토스트가 표시되고 이벤트가 저장된 위치로 돌아가는 모습을 확인해보세요. 코드: calendar-events-provider.tsx.
클라이언트 데이터 라이브러리는 언제 사용해야 할까요?
Huddle의 채널 레이아웃과 Flow의 캘린더 이벤트에서는 확정된 데이터가 서버 컴포넌트에 남아 있고, 액션이 끝나면 낙관적 상태는 사라집니다. 따라서 동기화해야 할 대상은 서버 데이터뿐입니다.
반면 Huddle의 메시지는 여러 컴포넌트에서 폴링과 낙관적 업데이트가 필요하므로, 여기서는 클라이언트 데이터 라이브러리를 사용합니다. 이 애플리케이션에는 TanStack Query와 SWR로 각각 구현한 버전이 있습니다.
SWR 버전에서는 MessageThread가 여전히 서버에서 메시지를 불러오고, 그 데이터로 SWR 캐시를 초기화합니다.
// features/message/components/message-thread.tsx
export async function MessageThread({ channelId }: { channelId: string }) {
// ...현재 사용자를 불러옵니다...
const messageData = preload(messageKeys.channel(channelId), () => getMessagesForUser(channelId, user.id));
return (
<SWRConfig value={{ cacheData: { ...messageData } }}>
<MessageList channelId={channelId} currentUserId={user.id} />
</SWRConfig>
);
}
클라이언트 훅은 동일한 키를 읽기 때문에 서버 데이터에서 시작한 뒤 폴링을 이어서 담당합니다.
// features/message/hooks/use-messages.ts
export function useSuspenseMessages(channelId: string) {
return useSWR<Message[]>(messageKeys.channel(channelId), fetchJson, {
refreshInterval: 10_000,
revalidateOnMount: false,
suspense: true,
});
}
이렇게 되면 두 개의 캐시를 함께 조정해야 하므로, mutation에서는 서버 함수 안에서 Next.js 캐시를 무효화하고 브라우저에서는 관련 SWR 키를 업데이트해야 합니다.
저는 메시지를 읽는 동안 새 메시지가 도착하는 경우처럼 데이터가 자체적으로 변경될 수 있을 때 클라이언트 데이터 라이브러리를 사용합니다. 채널 레이아웃은 누군가 직접 드래그할 때만 변경되므로 useActionState와 useOptimistic만으로도 충분합니다. Next.js 문서에는 SWR과 TanStack Query를 사용해 같은 방식으로 서버 데이터에서 클라이언트 데이터 관리로 넘겨주는 가이드도 있습니다.
마무리
제가 이 패턴에서 마음에 드는 점은 서버 컴포넌트가 계속해서 데이터를 소유한다는 것입니다. 인터렉션을 조정하는 데 필요한 만큼의 클라이언트 상태만 추가하고, 액션이 끝나면 서버의 결과가 다시 그 역할을 이어받도록 합니다.
리듀서, 액션 큐, 낙관적 상태를 하나로 조합하려면 여전히 적지 않은 연결 작업이 필요하며, Huddle과 Flow 모두 근본적으로 동일한 구성 요소를 사용합니다. 어쩌면 앞으로는 이런 패턴이 리액트나 Next.js 자체에 내장되는 모습을 보게 될지도 모르겠습니다.
이 글이 도움이 되었기를 바랍니다. 질문이나 의견이 있다면 언제든 알려주세요. 더 많은 업데이트를 받아보고 싶다면 Bluesky나 X에서 저를 팔로우해주세요. 즐거운 코딩 되세요! 🚀
'개발 > 번역' 카테고리의 다른 글
| [번역] 빅테크에서 프로젝트를 출시하는 방법 (0) | 2026.10.03 |
|---|---|
| [번역] 여전히 은탄환은 없습니다 (1) | 2026.08.25 |
| [번역] 팩토리 모델: 코딩 에이전트가 소프트웨어 엔지니어링을 어떻게 바꾸고 있는가 (1) | 2026.06.21 |
| [번역] 리액트 컴파일러, 18개월의 여정: 흐름, 논쟁, 그리고 앞으로의 방향 (1) | 2026.06.08 |
| [번역] startTransition은 언제 정말로 필요할까요? (2) | 2026.04.26 |