커뮤니티 좋아요 기능은 단순해 보이지만, "즉시 반응하는 UI"와 "서버 상태 동기화"를 동시에 만족시키려면 생각보다 복잡합니다. 기존에는 React Query의 setQueryData로 캐시를 직접 조작하는 방식을 사용했는데, 특히 댓글 좋아요의 경우 무한 스크롤 페이지를 순회하면서 재귀적으로 댓글 트리를 탐색해야 해서 코드 복잡도가 상당했습니다.
이번 글에서는 React 19의 useOptimistic으로 전환하면서 이 복잡도를 어떻게 제거했는지, 그리고 그 과정에서 마주친 디바운스와의 조합 문제를 어떻게 해결했는지 정리해보겠습니다.
게시글 좋아요는 비교적 단순했습니다. 쿼리 캐시에서 게시글 상세 데이터를 찾아 isLiked와 likeCount를 직접 수정하면 됩니다.
const applyOptimisticUpdate = async (desiredLiked: boolean) => {
const queryKey = communityQueries.post.postDetailKeyWithParams(boardId, postId);
await queryClient.cancelQueries({ queryKey });
queryClient.setQueryData<PostDetailResponse>(queryKey, (previous) => {
if (!previous) return previous;
return {
...previous,
isLiked: desiredLiked,
likeCount: Math.max(0, previous.likeCount + (desiredLiked ? 1 : -1)),
};
});
};
댓글은 useInfiniteQuery로 페이지네이션되고, 각 댓글은 children으로 대댓글을 가진 트리 구조입니다. 특정 댓글의 좋아요를 업데이트하려면:
// 무한 페이지 순회
const queries = queryClient.getQueriesData<InfiniteData<CommentListResponse>>({
queryKey: queryKeyPrefix,
exact: false,
});
queries.forEach(([key, data]) => {
if (!data) return;
const nextPages = data.pages.map((page) => {
const result = patchCommentLike(page.data, commentId, desiredLiked);
if (!result.changed) return page;
return { ...page, data: result.next };
});
queryClient.setQueryData(key, { ...data, pages: nextPages });
});
// 재귀 트리 탐색
function patchCommentLike(
comments: Comment[] | undefined,
commentId: number,
desiredLiked: boolean
): PatchCommentsResult {
if (!comments) return { next: comments, changed: false };
let changed = false;
const next: Comment[] = comments.map((comment) => {
const childrenResult = patchCommentLike(comment.children, commentId, desiredLiked);
const isTarget = comment.id === commentId;
if (!isTarget && !childrenResult.changed) return comment;
changed = true;
return {
...comment,
...(childrenResult.changed ? { children: childrenResult.next } : {}),
...(isTarget
? {
isLiked: desiredLiked,
likeCount: Math.max(0, (comment.likeCount ?? 0) + (desiredLiked ? 1 : -1)),
}
: {}),
};
});
return { next, changed };
}
"좋아요 토글"이라는 단순한 동작을 위해 ~120줄의 캐시 조작 코드가 존재했습니다. 처음 이 코드를 마주했을 때, 이게 정말 최선의 방법인지 의문이 들었습니다.
| 문제 | 설명 |
|---|---|
| 복잡한 캐시 조작 | 무한 페이지 순회 + 재귀 트리 탐색이 필요 |
| 데이터 구조 의존 | 댓글 트리 구조가 바뀌면 patchCommentLike도 수정 필요 |
| 관심사 혼재 | 훅이 UI 상태(낙관적 업데이트)와 서버 상태(캐시)를 동시에 관리 |
| 에러 복원 | cancelQueries로 진행 중인 쿼리를 취소하고, 에러 시 캐시를 원래대로 되돌려야 함 |
본격적인 전환 이야기에 앞서, React 19에서 새로 도입된 useOptimistic의 동작 원리를 짚어보겠습니다.
낙관적 업데이트(Optimistic Update)는 서버 응답을 기다리지 않고 UI를 먼저 변경하는 패턴입니다. 좋아요 버튼을 누르면 서버 응답이 오기 전에 하트가 즉시 채워지는 것이 대표적인 예입니다.
핵심은 "성공할 것이라 낙관적으로 가정" 한다는 것입니다. 서버가 실패를 반환하면 원래 상태로 되돌립니다.
기존에 React에서 이 패턴을 구현하려면 useState로 로컬 상태를 관리하면서 서버 응답에 따라 수동으로 동기화하거나, React Query의 setQueryData로 캐시를 직접 조작해야 했습니다. 두 방법 모두 동기화 타이밍 문제나 복잡한 캐시 조작이라는 부담이 있었죠.
const [optimisticState, setOptimisticState] = useOptimistic(baseState, updateFn);
| 파라미터 | 역할 |
|---|---|
baseState | 실제 서버 데이터. 비동기 작업이 없을 때 반환되는 기본값 |
updateFn(currentState, payload) | 낙관적 상태를 계산하는 리듀서. setOptimisticState 호출 시 실행 |
optimisticState | 현재 표시할 값. transition 중이면 낙관적 값, 아니면 baseState |
setOptimisticState(payload) | 낙관적 업데이트를 트리거. 반드시 startTransition 내부에서 호출 |
useOptimistic은 단독으로 동작하지 않습니다. useTransition과 반드시 함께 사용해야 합니다.
useOptimistic의 낙관적 상태는 transition이 활성화된 동안만 유지됩니다. transition이 끝나면 자동으로 baseState로 복원되는데, 이것이 핵심 메커니즘입니다.
const [isPending, startTransition] = useTransition();
const [optimistic, setOptimistic] = useOptimistic(serverData, updateFn);
const handleAction = () => {
startTransition(async () => {
setOptimistic(newValue); // 즉시 낙관적 상태로 변경
await serverAction(newValue); // 서버 요청 (Promise)
// Promise가 settle되면 transition 종료 → baseState로 자동 복원
});
};
타임라인으로 보면 다음과 같습니다:
시간 →
────────────────────────────────────────────────────────
클릭 서버 요청 시작 서버 응답 리렌더
│ │ │ │
▼ ▼ ▼ ▼
[baseState] → [optimistic] ──────────────→ [새 baseState]
◄──── transition 활성 ────►
낙관적 상태가 표시되는 구간
useOptimistic의 상태 전환을 더 자세히 들여다보겠습니다.
1단계 - 초기 상태: transition이 없으면 optimisticState === baseState
baseState: { isLiked: false, likeCount: 5 }
optimisticState: { isLiked: false, likeCount: 5 } ← 동일
2단계 - transition 시작: setOptimistic 호출 시 updateFn이 실행되어 낙관적 값 계산
baseState: { isLiked: false, likeCount: 5 } ← 변하지 않음
optimisticState: { isLiked: true, likeCount: 6 } ← updateFn 결과
3단계 - transition 종료: 서버 응답 후 부모가 리렌더되면서 새 props(=새 baseState) 전달
baseState: { isLiked: true, likeCount: 6 } ← 서버 데이터 반영
optimisticState: { isLiked: true, likeCount: 6 } ← baseState로 복원
에러 시: transition이 종료되면 baseState로 복원되는데, 서버가 실패했으므로 baseState는 변하지 않습니다.
baseState: { isLiked: false, likeCount: 5 } ← 원래 그대로
optimisticState: { isLiked: false, likeCount: 5 } ← 자동 롤백
에러 시 별도의 롤백 로직 없이 자동으로 원래 상태로 돌아갑니다. 이것이 useState와의 결정적 차이입니다.
useOptimistic은 baseState가 바뀌면 transition이 없는 한 즉시 반영합니다:
// 부모 컴포넌트에서 서버 데이터가 변경될 때마다 자동 반영
const LikeButton = ({ isLiked, likeCount, onLike }) => {
const [optimistic, setOptimistic] = useOptimistic(
{ isLiked, likeCount }, // ← props가 바뀌면 optimistic도 자동 갱신
updateFn
);
// transition이 없는 상태에서는 항상 optimistic === { isLiked, likeCount }
};
useState는 이런 자동 동기화가 없어서 useEffect로 props를 state에 수동 반영해야 합니다. 그 과정에서 transition 중에 props가 바뀌면 낙관적 상태를 덮어쓰는 버그가 발생할 수 있습니다.
기존에는 "캐시를 조작해서 UI를 바꾸자"라는 접근이었습니다. 하지만 useOptimistic을 알게 되면서 "각 컴포넌트가 자신의 낙관적 상태를 관리하자" 로 관점을 바꿀 수 있었습니다.
useOptimistic을 좋아요에 적용하면 다음과 같습니다:
const [optimistic, setOptimistic] = useOptimistic(
{ isLiked, likeCount }, // base state (서버 데이터)
(state, desiredLiked: boolean) => ({
isLiked: desiredLiked,
likeCount: Math.max(0, state.likeCount + (desiredLiked ? 1 : -1)),
})
);
캐시 조작도 없고, 재귀 탐색도 없습니다. 컴포넌트 레벨에서 깔끔하게 해결됩니다.
처음에는 useState로도 비슷하게 구현할 수 있지 않을까 생각했습니다. 하지만 useState는 props가 바뀌어도 자동으로 동기화되지 않습니다:
// useState 방식
const [optimistic, setOptimistic] = useState({ isLiked, likeCount });
// props → state 동기화가 필요
useEffect(() => {
setOptimistic({ isLiked, likeCount });
}, [isLiked, likeCount]);
이 useEffect는 transition 중에 서버 데이터가 도착하면 낙관적 상태를 덮어쓸 수 있는 타이밍 버그를 유발합니다. useOptimistic은 transition이 끝날 때만 base state로 복원하므로 이 문제가 없습니다.
낙관적 업데이트 로직을 LikeButton 컴포넌트 내부에 캡슐화했습니다. 게시글과 댓글에서 동일한 패턴이 중복되는 것을 방지하기 위함입니다.
const LikeButton = ({ size, isLiked, likeCount, onLike }: LikeButtonProps) => {
const [, startTransition] = useTransition();
const [optimistic, setOptimistic] = useOptimistic({ isLiked, likeCount }, (state, desiredLiked: boolean) => ({
isLiked: desiredLiked,
likeCount: Math.max(0, state.likeCount + (desiredLiked ? 1 : -1)),
}));
const handleToggle = () => {
const desiredLiked = !optimistic.isLiked;
startTransition(async () => {
setOptimistic(desiredLiked);
await onLike(desiredLiked);
});
};
return (
<ToggleButton
isOn={optimistic.isLiked}
onToggle={handleToggle}
onState={optimistic.likeCount}
offState={optimistic.likeCount}
// ...
/>
);
};
사용하는 쪽은 서버 데이터와 액션만 전달하면 됩니다:
// PostDetail.tsx
<LikeButton
isLiked={postDetail.isLiked ?? false}
likeCount={postDetail.likeCount ?? 0}
onLike={toggleLike}
/>
// CommentItem.tsx
<LikeButton
isLiked={comment.isLiked ?? false}
likeCount={comment.likeCount ?? 0}
onLike={handleLike}
/>
useOptimistic을 적용하고 나서 새로운 문제를 발견했습니다. 기존에 사용하던 useDebounce는 fire-and-forget 방식으로 Promise를 반환하지 않습니다. useOptimistic의 낙관적 상태는 startTransition이 활성화된 동안만 유지되므로, 이 조합이 문제가 됩니다:
startTransition(async () => {
setOptimistic(desiredLiked);
await onLike(desiredLiked); // ← Promise가 없으면 transition이 즉시 종료
});
onLike가 Promise를 반환하지 않으면 어떤 일이 벌어질까요?
await undefined → transition 즉시 종료useOptimistic이 base state로 즉시 복원결국 사용자 입장에서는 좋아요를 눌렀는데 깜빡이면서 두 번 바뀌는 어색한 경험을 하게 됩니다.
그래서 Promise를 반환하는 디바운스 훅을 직접 만들었습니다.
export const useDebouncedAction = <TArgs extends unknown[]>(
action: (...args: TArgs) => Promise<void>,
delay: number
): ((...args: TArgs) => Promise<void>) => {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resolversRef = useRef<Array<() => void>>([]);
const actionRef = useRef(action);
actionRef.current = action;
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
resolversRef.current.forEach((r) => r());
resolversRef.current = [];
};
}, []);
return useCallback(
(...args: TArgs): Promise<void> => {
if (timerRef.current) clearTimeout(timerRef.current);
return new Promise<void>((resolve) => {
resolversRef.current.push(resolve);
timerRef.current = setTimeout(async () => {
const pendingForThisBatch = resolversRef.current;
resolversRef.current = [];
try {
await actionRef.current(...args);
} finally {
pendingForThisBatch.forEach((r) => r());
}
}, delay);
});
},
[delay]
);
};
핵심 설계를 정리하면 다음과 같습니다:
| 설계 | 이유 |
|---|---|
| 각 호출이 Promise를 반환 | startTransition(async () => { await ... })와 조합 가능 |
| 디바운스 타이머 만료 시 action 실행 | 빠른 연속 클릭에서 마지막 의도만 서버에 전송 |
| action settle 시 모든 대기 Promise resolve | 모든 transition 종료 → useOptimistic 실제 데이터로 복원 |
| 실행 시점에 resolver를 snapshot | action 실행 중 추가되는 새 요청의 resolver는 다음 배치로 분리 |
actionRef로 최신 action 참조 | 반환 함수의 참조 안정성 보장 (deps에 action 불포함) |
특히 resolver snapshot은 중요한 설계 포인트입니다. action이 실행되는 동안 새로운 클릭이 들어올 수 있는데, 이때 기존 배치의 resolver와 새 배치의 resolver를 분리하지 않으면 새 요청의 transition까지 조기에 종료되어 버립니다.
빠른 연속 클릭 시나리오로 전체 흐름을 살펴보겠습니다:
[빠른 연속 클릭: like → unlike → like]
1. 클릭 like → Promise A 반환, transition A 시작, 낙관적 UI: liked
2. 클릭 unlike → 디바운스 리셋, Promise B 반환, transition B 시작, 낙관적 UI: unliked
3. 클릭 like → 디바운스 리셋, Promise C 반환, transition C 시작, 낙관적 UI: liked
4. 300ms 경과 → like API 호출
5. API 성공 → invalidateQueries → 서버 데이터 갱신
6. Promise A, B, C 모두 resolve → transition 종료 → base state(서버 데이터)로 자연스럽게 전환
사용자 입장에서는 매 클릭마다 UI가 즉시 반응하고, 실제 API 호출은 마지막 의도 하나만 전송됩니다.
Before: ~120줄
- InfiniteData import
- PatchCommentsResult 타입
- applyOptimisticUpdate (cancelQueries + getQueriesData + 무한 페이지 순회)
- patchCommentLike 재귀 함수
- useDebounce + likeMutation
- toggleLike (토글 + 낙관적 업데이트 + 디바운스)
After: ~50줄
- useMutation (mutationFn + onError + onSettled)
- useDebouncedAction(mutateAsync)
Before: ~65줄
- applyOptimisticUpdate (cancelQueries + setQueryData)
- useDebounce + likeMutation
- toggleLike (토글 + 낙관적 업데이트 + 디바운스)
After: ~30줄
- useMutation (mutationFn + onError + onSettled)
- useDebouncedAction(mutateAsync)
| 역할 | Before | After |
|---|---|---|
| 낙관적 UI | 훅 (setQueryData) | LikeButton (useOptimistic) |
| 서버 통신 | 훅 (useMutation) | 훅 (useMutation) |
| 캐시 동기화 | 훅 (수동 패치) | React Query (invalidateQueries) |
| 디바운스 | 훅 (useDebounce) | 훅 (useDebouncedAction) |
이번 전환을 통해 몇 가지 교훈을 얻을 수 있었습니다.
첫 번째는 캐시를 직접 조작하지 말 것입니다. setQueryData로 수동 패치하는 대신, invalidateQueries로 서버 데이터를 다시 가져오고, 그 사이의 UI 공백은 useOptimistic으로 채우는 것이 훨씬 깔끔했습니다.
두 번째는 낙관적 업데이트는 컴포넌트의 책임이라는 것입니다. 서버 상태를 관리하는 훅과 UI 상태를 관리하는 컴포넌트의 역할을 명확히 분리하면서, 훅은 API 호출과 캐시 무효화만, 컴포넌트는 useOptimistic으로 즉각적인 피드백만 담당하도록 구성할 수 있었습니다.
세 번째는 useOptimistic과 디바운스를 조합하려면 Promise가 필요하다는 것입니다. 기존 fire-and-forget 디바운스로는 transition을 유지할 수 없었습니다. Promise를 반환하는 디바운스 훅이 있어야 낙관적 상태가 서버 응답까지 유지됩니다.
마지막으로 useState 대신 useOptimistic을 쓸 것입니다. props → state 동기화를 위한 useEffect 없이도, transition이 끝나면 자동으로 서버 데이터로 복원됩니다. 타이밍 버그 없이 깔끔하게 동작하죠.
이번 작업은 기존 ~120줄의 캐시 조작 코드를 ~50줄로 줄인 것 이상의 의미가 있었습니다. 앞으로 토글 버튼처럼 서버 상태를 즉시 반영해야 하는 패턴이 더 생긴다면, 이번에 만든 LikeButton의 구조를 공통 컴포넌트로 확장해서 일관된 방식으로 대응할 수 있을 것이라 기대하고 있습니다.