Next.js 렌더링 전략 개선기

Next.js 그렇게 쓰는 거 아닌데

2025-08-08

서비스 내 Next.js의 라우트 세그먼트 옵션으로 발생한 비효율과 문제점을 구체적으로 살펴보고, 이를 개선해 나간 과정 그리고 배운 점을 정리해 봅니다.

✅ 해당 문서는 Next.js v14.2.29 기준으로 작성되었습니다. 배포 환경의 경우 GCP 환경입니다.

서버 렌더링 방식과 라우트 세그먼트 옵션

문제점을 파악하기 전에 우선 Next.js의 렌더링 방식과 라우트 세그먼트 옵션을 이해해야 할 필요가 있습니다.

서버 렌더링 방식

Next.js는 React의 서버 사이드 프레임워크로 기본적으로 모든 페이지를 서버 사이드에서 렌더링 합니다. 이 서버 사이드 렌더링은 전략에 따라 3 가지 방식으로 이루어집니다.

  1. SSR (Server Side Rendering)

    • Next.js 상에선 동적 렌더링(Dynamic Rendering)으로 명칭
    • 해당 전략은 요청 시 HTML 페이지를 생성하여 반환하는 방식
    • 개인화된 컨텐츠가 필요한 페이지, 또는 동적 데이터가 필요한 페이지에 적합
  2. SSG (Static Site Generation)

    • Next.js 상에선 정적 렌더링(Static Rendering)으로 명칭
      • 별도의 동적 API나 설정이 없을 시 모든 페이지가 기본적으로 정적 렌더링 됨
    • 해당 전략은 빌드 시 HTML을 미리 생성하여 반환하는 방식
  3. ISR (Incremental Static Regeneration)

    • SSG 기반으로 동작 -> 생명 주기가 부여되어 주기에 따라 재생성 됨
    • Next.js 상에선 시간 / 요청 기반 방식으로 구현 가능
      • 시간 기반(Time-based): 특정 시간 간격을 두고 데이터를 자동으로 캐시 무효화하고 페이지를 생성하는 방식
      • 요청 기반(On-demand): 특정 요청(태그 / 경로)에 의해 수동으로 캐시를 무효화하고 페이지를 생성하는 방식

각 렌더링 전략을 빵집에 비유하면 제법 이해가 쉽습니다. 🥐

SSR은 개인 제빵사처럼, 손님 주문을 받는 즉시 반죽부터 시작합니다. 항상 갓 구운 빵을 주지만, 손님은 기다려야 합니다.

SSG는 대형 빵집처럼, 아침에 모든 빵을 미리 구워 진열해 둡니다. 손님은 바로 빵을 살 수 있지만, 늦게 오면 신선하지 않을 수 있습니다.

ISR은 이 둘의 장점을 합친 똑똑한 동네 빵집입니다. 먼저 아침에 빵을 구워두어 손님을 바로 응대합니다(SSG 방식). 그리고 '1시간 규칙'에 따라, 오래된 빵을 찾는 손님에겐 일단 그 빵을 건네는 동시에, 다음 손님을 위해 즉시 새 빵을 굽기 시작합니다.

추가적으로 PPR(Partial Prerendering)이라는 전략도 존재합니다. 해당 전략은 바뀌지 않는(정적 쉘)은 미리 만들어 즉시 보이도록 하고, 실시간 데이터가 필요한 동적인 부분은 스트리밍을 통해 렌더링 하는 방식입니다.

해당 기능은 현시점 기준 실험적인 기능입니다. 🧪

렌더링 방식을 살펴보았으니 이제 라우트 세그먼트 옵션을 살펴보겠습니다.

라우트 세그먼트 옵션

라우트 세그먼트는 페이지, 레이아웃 그리고 라우트 핸들러의 동작 방식을 설정할 수 있는 옵션입니다. 아래와 같이 직접 export 하여 설정할 수 있습니다.

export const dynamic = 'auto'
export const dynamicParams = true
export const revalidate = false
export const fetchCache = 'auto'
export const runtime = 'nodejs'
export const preferredRegion = 'auto'
export const maxDuration = 5
 
export default function MyComponent() {}

