Tech Sep 17, 2026

Next.js 에이전트 가이드를 기반으로 성능 최적화 살펴보기

Next.js의 AI Coding Agents 가이드는 AI가 프로젝트에 설치된 버전의 문서를 참고하도록 안내합니다. next 패키지에 공식 문서가 함께 들어 있으니, AGENTS.md에 node_modules/next/dist/docs/ 경로를 적어 두는 방식입니다. 이렇게 연결하면 AI가 코드를 작성할 때 해당 버전의 API와 사용법을 확인할 수 있습니다.

AI에게 이 문서를 참고해 성능 최적화를 맡기더라도, 제안한 코드가 왜 더 나은지는 직접 판단할 수 있어야 합니다. 요청을 병렬로 보내거나 캐시를 적용하라는 권장 사항도 어떤 대기를 줄이는지 알아야 우리 코드에 필요한지 판단할 수 있습니다. 그래서 공식 문서의 최적화 방법을 예시에 적용하고, 변경 전후의 코드와 측정값을 함께 살펴보려 합니다.

Next.js 16.4.0-canary.31, React 19.3.0의 App Router로 예시를 만들어 테스트했습니다. 캐시 예제는 cacheComponents: true를 켠 상태입니다.

기다릴 필요가 없는 요청부터 시작하기

첫 번째 예시는 상품 목록과 인기 상품, 내 주문 내역을 보여주는 쇼핑몰 화면입니다. 주문 내역을 가져오려면 먼저 사용자 정보에서 ID를 받아야 합니다. 상품 목록과 인기 상품은 사용자 정보가 없어도 조회할 수 있습니다.

이 의존성을 코드로 확인하기 위해 사용자와 주문 내역 조회를 다음처럼 만들었습니다. queryUser()는 300ms 뒤에 사용자 정보를 반환하고, queryOrders()는 사용자 ID를 인수로 받습니다. 실제 서비스에서는 이 자리에 DB 조회나 외부 API 호출이 들어갑니다.

// lib/db.ts: 조회 순서를 실험하기 위한 축약 예제입니다.
const sleep = (ms: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, ms));

export async function queryUser() {
  await sleep(300);
  return { id: 'u1', name: '김지원' };
}

export async function queryOrders(userId: string) {
  await sleep(250);
  return [{ id: 'order-1', userId, productName: '무선 키보드' }];
}

queryUser()를 호출하면 조회가 시작되고 Promise가 반환됩니다. 여기에 await를 붙이면 결과가 나올 때까지 현재 함수의 다음 줄로 넘어가지 않습니다. 다른 작업은 계속 실행될 수 있지만, 다음 줄에 적은 조회는 그만큼 늦게 시작합니다.

순차 조회 코드에서는 네 조회를 모두 순서대로 기다립니다.

const user = await queryUser();
const catalog = await queryCatalog();
const popularProducts = await queryPopularProducts();
const orders = await queryOrders(user.id);

고정 지연만 더해도 300 + 500 + 400 + 250 = 1,450ms입니다. 상품 목록은 사용자 조회를, 인기 상품은 상품 목록 조회를 기다립니다. 두 조회 모두 앞의 결과를 사용하지 않는데 코드에 적힌 순서 때문에 기다리고 있습니다.

요청 워터폴은 앞 요청이 끝나야 다음 요청이 시작되는 흐름입니다. 뒤 요청의 시작이 계속 밀리면서 화면을 보여주기까지의 대기도 길어집니다.

병렬 조회 코드에서는 사용자, 상품 목록, 인기 상품 조회를 먼저 시작합니다. 주문 내역만 사용자 조회의 결과를 받아 이어서 실행합니다. 공식 문서의 Parallel data fetching에서도 독립적인 요청을 먼저 시작하고 결과를 함께 기다리는 방식을 안내합니다.

const userPromise = queryUser();
const catalogPromise = queryCatalog();
const popularProductsPromise = queryPopularProducts();

// 사용자 ID가 준비되는 즉시 주문 내역을 조회합니다.
const ordersPromise = userPromise.then((user) => queryOrders(user.id));

const [user, catalog, popularProducts, orders] = await Promise.all([
  userPromise,
  catalogPromise,
  popularProductsPromise,
  ordersPromise,
]);

사용자, 상품 목록, 인기 상품 조회는 함수를 호출할 때 시작합니다. 사용자 조회가 끝나면 주문 내역을 이어서 조회합니다. Promise.all은 이미 시작한 작업의 결과를 모아 기다립니다.

