빠른 손가락이 만드는 버그는 느린 네트워크가 드러낸다. 탭을 연속으로 누를 때 화면이 "깜빡이며 과거로 돌아간다"면, 그건 race condition이다.
문제: 탭을 빠르게 누르면 화면이 과거로 돌아간다
어웨어랩에서 투자 포트폴리오 분석 플랫폼을 만들고 있었다. 포트폴리오 대시보드에는 기간별 필터와 자산 유형별 탭이 있는데, 사용자가 탭이나 필터를 빠르게 전환하면 이상한 일이 벌어졌다.
- "1개월" 탭 클릭 → "3개월" 탭 클릭 → "1년" 탭 클릭
- 화면에 "1년" 데이터가 잠깐 보이다가, 갑자기 "3개월" 데이터로 바뀐다
- GSAP 차트 애니메이션이 깜빡이며 잘못된 데이터로 다시 그려진다
원인은 분명했다. 비동기 API 응답은 요청 순서대로 도착하지 않는다. "1년" 요청이 먼저 응답하고 "3개월" 요청이 나중에 도착하면, 늦게 온 "3개월" 응답이 setState를 호출해서 최신 화면을 덮어쓴다. 금융 데이터에서 이건 잘못된 수치를 보여주는 것이니 단순한 UX 문제가 아니라 데이터 정합성 문제였다.
기존 솔루션을 쓰지 않은 이유
React Query(TanStack Query)를 도입하면 race condition을 구조적으로 해결할 수 있다. 하지만 이 프로젝트는 이미 Zustand 기반으로 상태 관리가 설계되어 있었고, 필요한 건 딱 하나 — 같은 키로 in-flight 요청이 있을 때, 이전 요청의 응답을 무시하는 것이었다.
React Query를 도입하면 캐시 관리, 리트라이, 뮤테이션까지 따라오는데, 이 시점에서 필요한 건 in-flight 식별뿐이었다. 자체 캐시 훅을 만드는 게 더 가볍고 정확하다고 판단했다.
설계 핵심: Closure + Map + Symbol
세 가지 도구를 조합했다.
Map<string, T>— 키별 캐시 저장소. 이미 응답받은 데이터는 다시 요청하지 않는다.Map<string, symbol>— 키별 in-flight 토큰. 현재 진행 중인 요청을 식별한다.Symbol(key)— 요청마다 생성되는 유일한 토큰. 같은 키로 새 요청이 들어오면 이전 토큰이 무효화된다.
Symbol()이 이 패턴의 핵심이다. JavaScript에서 Symbol()은 호출할 때마다 전역적으로 유일한 값을 생성한다. 같은 설명 문자열을 넣어도 Symbol("tab") !== Symbol("tab")이다. 이 성질을 이용해 요청별 토큰을 만든다.
구현: 늦게 온 응답이 스스로를 무시하게 만들기
function useRequestCache<T>(fetcher: (key: string) => Promise<T>) {
const cache = useRef(new Map<string, T>());
const inflight = useRef(new Map<string, symbol>());
const [state, setState] = useState<{ key?: string; data?: T }>({});
const get = useCallback(async (key: string) => {
// 1. 캐시 히트 — 즉시 반환
if (cache.current.has(key)) {
setState({ key, data: cache.current.get(key) });
return;
}
// 2. 새 토큰 발급 — 이전 토큰은 자동 무효화
const token = Symbol(key);
inflight.current.set(key, token);
// 3. 네트워크 요청
const data = await fetcher(key);
// 4. 응답 시점에 토큰 비교 — stale이면 무시
if (inflight.current.get(key) !== token) return;
// 5. 유효한 응답만 캐시 + 렌더
cache.current.set(key, data);
setState({ key, data });
}, [fetcher]);
return [state, get] as const;
}흐름을 따라가 보자
사용자가 "1개월" → "3개월" → "1년" 순으로 탭을 빠르게 누르는 시나리오를 따라가 보자.
get("1m")호출 →token_A = Symbol("1m")발급, inflight에 저장get("3m")호출 →token_B = Symbol("3m")발급, inflight에 저장get("1y")호출 →token_C = Symbol("1y")발급, inflight에 저장- "1년" 응답 도착 →
inflight.get("1y") === token_C✓ → setState 실행, 캐시 저장 - "3개월" 응답 도착 →
inflight.get("3m") === token_B✓ → 하지만 화면은 이미 "1년"이므로 정상 - "1개월" 응답 도착 →
inflight.get("1m") === token_A✓ → 마찬가지로 정상
그런데 같은 키를 연속으로 누르는 경우는 다르다. get("1m") → get("1m")이면, 두 번째 호출에서 token_A가 token_B로 덮어씌워진다. 첫 번째 요청이 나중에 응답해도 inflight.get("1m") !== token_A이므로 자동으로 무시된다. 이게 Symbol의 유일성이 빛나는 지점이다.
Promise 중복 제거는 넣지 않은 이유
같은 키로 요청이 in-flight일 때 같은 Promise를 반환하는 패턴(dedup)도 고려했다. 실제로 Zustand 스토어 레벨에서는 pendingRequest를 저장하는 방식으로 별도 구현이 되어 있었다.
// Zustand 스토어에서의 Promise dedup 패턴
if (pendingRequest) {
await pendingRequest;
return;
}
const requestPromise = (async () => {
set({ isLoading: true, error: null });
try {
const data = await PortfolioDashboardApi.getPortfolioHoldings();
set({ data: sortHoldingsBy(data, sortOption), isLoading: false, pendingRequest: null });
} catch (error) {
set({ error: error.message, isLoading: false, pendingRequest: null });
}
})();
set({ pendingRequest: requestPromise });useRequestCache 훅에서는 이 패턴을 분리했다. 훅의 책임은 "stale 응답 무시"에 집중하고, Promise dedup은 스토어 레이어에서 처리하는 것이 관심사 분리에 맞다고 판단했다.
GSAP cleanup까지 묶어야 완성
데이터만 해결하면 끝이 아니었다. 포트폴리오 차트는 GSAP 애니메이션으로 그려지는데, 탭 전환 시 이전 애니메이션의 cleanup이 누락되면 새 데이터 위에 옛 애니메이션이 겹쳐 그려졌다. gsap.context().revert()를 컴포넌트 언마운트에 연결해서 이 문제까지 해결했다.
race condition은 데이터 페칭만의 문제가 아니다. 비동기로 동작하는 모든 사이드 이펙트 — 애니메이션, 구독, 타이머 — 가 잠재적인 race condition 소스이고, 각각에 대한 cleanup 전략이 필요하다.
돌아보며
이 훅은 50줄도 안 된다. React Query 같은 도구를 쓰면 더 많은 기능을 공짜로 얻을 수 있다. 하지만 "문제를 정확히 정의하고, 딱 그 문제만 푸는 도구를 만드는" 경험은 라이브러리를 쓸 때도 어디를 커스터마이즈해야 하는지 판단하는 근거가 된다.
- race condition의 본질은 "늦게 온 응답이 최신 상태를 덮어쓰는 것"
- Symbol()의 유일성으로 요청별 토큰을 만들면 stale 판별이 한 줄로 끝난다
- 캐시와 in-flight 식별은 관심사가 다르다 — 분리하라
- GSAP 등 비동기 사이드 이펙트도 cleanup 전략이 필요하다
- 라이브러리를 도입하기 전에, 진짜 필요한 범위를 먼저 정의하라