총 7 개의 옵션이 존재하지만 여기서 주의 깊게 살펴봐야 할 옵션은 dynamicrevalidate입니다. 이 두 옵션은 렌더링에 매우 직접적인 영향을 미치며, 렌더링 전략을 결정하는 중요한 요소입니다.

각 옵션이 어떻게 동작하는지 살펴보겠습니다.

  1. dynamic
export const dynamic = 'auto' | 'force-dynamic' | 'error' | 'force-static'

dynamic 옵션은 레이아웃 또는 페이지의 동작 방식을 결정하는 옵션입니다. 총 4 개의 값을 가질 수 있으며, 각 값에 따라 동작 방식이 달라집니다.

  • auto (기본값)

    • Next.js가 최대한 페이지를 정적으로 생성
    • 페이지 내 동적 API(cookies, headers 등)가 존재할 경우 -> SSR 전환
    • 또는 캐시를 사용하지 않는 fetch 요청을 발견할 경우 -> SSR 전환
  • force-dynamic

    • 동적 렌더링을 강제하는 값
    • 매 요청마다 서버에서 새로 렌더링
    • pages 디렉토리의 getServerSideProps와 동일한 동작
  • error

    • 정적 렌더링을 강제하는 값
    • 동적 API를 사용하는 페이지 또는 캐시 되지 않은 데이터 발견 시 에러 발생
  • force-static

    • 정적 렌더링을 강제하는 값
    • cookies, headers 그리고 useSearchParams 등은 빈 값으로 반환
  1. revalidate
export const revalidate = false | 0 | number

revalidate 옵션은 레이아웃 또는 페이지의 기본 캐시 재검증 시간(초)을 설정하여 ISR 또는 동적 렌더링을 제어합니다.

  • false (기본값)

    • 생성된 페이지를 무기한 캐시 (Infinity와 동일)
    • 기본적으로 정적(SSG)으로 동작하지만, 동적 API나 캐시 되지 않는 fetch를 만나면 동적 렌더링(SSR)으로 전환
  • 0

    • 페이지를 동적 렌더링으로 강제하고,
    • fetch의 기본 동작을 캐시 하지 않도록(no-store) 변경
    • fetch에 더 구체적인 캐시 옵션(force-cache 또는 양수 revalidate 값)이 있다면 그 설정 유지
  • number (초 단위)

    • ISR을 활성화
    • 설정한 시간(초) 동안은 캐시 된 페이지를 보여주고, 시간이 지난 후 다음 요청이 오면 백그라운드에서 페이지를 새로 만듦

revalidate에 숫자 값(예: 3600)을 설정해 ISR로 동작하도록 했더라도, 동적 API를 만나면 해당 페이지는 동적 렌더링(SSR)으로 전환됩니다. 정적 설정보다 동적 API가 항상 우선합니다.

또한 최솟값 우선 원칙을 적용하여 레이아웃, 페이지, fetch 옵션에 설정된 값 중 최솟값이 적용됩니다.

두 옵션을 정리하면, dynamic은 페이지의 동작 방식을 설정하고, revalidate는 재검증 시간을 설정하는 옵션입니다.

이제 본격적으로 문제점을 살펴보겠습니다.

문제점

Next.js는 흑마술을 🔮 부려 최대한 많은 페이지를 정적 페이지로 만드는데 실제 서비스에선 모든 페이지가 동적으로 생성되고 있었습니다.

"왜 모든 페이지가 빌드 시 동적으로 생성되는 거지?"라는 의문을 시작으로 문제점을 파악하게 되었습니다.

모든 페이지의 동적 생성

분명 Next.js는 기본적으로 최대한 정적으로 생성하려는 철학을 가지고 있을 텐데, 서비스 내 페이지들이 아래와 같이 빌드 시 모두 동적으로 생성되고 있었습니다.

페이지 동적 생성빌드 시 동적 페이지 생성

ƒ는 동적 생성을 의미하고, ○는 정적 생성을 의미합니다.

마치 f 학점을 받은 느낌이라 기분이 썩 좋지 않았습니다..🎓