이제 네 시간을 모두 더할 필요가 없습니다. 사용자와 주문 내역은 이어서 실행되므로 550ms, 상품 목록은 500ms, 인기 상품은 400ms가 걸립니다. 가장 늦게 끝나는 쪽이 550ms이므로 전체 대기도 그 정도로 줄어듭니다. 주문 내역은 여전히 사용자 정보를 받은 뒤 조회합니다.

Promise.all은 전달한 Promise 순서대로 결과 배열을 반환합니다. 하나라도 실패하면 전체 await가 실패하며, 이미 시작한 다른 요청은 자동으로 취소되지 않습니다. 일부 데이터가 없어도 화면을 보여줘야 한다면 Promise.allSettled로 결과별 실패를 처리하거나, 조회와 오류 처리를 화면 영역별로 나눌 수 있습니다.

아래 타임라인은 같은 네 조회의 실행 순서를 비교하는 모형입니다. 순차·병렬 모드에서 각 조회의 지연 시간은 같고, 주문 내역은 두 모드 모두 사용자 조회 뒤에 시작합니다.

쇼핑몰 데이터의 순차·병렬 조회

조회 방식을 선택하고 실행을 누르면 각 요청의 시작과 종료 시점을 비교할 수 있습니다. 주문 내역은 사용자 ID를 받은 뒤 조회합니다.

— 전체 조회 시간
— 불필요한 대기 합계
  • 사용자 정보 대기
  • 불필요한 대기
  • 조회 실행
사용자 정보 300ms
상품 목록 500ms
인기 상품 400ms
주문 내역 250ms
각 조회에 고정 지연을 넣은 브라우저 예시입니다. 표시된 시간에는 서버·네트워크 대기가 포함되지 않습니다.
방식고정 지연으로 계산한 시간
순차 조회1,450ms
병렬 조회와 의존 조회 연결550ms

클라이언트 조회에서도 불필요한 조건 걷어내기

앞에서는 await를 순서대로 적어 독립적인 조회까지 기다리게 했습니다. 같은 쇼핑몰 화면에서 데이터를 클라이언트로 가져오더라도, 요청을 시작하는 조건을 잘못 걸면 같은 대기가 생깁니다. 예를 들어 상품 목록과 인기 상품 조회에 사용자 정보가 있어야 한다는 조건을 붙인 경우입니다.

아래 useApi는 useEffect 안에서 fetch를 호출하는 커스텀 Hook입니다. path가 null이면 요청하지 않고, 경로가 주어지면 조회한 결과를 state에 저장합니다.

const user = useApi<User>('user', 'user', record);
const catalog = useApi<Product[]>(user ? 'catalog' : null, 'catalog', record);
const popularProducts = useApi<Product[]>(
  user ? 'popularProducts' : null,
  'popularProducts',
  record,
);

처음에는 user가 없으므로 상품 목록과 인기 상품을 요청하지 않습니다. 사용자 조회가 끝나고 state가 바뀐 뒤에야 두 요청이 시작됩니다. 순차 await가 없어도 user ? 경로 : null이라는 조건이 요청 사이에 의존 관계를 만든 셈입니다.

상품 목록과 인기 상품은 사용자 ID를 사용하지 않으므로 이 조건을 제거할 수 있습니다. 사용자 ID가 필요한 주문 내역에만 조건을 남깁니다.

const user = useApi<User>('user', 'user', record);
const catalog = useApi<Product[]>('catalog', 'catalog', record);
const popularProducts = useApi<Product[]>(
  'popularProducts',
  'popularProducts',
  record,
);
const orders = useApi<Order[]>(
  user ? `orders?userId=${encodeURIComponent(user.id)}` : null,
  'orders',
  record,
);

이제 각 Hook의 Effect가 실행되면 사용자, 상품 목록, 인기 상품 조회가 서로의 결과를 기다리지 않고 시작됩니다. 주문 내역만 사용자 조회가 끝난 뒤 요청하므로, 앞의 서버 예시와 같은 의존 관계가 됩니다.

상품 목록을 캐시해 반복 조회 줄이기

앞의 쇼핑몰 화면에서는 독립적인 조회를 동시에 시작해 대기를 줄였습니다. 그래도 페이지를 요청할 때마다 각 조회는 다시 실행됩니다. 다음 상품 목록 예시처럼 모든 방문자가 같은 데이터를 읽는다면, 조회 결과를 캐시에 저장해 반복 조회 자체를 줄일 수 있습니다.

