turin.blog
← 전체 글로 돌아가기

practical debugging note

Next.js 라우트 핸들러가 배포 후 예전 JSON을 계속 돌려준 날

Next.js Route Handler에서 캐시 의도를 명확히 적지 않아 배포 뒤에도 이전 응답이 보였던 상황을 확인 순서와 수정 기준으로 정리했습니다.

빠른 요약

증상은 코드보다 CDN을 먼저 의심하게 만들었다 어느 날 사이드 프로젝트의 /api/profile 응답에 새 필드가 추가됐는데, 운영 화면에서는 계속 예전 JSON이 내려왔다.

이 글에서 확인할 것
  • 증상은 코드보다 CDN을 먼저 의심하게 만들었다
  • 먼저 확인한 세 가지
  • 내가 놓친 코드
  • 수정 기준을 정해 두니 다음 문제가 줄었다
  • 다음 배포 때 보는 체크리스트

증상은 코드보다 CDN을 먼저 의심하게 만들었다

어느 날 사이드 프로젝트의 /api/profile 응답에 새 필드가 추가됐는데, 운영 화면에서는 계속 예전 JSON이 내려왔다. 로컬에서는 바로 보였고, 서버에 SSH로 들어가 curl localhost를 해도 새 값이 보였다. 그래서 처음에는 프록시나 CDN 캐시를 의심했다.

헷갈렸던 지점은 Next.js App Router의 Route Handler가 "그냥 API"처럼 느껴졌다는 점이다. 페이지 캐시만 신경 쓰고 Route Handler의 캐시 정책은 제대로 확인하지 않았다.

먼저 확인한 세 가지

운영 서버에서 같은 URL을 여러 방식으로 찍어 봤다.

curl -I https://example.com/api/profile
curl -s https://example.com/api/profile | jq
curl -s -H 'Cache-Control: no-cache' https://example.com/api/profile | jq

여기서 Cache-Control 헤더와 실제 응답 본문을 같이 봤다. 특히 브라우저 개발자 도구만 보고 판단하면 서비스 워커나 탭 캐시까지 섞여서 원인이 흐려졌다.

내가 놓친 코드

문제가 된 코드는 대략 이런 모양이었다.

export async function GET() {
  const profile = await getProfile();
  return Response.json(profile);
}

데이터가 자주 바뀌지 않는다고 생각해서 별도 옵션을 안 적었는데, 운영에서는 "언제 새로 계산해야 하는지"가 코드만 보고 명확하지 않았다. 결국 이 라우트는 항상 최신 값이 필요하다고 판단해서 아래처럼 바꿨다.

export const dynamic = 'force-dynamic';

export async function GET() {
  const profile = await getProfile();
  return Response.json(profile, {
    headers: {
      'Cache-Control': 'no-store',
    },
  });
}

수정 기준을 정해 두니 다음 문제가 줄었다

모든 API에 no-store를 붙이는 식으로 해결하지는 않았다. 내가 정한 기준은 간단하다.

  • 로그인 사용자마다 값이 달라지면 캐시하지 않는다.
  • 관리자 화면에서 수정한 값이 바로 보여야 하면 캐시하지 않는다.
  • 공개 목록처럼 조금 늦어도 괜찮으면 재검증 시간을 명시한다.
  • 캐시를 쓸 때는 응답 헤더로 의도를 확인한다.

다음 배포 때 보는 체크리스트

배포 후에는 화면만 새로고침하지 말고, API 응답과 헤더를 같이 확인한다.

curl -i https://example.com/api/profile | sed -n '1,20p'

이번 일로 배운 건 캐시가 나쁘다는 게 아니라, 캐시 의도를 코드에 남기지 않으면 나중의 내가 운영 환경에서 추리 게임을 하게 된다는 점이었다.