사실, 동적으로 모든 페이지가 생성되는 것은 기능적 관점에서 문제라고 보기에는 어렵습니다. 하지만, 모든 페이지를 동적으로 생성하면 서버가 사용자의 요청마다 HTML을 새롭게 만들어야 합니다. 이로 인해 서버 과부하 및 비용 증가, 페이지 깜빡임과 느린 페이지 전환, 클라이언트 상태 유실로 인한 유저 경험 저하 등 비효율적인 상황을 초래합니다.

이런 동적 생성의 또 다른 문제가 있었는데, 바로 모든 캐싱이 무시되는 문제였습니다.

동적 생성이 강제되며, 모든 캐싱이 무시

페이지가 동적으로 생성되더라도 페이지의 렌더링에 필요한 데이터가 캐싱 되어 있다면, 동적 렌더링의 성능 문제를 완화할 수 있습니다. 하지만, 모든 데이터의 캐시가 무시된다면 데이터베이스는 부하를 받는 병목 지점이 되고, 서비스 자체의 성능 저하의 원인이 됩니다.

페이지 동적 생성캐시 무시

위의 로그를 살펴보면 두 가지 정보를 알 수 있습니다.

  1. 모든 API 요청이 캐시를 무시하고 있다.
  2. 캐시를 무시한 이유가 명확하다.
    • Cache skipped reason: (revalidate: 0)

이 명백한 증거들 덕분에 우린 서비스의 성능을 훔쳐 간 범인을 찾을 수 있게 되었습니다. 🦹 증거를 기반으로 수사망을 좁히니 범인을 쉽게 포착할 수 있었는데, 바로 앱 최상위에 정의된 layout.tsx 파일 내에 있는 코드가 주범이라는 것을 알 수 있었습니다.

루트 레이아웃 내 동적 렌더링을 강제하는 설정

범인을 찾기 위해 루트 레이아웃을 살펴보았는데, 아래와 같은 코드를 발견했습니다.

export const dynamic = 'force-dynamic'
export const revalidate = 300;
 
// ...

처음 이 코드를 보고 저는 당혹감이 들었습니다. 왜 모든 페이지를 동적으로 생성하는 force-dynamic과 300초 간격으로 페이지를 재검증하는 revalidate 옵션을 같이 설정했을까? 하는 의문이 들었습니다.

이 두 옵션을 서로 상충되는 옵션으로, 비유하자면 마치 "술은 마셨지만 음주운전은 하지 않았다"와 같은 느낌입니다..😵‍💫

루트 레이아웃에 적용된 force-dynamic 설정으로 인해, Next.js의 우선순위 규칙에 따라 동적 렌더링이 강제되면서 모든 페이지의 데이터 캐시가 무시되고 있었습니다.

문제점을 찾았으니 드디어..! 이를 해결하는 방법을 살펴보겠습니다.

해결 과정

루트 레이아웃 내 동적 렌더링을 강제하는 세그먼트 옵션 제거

해결법은 너무 간단합니다. 루트 레이아웃에 적용된 세그먼트 옵션을 망설임 없이 제거하면 됩니다.

export const dynamic = 'force-dynamic' //  제거
export const revalidate = 300;

이렇게 동적 렌더링을 강제하는 옵션을 제거하면 Next.js에 의해 동적 API를 사용하는 곳은 동적으로 렌더링 되고, 그 외 페이지의 경우 정적으로 렌더링 됩니다.

이것으로 문제가 해결되었을까요? 정답은 아니었습니다.. export const dynamic = 'force-dynamic'을 제거하자 다른 문제가 발생했습니다.

빌드 시 API 서버 미실행 문제

실제로 개선된 코드가 적용이 되었는지 확인하기 위해 너무나 즐거운 마음으로 빌드 명령어를 실행했는데, 아래와 같은 에러가 발생했습니다.

{
  code: 500,
  message: 'INTERNAL SERVER ERROR',
  cause: 'UNKNOWN ERROR',
  digest: '3141274559'
}
TypeError: fetch failed
    at node:internal/deps/undici/undici:15422:13
    at process.processTicksAndRejections (node:internal/process/task_queues:105:5) {
  [cause]: AggregateError [ECONNREFUSED]:
      at internalConnectMultiple (node:net:1134:18)
      at afterConnectMultiple (node:net:1715:7) {
    code: 'ECONNREFUSED',
    [errors]: [ [Error], [Error] ]
  }
}