예시의 상품 목록 조회에는 800ms 지연이 있습니다. 캐시가 없는 페이지는 요청마다 queryProducts()를 호출하고, 캐시 페이지는 다음 함수를 사용합니다.

import { cacheLife, cacheTag } from 'next/cache';
import { queryProducts } from '@/lib/db';

export async function getCachedProducts() {
  'use cache';
  cacheLife('hours');
  cacheTag('products');

  const products = await queryProducts();
  return { products, cachedAt: new Date().toISOString() };
}

use cache는 getCachedProducts()의 반환값을 저장합니다. 저장된 결과를 재사용할 때는 함수 본문을 다시 실행하지 않으므로 queryProducts()를 호출하지 않고, cachedAt도 이전 값으로 반환합니다. 공식 Caching 문서

계산한 결과를 보관했다가 다시 쓴다는 점에서는 React의 useMemo와 비슷합니다. useMemo가 한 컴포넌트에서 의존성이 바뀌지 않은 동안 렌더링 사이에 계산 결과를 재사용한다면, 이 예시의 use cache는 서버의 조회 결과를 여러 요청에 걸쳐 재사용할 수 있습니다. 의존성 배열 대신 함수와 인수 등으로 캐시를 구분하고, 캐시 수명이나 명시적인 무효화로 갱신 시점을 관리합니다. React useMemo 문서, Next.js use cache 문서

처음 호출할 때는 저장된 결과가 없으므로 800ms 조회를 그대로 기다립니다. 이것이 캐시 미적중입니다. 이후 호출에서 저장된 결과를 재사용하는 캐시 적중이 발생하면 이 대기를 생략할 수 있습니다. 다만 캐시가 만료되면 다시 조회해야 하므로, 결과를 얼마나 오래 재사용할지도 정해야 합니다.

hours의 캐시 수명

cacheLife('hours')에는 서로 다른 역할을 하는 세 시간이 들어 있습니다. 예시의 주석에는 ‘최대 1시간 늦어도 된다’고 적혀 있지만, 기본 프리셋이 보장하는 동작과는 다릅니다. 공식 cacheLife 프리셋

속성기본값역할
stale5분클라이언트가 서버에 확인하지 않고 캐시를 재사용하는 기간입니다.
revalidate1시간이 시간이 지난 뒤 요청이 들어오면 서버에서 백그라운드 재검증을 시작합니다.
expire1일만료된 결과를 읽는 요청은 새 결과를 기다립니다.

한 시간이 지나도 아무도 요청하지 않으면 새 조회는 시작되지 않습니다. 그 뒤 요청이 들어와야 재검증을 시작하고, 갱신하는 동안에는 이전 결과를 보여줄 수 있습니다. 따라서 “데이터가 최대 한 시간까지만 늦어야 한다”는 요구사항을 hours만으로 보장할 수는 없습니다.

아래 모형은 경과 시간에 따라 요청이 어떻게 처리되는지 다섯 경우로 나눠 보여줍니다. 3번처럼 요청 없이 시간만 지나면 조회 횟수는 그대로입니다. revalidate 이후에 요청하면 저장된 결과를 먼저 반환하고 새 조회를 시작합니다.

cacheLife('hours')의 캐시 재사용과 갱신

재생하면 다섯 경우가 차례로 진행됩니다. 번호를 누르면 해당 경우만 재생합니다.

09:00 현재 시각
— 응답 대기
0 새로 조회한 횟수
브라우저 클라이언트 캐시 —
서버 서버 캐시 · 09:00 저장 —
DB 상품 조회 800ms —

재생 전

저장 후 경과 방금
stale 5분 revalidate 1시간 expire 1일
이 시점에 요청하면
응답 대기
반환할 데이터
상품 조회
hours 기본값(stale 5분 · revalidate 1시간 · expire 1일)을 적용한 브라우저 예시입니다. Next.js 캐시 저장소에 연결되지는 않습니다.

캐시 키로 재사용할 결과 구분하기

캐시 수명과 함께 구분해야 할 것은 어떤 결과를 재사용하느냐입니다. 상품별로 조회한다면 서로 다른 상품의 결과가 섞이지 않아야 합니다. Next.js는 함수와 전달한 인수 등을 조합한 키로 캐시를 구분하므로 getProduct('p1')과 getProduct('p2')는 각각 저장됩니다. 인수는 직렬화할 수 있어야 하며, 함수가 바깥 스코프에서 참조하는 값도 키에 포함될 수 있습니다. 공식 use cache 문서

