React Query의 isFetchedAfterMount와 캐시 데이터가 만드는 함정

기성·2026년 3월 21일

TIL

목록 보기
95/95

React Query의 isFetchedAfterMount를 폼 초기화 가드로 사용하면, 캐시 데이터가 존재할 때 폼이 영원히 초기화되지 않는 버그가 발생할 수 있다.

문제 상황

Next.js Pages Router + React Query로 구축된 관리자 페이지에서, 상세 페이지에 진입하면 모든 input이 빈 상태로 표시되는 버그가 발생했다.

증상

  • 목록에서 행을 클릭하여 상세 페이지 진입
  • 브라우저 Network 탭에서 API 호출이 보이지 않음
  • 페이지는 렌더링되지만 모든 input이 비어있음

혼란스러웠던 점

  • 코드를 아무리 봐도 논리적으로 문제가 없었다
  • 라우팅, 프록시, 인증 미들웨어 모두 정상이었다
  • id prop도 정상적으로 전달되고 있었다

코드 구조

이 프로젝트는 uncontrolled form 패턴을 사용한다. React 상태가 아닌 DOM 직접 조작으로 input 값을 설정하는 방식인데, 기존 레거시 코드가 이 패턴으로 작성되어 있었다.

// 상세 페이지 컴포넌트
function CustomerAccountDetailPage({ id }) {
  const refForm = useRef<HTMLFormElement>(null)
  const initializedDetailIdRef = useRef<number | null>(null)

  const { data: detailData, isFetchedAfterMount } = useUserManagementDetailQuery(
    id ? Number(id) : undefined,
  )

  // 폼 초기화 로직
  useEffect(() => {
    if (!detailData || !isFetchedAfterMount) {  // ← 여기가 문제
      return
    }
    if (initializedDetailIdRef.current === detailData.id) {
      return
    }
    handleSetInitialData(detailData)  // DOM 직접 조작으로 input 값 설정
    initializedDetailIdRef.current = detailData.id
  }, [detailData, isFetchedAfterMount])
}

QueryClient 전역 설정:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false,
      retry: false,
      staleTime: 5 * 60 * 1000,  // 5분
    },
  },
})

근본 원인 분석

isFetchedAfterMount란?

React Query v5의 isFetchedAfterMount컴포넌트가 마운트된 이후에 fetch가 완료되었는지를 나타내는 boolean 값이다.

상황isFetchedAfterMount
캐시 없음 → fetch 완료true
캐시 있음 (staleTime 이내) → fetch 안 함false
캐시 있음 (staleTime 초과) → refetch 완료true

버그 재현 흐름

1. 상세 페이지 첫 방문
   → 캐시 없음 → API 호출 → isFetchedAfterMount: true
   → useEffect 실행 → handleSetInitialData → 폼 정상 표시 ✅

2. 목록으로 돌아감

3. 같은 행 재클릭 (5분 이내)
   → 캐시 있음 (staleTime 5분 이내) → API 호출 안 함
   → detailData: 존재함 (캐시에서)
   → isFetchedAfterMount: false (새 fetch가 없었으므로)
   → useEffect 가드: !isFetchedAfterMount → return ❌
   → handleSetInitialData 실행 안 됨 → 빈 폼!

핵심은 데이터는 있는데 가드가 차단하는 것이다.


디버깅 과정

1단계: 라우팅 의심 (잘못된 방향)