난생처음으로 마주하는 에러여서 적잖이 당황하였습니다. 하지만 너무 친절한 에러 메시지 덕분에 문제를 쉽게 파악할 수 있었습니다.

해당 에러는 "빌드 시 API 서버가 실행이 안 되었는데 어떻게 API 요청을 하겠다는 거야?"로 한 줄 요약해 볼 수 있습니다.

해당 에러를 단계별로 분석해 보면 아래와 같습니다:

  1. code: 'ECONNREFUSED'

    • ECONNREFUSED는 Connection Refused (연결 거부)의 약자
    • HTTP 에러가 아닌, 더 낮은 단계인 운영체제 네트워크 레벨에서 발생하는 에러
    • 특정 포트로 찾아갔으나, 해당 주소엔 프로세스 자체가 없음 -> 사실상 문전박대..💧
  2. [cause]: AggregateError [ECONNREFUSED]:

    • 운영체제로부터 받은 'ECONNREFUSED'를 자바스크립트 세상으로 가져온 것
    • Node.js의 내장 모듈인 net 모듈에서 발생하는 에러
    • Node.js가 에러 상황을 보고하는 과정
  3. TypeError: fetch failed

    • 애플리케이션 레벨에서 발생하는 에러
    • undici 모듈에서 발생하는 에러 -> fetch 함수의 실제 구현체
    • 통신이 불가능하다 판단하여 발생

그렇다면 서비스의 어떤 코드 때문에 이런 에러를 불같이 뿜어대고 있는 걸까요? 위에서 force-dynamic을 잡았을 때처럼 범인을 색출하는 과정을 거쳐 용의자를 찾아냈습니다.

초기에는 rewrites 옵션이 범인이라고 생각했습니다. 하지만, 범인은 바로 fetch 함수 호출 시 사용되는 url 주소였습니다.

rewrites 옵션

그렇다면 저를 헷갈리게 만든 rewrites 옵션을 살펴보겠습니다.

async rewrites() {
  return [
    {
      // (1) 출발지 (Source)
      source: '/api/:path*',
 
      // (2) 목적지 (Destination)
      destination: {
        development: 'http://dev.api.kr/:path*',
        staging: 'http://staging.api.kr/:path*',
        qa: 'http://qa.api.kr/:path*',
        production: 'https://api.kr/:path*',
      }[ENV], // (3) 환경 변수(ENV)에 따라 목적지 선택
    },
  ];
},

해당 설정은 개발, 스테이징, 프로덕션 등 현재 실행 환경(ENV)에 맞게, 프론트엔드에서 /api/로 시작하는 모든 요청을 각 환경에 맞는 실제 API 서버 주소로 내부적으로 프록시를 태우는 설정입니다. 첨부된 이미지의 로그를 살펴보면 rewrites 설정에 따라 /api/ 경로로 들어온 요청들을 처리하고 있음을 보여줍니다.

빌드 에러의 진짜 원인

에러가 발생했던 이유는 바로, 빌드 시 존재하지 않는 http://localhost:로 API를 호출하였기 때문입니다. 즉, 개발 환경과 빌드 환경의 차이에서 발생한 문제였습니다.

개발 환경에서는 next dev 명령어로 웹 서버가 실행되어 있어 로컬호스트 주소가 유효하지만, next build는 코드를 정적 파일로 변환하는 과정일 뿐 실제 웹 서버를 실행하지 않습니다. 따라서 응답할 서버가 없는 상태에서 API를 호출하니 연결이 거부된 것입니다.

fetch 함수 호출 코드
const apiServer = `http://localhost:${process.env.USE_DYNAMIC_PORTS ?? 8080}`;
 
fetch(`${apiServer}/api/...`)

Next.js는 빌드 시 페이지를 생성하기 위해 해당 페이지에 종속된 데이터를 위한 API 요청이 필요합니다. 이때, 위 코드의 apiServer 변수를 사용해 fetch를 시도했고, 존재하지 않는 주소로 요청을 보내 ECONNREFUSED 에러를 일으켰던 것입니다.

이를 해결하기 위해 환경별 API 주소를 명확하게 정의하고, 빌드 시점에 실제 환경에 맞는 API 주소를 사용하도록 수정했습니다.