이처럼 여러 요청에 걸쳐 결과를 보관하는 use cache는 React의 cache()와 재사용 범위가 다릅니다. React의 cache()는 한 서버 요청 안에서 여러 Server Component가 같은 인수로 호출한 결과를 공유합니다. 같은 화면의 중복 조회에는 cache()를, 이 예시처럼 다음 요청에서도 결과를 재사용하려면 use cache를 사용할 수 있습니다. React cache 문서

일반적인 use cache 함수 안에서는 cookies()나 headers()를 직접 읽을 수 없습니다. 필요한 요청 정보는 캐시 바깥에서 읽어 인수로 전달합니다. 사용자별 데이터에는 인증·권한 검사와 사용자별 캐시 키도 필요합니다. 모든 방문자가 같은 결과를 보는 상품 목록과 달리, 누구의 데이터를 재사용하는지 구분해야 하기 때문입니다.

상품을 추가했는데 목록이 그대로라면

캐시를 붙인 뒤에는 상품을 추가해도 이전 목록이 나올 수 있습니다. DB에는 새 상품이 들어갔지만, 화면은 아직 캐시에 저장된 목록을 읽기 때문입니다. 저장이 성공한 다음 기존 캐시도 갱신 대상으로 바꿔야 합니다. 이 작업을 캐시 무효화라고 합니다.

cacheTag('products')는 관련 캐시를 함께 무효화할 때 사용할 태그를 붙입니다. 캐시 키와는 별개입니다. 목록과 상세 조회에 같은 태그를 붙이면 한 번에 갱신 대상으로 지정할 수 있고, product:p1처럼 태그를 나누면 특정 상품만 지정할 수 있습니다.

예시에는 차이를 비교할 수 있도록 상품 추가 버튼을 세 개 두었습니다. 모두 같은 저장 함수를 호출하고, 저장이 끝난 다음 실행하는 코드만 다릅니다.

추가 후 실행하는 코드동작
refresh()현재 화면을 갱신하도록 요청하지만, 상품 데이터 캐시를 무효화하지는 않습니다.
updateTag('products')해당 태그의 캐시를 즉시 만료시켜, 다음 읽기에서 새 데이터를 기다리도록 합니다.
revalidateTag('products', 'max')캐시를 오래된 상태로 표시하고, 다음 읽기에서 이전 결과를 제공하면서 백그라운드 갱신을 시작합니다.

상품을 추가한 사용자가 곧바로 새 목록을 확인해야 한다면 updateTag로 기존 캐시를 만료시킵니다. 이 API는 폼 제출 같은 변경 작업을 서버에서 수행하는 비동기 함수인 Server Action에서만 사용할 수 있습니다. 아래는 app/cache/actions.ts의 상품 추가 액션입니다. 인증·권한 검증은 생략한 예시 코드입니다. 공식 updateTag 문서

'use server';

import { updateTag } from 'next/cache';
import { insertProduct } from '@/lib/db';

export async function addWithUpdateTag() {
  await insertProduct();
  updateTag('products');
}

파일 맨 위의 'use server'는 이 파일에서 export하는 비동기 함수를 서버 함수로 선언합니다. 이를 폼의 action에 연결하면 제출 시 서버에서 실행할 수 있습니다. 앞에서 작성한 일반 데이터 조회 함수를 서버에 두기 위해 무조건 붙이는 표시는 아닙니다.

페이지에서는 addWithUpdateTag를 import해 폼의 action에 연결합니다.

// app/cache/cached/page.tsx
import { getCachedProducts } from '../_lib/products';
import { addWithUpdateTag } from '../actions';

export default async function Page() {
  const { products } = await getCachedProducts();

  return (
    <>
      <ul>
        {products.map((product) => (
          <li key={product.id}>{product.name}</li>
        ))}
      </ul>
      <form action={addWithUpdateTag}>
        <button type="submit">상품 추가</button>
      </form>
    </>
  );
}

폼을 제출하면 상품을 저장하고 products 태그의 캐시를 만료시킵니다. 다음 목록 조회는 새 데이터를 기다리므로 방금 추가한 상품도 결과에 포함됩니다. 자신이 쓴 내용을 이어지는 읽기에서 확인하는 성질을 read-your-own-writes라고 합니다. 실제 액션에서는 호출자의 권한과 입력값을 검사해야 하며, 'use server'가 이 검사를 대신하지는 않습니다.

