낡은 응답 막기 (useEffect의 ignore 패턴)

튜토리얼 — 낡은 응답 막기 (useEffect의 ignore 패턴)
책에서 본 UserCard는 의존성이 []라서 ignore 패턴을 넣어도 겉보기 변화가 없었습니다. 패턴은 배웠지만 "왜 필요한지"는 눈으로 못 봤죠. 이번 튜토리얼은 race condition(요청 경쟁)을 일부러 재현해서, ignore 패턴이 없을 때 깨지고 있을 때 고쳐지는 것을 직접 봅니다.
개념
문제를 다시 그려 봅니다. 사용자가 목록에서 사용자 1을 골랐다가 곧바로 사용자 2로 바꿉니다. 의존성이 [userId]인 fetch라면 요청이 두 번 나갑니다.
시간 → 0ms 사용자 1 요청 출발 (응답이 느림 — 1500ms 걸림) 80ms 사용자 2 요청 출발 (응답이 빠름 — 300ms 걸림) 380ms 사용자 2 응답 도착 → 화면: 사용자 2 ✅ (맞음) 1500ms 사용자 1 응답 도착 → 화면: 사용자 1 ❌ (틀림! 낡은 응답이 덮어씀)
마지막 줄이 문제입니다. 사용자는 분명 2를 골랐는데, 먼저 보낸 1의 응답이 뒤늦게 도착해 화면을 덮어씁니다. 이게 "낡은(stale) 응답" 문제입니다.
[] 의존성에서는 요청이 한 번뿐이라 이 경쟁이 안 생깁니다. 그래서 책 예제에서는 변화가 안 보였던 거예요. 의존성이 바뀌며 요청이 여러 번 나가는 fetch에서 비로소 드러납니다.
해결책은 5.4절의 정리(cleanup) 함수입니다. 효과가 다시 실행되기 직전, 이전 효과의 정리 함수가 먼저 실행됩니다. 거기서 ignore = true로 표시를 켜면, 그 요청의 응답이 뒤늦게 도착해도 if (!ignore)에 걸려 화면에 반영되지 않습니다.
함께 해보기
race condition을 보려면 "느린 응답"과 "빠른 응답"이 필요합니다. 실제 네트워크는 빠를지 느릴지 알 수 없으니, 응답 속도를 우리가 정하는 작은 API를 프로젝트 안에 만들어 씁니다.
1단계 — 일부러 느린 API 만들기
app/api/user/[id]/route.ts 파일을 만듭니다.
import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; // 사용자마다 응답 지연을 다르게 — 1번은 느리게, 2번은 빠르게 const USERS: Record<string, { id: number; name: string; delay: number }> = { "1": { id: 1, name: "김민준", delay: 1500 }, // 느림 "2": { id: 2, name: "이서연", delay: 300 }, // 빠름 "3": { id: 3, name: "박지후", delay: 800 }, // 중간 }; export async function GET( _req: Request, { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; const user = USERS[id]; if (!user) { return NextResponse.json({ error: "없는 사용자" }, { status: 404 }); } // 정해진 시간만큼 일부러 늦게 응답 await new Promise((resolve) => setTimeout(resolve, user.delay)); return NextResponse.json({ id: user.id, name: user.name }); }
/api/user/1은 1.5초, /api/user/2는 0.3초 뒤에 응답합니다. 1번을 고른 직후 2번을 고르면, 2번 응답이 먼저, 1번 응답이 한참 뒤에 도착하는 상황이 항상 재현됩니다.
2단계 — 사용자 프로필 컴포넌트
components/UserProfile.tsx 파일을 만듭니다. ignore 패턴을 버튼으로 껐다 켰다 할 수 있게 해서, 차이를 직접 비교합니다.
"use client"; import { useState, useEffect } from "react"; type User = { id: number; name: string }; export default function UserProfile() { const [userId, setUserId] = useState<number | null>(null); const [user, setUser] = useState<User | null>(null); const [loading, setLoading] = useState(false); const [useIgnore, setUseIgnore] = useState(false); // ignore 패턴 on/off useEffect(() => { if (userId === null) return; let ignore = false; // 이 효과만의 무시 표시 setLoading(true); async function loadUser() { const res = await fetch(`/api/user/${userId}`); const data: User = await res.json(); // ignore 패턴이 켜져 있고, 이 효과가 이미 정리됐다면 → 반영하지 않음 if (useIgnore && ignore) { console.log(`[무시됨] 사용자 ${data.id} — 이미 낡은 요청`); return; } console.log(`[반영됨] 사용자 ${data.id}`); setUser(data); setLoading(false); } loadUser(); return () => { ignore = true; // 정리: 이 요청의 결과는 무시하라 }; }, [userId, useIgnore]); return ( <div className="p-4 space-y-3"> <div className="space-x-2"> {[1, 2, 3].map((id) => ( <button key={id} className="rounded bg-blue-500 px-3 py-1 text-white" onClick={() => setUserId(id)} > 사용자 {id} </button> ))} </div> <button className="rounded bg-gray-600 px-3 py-1 text-white" onClick={() => setUseIgnore((v) => !v)} > ignore 패턴: <span data-testid="ignore-state">{useIgnore ? "켬" : "끔"}</span> </button> <p> 선택한 사용자 ID: <span data-testid="selected-id">{userId ?? "-"}</span> </p> <p> 화면에 표시된 사용자:{" "} <strong data-testid="displayed-user"> {user ? `${user.name} (ID ${user.id})` : "없음"} </strong> </p> {loading && <p data-testid="loading">불러오는 중...</p>} </div> ); }
핵심은 if (useIgnore && ignore) 한 줄입니다. ignore 패턴이 켜져 있을 때만 정리 표시를 검사해, 낡은 응답을 버립니다. 패턴이 꺼져 있으면 검사를 건너뛰므로 — 책에서 고치기 전의 버그 있는 코드와 똑같이 동작합니다.
3단계 — 페이지에 올리기
app/user/page.tsx 파일을 만듭니다.
import UserProfile from "@/components/UserProfile"; export default function UserPage() { return ( <main style={{ padding: 24 }}> <h2>사용자 프로필 — 낡은 응답 막기</h2> <UserProfile /> </main> ); }
4단계 — 버그 재현하고, 고치기
/user를 열고 콘솔(F12)을 켭니다.
① ignore 패턴 끔 (기본) — 버그 재현
ignore 패턴: 끔 상태에서, "사용자 1"을 누르고 곧바로 "사용자 2"를 누릅니다.
- 약 0.3초 뒤 → 화면에
이서연 (ID 2). 여기까진 맞습니다. - 약 1.5초 뒤 → 화면이
김민준 (ID 1)로 바뀝니다. 콘솔엔[반영됨] 사용자 1. - "선택한 사용자 ID"는
2인데 "표시된 사용자"는1— 어긋났습니다. 낡은 응답이 화면을 덮어쓴 겁니다. ❌
② ignore 패턴 켬 — 해결 확인
ignore 패턴 버튼을 눌러 켬으로 바꾸고, 다시 "사용자 1" → "사용자 2" 를 빠르게 누릅니다.
- 약 0.3초 뒤 →
이서연 (ID 2). - 1.5초가 지나도 화면은 계속
이서연 (ID 2). 콘솔엔[무시됨] 사용자 1 — 이미 낡은 요청. - "선택한 ID"와 "표시된 사용자"가 둘 다 2 — 일치합니다. ✅
확인 ✅ — 사용자 1의 효과는 사용자 2를 누른 순간 정리되며 ignore = true가 됩니다. 그래서 1.5초 뒤 도착한 사용자 1의 응답은 if (useIgnore && ignore)에 걸려 버려집니다. 낡은 응답이 새 화면을 덮어쓰지 못합니다.
정리
- 의존성이 바뀌며 fetch가 여러 번 나가면, 먼저 보낸 요청의 응답이 더 늦게 도착해 낡은 응답이 새 화면을 덮어쓸 수 있습니다.
useEffect안에let ignore = false를 두고, 상태를 바꾸기 전에if (!ignore)로 확인합니다.- 정리 함수에서
ignore = true로 표시하면, 그 효과의 뒤늦은 응답은 버려집니다. []의존성에서는 잘 안 보이지만,[userId]처럼 의존성 있는 fetch에는 습관적으로 넣어 둡니다.
연습 거리
- 사용자 1 → 3 → 2를 연속으로 빠르게 눌러 보고, 콘솔의
[반영됨]/[무시됨]로그로 어떤 응답이 살아남는지 확인하기 ignore검사를catch(에러 처리)에도 넣어, 낡은 요청의 에러가 화면을 덮지 않게 하기AbortController를 정리 함수에서abort()해, 낡은 요청을 아예 취소하는 방식도 찾아보기

댓글
댓글을 작성하려면 이 필요합니다.