환경별 API 주소 정의
// ...
const apiBaseUrl = {
  development: 'http://dev.api.kr',
  staging: 'http://staging.api.kr',
  qa: 'http://qa.api.kr',
  production: 'https://api.kr',
}
 
const apiServer = apiBaseUrl[environment]

이제 문제가 다 해결되었습니다. 실제로 잘 적용이 되었는지 확인해 보겠습니다.

페이지 정적 생성빌드 시 정적 페이지 생성

짜잔.. 드디어 정적 페이지들이 생성되게 되었습니다 🥳

이 과정에 저는 rewrites 옵션이 저뿐만 아니라 팀원들에게 불필요한 혼란을 주고 있고, 또 프록시 설정이 필요 없다는 생각이 들어 의사결정 후 제거하였습니다.

CORS 대응이 서버에서 되어 있으며, 각 환경별 API 주소가 명확하게 정의되어 있어, 굳이 프록시 설정을 두는 것보다 코드에서 직접 실제 API 주소를 호출하는 것이 더 직관적이라고 판단했습니다.

그럼 왜 force-dynamic 옵션이 존재했을까?

팀원들과 왜 코드 상에 해당 옵션이 존재했을까 머리를 맞대고 고민해 보았고, 두 가지 예상되는 이유를 정리해 보았습니다.

  1. Next.js 13 App 라우터 도입 시 숙고하지 않아 생긴 기술 부채

    • 도입 당시, 새로 등장한 멘탈 모델에 대한 이해 부족
    • 기본적으로 정적 페이지를 생성한다는 점 간과
  2. 빌드 시점의 API 호출 에러를 우회하려는 목적

    • 개발 환경에서는 로컬 호스트로 호출하는 것이 정상적으로 동작하지만, 빌드 환경에서는 로컬 서버가 실행되지 않아 로컬 호스트로 호출하는 것이 불가능함

저는 2번 빌드 에러라는 기술적 문제를 만나, 1번 미숙지 방식으로 해결을 한것이 아닐까 하는 결론을 내렸습니다.

그럼 이제 실제 개선의 효과를 살펴보겠습니다.

개선 효과

개선 효과는 기대 이상으로 극적이었습니다. GCP Cloud Run에서 수집된 실제 운영 데이터를 통해, 이번 최적화가 서버 부하, 응답 속도, 비용 효율성 등 다방면에 걸쳐 얼마나 긍정적인 영향을 미쳤는지 명확하게 확인할 수 있었습니다.

💡 모든 분석은 실제 프로덕션 운영 환경에서 25년 5월 14일 배포를 기점으로 수집된 정량적 로그를 기반으로 합니다.

먼저 시각적인 데이터를 통해 변화를 살펴보겠습니다. 아래 그래프들은 배포 시점을 기준으로 주요 지표들이 극적으로 안정화되는 모습을 명확히 보여줍니다.

컨테이너 CPU 사용률컨테이너 CPU 사용률
요청 수요청 수
수신 바이트수신 바이트

이러한 변화를 정량적으로 분석한 결과는 아래 표와 같습니다. 각 지표가 어떻게 개선되었고, 이것이 어떤 기술적 의미를 갖는지 구체적으로 풀어보겠습니다.

측정 지표

개선 전

개선 후

개선율

기술적 의미

요청 수

~80 req/s

~20 req/s

75% ▼

오리진 서버 부하의 근본적인 감소

요청 지연 시간 (p95)

~800 ms

~200 ms

75% ▼

사용자 체감 속도 및 Core Web Vitals 점수 대폭 향상

컨테이너 CPU 사용률

~70%

~40%

43% ▼

서버 연산 비용 감소 및 리소스 효율 증대

네트워크 트래픽

~25 MB/s

~5 MB/s

80% ▼

네트워크 비용 절감 및 빠른 콘텐츠 전송

활성 컨테이너 수

~10 개

~4 개

60% ▼