상품을 추가한 직후의 확인보다 목록 조회의 응답 속도가 중요하고 잠시 이전 결과를 보여줘도 된다면 revalidateTag('products', 'max')를 사용할 수 있습니다. 이 함수는 기존 캐시를 오래된 상태로 표시하고, 다음 읽기에서 이전 결과를 반환하면서 백그라운드 조회를 시작합니다. 이런 갱신 방식을 stale-while-revalidate라고 합니다. 공식 revalidateTag 문서

다만 백그라운드 조회가 끝났다고 이미 열린 화면의 목록까지 자동으로 바뀌지는 않습니다. 캐시를 갱신하는 것과 화면이 그 결과를 다시 읽는 것은 별개의 동작입니다. 앞의 폼에서는 방금 저장한 내용을 다음 읽기에서 확인해야 하므로, 이전 결과를 반환하는 대신 새 결과를 기다리는 updateTag를 사용했습니다.

캐시 적중 시 측정값

캐시 적용 전후 데이터 조회 시간은 801ms → 0ms로 측정됐습니다. 예시에서 이 숫자는 서버의 measure()가 데이터 함수 호출 시간을 재고 정수로 반올림한 값입니다. 캐시가 적중해 호출이 빠르게 끝났다는 의미이며, 네트워크와 렌더링까지 포함한 페이지 로딩 시간이 0ms가 되었다는 뜻은 아닙니다.

캐시된 상품 목록은 빌드 시 정적 셸에 포함될 수 있습니다. 호출 시간을 표시하는 컴포넌트는 요청마다 별도로 실행되므로, 상품 목록과 측정값이 화면에 나타나는 시점도 다를 수 있습니다. 조회 시각과 실제 조회 횟수를 함께 표시한 것은 함수 실행 없이 저장된 목록을 재사용했는지 확인하기 위해서입니다.

또한 상품 목록과 조회 카운터는 서버 메모리에 저장합니다. 재시작하면 초기화되고 여러 프로세스가 같은 저장소를 공유하지 않습니다. 이 결과로 서버리스나 여러 인스턴스 환경의 캐시 적중률까지 설명할 수는 없습니다.


본문 변환은 서버에, 상호작용은 클라이언트에

이번에는 데이터 조회 이후 브라우저가 실행하는 코드를 줄여 봅니다. 예시 페이지에는 Markdown 본문, 좋아요 버튼, 조회 수 차트가 있습니다. 변경 전에는 버튼의 state를 페이지에 두면서 페이지 전체에 'use client'를 선언했습니다.

marked, 전체 언어를 포함한 highlight.js, recharts를 이 페이지에서 직접 import합니다. 고정된 본문을 표시하는 데도 브라우저가 Markdown 변환과 코드 하이라이트 라이브러리를 내려받습니다.

Client Component는 state, 이벤트 핸들러, Effect, 브라우저 API가 필요한 UI를 작성하는 컴포넌트입니다. 'use client'를 파일 맨 위에 선언하면 그 파일을 진입점으로 클라이언트 모듈 경계가 만들어집니다.

'use client'는 해당 파일에서 끝나지 않습니다. 그 파일이 import해서 사용하는 일반 모듈도 클라이언트 코드에 포함됩니다. 하위 파일마다 지시문을 반복하지 않아도 되는 이유이기도 합니다. 페이지처럼 큰 단위에 선언하면 함께 가져오는 코드도 많아질 수 있습니다.

이 의존성을 묶고 최적화해 브라우저에 보내는 JavaScript 파일을 번들이라고 합니다. 번들은 여러 청크로 나뉠 수 있지만, 첫 화면에서 모두 필요하다면 브라우저는 결국 그 코드를 내려받아야 합니다.

Client Component도 첫 방문에서는 서버에서 HTML을 사전 렌더링할 수 있습니다. 브라우저는 먼저 HTML을 표시하고, JavaScript를 실행해 이벤트와 state를 연결합니다. 이 과정을 하이드레이션(hydration)이라고 합니다. 'use client'가 서버 렌더링을 끈다는 뜻은 아니므로, 렌더링 중 window를 바로 읽는 코드도 주의해야 합니다. 브라우저 전용 접근은 Effect나 이벤트 핸들러에서 수행할 수 있습니다.

공식 문서는 상호작용이 필요한 컴포넌트에 클라이언트 경계를 두도록 권장합니다. 예시의 변경 후 페이지는 Server Component에서 Markdown을 변환하고, 버튼과 진행률 표시를 Client Component로 남깁니다.

