- Published on
Next.js RSC 캐시 꼬임으로 상태 초기화되는 문제 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트 기반(App Router)으로 전환한 뒤, 특정 페이지에서만 전역 상태가 “가끔” 초기화되거나, 방금 입력한 폼 값이 사라지거나, 로그인 직후 UI가 예전 사용자처럼 보이는 현상을 겪는 경우가 있습니다. 많은 팀이 이를 “RSC 캐시가 꼬였다”라고 표현하는데, 실제로는 여러 레이어의 캐시와 렌더 경계가 예상과 다르게 결합되면서 클라이언트 컴포넌트가 언마운트/리마운트되거나 서버에서 내려오는 RSC 페이로드가 오래된 데이터를 포함해 “상태가 초기화된 것처럼” 보이는 경우가 대부분입니다.
이 글에서는 Next.js RSC에서 자주 헷갈리는 캐시 레이어를 분해하고, 재현 패턴과 해결책을 코드로 정리합니다. 캐시 문제를 다루는 사고방식은 Docker 빌드 캐시가 깨질 때 원인을 쪼개는 것과 유사합니다. 필요하다면 Docker 빌드가 느릴 때 - BuildKit 캐시 깨짐 해결도 함께 참고하면 “캐시 레이어 분리” 관점이 도움이 됩니다.
증상 체크리스트: “상태 초기화”처럼 보이는 대표 케이스
다음 중 하나라도 해당하면, 실제로는 상태가 날아간 게 아니라 렌더 경계가 바뀌었거나 서버 데이터가 캐시에서 재사용된 것일 수 있습니다.
layout.tsx아래에 둔 전역 Provider가 특정 네비게이션에서만 다시 마운트된다- 로그인/로그아웃 후 같은 URL로 돌아왔는데 사용자 정보가 이전 값으로 보인다
- 쿼리스트링만 바꿨는데 폼 입력이 초기화된다
router.refresh()이후 클라이언트 상태가 리셋된다- 개발 환경에서는 괜찮은데 프리뷰/프로덕션에서만 재현된다
핵심은 “RSC 캐시”라는 단일 캐시가 아니라, 아래가 섞여 있다는 점입니다.
- Router Cache(라우트 세그먼트 캐시): RSC 페이로드 단위로 세그먼트를 저장
- Data Cache(fetch 캐시): 서버에서
fetch결과를 캐시 - Full Route Cache(정적 라우트 캐시): 빌드/런타임에서 정적으로 굳는 경로
- Client state: 클라이언트 컴포넌트가 마운트된 동안만 유지
원인 1: Provider가 layout 경계 밖으로 밀려나 리마운트됨
App Router에서 전역 상태를 유지하려면, 상태를 들고 있는 Provider가 가능한 상위 layout.tsx에 고정되어야 합니다. 그런데 아래처럼 특정 라우트 그룹에서만 Provider를 두면, 그룹을 벗어나는 순간 Provider가 언마운트되어 상태가 초기화됩니다.
잘못된 예: 그룹별 layout에 Provider를 둔 경우
// app/(dashboard)/layout.tsx
'use client'
import { AppProvider } from '@/components/app-provider'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return <AppProvider>{children}</AppProvider>
}
이 상태에서 (dashboard) 밖으로 이동했다가 다시 들어오면 Provider가 새로 마운트됩니다.
해결: 최상위 layout에 Provider 고정
// app/layout.tsx
'use client'
import { AppProvider } from '@/components/app-provider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<AppProvider>{children}</AppProvider>
</body>
</html>
)
}
추가로, Provider 내부에서 key를 라우트나 사용자 값에 따라 바꾸는 코드가 있으면 의도치 않은 리마운트가 발생합니다.
// 나쁜 패턴: key가 바뀌면 Provider가 리셋됨
<AppProvider key={userId}>{children}</AppProvider>
key는 정말 “완전히 새 인스턴스가 필요할 때만” 사용하세요.
원인 2: 서버에서 내려오는 사용자 데이터가 캐시되어 “예전 상태”처럼 보임
로그인 직후 UI가 이전 사용자처럼 보이거나, 로그아웃했는데도 서버 컴포넌트가 “로그인 상태”로 렌더되는 경우는 대개 서버에서 사용자 정보를 가져오는 fetch가 캐시된 탓입니다.
재현 패턴
- 서버 컴포넌트에서
cookies()기반 토큰으로/api/me호출 fetch기본 캐시 동작으로 인해 결과가 재사용- 클라이언트는 새 토큰을 갖고 있어도, 서버 렌더 결과는 오래된 사용자로 내려옴
해결 1: 사용자 의존 fetch는 기본적으로 no-store
// lib/me.ts
export async function getMe() {
const res = await fetch('https://example.com/api/me', {
cache: 'no-store',
headers: {
// 필요 시 쿠키/토큰 전달
},
})
if (!res.ok) return null
return res.json()
}
cache: 'no-store'는 “매 요청마다 새로 가져오기”를 강제합니다. 사용자별 데이터(인증/권한/개인화)는 우선 이 선택이 안전합니다.
해결 2: 태그 기반 캐시 무효화로 정교하게 제어
자주 바뀌지 않지만 로그인/로그아웃 같은 이벤트에서만 갱신하면 되는 데이터는 next 태그를 붙이고, 서버 액션에서 무효화하는 방식이 좋습니다.
// lib/me.ts
export async function getMe() {
const res = await fetch('https://example.com/api/me', {
next: { tags: ['me'] },
})
if (!res.ok) return null
return res.json()
}
// app/actions/auth.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function logout() {
// 쿠키 삭제 등
revalidateTag('me')
}
이렇게 하면 평소에는 캐시를 활용하면서도, 인증 이벤트에서만 일관되게 갱신됩니다.
원인 3: router.refresh()가 “상태 초기화”를 유발하는 구조
router.refresh()는 현재 라우트의 서버 컴포넌트를 다시 가져오게 만듭니다. 이 자체가 클라이언트 상태를 무조건 날리지는 않지만, 다음 조건이 겹치면 리마운트가 발생할 수 있습니다.
- refresh 이후 서버 컴포넌트 트리가 달라짐(조건부 렌더)
- 특정 클라이언트 컴포넌트가 서버 컴포넌트의 자식으로서 새로 생성됨
- 상위에서
key가 바뀌거나,layout경계가 달라짐
해결: “상태를 들고 있을 컴포넌트”를 안정적인 경계로 올리기
예를 들어, 폼 상태를 유지해야 하는 컴포넌트가 서버 컴포넌트 아래에 매번 새로 생성되면 입력값이 사라질 수 있습니다.
// app/profile/page.tsx (Server Component)
import ProfileForm from './profile-form'
export default async function ProfilePage() {
const me = await getMe()
return <ProfileForm initialName={me?.name ?? ''} />
}
// app/profile/profile-form.tsx
'use client'
import { useState } from 'react'
export default function ProfileForm({ initialName }: { initialName: string }) {
const [name, setName] = useState(initialName)
return (
<form>
<input value={name} onChange={(e) => setName(e.target.value)} />
</form>
)
}
위 구조에서 router.refresh()로 initialName이 바뀌면, useState는 초기화되지 않지만 “서버에서 내려온 값과 동기화”하려고 useEffect를 추가하는 순간 리셋처럼 보일 수 있습니다.
// 주의: initialName 변경 시 입력 중인 값이 덮어써짐
useEffect(() => {
setName(initialName)
}, [initialName])
이 경우는 캐시 문제가 아니라 동기화 전략 문제입니다. 해결책은 보통 다음 중 하나입니다.
- 서버에서 내려오는 값은 최초 1회만 사용하고 이후에는 클라이언트 상태를 신뢰
- “저장 성공” 같은 명시적 이벤트에서만 서버 값으로 리셋
const [name, setName] = useState(() => initialName)
// initialName 변화에는 반응하지 않음
원인 4: searchParams 변경이 “다른 페이지”로 취급되어 트리가 재생성됨
App Router에서 쿼리스트링 변경은 기본적으로 같은 라우트이지만, 컴포넌트 구조에 따라 특정 부분이 다시 렌더/교체되며 상태가 초기화된 것처럼 느껴질 수 있습니다.
특히 리스트 페이지에서 필터를 쿼리스트링으로 관리하면서, 필터 UI 상태를 서버 컴포넌트 아래에 두면 흔들릴 수 있습니다.
해결: 필터 UI는 클라이언트에서 유지하고, 데이터만 서버에 위임
- 필터 입력 UI: 클라이언트 컴포넌트
- 결과 리스트: 서버 컴포넌트(또는 서버 데이터 호출)
또는, 필터 UI 상태를 URL과 완전히 동기화할 거면 “초기화가 정상”이 되도록 UX를 설계해야 합니다.
원인 5: 개발 환경의 캐시/프리패치가 문제를 숨기거나 과장함
개발 모드에서는 Fast Refresh, HMR, React Strict Mode의 이중 호출 등으로 증상이 다르게 보일 수 있습니다. 반대로 프로덕션에서는 프리패치와 캐시가 강해져서 “가끔만” 재현될 수 있습니다.
실전 디버깅 팁
- 서버 데이터 호출 지점에 로그를 남겨 “정말 fetch가 재호출되는지” 확인
- 사용자 의존 호출은 일단
cache: 'no-store'로 바꿔 증상이 사라지는지 확인 - Provider가 언마운트되는지 React DevTools로 확인
revalidateTag또는revalidatePath적용 전후 비교
캐시가 원인인지 확인하는 가장 빠른 실험은 아래처럼 “강제로 동적”으로 바꾸는 것입니다.
// app/some-page/page.tsx
export const dynamic = 'force-dynamic'
이 설정에서 문제가 사라지면, 대개 정적/캐시 경로로 굳어버린 것이 원인입니다. 다만 이는 근본 해결이 아니라 진단용으로만 쓰는 편이 좋습니다.
권장 해결 전략: 캐시 정책을 데이터 성격으로 나누기
RSC 캐시 꼬임처럼 보이는 문제를 줄이려면, 데이터를 아래처럼 분류하고 정책을 고정하는 것이 효과적입니다.
1) 사용자/권한/세션 의존 데이터
- 기본:
cache: 'no-store' - 또는:
next: { tags: ['me'] }후 로그인/로그아웃에만 무효화
2) 자주 바뀌지 않는 공용 데이터(카테고리, 설정 등)
revalidate를 명시
await fetch('https://example.com/api/categories', {
next: { revalidate: 3600 },
})
3) 완전 정적 데이터
- 빌드 타임에 고정하거나, 긴
revalidate
마무리: “캐시 꼬임”은 대부분 경계와 정책의 문제
정리하면, Next.js RSC에서 상태가 초기화되는 것처럼 보일 때는 다음 순서로 접근하는 것이 가장 빠릅니다.
- Provider가 상위
layout에 고정되어 있고key로 리셋되지 않는지 확인 - 사용자 의존
fetch가 캐시되지 않도록no-store또는 태그 무효화 적용 router.refresh()이후 트리 구조가 바뀌지 않는지(조건부 렌더, 라우트 그룹) 확인- URL 동기화로 인해 “초기화가 정상 동작”이 되는 지점을 구분
캐시 문제는 한 번에 잡히기보다, 레이어를 분리해 하나씩 제거하면서 원인을 좁히는 방식이 가장 안정적입니다. 토큰/JWKS 캐시처럼 “캐시 무효화 타이밍”이 핵심인 사례도 많으니, 인증 관련 캐시 전략은 JWT 서명검증 실패 - kid·JWKS 캐시·키회전처럼 캐시 갱신 관점을 함께 가져가면 운영에서의 재발을 줄일 수 있습니다.