배포 환경에서 데이터 갱신이 되지 않는 문제 해결
1. 배경: 실시간 닉네임 반영의 필요성
저희 팀은 Space Extension을 통해 익명 게시판 서비스를 개발하고 있습니다. 사용자는 헤더를 통해 자신의 정보를 확인하고, 상세 페이지에서 닉네임을 변경할 수 있습니다. 이때 사용자가 닉네임을 수정하면 별도의 새로고침 없이도 상단 헤더의 정보가 즉시 업데이트되어야 데이터의 일관성을 유지할 수 있습니다.
2. 문제 상황: 로컬과 배포 환경의 간극
- 로컬 환경(
next dev): 닉네임 수정 시invalidateQueries가 즉각 반응하여 헤더의 정보가 실시간으로 변경되었습니다. - 배포 환경: 수정 완료 후 상세 페이지로 리다이렉트(
router.push) 되어도, 헤더에 표시된 이전 닉네임은 그대로 유지되었습니다.
3. 단계별 디버깅 과정
시도 1: 강제 리페치(Refetch) 시도
단순히 stale 상태로 전환된 쿼리가 헤더에서 자동으로 감지되지 않는 문제라고 가정했습니다. invalidateQueries 이후 refetchQueries를 명시적으로 호출하여 백그라운드에서 강제로 데이터를 요청하도록 수정했습니다.
onSuccess: async () => {
await queryClient.invalidateQueries(['alienDetail', auth]);
await queryClient.refetchQueries(['alienDetail', auth]); // 명시적 호출 추가
router.push('/aliens/detail');
}결과: 여전히 배포 환경에서는 반응이 없었습니다. 네트워크 탭 분석 결과, 요청은 발생하지만 헤더 컴포넌트의 상태 변화로 이어지지 않았습니다.
시도 2: 렌더링 방식(SSG vs CSR)과 빌드 환경 분석
next dev는 개발 편의를 위해 CSR처럼 동작하지만, 실제 빌드된 서비스는 SSG 최적화가 적용됩니다. RootLayout에 정의된 Header 컴포넌트가 정적으로 생성된 후 고정되어 버린 것인지 의심했습니다. 이를 확인하기 위해 next build && next start를 통해 로컬에서 빌드 버전을 구동해보았습니다.
배포환경
개발환경
실행해 본 결과, 배포 환경과 동일한 증상이 나타났습니다. 하지만 Header 내부에 useEffect 같은 클라이언트 전용 코드를 추가해도 데이터는 요지부동이었고, 이는 단순 렌더링 방식의 차이 그 이상의 문제임을 시사했습니다.
시도 3: QueryClient 인스턴스의 생명주기 추적
근본적인 원인을 파악하기 위해 getQueryState를 사용하여 런타임 중 QueryClient 내부에 저장된 캐시 데이터를 로깅했습니다.
배포환경 테스트
개발환경 테스트
닉네임을 변경하는 페이지(modify/page.tsx)와 전역 Header 컴포넌트가 서로 다른 쿼리 정보를 보고 있었습니다. 개발 환경에서는 모든 데이터가 하나의 캐시 저장소에서 관리되었으나, 빌드 환경에서는 컴포넌트마다 QueryClient가 독립적으로 재생성되고 있었습니다.
즉, 수정 페이지에서 호출한 invalidateQueries는 자신이 가진 인스턴스의 쿼리만 무효화했을 뿐, 헤더가 참조하고 있는 다른 인스턴스의 쿼리에는 영향을 주지 못했던 것입니다.
4. 원인 분석
Next.js의 App Router 구조에서 클라이언트 컴포넌트인 RootLayout은 페이지 이동 시 리렌더링이 발생할 수 있습니다. 이때 QueryClient 인스턴스를 컴포넌트 내부에서 변수로 선언하거나 외부에서 불안정하게 주입할 경우, 렌더링 때마다 새로운 인스턴스가 생성될 위험이 있습니다.
결국 문제는 "애플리케이션 전체가 단 하나의 QueryClient 캐시 저장소를 공유하지 못했다"는 점에 있었습니다. 로컬 환경은 HMR 등이 작동하며 인스턴스가 유지되는 것처럼 보였으나, 최적화된 빌드 환경에서는 매번 새로운 객체가 생성되며 캐시 저장소가 격리된 것입니다.
5. 문제 해결: React State를 이용한 인스턴스 고정
TanStack Query 공식 문서의 권장 사항은 다음과 같았습니다.
Create a new QueryClient instance inside of your app, and on an instance ref (or in React state). This ensures that data is not shared between different users and requests, while still only creating the QueryClient once per component lifecycle.
권장사항에 따라 컴포넌트 생명주기 동안 QueryClient가 단 한 번만 생성되도록 useState를 활용해 관리 방식을 변경했습니다.
'use client'
import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export default function RootLayout({ children }: { children: React.ReactNode }) {
// useState의 Lazy Initializer를 사용해 단 한 번만 생성됨을 보장합니다.
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 9,
cacheTime: 1000 * 60 * 9,
},
},
}));
return (
<html lang="en">
<body>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Header />
<main>{children}</main>
</AuthProvider>
</QueryClientProvider>
</body>
</html>
)
}
이렇게 수정함으로써 페이지 이동이나 부모 컴포넌트의 리렌더링 상황에서도 queryClient 인스턴스는 동일한 메모리 주소를 유지하게 되었고, 전역적인 쿼리 무효화 및 데이터 동기화가 정상적으로 이루어지기 시작했습니다.
여기서 지연 초기화를 사용한 이유는 단순히 useState(new QueryClient())를 사용하면 리렌더링 때마다 new QueryClient()가 호출되어 인스턴스가 계속 생성될 위험이 있지만, 함수형 업데이트를 사용하면 초기 렌더링 시점에 단 한 번만 생성되므로 불필요한 자원 낭비를 막고 싱글톤 인스턴스를 보장할 수 있습니다.
6. 결론 및 배운 점
이번 문제를 통해 기술적으로 크게 세 가지를 배울 수 있었습니다.
- 인스턴스 관리의 엄격함: 클라이언트 사이드 라이브러리를 Next.js에 주입할 때는 해당 인스턴스의 생명주기가 애플리케이션 전체에서 싱글톤으로 유지되는지 반드시 검증해야 합니다.
- 환경 차이의 이해:
next dev와 빌드 결과물은 렌더링 최적화 로직이 상이하므로, 중요한 기능은 반드시 빌드 모드에서 사전에 테스트해야 합니다. - 공식 문서의 중요성: 라이브러리 제작자가 권장하는 인스턴스 생성 패턴(State/Ref 사용)에는 프레임워크의 특성을 고려한 깊은 이유가 있음을 다시 한번 체감했습니다.
참고
Comments (0)
댓글을 불러오는 중...