Skip to content
iik89
Go back

캐시 TTL과 스트리밍: TTFB 병목 없는 렌더링 설계

목차

캐시에 수명을 부여하는 이유

'use cache' 지시어로 컴포넌트를 캐시하면 성능은 극적으로 개선된다. 하지만 캐시된 데이터는 시간이 지나면 현실과 괴리가 생긴다. 3시간 전에 캐시된 뉴스 헤드라인을 보여주는 대시보드는 사용자에게 신뢰를 잃는다.

캐시의 본질적인 트레이드오프는 신선도(freshness)와 성능(performance) 사이의 균형이다. 이 균형점을 잡는 메커니즘이 TTL(Time To Live), 즉 캐시 유효 기간이다.


cacheLife: 세 개의 시간 축

Next.js의 cacheLife는 단순한 만료 시간이 아니다. stale, revalidate, expire 세 가지 독립적인 시간 축으로 캐시의 생애 주기를 제어한다.

"use cache";

import { cacheLife } from "next/cache";

export async function getBreakingNews() {
  cacheLife({
    stale: 60,       // 60초간 클라이언트가 캐시를 그대로 사용
    revalidate: 300,  // 300초 후 백그라운드에서 갱신 시작
    expire: 3600,     // 3600초(1시간) 후 캐시 완전 폐기
  });

  return fetchNewsFromAPI();
}

각 속성의 역할은 다음과 같다.

속성역할비유
stale클라이언트가 서버에 확인 없이 캐시를 사용하는 시간냉장고에서 꺼내 바로 먹는 기간
revalidate이 시간이 지나면 다음 요청 시 백그라운드에서 새 데이터를 가져온다유통기한이 지나면 새 제품을 주문하되, 기존 제품을 먼저 제공
expire이 시간이 지나도록 요청이 없으면 캐시를 완전히 삭제한다아무도 찾지 않는 재고를 창고에서 폐기

세 속성의 시간 흐름을 정리하면 이렇다.

요청 시점 ──────────────────────────────────────────────> 시간

├─ stale 구간 ──┤  클라이언트: 캐시 즉시 사용 (서버 통신 없음)
│               │
│               ├─ revalidate 구간 ──┤  서버: 캐시를 제공하면서 백그라운드 갱신
│               │                    │
│               │                    ├─ expire ──┤  캐시 폐기, 다음 요청은 대기 필요

핵심은 revalidate 구간에서의 동작이다. 이 구간에 들어온 요청은 만료된 캐시를 먼저 받고, 서버가 백그라운드에서 새 데이터를 준비한다. 준비가 끝나면 다음 요청부터 새 데이터가 제공된다. 이것이 Stale-While-Revalidate 패턴이다.


내장 프로필과 커스텀 설정

cacheLife는 문자열 프로필을 지원한다. 각 프로필의 기본값은 다음과 같다.

프로필stalerevalidateexpire
"seconds"없음1초60초
"minutes"5분1분1시간
"hours"5분1시간1일
"days"5분1일1주
"weeks"5분1주30일
"max"5분30일무제한

프로필이 맞지 않으면 next.config.ts에서 커스텀 프로필을 정의할 수 있다.

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: {
    breaking: {
      stale: 0,
      revalidate: 30,
      expire: 300,
    },
    editorial: {
      stale: 300,
      revalidate: 3600,
      expire: 86400,
    },
  },
};

export default nextConfig;

이후 코드에서 문자열로 참조한다.

"use cache";

import { cacheLife } from "next/cache";

export async function getEditorialPicks() {
  cacheLife("editorial");
  return fetchEditorialFromDB();
}

컴포넌트별 TTL 설계: 뉴스 대시보드 사례

하나의 페이지 안에 갱신 주기가 서로 다른 영역이 공존하는 경우를 생각해 보자. 뉴스 대시보드에는 다음 세 가지 영역이 있다.

영역데이터 특성적절한 TTL
사이트 네비게이션, 카테고리 목록거의 불변"max" (30일 revalidate)
편집자 추천 기사하루 1~2회 변경"days" (1일 revalidate)
속보 피드수시 갱신커스텀 (30초 revalidate)