인프라 규모 축소 및 직접적인 비용 절감

  1. 서버 부하 및 요청 수의 근본적 감소 (요청 수 75%▼, CPU 사용률 43%▼)

    가장 핵심적인 변화는 사용자 요청과 서버의 연산 부하를 분리한 것입니다. 캐시가 대부분의 트래픽을 처리하면서 오리진 서버로 향하는 실제 요청 수가 75% 감소했습니다. 이는 곧 서버가 수행해야 할 연산량이 43% 줄어드는 결과로 이어져, 전반적인 시스템 효율성과 안정성을 극대화했습니다.

  2. 사용자 경험 향상 (요청 지연 시간 75%▼)

    서버 부하 감소는 즉시 사용자 경험 개선으로 나타났습니다. 요청 지연 시간이 800ms에서 200ms로 4분의 1 수준으로 단축되면서, 사용자는 거의 즉각적인 페이지 로딩을 경험하게 되었습니다. 이는 서비스 만족도와 직결되는 Core Web Vitals 지표 개선에도 결정적인 기여를 합니다.

  3. 인프라 및 네트워크 비용의 직접적인 절감 (활성 컨테이너 수 60%▼, 네트워크 트래픽 80%▼)

    이번 최적화는 비용 절감 측면에서도 성과를 거두었습니다.

    • 인프라 비용: 동일한 트래픽을 처리하는 데 필요한 컨테이너 수가 60%나 줄었습니다. 이는 GCP Cloud Run의 핵심 과금 지표인 '청구 가능한 인스턴스 시간'의 대폭 감소를 의미하며, 인프라 비용을 직접적으로 절감시켰습니다.

    • 네트워크 비용: 불필요한 데이터 전송이 줄면서 네트워크 트래픽이 80% 감소했습니다. 이는 클라우드 비용 절감은 물론, 사용자가 더 적은 데이터로 빠르게 콘텐츠를 내려받을 수 있음을 의미합니다.

결론적으로, Next.js의 렌더링 전략을 올바르게 이해하고 적용하는 것만으로도 서버 부하와 비용은 극적으로 줄이고, 사용자 경험과 서비스 안정성을 크게 향상시킬 수 있게 되었습니다.

개선 과정에서 배운 점

Next.js를 실제로 사용하면서, 너무 자주 바뀌는 스펙과 API 등에 치여 피로도가 높아져 "버셀 또 너야?"라고 생각한 게 하루에도 수십 번이었습니다. 🥹 코드 상에도 기술 부채가 존재했지만, 저 스스로도 기술 부채가 점점 쌓이고 있음을 느꼈습니다. 이대론 안되겠다 싶어, 이번 개선을 진행하며 저 스스로 확신을 갖지 않고 사용하던 기능들의 개념을 확실히 이해하기로 했습니다.

1. revalidate = 0 === dynamic = ‘force-dynamic’?

revalidate = 0의 동작 방식

revalidate = 0은 해당 라우트의 기본 재검증 주기를 0초로 설정하여 모든 요청마다 데이터를 다시 가져오도록 합니다. 이 설정은 다음과 같은 특징을 가집니다.

  • 기본적으로 동적 렌더링: 해당 페이지는 매 요청 시 서버에서 새롭게 렌더링 됩니다.
  • 개별 fetch 요청의 캐시 설정 존중: 만약 페이지 내의 개별 fetch 요청에서 cache: 'force-cache'나 양수의 revalidate 값을 명시적으로 설정했다면, 해당 fetch 요청은 캐시 될 수 있습니다. 즉, 페이지 자체는 동적으로 렌더링 되지만, 페이지를 구성하는 일부 데이터는 캐시 된 값을 사용할 수 있는 유연성을 가집니다.

dynamic = 'force-dynamic'의 동작 방식

dynamic = 'force-dynamic'은 해당 라우트의 모든 캐싱을 비활성화하고 무조건 동적으로 렌더링하도록 강제합니다. 이 설정의 특징은 다음과 같습니다.

  • 강제적 동적 렌더링: 페이지는 항상 요청 시에 렌더링 되며, 정적으로 분석될 여지를 남기지 않습니다. 이는 과거 Pages Router의 getServerSideProps와 가장 유사한 동작 방식입니다.
  • 모든 fetch 요청 캐시 무시: 페이지 내의 모든 fetch 요청은 { cache: 'no-store' }로 처리되는 것과 동일합니다. 개별 fetch 요청에 cache 옵션을 설정하더라도 이 설정이 우선되어 캐시가 비활성화됩니다.

2. revalidate = 0, 개별 fetch 캐시 옵션