export default function Page() {
  const html = renderMarkdown(ARTICLE.body);

  return (
    <ReadingProgress>
      <article dangerouslySetInnerHTML={{ __html: html }} />
      <LazyChartToggle>
        <LikeButton />
      </LazyChartToggle>
    </ReadingProgress>
  );
}

ReadingProgress는 스크롤 이벤트를 처리하는 Client Component입니다. 그 안에 본문이 들어가지만, 본문을 만드는 코드는 서버 페이지에 있습니다. 부모가 만든 렌더링 결과를 children으로 전달하므로 ReadingProgress가 Markdown 변환 라이브러리를 import할 필요가 없습니다.

이렇게 서버가 만든 결과를 클라이언트 컴포넌트와 결합할 때는 RSC Payload가 사용됩니다. Server Component의 렌더링 결과, Client Component에 대한 참조, 전달할 props 등을 표현하는 React의 데이터 형식입니다. Next.js는 첫 방문에서 HTML과 이 데이터를 사용해 화면을 구성하고 클라이언트 컴포넌트를 하이드레이션합니다. 이때 서버에서 보내는 props는 React가 직렬화할 수 있어야 합니다. 일반 콜백 함수를 그대로 넘기는 대신, 이벤트 처리는 클라이언트 안에 두고 서버 변경 작업은 앞서 본 Server Action으로 연결합니다.

브라우저에는 본문을 표시할 HTML과 RSC Payload가 전달됩니다. 줄어드는 것은 Markdown 변환과 하이라이트를 수행하는 라이브러리 코드입니다. 예시는 저자가 작성한 고정 본문을 사용합니다. 외부 사용자 입력을 렌더링할 때는 HTML 정제가 별도로 필요합니다.

차트는 열 때 불러옵니다

본문 변환 코드를 서버로 옮긴 뒤에도 차트 라이브러리는 클라이언트에 남습니다. 하지만 차트는 사용자가 버튼을 눌러야 보이므로 첫 화면부터 내려받을 필요는 없습니다. 정적 import를 유지한 채 JSX만 조건부로 렌더링해서는 다운로드를 미룰 수 없어, 불러오는 방식도 함께 바꿨습니다.

코드를 별도 파일로 나누는 코드 분할과, 필요할 때 불러오는 지연 로딩을 함께 적용합니다. 파일을 나눠도 첫 렌더링에서 바로 사용하면 다운로드가 시작되므로, 버튼 상태로 렌더링 시점을 정합니다.

'use client';

import dynamic from 'next/dynamic';
import { useState } from 'react';

const LazyViewsChart = dynamic(() => import('./views-chart'), {
  ssr: false,
  loading: () => <p role="status">차트를 불러오는 중입니다.</p>,
});

export function LazyChartToggle() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button onClick={() => setOpen((value) => !value)}>차트 토글</button>
      {open && <LazyViewsChart />}
    </>
  );
}

dynamic()으로 코드를 분리하고, open이 참일 때 렌더링하도록 연결했습니다. 처음부터 렌더링한다면 분리된 코드도 곧바로 필요해집니다. ssr: false는 서버 사전 렌더링을 끄는 옵션이며, 클릭할 때까지 로딩을 미루는 조건을 대신하지 않습니다. 공식 Lazy Loading 문서

views-chart.tsx는 차트 컴포넌트를 default export하는 모듈입니다. dynamic() 선언은 렌더 함수 안이 아닌 모듈 최상위에 둡니다. 처음 버튼을 누른 사용자는 청크 다운로드를 기다릴 수 있으므로 loading UI도 제공했습니다. 첫 화면의 핵심 콘텐츠까지 같은 방식으로 미루면 오히려 표시가 늦어질 수 있어, 처음에는 닫혀 있는 기능부터 적용하는 편이 적절합니다.

예시에서 측정한 초기 JS 전송량은 다음과 같습니다.

비교변경 전변경 후
JS 전송량약 550KB약 137KB
압축 해제된 JS 크기약 1,790KB약 455KB

이 값은 성능 패널이 Resource Timing의 transferSize와 decodedBodySize를 합산한 결과입니다. 표시 단위는 1,024바이트 기준이며, 전송량에는 응답 헤더 등이 포함될 수 있습니다. 빌드 도구가 보여주는 번들 파일 크기와 정확히 같은 지표는 아닙니다.

차트를 열 때는 JS 요청이 두 개 추가로 발생했습니다. 초기 전송량에는 Markdown 관련 코드를 서버로 옮긴 효과와 차트 로딩을 미룬 효과가 함께 반영되어 있습니다. 클릭 반응 시간은 따로 측정하지 않았습니다.