이 설계에서 핵심은 각 컴포넌트가 서로 다른 캐시 수명을 독립적으로 갖는다는 점이다.

// src/app/dashboard/page.tsx
import { Suspense } from "react";
import Navigation from "./Navigation";
import EditorialPicks from "./EditorialPicks";
import BreakingFeed from "./BreakingFeed";

export default function DashboardPage() {
  return (
    <div>
      <Suspense fallback={<nav>메뉴 로딩...</nav>}>
        <Navigation />
      </Suspense>

      <main>
        <Suspense fallback={<p>추천 기사 로딩...</p>}>
          <EditorialPicks />
        </Suspense>

        <Suspense fallback={<p>속보 로딩...</p>}>
          <BreakingFeed />
        </Suspense>
      </main>
    </div>
  );
}

각 컴포넌트는 자신만의 TTL을 선언한다.

// Navigation.tsx
"use cache";

import { cacheLife } from "next/cache";

export default async function Navigation() {
  cacheLife("max");
  const categories = await fetchCategories();

  return (
    <nav>
      <ul>
        {categories.map((cat) => (
          <li key={cat.id}>{cat.name}</li>
        ))}
      </ul>
    </nav>
  );
}
// BreakingFeed.tsx
"use cache";

import { cacheLife, cacheTag } from "next/cache";

export default async function BreakingFeed() {
  cacheLife("breaking");  // 커스텀 프로필: 30초 revalidate
  cacheTag("breaking-news");

  const articles = await fetchBreakingNews();
  const updatedAt = new Date().toLocaleTimeString("ko-KR");

  return (
    <section>
      <h2>속보</h2>
      <p>마지막 갱신: {updatedAt}</p>
      <ul>
        {articles.map((a) => (
          <li key={a.id}>{a.title}</li>
        ))}
      </ul>
    </section>
  );
}

Navigation의 캐시가 30일간 유효한 동안, BreakingFeed는 30초마다 갱신된다. 두 컴포넌트는 같은 페이지에 있지만 캐시 생애 주기가 완전히 독립적이다.


TTFB 병목: 캐시 만료 시점의 위험

여기서 한 가지 문제가 발생한다. BreakingFeed의 30초 TTL이 만료된 직후, 첫 번째 요청이 들어오는 상황을 생각해 보자.

revalidate 구간이라면 만료된 캐시를 먼저 제공하므로 문제가 없다. 하지만 expire를 넘긴 경우, 또는 최초 요청으로 캐시 자체가 존재하지 않는 경우에는 서버가 데이터를 가져올 때까지 기다려야 한다.

이때 기존의 블로킹 렌더링 방식에서는 다음과 같은 일이 벌어진다.

사용자 요청

    ├─ Navigation (캐시 히트) ─── 0.001초에 준비 완료
    ├─ EditorialPicks (캐시 히트) ── 0.001초에 준비 완료
    ├─ BreakingFeed (캐시 미스) ── DB 쿼리 2초 소요

    └─ 전체 응답 전송 ── 2초 후 (모든 컴포넌트가 끝나야 전송 시작)

TTFB(Time To First Byte)는 서버가 첫 번째 바이트를 보내기까지의 시간이다. 블로킹 방식에서는 가장 느린 컴포넌트가 전체 응답 시간을 결정한다. 캐시에서 즉시 제공할 수 있는 NavigationEditorialPicks마저 BreakingFeed의 2초 뒤에야 브라우저에 도착한다. 사용자는 2초간 빈 화면을 본다.


스트리밍이 TTFB 병목을 제거하는 방식

PPR(Partial Prerendering)과 스트리밍은 이 병목을 구조적으로 제거한다. 핵심 원리는 준비된 부분부터 먼저 보낸다는 것이다.

