Published on

Next.js App Router RSC 캐시·상태 꼬임 해결

Authors

서버 컴포넌트(React Server Components, RSC) 기반의 Next.js App Router는 성능과 DX를 크게 올려주지만, 캐시 계층이 여러 겹으로 쌓이면서 데이터는 갱신됐는데 UI는 이전 값을 보여주거나, 반대로 클라이언트 상태는 최신인데 서버 렌더 결과는 과거인 “상태 꼬임(desync)”이 자주 발생합니다.

이 글은 “왜 이런 일이 생기는지”를 캐시 레이어별로 분해하고, 재현 가능한 진단 루틴안전한 해결 패턴을 코드로 정리합니다.

1) App Router에서 ‘캐시’가 꼬이는 전형적 증상

다음 중 하나라도 겪었다면, 높은 확률로 RSC 캐시·재검증 경로가 어긋난 상태입니다.

  • POST/PATCH로 변경 후 router.refresh()를 했는데도 목록이 그대로
  • 특정 사용자만 “가끔” 최신 데이터가 안 보임(서버 인스턴스/리전/에지에 따라 다름)
  • revalidateTag()를 호출했는데 해당 페이지는 안 바뀌고 다른 페이지는 바뀜
  • 같은 API를 fetch()로 부르면 최신인데, 서버 컴포넌트에서 부르면 과거 데이터
  • 개발환경에서는 정상인데 프로덕션에서만 재현

이런 문제는 보통 “한 군데만 고치면” 해결되지 않습니다. 어떤 캐시가 값을 잡고 있는지를 먼저 특정해야 합니다.

2) RSC 렌더 경로의 캐시 레이어 지도

App Router에서 데이터가 화면에 도달하는 경로에는 대표적으로 아래 레이어가 있습니다.

  1. Data Cache: 서버에서 fetch() 결과를 캐싱
  2. Full Route Cache: 라우트 단위 HTML/RSC 페이로드 캐싱(정적화되면 강력)
  3. Client Router Cache: 클라이언트 내비게이션 시 RSC 결과를 재사용
  4. 외부 캐시: CDN, 프록시, 브라우저 캐시, 백엔드 캐시

핵심은 “내가 바꾼 설정이 어느 레이어를 무효화하는지”입니다.

  • revalidatePath() / revalidateTag()는 주로 Data Cache / Route Cache 재검증 관점
  • router.refresh()Client Router Cache를 갱신하며 서버에 RSC를 다시 요청
  • fetch(..., { cache: 'no-store' })Data Cache를 아예 쓰지 않음

즉, router.refresh()만으로는 Data Cache가 여전히 과거라면 그대로 과거가 다시 내려올 수 있습니다.

3) 가장 흔한 원인 5가지

원인 A: 서버 컴포넌트 fetch()가 기본 캐시로 고정됨

서버 컴포넌트에서 같은 URL을 fetch()하면, Next가 Data Cache에 저장해 재사용할 수 있습니다. 데이터 변경 후에도 캐시 무효화가 안 되면 “영원히” 예전 값처럼 보일 수 있습니다.

해결은 두 갈래입니다.

  • 변경이 잦고 항상 최신이 필요하면 no-store
  • 일정 주기로 괜찮으면 revalidate + 태그 무효화

원인 B: Full Route Cache로 라우트 자체가 정적화됨

페이지가 의도치 않게 정적(Static)으로 판단되면, 라우트 결과가 캐시되어 “서버에서 다시 렌더” 자체가 일어나지 않습니다.

단서

  • 서버 로그가 안 찍힘
  • 배포 후 특정 시점까지 값이 고정

대응

  • 해당 라우트에서 export const dynamic = 'force-dynamic' 또는 적절한 revalidate 설정

원인 C: revalidateTag()는 했는데 fetchtags를 안 달았다

태그 기반 무효화는 “태그를 달고 캐시한 데이터”에만 적용됩니다. 태그를 빼먹으면 revalidateTag('x')는 아무 효과가 없습니다.

원인 D: 클라이언트에서 낙관적 업데이트(optimistic) 후 서버와 불일치

예를 들어 목록에서 아이템을 삭제했다 치고, 클라이언트는 즉시 제거했는데 서버는 캐시로 인해 삭제 전 목록을 다시 내려주면, refresh 순간에 아이템이 “부활”합니다.

이때는

  • 서버가 최신을 내려주도록 캐시 무효화를 맞추거나
  • 클라이언트 상태를 서버 응답으로 강제 동기화

둘 중 하나가 필요합니다.

