메인 콘텐츠로 바로가기
노트 목록

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

김성박681
목차9

튜토리얼 — 낡은 응답 막기 (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. 사용자 1 → 3 → 2를 연속으로 빠르게 눌러 보고, 콘솔의 [반영됨]/[무시됨] 로그로 어떤 응답이 살아남는지 확인하기
  2. ignore 검사를 catch(에러 처리)에도 넣어, 낡은 요청의 에러가 화면을 덮지 않게 하기
  3. AbortController를 정리 함수에서 abort()해, 낡은 요청을 아예 취소하는 방식도 찾아보기

댓글

불러오는 중...

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