사용자 요청

    ├─ [0.001초] 정적 셸 + Navigation + EditorialPicks 즉시 전송
    │             BreakingFeed 자리에는 fallback 포함

    ├─ [0.001~2초] 브라우저: 정적 셸 렌더링, 사용자는 콘텐츠를 읽기 시작
    │               서버: BreakingFeed DB 쿼리 진행 중

    └─ [2초] BreakingFeed HTML 조각이 같은 HTTP 연결로 스트리밍
             브라우저: fallback을 실제 콘텐츠로 교체

TTFB가 0.001초로 단축된다. 사용자는 BreakingFeed가 준비되는 2초 동안 나머지 콘텐츠를 이미 소비하고 있다.

이 동작은 HTTP의 Transfer-Encoding: chunked 메커니즘을 기반으로 한다. 서버는 응답의 전체 크기를 알 수 없으므로, 준비된 HTML 조각을 순차적으로 청크 단위로 전송한다. 브라우저는 각 청크가 도착할 때마다 <Suspense> fallback을 실제 콘텐츠로 교체한다.


Suspense 경계가 스트리밍 단위를 결정한다

스트리밍의 단위는 <Suspense> 경계가 결정한다. 하나의 <Suspense>로 여러 컴포넌트를 감싸면, 그 안의 모든 컴포넌트가 완료될 때까지 fallback이 유지된다.

// 안티패턴: 모든 동적 영역을 하나로 묶음
<Suspense fallback={<p>로딩...</p>}>
  <EditorialPicks />   {/* 0.5초 소요 */}
  <BreakingFeed />     {/* 2초 소요 */}
</Suspense>

이 경우 EditorialPicks가 0.5초에 준비되더라도, BreakingFeed가 끝나는 2초까지 둘 다 fallback 상태로 남는다.

// 권장: 독립적인 Suspense 경계
<Suspense fallback={<p>추천 기사 로딩...</p>}>
  <EditorialPicks />
</Suspense>

<Suspense fallback={<p>속보 로딩...</p>}>
  <BreakingFeed />
</Suspense>

각 컴포넌트가 독립된 스트리밍 단위가 되어, 준비되는 즉시 화면에 나타난다.


TTL 설계 기준

컴포넌트의 TTL을 결정할 때 고려할 기준을 정리한다.

기준짧은 TTL (초~분)긴 TTL (시간~일)
데이터 변경 빈도분 단위로 변경일~주 단위로 변경
비즈니스 영향도지연 시 매출/신뢰 손실약간의 지연 허용
서버 비용DB 쿼리 비용 낮음DB 쿼리 비용 높음
사용자 기대실시간성을 기대정확도보다 속도 중시

TTL이 짧을수록 데이터는 신선하지만 서버 부하가 증가한다. TTL이 길수록 서버 부하는 줄지만 사용자가 오래된 데이터를 볼 가능성이 높아진다. 이 균형점은 기술적 판단만으로 결정할 수 없다. 데이터의 비즈니스 중요도에 따라 “몇 분 정도 지연된 데이터를 보여줘도 문제가 없는가”를 기준으로 판단한다.

revalidateexpire의 관계도 중요하다. expire는 반드시 revalidate보다 길어야 한다. 그렇지 않으면 Next.js가 설정 오류를 발생시킨다. 또한 revalidate 기간 내에 요청이 들어와야 백그라운드 갱신이 발동하므로, 트래픽이 적은 페이지는 expire를 넉넉하게 잡아두는 것이 좋다.


정리

캐시 TTL과 스트리밍은 별개의 문제를 해결하지만, 함께 사용할 때 완전한 아키텍처가 된다.

하나의 페이지에서 30일짜리 네비게이션, 1일짜리 편집자 추천, 30초짜리 속보 피드가 각자의 수명을 가지면서도, 어느 하나의 갱신 지연이 다른 영역의 렌더링을 막지 않는 것. 이것이 컴포넌트 단위 TTL과 스트리밍이 조합된 렌더링 아키텍처의 핵심이다.


Share this post on:

Previous Post
PPR 도입 판단 기준: Suspense 경계와 cacheLife를 적용할 시점
Next Post
Next.js 16의 use cache 지시어와 PPR: 컴포넌트 단위 캐싱 실전 가이드