처음에는 Next.js 라우팅 문제를 의심했다.

  • detail/index.tsxdetail/[id].tsx가 공존하면서 충돌하는 것이 아닌가?
  • _next/data/*.json만 로드되고 API 호출이 안 되는 건 라우팅 문제 아닌가?

하지만 Next.js Pages Router에서 이 두 파일은 서로 다른 URL을 담당하므로 충돌하지 않는다:

  • detail/index.tsx/customer-mng/account/detail (신규 등록)
  • detail/[id].tsx/customer-mng/account/detail/:id (상세 조회)

2단계: 진단 로그 추가

getServerSideProps와 컴포넌트에 로그를 추가하여 데이터 흐름을 추적했다.

// getServerSideProps
console.log('[account-detail] getServerSideProps:', {
  params: JSON.stringify(params),
  query: JSON.stringify(query),
  resolvedId: id,
})

// 컴포넌트
console.log('[account-detail] Component props:', {
  id, idType: typeof id, numId: id ? Number(id) : undefined,
})

결과: id는 정상적으로 전달되고 있었다. 라우팅은 문제가 아니었다.

3단계: useEffect 가드 추적 (핵심 발견)

useEffect 내부에 진단 로그를 추가했다:

console.log('[account-detail] useEffect trigger:', {
  hasDetailData: !!detailData,        // true ← 데이터 있음!
  isFetchedAfterMount,                // false ← 여기가 문제!
  detailDataId: detailData?.id,
  initializedId: initializedDetailIdRef.current,
})

detailData는 존재하지만 isFetchedAfterMountfalse라서 useEffect가 조기 return되는 것을 확인했다.


해결 방법

isFetchedAfterMount 가드를 제거했다. initializedDetailIdRef가 이미 중복 초기화를 방지하고 있으므로 불필요했다.

// Before (버그)
useEffect(() => {
  if (!detailData || !isFetchedAfterMount) {  // 캐시 데이터 차단
    return
  }
  if (initializedDetailIdRef.current === detailData.id) {
    return
  }
  handleSetInitialData(detailData)
  initializedDetailIdRef.current = detailData.id
}, [detailData, isFetchedAfterMount])

// After (수정)
useEffect(() => {
  if (!detailData) {
    return
  }
  if (initializedDetailIdRef.current === detailData.id) {  // 이것만으로 충분
    return
  }
  handleSetInitialData(detailData)
  initializedDetailIdRef.current = detailData.id
}, [detailData])

왜 initializedDetailIdRef만으로 충분한가?

id 변경 시:
  useEffect([id]) → initializedDetailIdRef.current = null

detailData 도착 시:
  initializedDetailIdRef.current (null) !== detailData.id (123)
  → handleSetInitialData 실행
  → initializedDetailIdRef.current = 123

같은 데이터 재진입 시:
  initializedDetailIdRef.current (123) === detailData.id (123)
  → return (중복 초기화 방지)

교훈

1. isFetchedAfterMount는 캐시와 궁합이 나쁘다

isFetchedAfterMount는 "이전 캐시 데이터를 표시하지 않기" 위한 용도로 설계되었다. 하지만 staleTime이 설정된 환경에서는 유효한 캐시 데이터까지 차단해버린다.

2. uncontrolled form + React Query = 주의 필요

React 상태로 관리되는 controlled form이라면 detailData가 변경될 때 자동으로 리렌더링된다. 하지만 uncontrolled form (DOM 직접 조작)은 useEffect에서 명시적으로 값을 설정해야 하므로, 가드 조건이 하나라도 잘못되면 폼이 빈 상태로 남는다. 이번 버그를 계기로, 레거시 폼들을 React Hook Form으로 마이그레이션하는 것을 검토하고 있다. RHF의 reset()을 사용하면 서버 데이터와 폼 상태의 동기화를 선언적으로 처리할 수 있어서, 이런 류의 가드 조건 실수를 구조적으로 방지할 수 있다.

3. 진단 로그가 가장 빠른 디버깅

코드 분석만으로 시간을 쓸 수 있는 문제를, 진단 로그 몇 개로 빠르게 찾았다. 특히 useEffect 내부의 가드 조건들을 로깅하는 것이 효과적이다.

4. 캐시 관련 버그는 재현 조건이 까다롭다

  • 첫 방문에서는 정상 동작 (캐시 없으므로)
  • 재방문 시에만 발생 (staleTime 이내)
  • 5분이 지나면 또 정상 동작 (staleTime 초과 → refetch)

이런 간헐적 패턴 때문에 "가끔 되고 가끔 안 된다"로 인식되기 쉽다.


요약

항목내용
문제상세 페이지 재방문 시 빈 폼
원인isFetchedAfterMount: false가 캐시 데이터의 폼 초기화를 차단
해결isFetchedAfterMount 가드 제거, initializedDetailIdRef로 충분
영향도동일 패턴 사용하는 모든 상세 페이지

참고

profile
프론트가 하고싶어요

0개의 댓글