C-7: 서버 상태 ② — useMutation·낙관적 업데이트
목차 63
앱을 켜고 첫 게시물의 하트를 눌러보세요. 숫자가 1240에서 1241로 올라갑니다. 좋아요가 됐네요.
이제 새로고침을 눌러보세요.
1240입니다.
안녕하세요, 홍순구입니다. 지난 시간에 우리는 서버에서 게시물을 가져오는 데까지 갔는데, 하트는 여전히 우리 화면 안에서만 눌립니다. 서버는 우리가 뭘 눌렀는지 모르고, 새로고침하면 서버가 아는 것으로 되돌아가요.
오늘은 서버에 쓰는데, 쓰는 일에는 읽기에 없던 문제가 줄줄이 딸려 나오니까 하나씩 만나면서 풀어갈게요.
💡 오늘 수업의 핵심 — "화면이 자기 숫자를 세기 시작하면 언젠가 서버와 갈린다"
지난 시간에 배운 것은 "서버 값은 우리 것이 아니다" 였습니다. 오늘은 그 말의 뒷면을 봅니다.
우리 화면은 지금 좋아요 개수를 자기가 셉니다. 1240에 1을 더해서 1241을 만들어요. 서버도 자기 나름대로 셉니다. 둘이 늘 같으면 좋겠지만, 그사이 다른 사람이 누르거나 서버가 우리 요청을 거절하면 갈라집니다.
오늘 우리는 화면에서 그 셈을 걷어냅니다. 대신 "먼저 그렇게 보여주고, 서버가 아니라면 되돌리기"로 갑니다.
🎯 학습 목표
useMutation으로 서버에 쓰고, 읽기와 무엇이 다른지 요청 횟수로 확인합니다.- 쓰고 나면 읽어둔 것이 낡는다는 것을 확인하고
invalidateQueries로 맞춥니다. - 화면이 들고 있던 사본을 걷어내 창고를 유일한 진실로 만듭니다.
- 서버를 기다리지 않고 화면을 먼저 바꾸고, 거절당하면 되돌립니다.
- 화면이 뜨기 전에 데이터를 준비하는 방식과 캐시를 화해시킵니다.
- 읽다가 실패했을 때 그 처리를 화면 밖으로 내보냅니다.
오늘의 여정
1. 서버에 보낸다 useMutation · 출입증
↓
2. 읽어둔 것이 낡는다 invalidateQueries · 창고를 들여다보기
↓ (그런데 화면이 안 바뀐다)
3. 진실이 둘이면 하나는 거짓말 화면이 든 사본을 걷어낸다
↓ (그런데 0.4초가 답답하다)
4. 먼저 바꾸고 나중에 확인 onMutate · setQueryData
↓ (그런데 거절당하면?)
5. 거절당하면 되돌린다 onError · onSettled
↓
6. 뜨기 전에 채워둔 창고 loader 와 useQuery 의 화해
↓
7. 서버가 거절한 삭제 403 과 확인 상자
↓
8. 실패를 화면 밖으로 throwOnError
Step 1: "좋아요를 서버에 보낸다"
먼저 출입증이 필요합니다
지난 시간에 우리는 요청에 토큰을 붙이는 것까지 만들어뒀는데, 그 토큰을 받아오는 화면이 아직 없어요. 로그인 화면은 인증을 본격적으로 다루는 시간에 제대로 만들 겁니다.
지금은 임시로 출입증만 받아옵니다. 연습용 서버가 아는 사람은 jaehoon 한 명뿐이라 아이디를 물어볼 것도 없어요.
// apps/web-spa/src/components/SignInButton.tsx
import { useMutation } from '@tanstack/react-query';
import { login } from '../api/auth';
import { Button } from './ui/button';
const ME = 'jaehoon';
export function SignInButton() {
// 서버에 무언가를 시키는 일에도 도구가 있다. 읽기와 달리 우리가 부를 때만 나간다.
const signIn = useMutation({
mutationFn: () => login(ME),
});
// 받아온 뒤에는 누구로 들어와 있는지만 보여준다
if (signIn.data !== undefined) {
return <span className="text-sm text-faint">{signIn.data.username}</span>;
}
return (
<Button
variant="outline"
size="sm"
disabled={signIn.isPending}
onClick={() => signIn.mutate()}
>
{signIn.isPending ? '보내는 중…' : '로그인'}
</Button>
);
}
이 버튼을 머리말에 놓습니다. 화면 밝기 고르개 옆이에요.
여기서 처음 보는 것이 useMutation입니다. 지난 시간의 useQuery와 나란히 놓고 보면 차이가 선명해요.
useQuery useMutation
───────────────────── ─────────────────────
화면이 뜨면 알아서 나간다 mutate() 를 불러야 나간다
queryKey 로 창고에 이름표를 붙인다 이름표가 없다
같은 키면 겹친 요청을 합친다 누른 만큼 나간다
결과를 창고에 보관한다 보관하지 않는다
useQuery는 "이 화면에는 이 데이터가 필요하다"는 선언이라 React가 알아서 부릅니다. useMutation은 "사용자가 이걸 누르면 서버에 보내라"는 준비라서, 실제로 보내는 것은 우리가 부르는 mutate()예요.
좋아요를 보내는 함수
먼저 API 함수부터 만듭니다. 지난 시간에 만든 인스턴스를 그대로 쓰니까 주소도 봉투도 신경 쓸 게 없어요.
// apps/web-spa/src/api/posts.ts
/** 좋아요를 누른 뒤 서버가 알려주는 것 — 게시물 전체가 아니라 바뀐 값만 온다 */
export interface LikeResult {
id: number;
liked: boolean;
likeCount: number;
}
// 읽을 때와 다른 점은 post 라는 것 하나다. 서버에 무언가를 시키는 요청이다.
export async function likePost(id: number): Promise<LikeResult> {
const response = await api.post<LikeResult>(`/posts/${id}/like`);
return response.data;
}
이어서 이것을 부르는 훅을 만드는데, 지금은 정말 한 줄이에요.
// apps/web-spa/src/queries/posts.ts
// 읽기와 쓰기는 성질이 다르다. 읽기는 화면이 뜨면 알아서 나가지만
// 쓰기는 사람이 누를 때만 나가야 하고, 두 번 누르면 두 번 나가야 한다.
// 그래서 키도 없고, 부르는 함수(mutate)가 따로 있다.
export function useLikeMutation() {
return useMutation({
mutationFn: likePost,
});
}
피드도 이제 이것을 쓰는데, 지금은 화면도 바꾸고 서버에도 알려요.
// apps/web-spa/src/components/FeedSection.tsx
export function FeedSection({ posts: initialPosts }: FeedSectionProps) {
const { posts, likedCount, toast, toggleLike, reachBottom, dismissToast } = useFeed(initialPosts);
const likeMutation = useLikeMutation();
// 한 번 누르면 두 곳에 알린다 — 화면과 서버.
function handleToggleLike(id: number) {
toggleLike(id);
likeMutation.mutate(id);
}
이제 새로고침을 견딥니다
먼저 머리말의 로그인 버튼을 누르세요. 그다음 하트를 누르고 새로고침해 보세요.
1241 그대로입니다. 서버가 알게 됐어요.
읽기는 합쳐주고 쓰기는 안 합쳐줍니다
지난 시간에 재봤던 것을 기억하시나요. 개발 모드에서 React는 컴포넌트를 일부러 두 번 그립니다. 그때 손으로 짠 데이터 가져오기는 요청이 두 번 나갔어요. 도구로 옮긴 뒤에는 한 번이었고요. 같은 이름표를 단 요청이 겹치면 하나로 합쳐지기 때문입니다.
그런데 쓰기는 다릅니다. 하트를 두 번 누르면 요청이 두 번 나가요. 서버 숫자도 1240 → 1241 → 1240으로 왔다 갔다 합니다.
당연한 이야기 같지만 이유가 있는데, 뮤테이션에는 이름표가 없어서 합칠 근거가 없고, 합치면 안 되기도 합니다. 사용자가 두 번 눌렀다는 것은 두 번 시켰다는 뜻이니까요.
🙋 학생 질문 — "출입증 없이 누르면 어떻게 되나요?"
요청은 나가고 서버가 401로 거절해요. 지난 시간에 만든 인터셉터가 토큰을 갱신해보려고 하지만, 애초에 받아둔 것이 없으니 그것도 실패합니다.
재밌는 것은 화면입니다. 하트는 빨갛게 켜져 있고 숫자도 1241 인데, 우리 화면이 자기 셈으로 그렇게 만들었으니까요.
서버는 아무것도 안 바꿨으니, 새로고침하면 1240으로 돌아가면서 화면과 서버가 갈립니다. 이 갈라짐이 오늘 Step 2·3의 주제예요.
💡 한 줄 정리
읽기는 화면이 뜨면 알아서 나가고 같은 것끼리 합쳐지지만, 쓰기는 우리가 부를 때만 누른 만큼 나갑니다.
Step 2: "쓰고 나면 읽어둔 것이 낡는다"
좋아요가 서버에 저장됐습니다. 그런데 우리가 지난 시간에 만든 창고에는 뭐가 들어 있을까요?
처음 받아온 피드 그대로입니다. 1240이라고 적힌 배열이요. 우리는 방금 서버 숫자를 1241로 바꿔놓고는, 창고에는 그 사실을 안 알려줬어요.
무효화는 지우기가 아니라 다시 물어보기입니다
이럴 때 쓰는 것이 무효화입니다. 이름 때문에 "지운다"로 오해하기 쉬운데, 실제로 하는 일은 "이거 낡았으니 다시 물어봐"예요.
무효화 전 무효화 후
────────────── ──────────────
창고: [1240 이 든 피드] 창고: [1240 이 든 피드] ← 그대로 있다
화면: 1240 을 그린다 화면: 1240 을 그린다 ← 안 비워진다
↓ 뒤에서 다시 물어본다
창고: [1241 이 든 피드]
화면: 1241 로 갈린다
화면이 잠깐이라도 비지 않습니다. 있던 값을 계속 보여주다가 새 답이 오면 그때 갈려요.
// apps/web-spa/src/queries/posts.ts
export function useLikeMutation() {
return useMutation({
mutationFn: likePost,
// 쓰고 나면 읽어둔 것이 낡는다.
// 무효화는 "지우기" 가 아니라 "다시 물어보기" 다 — 화면은 안 비고,
// 새 답이 오면 그때 갈린다. staleTime 이 남아 있어도 이건 나간다.
onSuccess: (_result, _postId, _onMutateResult, context) => {
void context.client.invalidateQueries({ queryKey: ['posts'] });
},
});
}
onSuccess는 서버가 성공으로 답했을 때 불리는 함수예요. 그 안에서 창고에게 "posts로 시작하는 것들 다시 물어봐"라고 시킵니다.
이제 하트를 누르면 요청이 두 번 나가요. 좋아요 하나, 그리고 피드 다시 받기 하나.
⚠️ 검색하면 다른 모양이 나올 겁니다
onSuccess가 받는 인자를 보세요. 네 개입니다.
검색해서 나오는 자료는 대부분 세 개로 적혀 있고, 세 번째를 context라고 부릅니다. 그런데 우리 코드에서는 세 번째가 onMutateResult이고 네 번째가 context 죠.
이건 라이브러리가 5.89.0에서 바꾼 것인데, 원래 세 번째였던 것에 onMutateResult라는 정확한 이름을 주고, 그 뒤에 context를 새로 붙였어요. 이 context 안에는 창고 자체(client)가 들어 있어서, 우리는 창고를 따로 꺼내오는 훅을 안 불러도 돼요.
옛 형태를 그대로 베껴 써도 안 터지는데, 값의 순서가 같기 때문에 이름만 거짓말이 될 뿐입니다. 옛 이름 위에 새 사용법을 얹으면 그때는 타입 검사가 막아줍니다.
| 쓴 모양 | 결과 |
|---|---|
세 번째를 context라 부르고 onMutate 반환값으로만 씀 |
✅ 동작함 (이름만 옛것) |
세 번째를 context라 부르고 context.client를 씀 |
❌ 타입 검사가 막음 |
네 번째를 context로 받아 context.client를 씀 |
✅ 현재 문서의 모양 |
창고를 눈으로 들여다봅시다
지금까지 창고 이야기를 말로만 했는데, 실제로 볼 수 있는 도구가 있습니다. 지난 시간에 이미 설치해뒀어요.
// apps/web-spa/src/main.tsx
createRoot(rootElement).render(
<StrictMode>
<AppProviders>
<RouterProvider router={router} />
{/* 창고를 들여다보는 창. 개발할 때만 붙고 배포본에는 안 들어간다.
AppProviders 안쪽이어야 같은 캐시를 본다. */}
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</AppProviders>
</StrictMode>,
);
화면 구석에 아이콘이 하나 생깁니다. 눌러보면 창고 안의 이름표들이 색깔과 함께 보여요.
- 초록: 아직 쓸 만하다고 본 값
- 노랑: 낡았다고 표시된 값
- 회색: 지금 아무 화면도 안 보는 값
하트를 누르는 순간을 보세요. ["posts"]가 초록에서 노랑으로 바뀌었다가, 새 답이 오면 다시 초록이 됩니다. 우리가 방금 말로 설명한 것이 그대로 보여요.
import.meta.env.DEV로 감싼 덕에 배포용으로 빌드하면 이 도구는 결과물에 안 들어갑니다. 빌드한 파일에서 이름을 찾아보면 한 건도 안 나와요.
⚠️ 그런데 화면이 안 바뀝니다
여기서 이상한 일이 생깁니다. 이렇게 해봅시다.
- 화면을 켭니다. 첫 게시물이 1240이라고 떠요.
- 그사이 다른 사람들이 그 게시물에 좋아요를 눌러 서버 숫자가 1300이 됐습니다.
- 우리가 하트를 누릅니다. 서버는 1301을 돌려주고, 무효화가 돌아 창고에도 1301이 들어옵니다.
- 화면을 봅니다.
1241입니다.
창고에는 분명히 1301이 들어 있는데, 화면에는 1301이라는 숫자가 아예 없어요. 화면은 자기가 센 숫자를 그대로 들고 있습니다.
무효화는 제대로 돌았어요. 요청도 나갔고 창고도 갱신됐는데, 화면이 그걸 안 봅니다.
왜 그런지가 다음 단계의 주제예요.
🙋 학생 질문 — "지난 시간에 30초 동안은 다시 안 물어본다고 하지 않았나요?"
맞습니다. 우리는 받아둔 값을 30초 동안 쓸 만하다고 보기로 정해뒀어요. 그 시간 안에 화면에 돌아와도 다시 안 물어보는데, 무효화는 그 시간을 무시하고 나갑니다. 재보니 30초를 그대로 둔 채로 하트를 눌러도 피드 요청이 한 번 더 나갑니다.
생각해보면 당연한데, 그 30초는 "우리가 아무것도 안 했으면 아마 안 바뀌었을 것"이라는 짐작이었습니다. 그런데 방금 우리가 서버를 바꿨으니 짐작이 틀렸다는 걸 우리가 아는데, 그 시간을 지킬 이유가 없습니다.
💡 한 줄 정리
무효화는 창고를 비우는 것이 아니라 다시 물어보게 하는 것이고, 우리가 직접 바꾼 뒤라면 아무리 최근에 받아온 값이라도 다시 물어봅니다.
Step 3: "진실이 둘이면 하나는 거짓말"
창고에는 1301이 있는데 화면은 1241을 보여줍니다. 범인을 찾아봅시다.
우리 피드 화면은 이렇게 생겼어요.
서버 ──→ 창고 ──→ FeedSection 이 받는다 ──→ 자기 안에 복사해둔다 ──→ 그 사본을 그린다
(useFeed 안의 상태)
FeedSection은 게시물 배열을 넘겨받아서 자기 안에 복사해뒀다가 그것을 그립니다. 좋아요를 누르면 그 사본을 고쳤고요.
문제는 그 복사가 한 번만 일어난다는 겁니다. 처음 그려질 때 딱 한 번이요. 나중에 창고가 갱신돼서 새 배열이 내려와도 사본은 그대로입니다.
이 성질은 사실 우리가 이미 만난 적이 있어요. 지난 시간 첫 단계에서 "나중에 도착한 배열이 화면에 안 들어간다"는 것을 확인하고, 데이터가 온 뒤에 그려지는 자식으로 내려서 피했었죠. 그때는 피했지만 오늘은 정면으로 만난 겁니다.
진실은 하나여야 합니다
고치는 방법은 사본을 없애는 거예요. 창고에 있는 것을 그대로 그리고, 좋아요는 서버에만 보냅니다.
// apps/web-spa/src/components/FeedSection.tsx
// 게시물을 이미 손에 쥔 다음부터의 일만 맡는다.
// 가져오는 일은 이 컴포넌트를 그리는 쪽이 한다.
//
// 좋아요는 더 이상 여기 상태가 아니다. 우리가 그리는 posts 는 창고에 있는 것
// 그대로이고, 누르면 서버에 보낸다. 화면이 따로 세지 않으니 틀릴 일도 없다.
export function FeedSection({ posts }: FeedSectionProps) {
const likeMutation = useLikeMutation();
// 알림은 이 화면에만 있다가 사라지는 것이라 여기 그대로 둔다.
const [toast, setToast] = useState<FeedToast | null>(null);
// 세는 것도 넘겨받은 것에서 센다. 따로 들고 있는 숫자가 없다.
const likedCount = posts.filter((post) => post.liked).length;
function handleToggleLike(id: number) {
const target = findById(posts, id);
if (!target) {
return;
}
setToast({ message: likeToastMessage(target) });
likeMutation.mutate(id);
}
바뀐 것을 정리하면 이렇습니다.
| 전 | 후 |
|---|---|
| 넘겨받은 배열을 복사해 상태로 들고 있음 | 넘겨받은 것을 그대로 그림 |
| 좋아요 누른 개수를 상태에서 셈 | 넘겨받은 것에서 셈 |
| 하트를 누르면 사본을 고치고 서버에도 보냄 | 하트를 누르면 서버에만 보냄 |
| 알림은 상태 | 알림은 상태 (그대로) |
알림만 남긴 이유가 있어요. C-4에서 상태를 어디에 둘지 정하는 규칙을 세웠었죠. 알림은 이 화면에만 잠깐 있다가 사라지는 것이고 서버도 다른 화면도 알 필요가 없습니다. 그러니까 여기 있는 게 맞아요.
이제 숫자가 맞습니다
아까 그 상황을 다시 해봅시다.
| 창고 | 화면 | |
|---|---|---|
| 고치기 전 | 1301 | 1241 |
| 고친 뒤 | 1301 | 1301 |
화면에서 1241이라는 숫자는 이제 아예 안 나옵니다.
⚠️ 그런데 답답해졌습니다
하트를 눌러 세어보면, 하나, 둘… 0.4초쯤 지나서야 하트가 빨개집니다.
당연한데, 화면은 이제 서버가 답을 줘야만 바뀌니까 서버에 갔다 오는 시간을 고스란히 기다립니다.
실제 서비스에서 이러면 앱이 고장 난 것처럼 느껴져요. 사용자는 자기가 잘못 눌렀나 싶어서 또 누릅니다.
우리는 방금 정확한 앱을 만들었는데, 대신 굼떠졌습니다.
🙋 학생 질문 — "그냥 사본을 두고 나중에 온 값도 반영되게 하면 안 되나요?"
할 수는 있습니다. 새 배열이 내려올 때마다 사본을 갈아 끼우면 되는데, 그러면 사본을 두는 의미가 없어집니다. 창고를 그대로 따라 그리기만 할 거면 그냥 창고를 그리면 되니까요. 중간에 사본을 하나 더 두면 "갈아 끼우는 것을 빠뜨리지 않았나"를 계속 신경 써야 해요.
더 중요한 건 이겁니다. 같은 데이터를 두 곳이 들고 있으면 어느 쪽이 맞는지 정해야 합니다. 그런데 정해두지 않으면 오늘 본 것처럼 조용히 갈라지고 화면도 에러도 안 난 채 그냥 틀린 숫자만 보일 뿐이니, 서버에서 온 데이터는 창고 하나만 들고 있게 합니다.
💡 한 줄 정리
서버에서 온 값을 화면이 복사해두면 언젠가 조용히 갈라지므로, 창고 하나만 진실로 두고 화면은 그것을 그대로 그립니다.
Step 4: "기다리지 않고 먼저 바꾼다"
0.4초를 없애는 방법은 사실 간단합니다. 서버에 물어보기 전에 창고를 먼저 바꾸는 거예요.
화면은 창고를 그리니까, 창고가 바뀌면 화면도 그 순간 바뀝니다. 서버 답은 뒤에서 조용히 받고요.
지금까지 이제부터
────────────── ──────────────
누른다 누른다
↓ ↓ 창고를 먼저 바꾼다 (0ms)
서버에 보낸다 화면이 바뀐다
↓ 0.4초 ↓ 서버에 보낸다
답이 온다 ↓ 0.4초
↓ 답이 온다
화면이 바뀐다 맞는지 확인만 한다
이런 방식을 낙관적 업데이트라고 부릅니다. "아마 잘될 것"이라고 낙관하고 먼저 보여준다는 뜻이에요.
보내기 전에 불리는 함수
onMutate는 요청을 보내기 직전에 불립니다. 여기서 창고를 미리 고쳐둡니다.
// apps/web-spa/src/queries/posts.ts
export function useLikeMutation() {
return useMutation({
mutationFn: likePost,
// 서버에 물어보기 *전에* 먼저 창고를 바꾼다. 화면은 창고를 그리니까
// 누른 순간 하트가 빨개진다.
onMutate: async (postId, context) => {
// 지금 나가 있는 피드 요청을 세운다. 안 세우면 그 늦은 답이
// 우리가 방금 바꾼 것을 옛 값으로 덮어쓴다.
await context.client.cancelQueries({ queryKey: feedKey() });
// B-2 에서 만든 그 함수다. 창고 안의 배열에도 똑같이 쓴다.
context.client.setQueryData<Post[]>(feedKey(), (previous) =>
previous === undefined ? previous : toggleLike(previous, postId),
);
},
onSuccess: (_result, _postId, _onMutateResult, context) => {
void context.client.invalidateQueries({ queryKey: ['posts'] });
},
});
}
여기서 반가운 것 하나. toggleLike는 우리가 B-2에서 만든 그 함수입니다. 원본 배열을 안 건드리고 바뀐 게시물만 새로 만들어 돌려주는 순수 함수요.
그때는 화면 상태를 고치려고 만들었는데, 지금은 창고 안의 배열을 고치는 데 씁니다. 함수는 한 글자도 안 바뀌었어요. 값을 안 건드리고 새로 만들어 돌려주는 함수는 이렇게 어디서든 다시 쓰입니다.
이제 즉시 반응합니다
서버 응답을 일부러 0.4초 늦춰놓고 재봤습니다.
| 시점 | 화면 | 서버 |
|---|---|---|
| 누르기 전 | 1240 | 1240 |
| 누른 직후 | 1241 | 1240 |
| 0.4초 뒤 | 1241 | 1241 |
누른 직후를 보세요. 서버는 아직 아무것도 모르는데 화면은 이미 1241입니다. 하트도 그 순간 빨개지고, 답이 오면 서버 값으로 맞춰집니다. 그사이 다른 사람들이 눌러서 진짜 값이 1301 이었다면, 화면은 1241을 잠깐 보여주다가 1301로 갈립니다.
나가 있는 요청을 세우는 이유
onMutate 첫 줄의 cancelQueries가 뭘 하는지 궁금하실 거예요. 이게 없으면 어떻게 되는지 재봤습니다.
이런 상황을 만들었어요. 피드를 다시 물어보는 요청이 나가 있는 중에 (아직 답이 안 왔습니다) 우리가 하트를 누릅니다.
세울 때 안 세울 때
────────────── ──────────────
1241 1241
(계속 1241) 1240 ← 늦게 온 옛 답이 덮어썼다
1241 ← 좋아요 답이 와서 다시 올라간다
안 세우면 하트가 빨개졌다가, 잠깐 꺼졌다가, 다시 켜집니다. 나가 있던 요청은 우리가 누르기 전에 출발했으니 1240을 들고 옵니다. 그게 도착하면서 우리가 방금 만든 1241을 밀어내는 거예요.
cancelQueries는 그 요청에게 "너는 이제 낡은 걸 들고 오는 중이니 결과를 버려라"라고 알립니다.
⚠️ 여기서 조심할 것이 있는데, 끝나고 나서 보면 어느 쪽이든 결국 1241입니다. 뒤에 무효화가 돌아서 서버 값으로 맞춰주기 때문에 "마지막 값"만 보면 둘의 차이를 알 수 없고, 갈리는 것은 가는 길에 한 번 되돌아가느냐예요.
🙋 학생 질문 — "창고를 직접 고쳐도 되나요? 서버가 진실이라면서요."
좋은 질문이에요. Step 3에서 "창고 하나만 진실"이라고 했는데 지금 우리가 그 창고에 손을 대고 있으니까요.
차이는 이겁니다. Step 3에서 없앤 것은 창고와 나란히 존재하는 또 다른 진실 이었어요. 화면이 자기만의 숫자를 따로 세고 있었죠.
지금 하는 건 창고에 미리 적어두는 것입니다. 진실은 여전히 하나예요. 다만 서버 답이 오기 전에 "아마 이렇게 될 것"을 임시로 적어두는 거고, 답이 오면 반드시 진짜 값으로 덮어씁니다.
핵심은 그 임시 값이 오래 안 남는다는 겁니다. 성공하면 무효화가 진짜 값을 가져오고, 실패하면 되돌려야 해요. 되돌리는 건 다음 단계에서 만듭니다.
💡 한 줄 정리
먼저 창고를 바꿔 화면을 즉시 반응하게 하고, 나가 있던 요청은 세워서 늦게 온 옛 값이 덮어쓰지 않게 합니다.
Step 5: "거절당하면 되돌린다"
우리 연습용 서버는 좋아요를 다섯 번에 한 번 거절합니다. 실제 서버도 가끔 실패하는데, 네트워크가 끊기거나 서버가 잠깐 바쁘면 어떻게 될까요?
화면은 이미 1241이라고 바꿔놨는데 서버는 저장을 안 했고, 아무도 그걸 되돌리지 않습니다.
사용자는 좋아요가 눌린 줄 압니다. 나중에 새로고침하고 나서야 안 눌렸다는 걸 알게 되죠.
바꾸기 전 모습을 챙겨둡니다
되돌리려면 되돌릴 것을 들고 있어야 합니다. onMutate에서 바꾸기 전 배열을 챙겨두고, 그것을 돌려줍니다.
// apps/web-spa/src/queries/posts.ts
onMutate: async (postId, context) => {
await context.client.cancelQueries({ queryKey: feedKey() });
// 바꾸기 전 모습을 챙겨둔다. 서버가 거절하면 이걸로 되돌린다.
const previous = context.client.getQueryData<Post[]>(feedKey());
context.client.setQueryData<Post[]>(feedKey(), (current) =>
current === undefined ? current : toggleLike(current, postId),
);
// 여기서 돌려준 것이 아래 콜백들의 세 번째 인자로 온다.
return { previous };
},
// 거절당하면 챙겨둔 모습으로 되돌린다. 화면은 눌리기 전으로 돌아간다.
onError: (_error, _postId, onMutateResult, context) => {
if (onMutateResult?.previous !== undefined) {
context.client.setQueryData(feedKey(), onMutateResult.previous);
}
},
onMutate가 돌려준 것이 onError의 세 번째 인자로 옵니다. Step 2에서 이야기한 그 onMutateResult인데, 왜 그 이름인지 이제 아시겠죠. 말 그대로 onMutate가 돌려준 결과예요.
성공이든 실패든 마지막에
그리고 무효화를 onSuccess에서 onSettled로 옮깁니다.
// apps/web-spa/src/queries/posts.ts
// onSuccess 가 아니라 onSettled 인 이유: 실패했을 때도 서버에 진짜
// 무슨 일이 있었는지 물어봐야 한다. 되돌린 값이 맞다는 보장이 없다.
onSettled: (_result, _error, _postId, _onMutateResult, context) => {
void context.client.invalidateQueries({ queryKey: ['posts'] });
},
세 콜백을 나란히 놓으면 이렇습니다.
mutate() 를 부른다
↓
onMutate 보내기 전 창고를 미리 바꾸고, 바꾸기 전 것을 챙겨둔다
↓
(서버에 다녀온다)
↓
┌────┴────┐
onSuccess onError 갈린다 성공: 할 일 없음 / 실패: 챙겨둔 것으로 되돌린다
└────┬────┘
↓
onSettled 둘 다 지나서 서버에 다시 물어본다
왜 실패했는데도 다시 물어볼까요? 되돌린 값이 맞다는 보장이 없기 때문입니다.
이런 경우를 생각해보세요. 우리 요청은 거절당했는데, 그사이 다른 사람들이 눌러서 진짜 값은 1300이 됐어요. 되돌리기만 하면 화면은 1240인데, 우리가 챙겨둔 것이 그거니까요.
무효화가 뒤따라 돌아서 서버에 물어보면 1300이 옵니다. 재봤더니 화면이 1300으로 맞춰졌어요. 되돌리기는 급한 불을 끄고, 무효화가 진짜 답을 가져옵니다.
되돌리기와 무효화는 하는 일이 다릅니다
여기서 헷갈리기 쉬운 게 있어요. "어차피 무효화가 서버 값을 가져올 텐데 되돌리기가 왜 필요한가" 하는 겁니다.
갈리는 것은 언제 되돌아오느냐입니다. 재봤습니다. 좋아요는 0.2초 만에 거절당하고, 피드를 다시 받아오는 데는 0.8초가 걸리는 상황이에요.
| 1241 로 바뀜 | 1240 으로 돌아옴 | |
|---|---|---|
| 되돌리기 있음 | 0초 | 0.2초 (거절당한 그 순간) |
| 되돌리기 없음 | 0초 | 1.0초 (피드를 다시 받은 뒤) |
0.8초 차이입니다. 그동안 사용자는 좋아요가 눌린 화면을 보고 있어요. 짧아 보이지만 그사이에 스크롤을 내리거나 다른 걸 누를 수 있는 시간입니다.
이제 이렇게 보입니다
다섯 번째 클릭에서 하트를 지켜보세요.
빨개졌다가, 다시 꺼집니다. 숫자도 1241로 올라갔다가 1240으로 내려와요.
🙋 학생 질문 — "실패했다고 사용자에게 알려주지 않아도 되나요?"
좋아요 같은 동작은 안 알려주는 쪽이 흔합니다. 하트가 도로 꺼지는 것 자체가 "안 됐다"는 신호거든요. 실패할 때마다 경고창이 뜨면 오히려 거슬려요.
하지만 사용자가 공들인 동작은 반드시 알려야 합니다. 긴 글을 쓰고 저장을 눌렀는데 조용히 실패하면, 사용자는 저장된 줄 알고 창을 닫아버려요.
기준을 하나 잡자면 이렇습니다. 되돌아간 것을 사용자가 눈치챌 수 있으면 안 알려도 되고, 눈치 못 챌 만한 것이면 알려야 합니다. 오늘 뒤에서 만들 댓글 삭제는 알려주는 쪽으로 갑니다.
💡 한 줄 정리
바꾸기 전 모습을 챙겨뒀다가 거절당하면 즉시 되돌리고, 성공이든 실패든 마지막에는 서버에 다시 물어봅니다.
Step 6: "loader와 useQuery가 만나면"
이제 게시물 상세 화면(/p/1)을 봅시다.
이 화면은 아직 지난 시간의 흉내 함수를 씁니다. 진짜 서버에 안 물어보고 코드 안 배열에서 찾아요. 게다가 우리가 홈에서 좋아요를 눌러도 이 화면은 모르는데, 창고와 아무 상관 없이 살고 있거든요.
이 화면에는 우리가 C-2에서 만든 것이 있는데, 화면이 뜨기 전에 데이터를 준비하는 loader요.
둘이 싸울 것 같지만
얼핏 보면 두 방식이 충돌합니다.
loader useQuery
───────────────────── ─────────────────────
화면 밖(라우터)에서 부른다 화면 안(컴포넌트)에서 부른다
화면이 뜨기 전에 끝난다 화면이 뜬 다음에 받는다
결과를 화면에 넘겨준다 결과를 창고에 보관한다
둘 다 데이터를 가져옵니다. 어느 쪽을 써야 할까요?
둘 다 씁니다. 하는 일이 다르거든요. loader는 "언제 가져오나"를 맡고, 창고는 "가져온 걸 어디에 두나"를 맡아요. 겹치지 않습니다.
loader가 창고를 채우고, 화면은 그 창고를 읽으면 됩니다.
이름을 한 곳에 모읍니다
그러려면 loader와 화면이 같은 이름표를 써야 해요. 한쪽이 ['posts', 1]이라고 적고 다른 쪽이 ['post', 1]이라고 적으면 서로 다른 칸을 보게 되므로, 이름표와 부를 함수를 한 덩어리로 묶어둡니다.
// apps/web-spa/src/queries/posts.ts
// 키와 부를 함수를 한 덩어리로 묶어 이름을 붙인다.
// 화면도 loader 도 이 하나를 가져다 쓴다 — 키가 어긋날 자리가 없어진다.
export function postQuery(id: number) {
return queryOptions({
queryKey: ['posts', id],
queryFn: () => fetchPostById(id),
});
}
queryOptions는 이 묶음을 만들어주는 함수예요. 그냥 객체를 만들어도 되지만, 이걸 쓰면 이름표와 함수의 타입이 서로 맞는지도 검사해줍니다.
loader가 창고를 채웁니다
// apps/web-spa/src/routes/postLoader.ts
export async function postLoader({ params }: LoaderFunctionArgs) {
const id = Number(params.postId);
// 주소는 사용자가 손으로 칠 수 있는 자리다. 무엇이든 들어온다.
if (Number.isNaN(id)) {
throw new Error(`게시물 번호가 아닙니다: ${params.postId}`);
}
try {
// "창고에 있으면 그걸 쓰고, 없으면 받아서 채워라."
// 두 가지를 한 번에 한다 — 창고를 채우고, 그 값을 돌려준다.
const post = await queryClient.ensureQueryData(postQuery(id));
return { post };
} catch (error) {
// 없는 번호면 서버가 404 와 사유를 함께 보낸다.
// 그것을 라우터가 아는 모양(Response)으로 바꿔 던진다.
if (error instanceof ApiError && error.status === 404) {
throw new Response(error.message, { status: 404 });
}
throw error;
}
}
ensureQueryData라는 이름이 하는 일을 그대로 말해줍니다. "이 데이터가 창고에 있는 것을 보장해라."
- 창고에 쓸 만한 값이 있으면 그것을 그대로 씁니다. 요청을 안 보내요.
- 없거나 낡았으면 받아와서 창고에 넣습니다.
- 어느 쪽이든 그 값을 돌려줍니다.
화면은 그것을 읽습니다
// apps/web-spa/src/routes/PostDetailPage.tsx
export function PostDetailPage() {
// 꺾쇠 안을 비우면 any 가 돌아온다. 타입 검사가 통과해버리니 아무도 안 알려준다.
// loader 가 무엇을 돌려주는지 알려주면 여기서부터 타입이 산다.
const { post: loaded } = useLoaderData<typeof postLoader>();
const { postId } = useParams();
const navigate = useNavigate();
// loader 가 받아온 것을 첫 값으로 건네준다.
// 그래서 이 화면에는 여전히 기다리는 갈래가 없다 — 처음부터 손에 있다.
// 그 뒤로는 창고가 맡는다. 좋아요를 눌러 창고가 갱신되면 여기도 따라 바뀐다.
const { data: post } = useQuery({
...postQuery(Number(postId)),
initialData: loaded,
});
initialData는 "이 값으로 시작해라"라는 뜻이에요. loader가 이미 받아온 것이 있으니 그걸 첫 값으로 줍니다.
이렇게 하면 C-2에서 얻었던 것을 그대로 지킬 수 있어요. 이 화면에는 여전히 "불러오는 중" 갈래가 없습니다. 화면이 그려지는 순간 게시물이 이미 손에 있으니까요.
새로 얻는 것도 있는데, 이 화면이 이제 창고에 연결돼서 홈에서 좋아요를 눌러 창고가 갱신되면 이 화면도 따라 바뀝니다.
재보면 이렇습니다
| 상황 | 요청 | 화면 |
|---|---|---|
/p/1에 처음 들어감 |
GET /api/posts/1 한 번 |
뜨자마자 게시물이 들어 있음 |
| 다른 화면 갔다가 다시 옴 | 0번 | 뜨자마자 게시물이 들어 있음 |
없는 번호 /p/999 |
한 번 (404 가 옴) | "없는 페이지예요 · 404 게시물을 찾을 수 없습니다" |
번호가 아닌 /p/abc |
0번 | "문제가 생겼어요" |
두 번째 줄을 보세요. 이미 받아둔 게시물이면 요청이 아예 안 나갑니다. 그런데 loader만 있던 시절에는 화면에 들어갈 때마다 무조건 다시 받았어요. 창고와 만나면서 그게 사라졌습니다.
마지막 줄도 보세요. 번호가 아니면 loader가 서버에 물어보기도 전에 막습니다. 쓸데없는 요청을 안 보내는 거예요.
🙋 학생 질문 — "loader에서 던진 404는 왜 Response 인가요?"
C-2에서 배운 그대로입니다. 라우터에게 "이건 오류라기보다 상태"라고 알리려면 번호를 함께 줘야 해요. Response로 던지면 오류 화면에서 isRouteErrorResponse로 갈래를 나눌 수 있습니다.
달라진 것은 그 안에 넣는 말입니다. 예전에는 우리가 직접 적었어요. 하지만 지금은 서버가 보낸 사유를 그대로 넣습니다.
throw new Response(error.message, { status: 404 });
서버가 "게시물을 찾을 수 없습니다"라고 하면 그 말이 화면에 뜹니다. 나중에 서버가 그 문구를 다듬으면 우리 코드는 안 고쳐도 돼요.
⚠️ 그리고 그 말은 반드시 본문에 담아야 합니다. statusText에 한글을 넣으면 만드는 순간 터져요. C-2에서 확인했던 그 함정입니다.
💡 한 줄 정리
loader는 언제 가져올지를 맡고 창고는 어디에 둘지를 맡으므로, loader가 창고를 채우고 화면이 그것을 읽으면 둘 다 쓸 수 있습니다.
Step 7: "서버가 거절한 삭제를 되돌린다"
좋아요는 실패해도 하트가 도로 꺼지는 것으로 충분했습니다. 이번에는 실패를 반드시 알려야 하는 동작을 봅니다.
상세 화면에 서버 댓글을 붙일 거예요. 1번 게시물에는 minji와 seungwoo가 쓴 댓글이 있습니다. 둘 다 남의 댓글이에요. 우리 서버는 남의 댓글을 지우려 하면 403으로 거절합니다.
좋아요와 똑같이 만듭니다
// apps/web-spa/src/queries/comments.ts
// 좋아요와 같은 결이다 — 먼저 지우고, 거절당하면 되돌린다.
// 다른 점은 되돌릴 것이 숫자가 아니라 목록의 한 줄이라는 것뿐이다.
export function useDeleteCommentMutation(postId: number) {
return useMutation({
mutationFn: deleteComment,
onMutate: async (commentId, context) => {
const key = commentsQuery(postId).queryKey;
await context.client.cancelQueries({ queryKey: key });
const previous = context.client.getQueryData<Comment[]>(key);
context.client.setQueryData<Comment[]>(key, (current) =>
current?.filter((comment) => comment.id !== commentId),
);
return { previous };
},
onError: (_error, _commentId, onMutateResult, context) => {
if (onMutateResult?.previous !== undefined) {
context.client.setQueryData(commentsQuery(postId).queryKey, onMutateResult.previous);
}
},
onSettled: (_result, _error, _commentId, _onMutateResult, context) => {
void context.client.invalidateQueries({ queryKey: commentsQuery(postId).queryKey });
},
});
}
Step 5에서 만든 것과 뼈대가 같습니다. 챙겨두고, 먼저 바꾸고, 거절당하면 되돌리고, 마지막에 다시 물어봐요.
다른 것은 딱 하나입니다. 좋아요는 숫자를 바꿨고 여기는 목록에서 한 줄을 뺍니다.
지우기 전에 물어봅니다
C-4에서 만든 확인 상자가 여기서 다시 나옵니다. 앱에 하나만 그려두고, 물어볼 말과 확인했을 때 할 일만 넘기면 됐죠.
// apps/web-spa/src/components/PostComments.tsx
export function PostComments({ postId }: PostCommentsProps) {
const { data: comments, isPending, error } = useCommentsQuery(postId);
const removeComment = useDeleteCommentMutation(postId);
// C-4 에서 만든 그 상자다. 무엇을 물어볼지만 넘기면 된다.
const ask = useConfirmStore((state) => state.ask);
삭제 버튼은 이렇게 부릅니다.
// apps/web-spa/src/components/PostComments.tsx
onClick={() => ask('댓글을 지울까요?', () => removeComment.mutate(comment.id))}
그 상자가 어떻게 생겼는지, 어디에 그려지는지 이 컴포넌트는 전혀 모릅니다. 물어봐 달라고 부탁만 해요.
실패는 알려줍니다
댓글 삭제는 되돌아가도 사용자가 눈치채기 어렵습니다. 목록이 잠깐 줄었다 늘어나는 걸 못 보고 지나칠 수 있어서, 서버가 보낸 사유를 알림으로 띄웁니다.
// apps/web-spa/src/components/PostComments.tsx
{removeComment.isError && (
<Toast
message={
removeComment.error instanceof ApiError
? removeComment.error.message
: '댓글을 지우지 못했어요'
}
/>
)}
removeComment.error에는 지난 시간에 만든 그 오류 객체가 들어 있어요. 인터셉터가 봉투에서 사유를 꺼내 담아둔 것입니다.
눌러보면 이렇게 됩니다
1번 게시물에 들어가서 minji의 댓글 옆 삭제 버튼을 누르세요.
확인 상자가 뜬다 "댓글을 지울까요?"
↓ 지우기
목록에서 사라진다 (아직 서버는 모른다)
↓ 0.3초
다시 나타난다 서버가 403 으로 거절했다
↓
알림이 뜬다 "내가 쓴 댓글만 지울 수 있습니다"
서버 쪽 댓글은 그대로 둘입니다. 아무것도 안 지워졌어요.
취소를 누르면 어떻게 될까요? 요청이 아예 안 나갑니다. 확인 상자가 앞에서 막아주니까요.
🙋 학생 질문 — "카드 안에도 댓글 목록이 있는데 왜 새로 만들었나요?"
피드 카드 안의 댓글 목록은 아직 안 보낸 초안을 그립니다. 입력창에 글을 쓰고 등록을 누르면 그 카드 안에만 쌓이는 목록이에요. 서버와 아무 상관이 없습니다.
그런데 오늘 만든 것은 서버에 있는 댓글입니다. 남이 쓴 것도 있고, 지우려면 권한이 필요하고요. 같은 "댓글"이라는 말을 쓰지만 성질이 완전히 다르기 때문에, 하나로 합치지 않고 상세 화면에 따로 붙였습니다. 실제 인스타그램도 피드에서는 댓글 몇 개만 보여주고 전체는 상세에서 보여주죠.
💡 한 줄 정리
되돌리는 뼈대는 좋아요와 똑같고, 사용자가 되돌아간 것을 눈치채기 어려운 동작이면 서버가 보낸 사유를 알림으로 띄웁니다.
Step 8: "실패를 화면 밖으로"
마지막으로 정리 한 번 하겠습니다.
지금 우리 화면들을 보세요. 홈도, 탐색도, 댓글 목록도 전부 이렇게 생겼습니다.
실패했나? → 오류 문구를 그린다
기다리는 중? → 기다리는 문구를 그린다
아니면 → 진짜 화면을 그린다
화면이 늘어날 때마다 이 세 갈래를 다시 쓰는데, 실패했을 때 보여주는 것은 대체로 비슷합니다. "문제가 생겼어요" 하고 홈으로 가는 링크 하나요.
우리는 그런 화면을 이미 갖고 있습니다. C-2에서 만든 오류 화면이요.
위로 던지게 합니다
읽다가 실패하면 화면에서 처리하지 말고 위로 던지도록 정합니다. 창고를 만들 때 한 번만 적으면 돼요.
// apps/web-spa/src/queries/queryClient.ts
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: STALE_TIME_MS,
gcTime: GC_TIME_MS,
// 읽다가 실패하면 화면에서 처리하지 말고 위로 던진다.
// 라우트마다 세워둔 ErrorBoundary 가 그것을 받는다.
// 쓰기(뮤테이션)는 이 값을 안 따른다 — 실패해도 그 자리에서 알려야 한다.
throwOnError: true,
},
},
});
그러면 화면에서 오류 갈래를 지울 수 있습니다.
// apps/web-spa/src/routes/HomePage.tsx
export function HomePage() {
// 갈래가 또 하나 줄었다. 실패는 이제 이 화면이 처리하지 않는다.
//
// 그런데 타입은 아직 실패를 안다 — 위로 던지기로 한 것은 우리 약속이지
// 타입이 아는 사실이 아니다. 그래서 "성공했을 때만" 이라고 물어본다.
const { data: posts, isSuccess } = useFeedQuery();
if (!isSuccess) {
return <p className="text-sm text-faint">피드를 불러오는 중이에요…</p>;
}
return <FeedSection posts={posts} />;
}
⚠️ 여기서 하나 걸립니다
처음에는 이렇게 썼습니다. 오류 갈래만 지우고 isPending은 그대로 뒀어요.
const { data: posts, isPending } = useFeedQuery();
if (isPending) {
return <p>피드를 불러오는 중이에요…</p>;
}
return <FeedSection posts={posts} />;
화면은 잘 도는데, 타입 검사가 막았어요.
Type 'Post[] | undefined' is not assignable to type 'Post[]'.
이유를 생각해보면 맞는 말입니다. isPending이 false라는 건 "기다리는 중이 아니다" 일 뿐이에요. 성공했을 수도 있고 실패했을 수도 있는데, 실패한 경우에는 데이터가 없죠.
우리는 "실패하면 위로 던지니까 여기까지 안 온다"는 걸 알지만, 그건 우리끼리의 약속일 뿐이라 타입은 그 약속을 모릅니다.
그 대신 isPending 대신 isSuccess로 물어보는데, "성공했을 때만 그린다" 고 하면 타입도 납득해요.
재밌는 건 갈래가 오히려 줄었다는 겁니다. 원래 둘(기다리는 중 / 실패)이던 것이 하나(성공이 아님)가 됐어요.
쓰기는 위로 안 던집니다
throwOnError는 queries에만 준 값입니다. 뮤테이션은 안 따라요.
이유가 있습니다. 읽기 실패와 쓰기 실패는 사용자에게 다른 일이거든요.
읽기가 실패했다 쓰기가 실패했다
───────────────── ─────────────────
보여줄 게 없다 보고 있던 건 그대로 있다
화면 전체가 의미 없다 한 가지 동작만 안 됐다
→ 화면을 통째로 갈아준다 → 보던 화면에서 알려준다
피드를 못 받아오면 보여줄 게 없으니 화면을 통째로 오류 화면으로 갈아주는 게 맞습니다. 하지만 댓글 삭제가 거절당했다고 화면을 통째로 갈아버리면 사용자는 보고 있던 게시물을 잃어요.
재봤습니다. 남의 댓글을 지우려다 403을 받아도 오류 화면으로 안 넘어갑니다. 게시물은 그대로 있고 알림만 떠요.
실패해도 바로 포기하지 않습니다
한 가지 더. 읽기가 실패하면 곧바로 오류 화면이 뜰 것 같지만 아닙니다.
기본값이 세 번 더 물어보게 돼 있어서 요청이 모두 네 번 나가는데, 그 사이사이 조금씩 기다렸다가 보내요.
네트워크가 잠깐 끊겼거나 서버가 순간적으로 바빴던 것뿐이라면 두 번째나 세 번째에 성공합니다. 사용자는 오류 화면을 아예 못 보고 지나가요.
🙋 학생 질문 — "오류 화면에는 왜 머리말이 없나요?"
C-2·C-3에서 확인했던 그대로입니다. 오류 화면은 껍데기를 대신하기 때문이에요.
우리 라우트 표를 보면 오류 화면이 Layout과 짝을 이루고 있는데, 아래에서 무엇이 던져지든 Layout을 통째로 대체하기 때문에 메뉴도 밝기 고르개도 안 보입니다.
대신 홈으로 가는 링크를 하나 두었죠. 그게 없으면 사용자가 갇힙니다.
C-3에서 화면 밝기를 라우터 바깥으로 올린 것도 이 때문이었어요. 오류 화면에서도 어두운 화면은 어두워야 하니까요.
💡 한 줄 정리
읽기 실패는 보여줄 것 자체가 없으니 화면 밖으로 던져 오류 화면에 맡기고, 쓰기 실패는 보던 화면을 지키며 그대로 알립니다.
마무리
오늘 한 일을 한 줄로 줄이면 이렇습니다. 화면이 자기 숫자를 세는 것을 그만두고, 대신 먼저 보여주고 아니면 되돌리기로 바꿨습니다.
오늘 배운 핵심 세 가지
💡 하나 — 같은 값을 두 곳이 들고 있으면 언젠가 갈라집니다. 그리고 그 갈라짐은 조용해요. 에러도 경고도 없이 그냥 틀린 숫자가 보입니다. 서버에서 온 값은 창고 하나만 들고 있게 합니다.
💡 둘 — 빠른 앱은 서버가 빨라서 빠른 게 아닙니다. 우리는 서버를 하나도 안 빠르게 했는데 하트가 즉시 반응하게 만들었어요. 먼저 보여주고 뒤에서 확인하기 때문입니다. 대신 아니라고 하면 반드시 되돌려야 합니다.
💡 셋 — 실패에도 종류가 있습니다. 보여줄 것 자체가 없는 실패는 화면을 통째로 갈고, 한 가지 동작만 안 된 실패는 보던 화면을 지키며 알립니다. 이 구분이 코드 한 줄(throwOnError)로 표현됩니다.
오늘 닫은 것
| 지난 시간에 넘긴 것 | 닫힌 곳 |
|---|---|
| 좋아요를 서버에 보내기 | ✅ Step 1 |
| 쓰고 나면 읽어둔 것이 낡는다 | ✅ Step 2 |
| 기다리는 동안 화면을 어떻게 할지 | ✅ Step 4 |
| 삭제가 실패하면 화면 되돌리기 | ✅ Step 5·7 |
| 상세 화면의 흉내 함수와 캐시의 화해 | ✅ Step 6 |
| 확인 상자를 다시 쓰기 | ✅ Step 7 |
다음 시간 예고
오늘 우리는 "빨라 보이게" 만들었는데, 그건 눈속임에 가까웠습니다. 실제로 빠르게 만든 것은 아니거든요.
다음 시간에는 진짜로 빠른지 재봅니다.
여러분은 아마 이런 조언을 들어보셨을 거예요. "다시 그려지는 걸 막으려면 값을 기억해둬라." 저는 오늘까지 그런 이야기를 한 번도 안 했습니다. 이유가 있어요. 지금 그 일을 사람이 안 하고 있거든요. 빌드할 때 자동으로 처리하는 도구가 이미 우리 설정에 들어가 있습니다.
다음 시간에는 그게 실제로 일하고 있는지 도구로 확인하고, 그러고도 남는 느린 곳을 어떻게 찾는지 봅니다. 그때 처음으로 손으로 기억해두는 방법도 배우는데, 순서가 중요합니다. 재보고 나서 쓰는 것이지, 미리 쓰는 게 아니거든요.
그밖에도 오늘 내내 걸리는 게 하나 있었을 겁니다. 우리는 우리가 누를 때만 화면이 바뀝니다. 다른 사람이 내 게시물에 좋아요를 누르면? 우리는 모릅니다. 다시 물어보기 전까지는요.
지난 시간 과제에서 "실시간 경매 최고가"를 만났을 때 이야기했던 그 한계예요. 물어봐야만 아는 방식으로는 못 푸는 문제가 있습니다. 그건 서버가 우리에게 먼저 말을 걸어야 풀려요. 그 이야기는 조금 더 뒤에서 합니다.
과제
[구현] 좋아요를 상세 화면에서도
/p/1로 들어가면 좋아요 개수는 보이는데 누를 수가 없습니다. 하트를 붙여보세요.
- 이미 만들어둔
useLikeMutation을 그대로 씁니다. 새로 만들 것이 없어요. - 다 만들었으면 홈에서 하트를 누르고 상세로 들어가 보세요. 숫자가 맞나요?
- 반대로 상세에서 누르고 홈으로 나가 보세요. 이쪽은 어떤가요?
- ⚠️ 한쪽이 안 맞을 겁니다. 왜 그런지 창고를 열어보고 한 줄로 적어주세요.
[구현] 실패를 눈에 보이게
좋아요가 실패하면 지금은 하트가 조용히 꺼집니다. 다섯 번에 한 번은 실패하는데 눈치채기 어려워요.
실패했을 때 알림을 띄우도록 바꿔보세요.
- 알림은 이미 피드에 있습니다. 어떤 값을 보고 띄울지만 정하면 돼요.
- 서버가 보낸 사유를 그대로 띄웁니다. 우리가 문구를 지어내지 마세요.
- 다 만들었으면 하트를 다섯 번 눌러 확인하세요.
[탐구] 세 콜백을 하나씩 지워보기
useLikeMutation에서 다음을 하나씩 지우고 무엇이 달라지는지 적어주세요. 하나 확인하면 되돌려놓고 다음 것으로 갑니다.
| 지운 것 | 무엇이 달라지나 |
|---|---|
cancelQueries 한 줄 |
|
onError 통째로 |
|
onSettled를 onSuccess로 |
⚠️ 두 번째와 세 번째는 끝난 뒤의 숫자만 보면 차이가 안 보일 수 있습니다. 눌러서 답이 올 때까지 화면을 계속 지켜보세요. 창고를 열어두고 보셔도 좋아요.
[탐구] 무효화의 범위
지금 우리는 ['posts']로 무효화합니다. 다음 셋을 각각 넣고 탐색 화면(/explore)에서 태그를 하나 고른 뒤 하트를 눌러보세요.
{ queryKey: ['posts'] }{ queryKey: feedKey() }{ queryKey: ['posts'], exact: true }
어떤 요청이 나가고 안 나가는지 세어보고, 왜 그런지 이름표 모양과 연결해서 적어주세요.
[설계]이 동작들은 먼저 보여줘도 될까요
아래 다섯 가지에 대해 "먼저 보여주고 나중에 확인"을 써도 될지 정하고, 이유를 한 문장씩 적으세요.
- 게시물 좋아요
- 댓글 달기
- 프로필 사진 바꾸기
- 계좌 이체
- 장바구니에 담기
⚠️ 판단 기준을 스스로 한 줄로 만들어 보세요. "실패했을 때 되돌리는 비용"을 생각해보시면 실마리가 잡힙니다.
생각해볼 주제
1. 낙관적 업데이트는 사용자에게 거짓말일까요
우리는 서버가 답하기도 전에 "됐습니다"라고 보여줍니다. 아직 안 된 일을 된 것처럼 보여주는 거죠.
대부분은 실제로 성공하니까 괜찮다고 볼 수도 있습니다. 하지만 실패했을 때 사용자는 한 번 "됐다" 고 믿었다가 뒤집히는 경험을 해요.
어떤 화면에서 이 방식이 정직하고, 어떤 화면에서 정직하지 않을까요, 그리고 그 경계를 팀에서 어떻게 합의해두면 좋을까요.
2. 무효화를 넓게 할까요 좁게 할까요
우리는 좋아요 하나를 눌러놓고 ['posts']로 시작하는 모든 것을 다시 물어봅니다. 태그별 피드도, 게시물 하나짜리도 전부요.
좁게 잡으면 요청이 줄지만, 어딘가 갱신 안 된 화면이 남을 수 있습니다. 넓게 잡으면 안전하지만 요청이 늘어요.
여러분이라면 어느 쪽을 기본으로 잡겠습니까, 그리고 서비스가 커져서 화면이 백 개쯤 됐을 때는 그 결정이 어떻게 달라질까요.
3. 서버가 준 답을 그대로 믿어도 될까요
좋아요를 누르면 서버가 새 개수를 돌려주는데, 우리는 그 값을 창고에 바로 넣지 않고 무효화해서 피드를 통째로 다시 받아왔습니다.
서버가 준 답을 그대로 창고에 넣으면 요청 한 번을 아낄 수 있습니다. 실제로 그렇게 하는 코드도 많아요.
어느 쪽이 나을까요, 그리고 "그대로 넣기"를 택했을 때 조심해야 할 것은 무엇일까요.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 좋아요를 상세 화면에서도
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 이미 있는 훅을 그대로 씀 | 20 | useLikeMutation을 새로 만들지 않고 가져다 씀 |
| 하트가 실제로 서버에 감 | 20 | 누르면 POST /api/posts/1/like가 한 번 나감 |
| 홈에서 누른 것이 상세에 반영됨 | 15 | 상세로 들어가면 맞는 숫자가 보임 |
| 안 맞는 쪽을 정확히 짚음 | 30 | 상세에서 누르면 즉시 안 바뀐다는 것을 확인 |
| 그 이유를 이름표로 설명함 | 15 | 낙관적 갱신이 ["posts"]만 고친다는 것 |
풀이 예시
// apps/web-spa/src/routes/PostDetailPage.tsx
export function AnswerPostDetail() {
const { post: loaded } = useLoaderData<typeof postLoader>();
const { postId } = useParams();
const navigate = useNavigate();
const likeMutation = useLikeMutation();
const { data: post } = useQuery({
...postQuery(Number(postId)),
initialData: loaded,
});
return (
<Section title="게시물">
<button onClick={() => navigate(-1)}>뒤로</button>
<p>{post.content}</p>
<LikeButton
liked={post.liked}
likeCount={post.likeCount}
onToggle={() => likeMutation.mutate(post.id)}
/>
</Section>
);
}
새로 만든 것이 하나도 없다는 게 핵심입니다. 훅도, API 함수도, 버튼도 전부 있던 것을 가져다 썼어요.
안 맞는 쪽은 "상세에서 누를 때"입니다
두 방향을 각각 눌러보면 이렇게 갈립니다.
| 어디서 눌렀나 | 그 화면이 즉시 바뀌나 |
|---|---|
| 홈에서 눌렀다 | ✅ 누른 순간 바뀜 |
| 상세에서 눌렀다 | ❌ 0.4초 기다린 뒤에 바뀜 |
이유는 낙관적 갱신이 손대는 이름표에 있습니다. 우리 onMutate는 이렇게 돼 있어요.
context.client.setQueryData<Post[]>(feedKey(), ...)
feedKey()는 ["posts"], 즉 피드 목록의 이름표입니다. 그런데 상세 화면이 보는 것은 ["posts", 1]이라 서로 다른 칸이에요.
창고를 열어놓고 상세에서 하트를 누르면 이게 보입니다.
| 이름표 | 누른 직후 |
|---|---|
["posts"] |
1241 로 바뀜 |
["posts", 1] |
1240 그대로 |
그러다 onSettled의 무효화가 돌고 상세 화면이 다시 물어봐서 1241이 되니, 결국은 맞지만 즉시는 아닌 거예요.
반대 방향은 왜 맞을까요
홈에서 누르면 무효화가 ["posts"]로 시작하는 것을 전부 잡습니다. ["posts", 1]도 거기 포함돼요.
한 가지 더 있는데, 그때 상세 화면은 안 떠 있잖아요. 무효화는 아무도 안 보고 있는 것을 굳이 다시 안 물어봅니다. 낡았다는 표시만 붙여둬요.
재보면 이렇습니다.
홈에서 하트를 누른다
↓
["posts", 1] 에 "낡음" 표시가 붙는다 값은 1240 그대로 (요청 0번)
↓
상세로 들어간다
↓
표시가 붙어 있으니 뜨면서 다시 물어본다 1241 로 맞춰진다
자주 나오는 실수
postQuery를 안 쓰고 새로 이름표를 짓는 경우. useQuery({ queryKey: ['post', postId], ... })처럼 쓰면 loader가 채운 칸과 다른 칸을 보게 되면서 화면이 뜰 때 "불러오는 중"이 다시 생겨요. 이름표를 한 곳에 모아둔 이유가 이겁니다.
initialData를 빼는 경우. 타입 검사가 막습니다. data가 Post | undefined가 되거든요.
💡 튜터의 한마디
이 과제의 진짜 목적은 "안 맞는 쪽을 찾는 것" 이었습니다. 만들어놓고 홈에서만 눌러봤다면 아무 문제도 못 느꼈을 거예요.
실무에서 이런 게 제일 무섭습니다. 대부분의 경로에서는 잘 되는데 특정 경로에서만 반응이 굼뜬 거요. 버그 리포트도 "가끔 느려요" 같은 말로 옵니다.
창고를 열어보는 습관이 여기서 값을 합니다. 화면만 보면 "왜 느리지"로 끝나는데, 이름표 두 개의 값을 나란히 보면 원인이 한눈에 보이거든요.
🎯 [과제 2 예시답안] 실패를 눈에 보이게
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 어떤 값을 보고 띄울지 정확함 | 25 | isError를 봄 |
| 서버가 보낸 사유를 그대로 씀 | 30 | 문구를 지어내지 않음 |
| 모르는 오류에 대비함 | 25 | ApiError가 아닐 때 우리 말로 대신함 |
| 실패했을 때만 뜸 | 20 | 평소에는 아무것도 안 그림 |
풀이 예시
// apps/web-spa/src/components/FeedSection.tsx
export function likeErrorMessage(error: unknown): string {
return error instanceof ApiError ? error.message : '좋아요를 저장하지 못했어요';
}
이렇게 만든 값은 피드에서 알림으로 넘깁니다.
// apps/web-spa/src/components/FeedSection.tsx
{likeMutation.isError && <Toast message={likeErrorMessage(likeMutation.error)} />}
왜 사유를 지어내면 안 되나요
우리가 "좋아요에 실패했어요"라고 적어두면 서버가 어떤 이유로 거절했든 늘 같은 말이 뜹니다. 그런데 실제 서버는 이유를 구분해서 보내요.
| 상황 | 서버가 보내는 말 |
|---|---|
| 저장 실패 (500) | 좋아요를 저장하지 못했습니다 |
| 로그인 안 됨 (401) | 로그인이 필요합니다 |
| 없는 게시물 (404) | 게시물을 찾을 수 없습니다 |
우리가 문구를 지어내면 이 구분이 화면에서 사라집니다. 사용자는 로그인이 필요한 건지 서버가 잠깐 아픈 건지 알 수가 없어요.
그런데 대비는 필요합니다
error는 타입이 unknown이라 곧바로 .message를 못 꺼내고, 실제로 우리 오류가 아닌 것이 올 수도 있어요. 코드가 터졌다거나요.
그 안에서 instanceof로 확인해 우리 오류가 맞을 때만 그 말을 쓰고, 아니면 우리 말로 대신합니다. A-2에서 배운 좁히기가 여기서 그대로 쓰여요.
자주 나오는 실수
error?.message로 짧게 쓰려는 경우. unknown에는 옵셔널 체이닝도 안 됩니다. 타입 검사가 막아요. 좁히기 없이는 아무것도 못 꺼내는 게 unknown의 성질입니다.
isError 대신 error !== null을 쓰는 경우. 동작은 같지만 isError 쪽이 더 읽기 쉬워요.
💡 튜터의 한마디
오늘 배운 것 중에 실무에서 가장 자주 어기는 규칙이 이겁니다. 오류 문구를 프런트에서 지어내는 거요.
급할 때는 편한데, 서버가 뭘 보내는지 안 봐도 되기 때문이에요. 몇 달 지나면 사용자 문의가 "좋아요가 안 돼요"처럼 오는데, 화면이 이유를 안 보여줬으니 그게 왜 안 되는지는 로그를 파봐야만 알 수 있어요.
🎯 [과제 3 예시답안] 세 콜백을 하나씩 지워보기
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
cancelQueries 제거 시 되돌아감을 관찰 |
30 | 값이 한 번 되돌아갔다 다시 온다는 것 |
onError 제거 시 늦게 돌아옴을 관찰 |
30 | 결국은 맞지만 훨씬 늦다는 것 |
onSettled → onSuccess 차이 |
25 | 실패하면 다시 안 물어본다는 것 |
| 끝난 뒤 값만 보면 안 된다는 것을 알아챔 | 15 | 가는 길을 봐야 갈린다는 것 |
답
| 지운 것 | 무엇이 달라지나 |
|---|---|
cancelQueries 한 줄 |
나가 있던 피드 요청이 늦게 도착하면서 우리가 바꾼 값을 한 번 덮어쓴다. 하트가 빨개졌다 잠깐 꺼졌다 다시 켜진다. |
onError 통째로 |
거절당해도 즉시 안 되돌아온다. 뒤이어 도는 무효화가 서버 값을 가져올 때까지 틀린 화면이 남는다. |
onSettled → onSuccess |
실패했을 때 서버에 다시 안 물어본다. 되돌린 값이 맞는지 확인할 길이 없어진다. |
세 개를 값의 흐름으로 보면
cancelQueries를 지웠을 때 (피드를 다시 물어보는 중에 하트를 누른 경우)
있을 때 1241 ─────────────────────────
없을 때 1241 ── 1240 ── 1241
↑
늦게 온 옛 답이 덮어썼다
onError를 지웠을 때 (좋아요가 0.2초 만에 거절당하고, 피드는 0.8초 걸리는 경우)
있을 때 1241 ── 1240 0.2초에 돌아온다 (거절당한 그 순간)
없을 때 1241 ──────── 1240 1.0초에 돌아온다 (피드를 다시 받은 뒤)
onSuccess로 바꿨을 때
성공하면 아무 차이가 없습니다. 실패했을 때만 갈려요. 실패한 뒤 피드 요청이 안 나갑니다.
⚠️ 끝난 뒤의 값만 보면 셋 다 똑같아 보입니다
이 과제에서 제일 중요한 게 이겁니다. 눌러놓고 잠깐 뒤에 화면을 보면 어느 경우든 서버 값으로 맞아 있어요.
cancelQueries를 지워도 마지막엔 1241입니다. onError를 지워도 마지막엔 1240 이고요.
갈리는 것은 가는 길입니다. 사용자가 그 길을 지나가면서 무엇을 보느냐요. 잘못된 값을 0.2초 보느냐 1초 보느냐, 하트가 한 번 깜빡이느냐 안 깜빡이느냐가 다릅니다. 따라서 이런 것을 확인할 때는 결과가 아니라 과정을 봐야 해요. 창고를 열어두고 값이 바뀌는 순서를 지켜보시면 됩니다.
자주 나오는 실수
셋을 한꺼번에 지우는 경우. 무엇 때문에 달라졌는지 알 수 없게 됩니다. 하나 지우고 확인하고 되돌려놓은 다음 다음 것으로 가세요.
cancelQueries 차이를 못 보는 경우. 이건 나가 있는 요청이 있어야만 보입니다. 그냥 하트만 누르면 차이가 없어요. 피드를 다시 물어보게 만들어 두고 그 사이에 눌러야 합니다.
💡 튜터의 한마디
가드를 만들었으면 일부러 깨보세요. 이건 제가 실제로 하는 방식이기도 합니다.
이 세 줄은 다 있으면 조용히 잘 돌아가고 없어도 대충 돌아가서, "이게 진짜 필요한가"를 확인하려면 빼보는 수밖에 없습니다.
빼봤는데 아무것도 안 달라진다면 둘 중 하나예요. 정말 필요 없거나, 아니면 여러분이 보는 방법이 틀렸거나. 이번 경우는 후자였죠. 결과만 보면 안 보이고 과정을 봐야 보였습니다.
🎯 [과제 4 예시답안] 무효화의 범위
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| ①과 ②가 같다는 것을 알아챔 | 30 | feedKey()가 ['posts']라는 것 |
| 앞부분 일치로 잡힌다는 것을 설명 | 30 | 이름표의 앞쪽만 본다는 것 |
exact의 뜻을 정확히 씀 |
25 | 딱 그것 하나만 |
| 요청이 안 나가는 경우를 구분 | 15 | 안 보는 것은 표시만 붙음 |
답
창고에 이름표 셋이 있는 상태에서 재봤습니다.
| 넣은 것 | 잡히는 이름표 |
|---|---|
① { queryKey: ['posts'] } |
["posts"] · ["posts",{"tag":"한강"}] · ["posts",1] |
② { queryKey: feedKey() } |
①과 완전히 같음 |
③ { queryKey: ['posts'], exact: true } |
["posts"] 하나만 |
①과 ②가 같은 이유
feedKey()를 인자 없이 부르면 돌려주는 것이 ['posts']이므로, ①과 ②는 글자만 다르고 완전히 같은 코드예요.
// apps/web-spa/src/queries/posts.ts
export function feedKey(tag?: string) {
return tag === undefined ? (['posts'] as const) : (['posts', { tag }] as const);
}
만약 feedKey('한강')을 넣었다면 달라집니다. 그건 ['posts', { tag: '한강' }]이라서 한강 태그 피드 하나만 잡아요.
앞부분만 봅니다
무효화는 이름표를 앞에서부터 견줍니다. 준 것이 ['posts'] 면 ['posts']로 시작하는 모든 이름표가 걸려요.
준 것: ["posts"]
["posts"] → 걸린다 (앞부분이 같다)
["posts", {tag: "한강"}] → 걸린다
["posts", 1] → 걸린다
["posts", 1, "comments"] → 걸린다
["tags"] → 안 걸린다
그래서 이름표를 지을 때 넓은 것부터 좁은 것 순서로 늘어놓습니다. ['posts', 1, 'comments']처럼요. 이렇게 지어두면 "그 게시물에 딸린 것 전부"를 한 번에 잡을 수 있어요.
exact: true를 주면 앞부분 견주기를 안 하고 통째로 같은 것만 찾습니다.
그런데 잡혔다고 요청이 나가는 건 아닙니다
여기서 한 겹 더 있습니다. 잡힌 이름표에 낡음 표시가 붙는 것과, 실제로 다시 물어보는 것은 다른 일이에요.
지금 화면에 떠 있어서 누군가 보고 있는 이름표는 곧바로 다시 물어봅니다. 아무도 안 보는 것은 표시만 붙고 요청은 안 나가요. 나중에 그 화면에 들어가는 순간 물어봅니다.
이건 낭비를 줄이는 방식입니다. 창고에 이름표가 백 개 있는데 좋아요 한 번에 백 개를 다 다시 받아오면 안 되니까요.
자주 나오는 실수
['posts']와 'posts'를 헷갈리는 경우. 이름표는 반드시 배열입니다. 문자열을 넣으면 타입 검사가 막아요.
태그별 피드가 왜 걸리는지 모르는 경우. ['posts', { tag: '한강' }]의 첫 칸이 'posts'라서 그렇습니다. 둘째 칸이 객체든 숫자든 상관없어요. 앞에서부터 견주다가 준 것이 끝나면 거기까지만 봅니다.
💡 튜터의 한마디
이름표를 어떻게 짓느냐가 나중에 무효화를 얼마나 편하게 하느냐를 정합니다.
['posts', 1, 'comments']처럼 넓은 것에서 좁은 것으로 내려가게 지어두면, 나중에 "이 게시물과 관련된 것 전부"도 되고 "이 게시물의 댓글만"도 됩니다. 반대로 ['comments-of-post-1']처럼 한 덩어리로 지어두면 그런 게 안 돼요.
지금은 이름표가 셋뿐이라 감이 안 오실 수 있습니다. 서른 개쯤 되면 이 결정이 매일 값을 합니다.
🎯 [과제 5 예시답안]이 동작들은 먼저 보여줘도 될까요
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 다섯 개에 대해 판단과 이유를 씀 | 30 | 각각 한 문장씩 |
| 계좌 이체를 정확히 걸러냄 | 25 | 되돌릴 수 없는 동작 |
| 판단 기준을 스스로 한 줄로 만듦 | 30 | 되돌리는 비용 관점 |
| 애매한 것에 조건을 붙임 | 15 | "이럴 때만" 을 구분 |
답
| 동작 | 먼저 보여줘도 되나 | 이유 |
|---|---|---|
| 게시물 좋아요 | ✅ 된다 | 실패해도 하트가 도로 꺼지면 끝. 잃는 게 없다 |
| 댓글 달기 | 🌟 조건부 | 실패하면 쓴 글이 사라지므로, 내용을 안 날리는 장치가 함께 있어야 한다 |
| 프로필 사진 바꾸기 | ✅ 된다 | 되돌리면 옛 사진이 다시 뜰 뿐이다 |
| 계좌 이체 | ❌ 안 된다 | 되돌릴 수 없고, 됐다고 믿은 사용자가 다른 행동을 한다 |
| 장바구니에 담기 | ✅ 된다 | 되돌려도 손해가 없고, 담는 동작은 자주 일어나 즉시 반응이 중요하다 |
판단 기준 한 줄
"실패해서 되돌렸을 때 사용자가 잃는 것이 있는가."
이 질문으로 다섯 개가 전부 갈립니다.
- 좋아요·프로필 사진·장바구니: 되돌리면 그냥 원래대로입니다. 잃는 게 없어요.
- 댓글: 되돌리면 쓴 글이 사라집니다. 그게 손해예요.
- 이체: 되돌릴 수가 없습니다. 사용자가 "보냈다" 고 믿고 상대에게 연락했다면 이미 늦었어요.
댓글이 왜 조건부인가
댓글은 먼저 보여줘도 됩니다. 실제로 그렇게 하는 서비스가 많지만, 실패했을 때 입력창을 비우면 안 됩니다.
나쁜 흐름 좋은 흐름
────────────── ──────────────
등록을 누른다 등록을 누른다
목록에 뜬다 목록에 뜬다 (흐리게)
입력창이 비워진다 입력창은 그대로 둔다
실패한다 실패한다
목록에서 사라진다 목록에서 사라진다
→ 쓴 글이 어디에도 없다 → 입력창에 그대로 있다
먼저 보여주기를 쓰려면 "되돌릴 준비"가 함께 있어야 한다는 뜻입니다. 되돌릴 준비가 안 되면 그냥 기다리는 게 나아요.
이체는 왜 절대 안 되나
기술적으로는 할 수 있습니다. 화면에 "이체 완료"를 먼저 띄우고 실패하면 되돌리면 돼요.
문제는 사용자가 그 0.5초 사이에 행동한다는 겁니다. 스크린샷을 찍어 보내거나, 상대에게 "보냈어"라고 연락하거나요. 되돌아간 화면을 사용자가 다시 볼 거라는 보장이 없습니다.
돈이나 계약처럼 되돌릴 수 없는 동작은 서버가 확실히 답한 뒤에 보여줘야 합니다. 대신 기다리는 동안 무슨 일이 일어나고 있는지 성실하게 알려주는 쪽으로 갑니다.
자주 나오는 실수
"실패 확률이 낮으니까 다 먼저 보여주자"는 판단. 확률이 아니라 실패했을 때의 크기가 기준입니다. 천 번에 한 번 실패하는 이체도 그 한 번이 치명적이에요.
"되돌리면 되니까 괜찮다"는 판단. 되돌리는 것과 사용자가 그걸 아는 것은 다릅니다. 되돌아간 것을 사용자가 못 볼 수 있어요.
💡 튜터의 한마디
이 판단은 사실 프런트엔드만의 문제가 아닙니다. "얼마나 낙관해도 되는가"는 제품을 만드는 사람이 늘 내리는 결정이에요.
면접에서 이 이야기가 나오면 기술 이야기로만 답하지 마세요. onMutate를 안다는 것보다 "이 동작은 되돌릴 수 있는가"를 먼저 묻는다는 태도가 훨씬 좋은 인상을 줍니다.
🤔 [생각해볼 주제 1] 낙관적 업데이트는 사용자에게 거짓말일까요
문제 상황 요약
우리는 서버가 답하기도 전에 "됐습니다"라고 보여줍니다. 대부분은 실제로 성공하니 괜찮아 보이지만, 실패하면 사용자는 한 번 믿었다가 뒤집히는 경험을 합니다.
튜터의 가이드 및 해설
거짓말이냐 아니냐로 나누면 답이 안 나옵니다. "어떤 종류의 약속인가"로 보는 게 낫습니다.
낙관적 업데이트는 "이미 됐다"는 통보가 아니라 "이렇게 될 예정이다"는 예고입니다. 문제는 화면이 그 둘을 똑같이 그린다는 거예요. 하트가 빨개진 화면은 "저장됐다"인지 "저장하는 중"인지 구분해주지 않습니다.
그래서 갈리는 기준을 이렇게 잡습니다.
하나 — 되돌아간 것을 사용자가 반드시 보게 되는가. 좋아요는 화면에 계속 떠 있으니 되돌아가는 것을 봅니다. 그런데 누르자마자 다른 화면으로 넘어가는 동작이라면 되돌아간 것을 영영 못 봐요. 그런 동작에는 쓰면 안 됩니다.
둘 — 사용자가 그 사이에 무언가를 하는가. 이체가 안 되는 이유가 이겁니다. 0.5초 안에 사용자가 스크린샷을 찍고 상대에게 보냅니다. 우리가 되돌려도 그 행동은 안 되돌아가요.
셋 — 실패를 얼마나 정직하게 알리는가. 이게 거짓말이냐 아니냐를 실제로 가릅니다. 조용히 되돌리고 넘어가면 거짓말에 가깝고, 되돌리면서 이유를 알려주면 예고였던 게 됩니다.
실무에서는 중간 표현을 쓰기도 합니다. 저장되는 동안 그 항목을 살짝 흐리게 그려서 "아직 확정이 아니다"를 시각적으로 말해주는 거죠. 통신사 메시지 앱에서 전송 중인 메시지가 흐리게 보이는 것과 같은 방식입니다.
팀에서 합의할 때는 동작 목록을 만들어놓고 위 세 질문을 통과시키는 게 현실적입니다. "우리 서비스에서 낙관적으로 처리하는 동작" 목록을 문서로 두고, 새 기능이 들어올 때마다 거기에 넣을지 정하는 거예요.
🎯 면접관을 홀리는 핵심 멘트
"낙관적 업데이트는 '됐다'는 통보가 아니라 '이렇게 될 예정'이라는 예고라고 봅니다. 그래서 저는 도입 전에 두 가지를 확인해요. 되돌아간 것을 사용자가 반드시 보게 되는가, 그리고 그 사이에 사용자가 되돌릴 수 없는 행동을 하는가. 둘 중 하나라도 아니면 그냥 기다리게 합니다. 빠른 것보다 틀리지 않은 게 먼저니까요."
🤔 [생각해볼 주제 2] 무효화를 넓게 할까요 좁게 할까요
문제 상황 요약
좋아요 하나를 눌러놓고 ['posts']로 시작하는 모든 것을 다시 물어봅니다. 좁게 잡으면 요청이 줄지만 갱신 안 된 화면이 남을 수 있고, 넓게 잡으면 안전하지만 요청이 늘어납니다.
튜터의 가이드 및 해설
기본은 넓게입니다. 이유가 셋 있어요.
하나 — 틀린 화면의 비용이 요청 한 번보다 큽니다. 좁게 잡았다가 어딘가 갱신을 빠뜨리면 사용자는 틀린 숫자를 봅니다. 그리고 그건 조용히 일어나요. 반면 요청이 한 번 더 나가는 것은 눈에 보이고 재볼 수 있습니다.
둘 — 넓은 무효화가 생각만큼 안 비쌉니다. 오늘 확인했듯이 지금 아무도 안 보는 이름표는 표시만 붙고 요청이 안 나갑니다. 창고에 이름표가 백 개라도 화면에 떠 있는 건 두세 개예요. 실제로 나가는 요청은 그 두세 개뿐입니다.
셋 — 좁히는 건 나중에 할 수 있습니다. 넓게 시작했다가 느린 곳이 나오면 그때 좁히면 돼요. 반대로 좁게 시작하면 어디가 안 맞는지 찾는 일이 훨씬 어렵습니다.
넓게 가더라도 이름표를 잘 지어두는 것이 전제입니다. ['posts', 1, 'comments']처럼 넓은 것에서 좁은 것으로 내려가게 지으면, 나중에 범위를 조절할 여지가 생겨요. 이름을 아무렇게나 지어두면 넓게도 좁게도 못 합니다.
화면이 백 개쯤 되면 두 가지가 달라집니다.
먼저 이름표를 짓는 규칙을 문서로 정해둬야 합니다. 사람마다 다르게 지으면 무효화 범위를 예측할 수 없거든요. 실무에서는 이름표를 만드는 함수를 한 파일에 모아두고 그것만 쓰게 합니다. 우리가 feedKey·postQuery를 한곳에 모아둔 것과 같은 방식이에요.
그리고 비싼 이름표를 따로 관리하게 됩니다. 통계나 집계처럼 서버가 계산에 오래 걸리는 것들이요. 그런 건 넓은 무효화에 딸려 들어가지 않도록 이름표 앞부분을 아예 다르게 지어둡니다.
🎯 면접관을 홀리는 핵심 멘트
"기본은 넓게 잡습니다. 틀린 화면이 남는 비용이 요청 한 번보다 크고, 무효화는 지금 화면에 떠 있는 것만 실제로 다시 물어보기 때문에 넓게 잡아도 생각만큼 안 비쌉니다. 대신 이름표를 넓은 것에서 좁은 것 순으로 짓는 규칙을 팀에서 정해둡니다. 그래야 나중에 느린 곳이 나왔을 때 좁힐 수 있거든요."
🤔 [생각해볼 주제 3] 서버가 준 답을 그대로 믿어도 될까요
문제 상황 요약
좋아요를 누르면 서버가 새 개수를 돌려줍니다. 우리는 그 값을 쓰지 않고 무효화해서 피드를 통째로 다시 받아왔습니다. 그대로 창고에 넣으면 요청 한 번을 아낄 수 있습니다.
튜터의 가이드 및 해설
둘 다 실무에서 씁니다. 갈리는 기준은 서버가 돌려준 것이 "그 화면에 필요한 전부"인가예요.
우리 경우를 보면 서버는 이렇게 답합니다.
{ id: 1, liked: true, likeCount: 1241 }
게시물 하나에 대한 세 값뿐인데, 피드 화면은 게시물 열 장을 그리고, 그 열 장 중 다른 게시물도 그사이에 바뀌었을 수 있습니다.
서버 답을 그대로 넣으면 우리가 누른 그 게시물만 정확해지고 나머지는 여전히 낡은 값입니다. 무효화는 열 장을 전부 새로 받아오고요.
그대로 넣기를 택했을 때 조심할 것을 정리하면 이렇습니다.
하나 — 부분만 갱신된다는 것을 알고 있어야 합니다. "이걸로 다 맞았다" 고 착각하면 나머지 아홉 장이 언제 갱신되는지 아무도 관리하지 않게 됩니다.
둘 — 서버 응답 모양이 바뀌면 조용히 깨집니다. 서버가 나중에 필드를 하나 빼면 우리 창고에 그 필드가 없는 게시물이 들어갑니다. 화면에서 undefined가 뜨는데 원인을 찾기 어려워요. 지난 시간에 배운 "제네릭은 약속이지 검사가 아니다"가 여기서 물립니다.
셋 — 목록 안의 한 칸만 갈아 끼우는 코드를 직접 써야 합니다. 서버가 준 것을 그 위치에 넣는 일을 우리가 합니다. 그 코드가 낙관적 갱신 코드와 거의 같아서 두 곳에 비슷한 게 생겨요.
그래서 저는 이렇게 나눕니다.
- 목록 화면 — 무효화. 여러 항목이 함께 낡으므로 통째로 다시 받는 게 안전합니다.
- 상세 화면 — 서버 답을 그대로. 서버가 그 화면에 필요한 전부를 돌려주는 경우가 많습니다.
요청 한 번을 아끼는 것이 정말 중요한 상황이라면 (모바일 데이터, 아주 큰 목록) 그때 그대로 넣기로 바꾸고, 대신 위 세 가지를 감당하겠다고 팀이 합의하면 됩니다.
🎯 면접관을 홀리는 핵심 멘트
"서버 답을 그대로 넣을지는 '그 답이 그 화면에 필요한 전부인가'로 판단합니다. 좋아요 응답은 게시물 하나에 대한 세 값뿐인데 피드는 열 장을 그리니까, 그대로 넣으면 내가 누른 것만 맞고 나머지는 낡은 채로 남습니다. 그래서 목록은 무효화로, 상세는 응답 그대로로 나눠서 씁니다."