revalidate = 0: 개별 fetch 캐시 옵션을 따라감 ✅

이 설정은 페이지의 기본 동작을 동적으로 만들 뿐, 개별 fetch 요청에 설정된 고유한 캐시 전략을 막지 않습니다.

따라서 페이지 자체는 매번 새로 렌더링 되도록 준비되지만, 그 안의 특정 fetch 요청에 next: { revalidate: 60 }과 같은 캐시 옵션이 있다면, 해당 데이터는 설정된 캐시 옵션을 그대로 따라갑니다. 이는 페이지의 동적 렌더링과 데이터의 선택적 캐시를 조합할 수 있는 유연성을 제공합니다.


3. dynamic = ‘force-dynamic’ , 개별 fetch 캐시 옵션

dynamic = 'force-dynamic': 개별 fetch 캐시 옵션을 상쇄(무시)함 ✅

이 설정은 더 강력하고 포괄적인 규칙입니다. 라우트 전체에 "어떠한 캐시도 허용하지 말고 무조건 동적으로만 동작해"라는 명령과 같습니다.

그 결과, 해당 라우트 내의 모든 fetch 요청은 개별적으로 어떤 캐시 옵션(force-cacherevalidate 시간 설정 등)을 가지고 있더라도 모두 무시되고 { cache: 'no-store' }로 강제됩니다.


4. revalidate 최솟값 우선 규칙

페이지를 생성할 때 여러 fetch 요청이 각각 다른 revalidate 시간을 가지고 있다면, Next.js는 그중 가장 짧은 revalidate 시간을 해당 페이지 전체의 재검증 주기로 사용합니다.

이 방식은 ISR의 핵심 동작 원리입니다.

최소 revalidate 속성을 따르는 이유

Next.js가 가장 짧은 주기를 선택하는 이유는 데이터의 최신성을 보장하기 위해서입니다.

예를 들어, 한 페이지 안에 다음과 같은 두 개의 데이터 요청이 있다고 가정해 보겠습니다.

  • 실시간 인기 뉴스 목록: 10초마다 갱신 필요 (revalidate: 10)
  • 날씨 정보: 1시간마다 갱신 필요 (revalidate: 3600)

만약 이 페이지가 1시간 주기로 갱신된다면, 인기 뉴스 목록은 최대 1시간 동안 이전의 낡은 정보로 남아있게 됩니다. 이를 방지하기 위해 Next.js는 가장 짧은 주기인 10초를 페이지 전체의 재검증 주기로 설정합니다. 이렇게 함으로써 페이지의 최신성을 보장할 수 있습니다. 🧐

따라서 '최소 revalidate 속성을 따른다'는 규칙은 정적 페이지에 '재생성'이라는 동적인 요소를 부여하는 ISR의 고유한 특징이자 동작 방식이라고 할 수 있습니다.


5. revalidate 최솟값 우선 규칙은 언제 적용되는가

제가 가장 헷갈렸던 내용입니다.

다음 설정들은 페이지를 동적 렌더링으로 강제하는 '모드 스위치' 역할을 합니다.

  • export const revalidate = 0;
  • export const dynamic = 'force-dynamic';
  • 페이지/레이아웃에서 cookies(), headers() 등 동적 API 사용

저는 export const revalidate = 0; 을 사용하면 최솟값이 0이기에 해당 설정이 우선되어야 하는 의문을 가졌습니다. 하지만 이러한 동적 렌더링 스위치가 켜지면, ISR이 아닌 SSR로 동작하게 됩니다. 따라서 ISR에서 적용되는 '최솟값 우선 규칙'은 적용되지 않습니다.


6. 데이터 캐시 vs 전체 라우트 캐시

Next.js의 캐싱은 크게 두 가지로 나뉩니다.

  • 데이터 캐시 (Data Cache): fetch를 통해 가져온 개별 데이터를 캐싱 합니다. 요청 간에 공유됩니다.
  • 전체 라우트 캐시 (Full Route Cache): 렌더링 된 페이지와 RSC 페이로드를 캐싱 합니다. 이 덕분에 정적 페이지(SSG/ISR)는 매우 빠르게 로드될 수 있습니다. 또한 페이지 이동 시 RSC 페이로드를 사용하여 빠르게 탐색할 수 있습니다. 렌더링 전략별 캐시 사용법