원인 E: 동일한 데이터 소스를 서로 다른 방식으로 가져옴

어떤 곳은 서버 컴포넌트 fetch()로, 어떤 곳은 클라이언트에서 API 호출로 가져오면 캐시 정책이 달라집니다. 결과적으로 “한 화면 안에서도” 데이터가 서로 다른 시점을 가리킬 수 있습니다.

4) 진단 루틴: 어떤 캐시가 문제인지 10분 안에 좁히기

4-1. 서버 컴포넌트에 로그 심기

서버 컴포넌트는 서버에서 실행되므로, 가장 먼저 “진짜로 다시 렌더링이 일어나는지” 확인합니다.

// app/items/page.tsx
export default async function Page() {
  console.log('[items] render at', new Date().toISOString())
  // ...
}
  • router.refresh() 후에도 로그가 안 뜨면 Full Route Cache 또는 정적화 의심
  • 로그는 뜨는데 데이터만 과거면 Data Cache 또는 외부 캐시 의심

4-2. 문제 데이터 fetch에 임시로 no-store 붙여보기

await fetch('https://api.example.com/items', { cache: 'no-store' })

이걸로 문제가 사라지면 Data Cache 관련입니다. 그 다음 “영구 no-store로 갈지” “태그/재검증으로 갈지”를 결정합니다.

4-3. 네트워크에서 RSC 요청이 실제로 나가는지 확인

브라우저 개발자도구에서 내비게이션/리프레시 시점에

  • RSC 페이로드 요청이 발생하는지
  • 응답 헤더에서 캐시 관련 힌트가 있는지

를 봅니다.

4-4. 변경 API가 성공했는데도 안 바뀐다면 ‘무효화 호출 위치’를 의심

revalidatePath() / revalidateTag()서버에서만 의미가 있습니다. 클라이언트에서 호출하려고 하면 구조적으로 맞지 않습니다.

서버 액션 또는 API 라우트에서 mutation 직후 실행되도록 고정하세요.

5) 해결 패턴 1: 태그 기반 캐시 + 서버 액션 무효화(추천)

변경이 발생할 때만 정확히 무효화하고, 읽기는 캐시를 활용하는 가장 균형 잡힌 방식입니다.

5-1. 읽기: fetch에 태그와 revalidate 부여

// app/lib/items.ts
export async function getItems() {
  const res = await fetch('https://api.example.com/items', {
    next: {
      tags: ['items'],
      revalidate: 60,
    },
  })

  if (!res.ok) throw new Error('failed to fetch items')
  return res.json() as Promise<Array<{ id: string; name: string }>>
}

포인트

  • tags가 있어야 revalidateTag('items')가 작동합니다.
  • revalidate: 60은 “최대 60초까지는 캐시를 재사용”한다는 의미입니다.

5-2. 쓰기: 서버 액션에서 변경 후 revalidateTag

// app/items/actions.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function deleteItem(id: string) {
  const res = await fetch(`https://api.example.com/items/${id}`, {
    method: 'DELETE',
    cache: 'no-store',
  })

  if (!res.ok) throw new Error('failed to delete')

  revalidateTag('items')
}

여기서 mutation 요청은 보통 캐시하면 안 되므로 cache: 'no-store'를 붙이는 편이 안전합니다.

5-3. UI: 액션 후 router.refresh()로 클라이언트 캐시 동기화

// app/items/ItemListClient.tsx
'use client'

import { useRouter } from 'next/navigation'
import { deleteItem } from './actions'

export function ItemListClient({ items }: { items: Array<{ id: string; name: string }> }) {
  const router = useRouter()

  return (
    <ul>
      {items.map((it) => (
        <li key={it.id}>
          {it.name}{' '}
          <button
            onClick={async () => {
              await deleteItem(it.id)
              router.refresh()
            }}
          >
            delete
          </button>
        </li>
      ))}
    </ul>
  )
}

정리하면

  • revalidateTag('items')로 서버 캐시 무효화
  • router.refresh()로 클라이언트 라우터 캐시 갱신

이 두 개가 함께 가야 “삭제했는데 목록이 다시 살아나는” 현상을 줄일 수 있습니다.

6) 해결 패턴 2: 페이지 단위 무효화가 필요하면 revalidatePath

목록뿐 아니라 여러 컴포넌트가 얽혀 있고 “해당 경로 전체를” 다시 계산해야 한다면 revalidatePath()가 단순합니다.

'use server'

import { revalidatePath } from 'next/cache'

export async function updateProfile() {
  // ...mutation...
  revalidatePath('/settings')
}