준비된 상품 정보부터 보여주기

JavaScript 전송량을 줄여도 서버에서 느린 조회가 끝나기를 기다린다면 본문 표시는 늦어집니다. 마지막 예시는 상품 상세 페이지로, 상품 정보는 200ms, 리뷰는 1,500ms, 추천 상품은 2,500ms가 걸립니다. 세 조회를 이미 병렬로 시작하고 있어, 첫 번째 예시처럼 실행 순서만 바꿔서는 더 줄일 대기가 없습니다.

// app/streaming/blocking/page.tsx: 측정 코드를 생략한 조회 부분
const [product, reviews, items] = await Promise.all([
  queryProduct(),
  queryReviews(),
  queryRecommendations(),
]);

return (
  <>
    <ProductInfo product={product} />
    <Reviews reviews={reviews} />
    <Recommendations items={items} />
  </>
);

그래도 상품 정보는 2.5초 가까이 기다려야 보입니다. 페이지가 Promise.all로 세 결과를 모두 받은 뒤에야 JSX를 반환하기 때문입니다. loading.tsx 덕분에 로딩 UI는 보이지만, 이미 준비된 상품명과 가격도 추천 결과를 기다리고 있습니다.

Suspense는 하위 UI가 렌더링 중 준비되지 않았을 때 가장 가까운 경계의 fallback을 보여주는 React 컴포넌트입니다. 스트리밍은 서버가 모든 렌더링이 끝나기를 기다리지 않고 준비된 결과부터 나누어 보내는 방식입니다.

Next.js의 비동기 Server Component가 데이터를 기다리는 동안에는 Suspense가 대체 UI를 제공할 수 있습니다. 결과가 준비되면 서버가 해당 영역을 이어서 보내고 브라우저가 대체 UI를 바꿉니다. 앞에서 useEffect로 조회하던 코드를 Suspense로 감싸기만 해서는 이 동작이 생기지 않습니다. Suspense는 Effect 안의 일반적인 fetch와 로딩 state를 자동으로 감지하지 않습니다. React Suspense 문서

Cache Components에서 말하는 정적 셸은 미리 렌더링해 둘 수 있는 바깥 UI와 Suspense의 대체 UI 등을 포함한 결과입니다. 사용자 요청이 들어와야 만들 수 있는 내용을 기다리는 동안에도 이 셸을 먼저 보여줄 수 있습니다. 정적 셸에 포함할 수 있는 캐시된 결과의 범위는 데이터의 의존성과 캐시 수명에 따라 달라집니다.

변경 후에는 각 영역에 Suspense 경계를 둡니다. ProductInfo 등은 조회 결과를 표시하는 컴포넌트이고, ProductSkeleton 등은 기다리는 동안 보여줄 UI입니다.

import { Suspense } from 'react';
import { connection } from 'next/server';

export default function Page() {
  return (
    <>
      <Suspense fallback={<ProductSkeleton />}>
        <ProductSection />
      </Suspense>
      <Suspense fallback={<ReviewsSkeleton />}>
        <ReviewsSection />
      </Suspense>
      <Suspense fallback={<RecommendationsSkeleton />}>
        <RecommendationsSection />
      </Suspense>
    </>
  );
}

async function ProductSection() {
  await connection();
  const product = await queryProduct();
  return <ProductInfo product={product} />;
}

async function ReviewsSection() {
  await connection();
  const reviews = await queryReviews();
  return <Reviews reviews={reviews} />;
}

async function RecommendationsSection() {
  await connection();
  const items = await queryRecommendations();
  return <Recommendations items={items} />;
}

await connection() 이후의 코드는 실제 사용자 요청이 들어온 뒤 실행됩니다. DB 연결 함수가 아니라 렌더링 시점을 정하는 Next.js API입니다. 예시에서는 고정 지연 조회를 요청마다 관찰하기 위해 사용했습니다.

이미 cookies() 같은 요청 API를 사용하는 코드라면 같은 목적으로 추가할 필요가 없습니다. 이 버전의 문서는 Cache Components에서 캐시·프리패치를 활용하는 경우 io()도 안내합니다. 예시는 실제 요청을 기다려야 하므로 connection()을 사용합니다. 공식 connection 문서