어떤 렌더링 전략을 사용하느냐에 따라 사용하는 캐시가 다릅니다.

  • SSG / ISR (정적 페이지): 두 캐시 모두 사용 ✅
    • fetch로 데이터를 가져와 데이터 캐시에 저장하고, 그 데이터로 렌더링 한 페이지를 전체 라우트 캐시에 저장합니다.
  • SSR (동적 페이지): 데이터 캐시만 사용 ✅
    • 페이지를 요청마다 새로 렌더링 하므로 전체 라우트 캐시는 사용하지 않습니다.
    • 하지만 렌더링 과정에서 데이터 캐시를 활용하여 불필요한 API 호출을 줄이고 속도를 높입니다.

7. fetch no-store

동작 방식의 차이: 상향식 vs 하향식

  • fetch(..., { cache: 'no-store' }): 상향식(Bottom-up) 결정

    • 개별 데이터 하나를 캐싱 하지 않겠다고 선언하는 것입니다.
    • 이 동적인 특성 때문에, 해당 데이터를 사용하는 페이지 전체가 동적 렌더링으로 전환됩니다.
    • 하지만 이 결정이 페이지 내의 다른 fetch 요청에는 영향을 주지 않습니다. 다른 fetchforce-cacherevalidate 시간을 가지고 있다면 그 캐시는 여전히 존중됩니다.
  • dynamic = 'force-dynamic': 하향식(Top-down) 결정

    • 페이지 전체가 동적으로 렌더링 되어야 한다고 명령하는 것입니다.
    • 이 라우트 세그먼트는 그 하위 모든 fetch 요청에 전파되어, 개별 fetch가 어떤 캐시 설정을 가지고 있더라도 모두 무시하고 강제로 no-store처럼 동작하게 만듭니다.

7. next 15의 변화

fetch 기본값 변경: auto no cache

Next.js 15의 기본값(auto no cache)은 개발 환경에서 항상 신선한 데이터를 가져오고, 빌드/프로덕션에서는 동적 API의 존재 여부에 따라 정적으로 프리랜더 되거나 요청마다 SSR로 동작합니다.


8. dynamic = 'force-dynamic' 아닌 동적 API를 만나서 동적 렌더링으로 전환될 경우도 데이터 캐시는 유지되는가

  • cookies() 등 동적 API 사용 시:
    1. Next.js가 동적 API를 감지하고 "이 페이지는 요청 시 렌더링 해야겠다"고 결정합니다.
    2. 페이지 렌더링 과정에서 만나는 fetch 요청들은 자신이 가진 캐시 옵션(revalidate 시간 등)을 그대로 유지합니다.
    3. 따라서 캐시 된 데이터가 유효하면 데이터 캐시에서 값을 가져와 렌더링 속도를 높이고, 유효하지 않으면 새로 가져옵니다. 데이터 캐시를 적극적으로 활용합니다.
  • export const dynamic = 'force-dynamic' 사용 시:
    1. Next.js가 이 설정을 보고 "이 페이지의 모든 것은 예외 없이 동적이어야 해"라고 결정합니다.
    2. 페이지 내의 모든 fetch 요청에 전파되어, 개별 캐시 설정을 무시하고 { cache: 'no-store' }로 강제합니다.
    3. 결과적으로 데이터 캐시를 전혀 사용하지 않게 됩니다.

후기

이번 개선을 통해 문제를 최초로 제기하고 PoC를 통해 문제를 확인하고 해결하는 경험을 할 수 있었습니다. 스스로 왜라는 질문을 끊임없이 던지며 문제 해결 과정에서 많은 것을 배웠고, 명확하지 않았던 개념들을 확실히 이해하고 적용할 수 있었습니다.

단순 개선에 그치지 않고 Next.js의 렌더링 전략과 라우트 세그먼트의 동작 방식 등에 대해 습득할 수 있었으며, 이를 바탕으로 서비스 특성에 맞는 렌더링 전략으로 적용할 수 있었습니다. 또한 데이터 기반의 성과 측정을 통해 개선 효과를 정량적으로 입증할 수 있었던 점이 특히 만족스럽습니다.