주의점

  • 경로 기반 무효화는 영향 범위가 넓어 비용이 커질 수 있습니다.
  • revalidatePath도 결국 “그 경로가 캐시되고 있을 때” 의미가 있습니다.

7) 해결 패턴 3: 정말로 항상 최신이어야 하면 no-store

관리자 화면, 결제 상태, 재고/좌석 같은 “지금 이 순간이 중요”한 데이터는 캐시가 독이 됩니다.

export async function getStock(productId: string) {
  const res = await fetch(`https://api.example.com/stock?productId=${productId}`, {
    cache: 'no-store',
  })
  if (!res.ok) throw new Error('failed to fetch stock')
  return res.json() as Promise<{ qty: number }>
}

이 패턴은 단순하지만 트래픽이 늘면 백엔드 부담이 커집니다. 가능하면 “핵심 데이터만” no-store로 제한하세요.

8) 상태 꼬임을 줄이는 UI 설계: 단일 소스와 키 전략

8-1. 서버가 내려준 값을 클라이언트 상태의 기준으로 삼기

클라이언트에서 useState로 복제한 뒤 오래 들고 있으면, 서버 갱신과 어긋나기 쉽습니다. 가능한 한

  • 서버 컴포넌트에서 데이터를 가져오고
  • 클라이언트 컴포넌트는 인터랙션만 담당

하도록 경계를 명확히 합니다.

8-2. 리스트 키는 안정적인 식별자 사용

리스트에서 key를 인덱스로 쓰면 삭제/삽입 시 UI가 엉뚱한 항목과 상태를 공유해 “꼬임”이 심해집니다.

{items.map((it) => (
  <Row key={it.id} item={it} />
))}

8-3. 낙관적 업데이트를 쓴다면 서버 응답으로 최종 정합성 확정

낙관적 업데이트는 UX는 좋지만 캐시와 만나면 부작용이 큽니다. 최종적으로는

  • 액션 성공 후 router.refresh()
  • 또는 서버가 반환한 최신 엔티티로 상태를 덮어쓰기

중 하나로 “정답을 서버로” 확정하세요.

9) 프로덕션에서만 재현될 때 체크리스트

  • 배포 환경에 CDN 캐시가 있는가
  • API 응답 헤더에 Cache-Control이 과하게 설정되어 있지 않은가
  • 서버리스/엣지에서 리전별로 캐시가 분리되어 “어떤 사용자는 최신, 어떤 사용자는 과거”가 되는가
  • 로그가 특정 인스턴스에서만 찍히는가

이때는 “웹 성능 문제를 Long Task로 쪼개듯” 관측 지점을 늘려야 합니다. 프론트 상호작용이 느려서 갱신이 늦어 보이는 착시도 있으니, 필요하면 Chrome INP 급락 - Long Task 찾고 쪼개기처럼 성능 관점도 함께 확인하세요.

또한 분산 환경에서는 “재시도/백오프/큐잉” 같은 안정화 기법이 캐시 무효화 호출 폭주를 막는 데 도움이 됩니다. 대규모 트래픽에서 무효화 이벤트가 몰린다면 OpenAI 429 Rate Limit 재시도·백오프 구현 가이드의 접근을 웹훅/이벤트 처리에도 응용할 수 있습니다.

10) 실전 결론: 가장 안전한 기본 조합

대부분의 CRUD 화면에서 “성능과 정합성”을 균형 있게 가져가려면 아래 조합이 재현성과 유지보수성이 좋습니다.

  • 읽기 fetch: next: { tags: ['domain-x'], revalidate: N }
  • 쓰기(mutation): 서버 액션에서 처리하고 성공 직후 revalidateTag('domain-x')
  • 클라이언트: 액션 후 router.refresh()로 RSC 재요청
  • 정말 최신 필수 데이터만 cache: 'no-store'

이 조합을 팀 규칙으로 정하면 “왜 어떤 화면은 갱신되고 어떤 화면은 안 되지” 같은 논쟁이 크게 줄어듭니다.


부록: 최소 예제 구조

app/
  items/
    actions.ts
    page.tsx
    ItemListClient.tsx
  lib/
    items.ts
// app/items/page.tsx
import { getItems } from '../lib/items'
import { ItemListClient } from './ItemListClient'

export default async function Page() {
  const items = await getItems()
  return <ItemListClient items={items} />
}

이 구조를 기준으로, “읽기는 태그 캐시, 쓰기는 무효화, UI는 refresh로 동기화”를 습관화하면 App Router의 RSC 캐시로 인한 상태 꼬임을 대부분 예방할 수 있습니다.