React Query의 isFetchedAfterMount를 폼 초기화 가드로 사용하면, 캐시 데이터가 존재할 때 폼이 영원히 초기화되지 않는 버그가 발생할 수 있다.
Next.js Pages Router + React Query로 구축된 관리자 페이지에서, 상세 페이지에 진입하면 모든 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분
},
},
})
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 실행 안 됨 → 빈 폼!
핵심은 데이터는 있는데 가드가 차단하는 것이다.
처음에는 Next.js 라우팅 문제를 의심했다.
detail/index.tsx와 detail/[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 (상세 조회)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는 정상적으로 전달되고 있었다. 라우팅은 문제가 아니었다.
useEffect 내부에 진단 로그를 추가했다:
console.log('[account-detail] useEffect trigger:', {
hasDetailData: !!detailData, // true ← 데이터 있음!
isFetchedAfterMount, // false ← 여기가 문제!
detailDataId: detailData?.id,
initializedId: initializedDetailIdRef.current,
})
detailData는 존재하지만 isFetchedAfterMount가 false라서 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])
id 변경 시:
useEffect([id]) → initializedDetailIdRef.current = null
detailData 도착 시:
initializedDetailIdRef.current (null) !== detailData.id (123)
→ handleSetInitialData 실행
→ initializedDetailIdRef.current = 123
같은 데이터 재진입 시:
initializedDetailIdRef.current (123) === detailData.id (123)
→ return (중복 초기화 방지)
isFetchedAfterMount는 "이전 캐시 데이터를 표시하지 않기" 위한 용도로 설계되었다. 하지만 staleTime이 설정된 환경에서는 유효한 캐시 데이터까지 차단해버린다.
React 상태로 관리되는 controlled form이라면 detailData가 변경될 때 자동으로 리렌더링된다. 하지만 uncontrolled form (DOM 직접 조작)은 useEffect에서 명시적으로 값을 설정해야 하므로, 가드 조건이 하나라도 잘못되면 폼이 빈 상태로 남는다. 이번 버그를 계기로, 레거시 폼들을 React Hook Form으로 마이그레이션하는 것을 검토하고 있다. RHF의 reset()을 사용하면 서버 데이터와 폼 상태의 동기화를 선언적으로 처리할 수 있어서, 이런 류의 가드 조건 실수를 구조적으로 방지할 수 있다.
코드 분석만으로 시간을 쓸 수 있는 문제를, 진단 로그 몇 개로 빠르게 찾았다. 특히 useEffect 내부의 가드 조건들을 로깅하는 것이 효과적이다.
이런 간헐적 패턴 때문에 "가끔 되고 가끔 안 된다"로 인식되기 쉽다.
| 항목 | 내용 |
|---|---|
| 문제 | 상세 페이지 재방문 시 빈 폼 |
| 원인 | isFetchedAfterMount: false가 캐시 데이터의 폼 초기화를 차단 |
| 해결 | isFetchedAfterMount 가드 제거, initializedDetailIdRef로 충분 |
| 영향도 | 동일 패턴 사용하는 모든 상세 페이지 |