조회가 각 하위 컴포넌트로 옮겨졌으므로 부모 페이지는 결과를 기다리지 않고 정적 셸과 대체 UI를 반환합니다. 서버는 상품 정보, 리뷰, 추천 결과를 준비되는 순서대로 이어서 보냅니다. 공식 Streaming 설명

그래서 Suspense 경계를 추가할 때 await도 각 경계 안의 컴포넌트로 옮겼습니다. 부모 페이지에서 모든 데이터를 기다린 뒤 Suspense를 반환하면, 경계가 렌더링될 때는 이미 조회가 끝나 있습니다. 이 경우에는 준비된 상품 정보부터 보내는 효과를 얻을 수 없습니다.

loading.tsx는 해당 라우트 세그먼트의 페이지와 하위 콘텐츠를 감싸는 Suspense 경계를 자동으로 만듭니다. 같은 세그먼트의 레이아웃 안쪽에 배치되므로 그 레이아웃 자체의 대기까지 처리하지는 않습니다. 페이지 전체를 한 번에 기다려도 된다면 loading.tsx를 사용하고, 상품 정보와 리뷰를 따로 보여주려면 지금처럼 영역별 경계를 둡니다.

상품명과 가격을 한 경계에 두면 함께 표시되고, 리뷰를 별도 경계에 두면 나중에 표시할 수 있습니다. 한 경계 안의 조회들은 그 영역에서 함께 기다립니다.

대체 UI는 실제 콘텐츠와 비슷한 크기로 잡아 두면 교체될 때 화면이 덜 밀립니다. 조회가 실패했을 때의 UI는 따로 필요하므로, error.tsx나 해당 영역의 오류 처리도 준비합니다.

다음 측정값에서 FCP는 첫 텍스트나 이미지 등의 콘텐츠가 그려진 시점이고, LCP는 화면 안에서 가장 큰 콘텐츠 요소가 그려진 시점을 나타냅니다. FCP에는 로딩 문구도 반영될 수 있으며, LCP는 페이지의 모든 조회가 끝난 시각을 의미하지 않습니다.

방식FCPLCP
페이지에서 모든 결과 대기28ms2,532ms
영역별 Suspense36ms368ms

변경 전에도 로딩 UI가 먼저 표시되어 FCP는 28ms로 짧았습니다. 하지만 상품명과 가격은 추천 조회가 끝날 때까지 보이지 않았으므로, FCP만 보면 사용자가 실제 상품 정보를 기다린 시간을 놓치게 됩니다.

경계를 나눈 뒤에는 추천 조회에 여전히 2,500ms가 걸려도 상품 정보가 먼저 표시되고, 이 환경의 LCP는 368ms로 기록됐습니다. 다만 LCP가 가리키는 요소는 화면 크기와 콘텐츠 구성에 따라 달라집니다. 이 수치를 모든 화면의 표시 시간이나 전체 조회 완료 시간으로 해석해서는 안 됩니다.

측정 조건

예시의 성능 패널은 문서 로드 한 번을 기준으로 지표를 모읍니다. 페이지 이동 링크도 일반 <a>를 사용합니다. 따라서 이 결과로 Next.js 클라이언트 내비게이션이나 프리패치 성능까지 비교할 수는 없습니다.

또한 타임라인의 hydrate 구간은 첫 응답부터 첫 API 조회가 시작되기까지의 시간입니다. 순수 하이드레이션 실행 시간만 분리해 측정한 값은 아닙니다. 스트리밍의 reviews, recommendations Element Timing도 해당 제목 요소의 표시 시점이므로, 영역 전체의 렌더링 완료 시각과 구분해야 합니다.

다시 측정할 때는 프로덕션 빌드에서 화면 크기와 CPU·네트워크 조건을 고정하고 여러 번 비교해야 합니다. 초기 JS는 차트를 열기 전에 측정하고, 캐시는 미적중과 적중을 나눠 확인하는 편이 좋습니다. 브라우저의 Disable cache 설정으로 서버의 use cache까지 초기화되는 것은 아닙니다.

참고한 로컬 문서

관련 문서는 패키지 안에서 다음 위치에 있습니다. 경로는 node_modules/next/dist/docs/를 기준으로 합니다.

주제로컬 문서
데이터 조회와 스트리밍01-app/01-getting-started/06-fetching-data.md
캐시01-app/01-getting-started/08-caching.md
서버와 클라이언트 컴포넌트01-app/01-getting-started/05-server-and-client-components.md
지연 로딩01-app/02-guides/lazy-loading.md
캐시 수명01-app/03-api-reference/04-functions/cacheLife.md