Building public pages

김동현·2026년 3월 4일

next.js 공식문서 번역

목록 보기
30/79

퍼블릭 페이지는 사이트에 접속하는 모든 사용자에게 동일한 콘텐츠를 보여주는 페이지를 말해요. 우리가 흔히 접하는 랜딩 페이지나 마케팅 페이지, 그리고 상품 목록 페이지 같은 것들이 아주 대표적인 예시죠.

이런 페이지들은 사용자들이 같은 데이터를 공유하기 때문에, 사용자 요청이 오기 전에 미리 페이지를 만들어두는 사전 렌더링(Prerendering) 기법을 사용해서 재사용할 수 있답니다. 이렇게 하면 페이지 로딩 속도도 훨씬 빨라지고, 서버 비용도 확 줄일 수 있어요!

💡 강사의 팁!
사전 렌더링을 하면 완성된 HTML 파일이 전 세계에 퍼져 있는 CDN(Content Delivery Network)에 미리 저장(캐싱)됩니다. 즉, 사용자가 접속할 때마다 무거운 서버 연산을 할 필요 없이 제일 가까운 CDN에서 완성된 화면을 쓱 던져주기만 하면 되니까 엄청나게 빠르고 서버 부하도 줄어드는 거랍니다. 현업에서 트래픽을 감당하기 위해 반드시 알아야 하는 개념이에요!

이 가이드에서는 여러 사용자 간에 데이터를 공유하는 퍼블릭 페이지를 구축하는 방법을 저와 함께 단계별로 알아보겠습니다.

예제 (Example)

이해를 돕기 위해 상품 목록 페이지를 함께 만들어 볼게요.

우선 아주 정적인 헤더로 시작해서, 비동기(async) 방식으로 외부 데이터를 가져오는 상품 목록을 추가해 볼 거예요. 그리고 이 과정에서 전체 화면 로딩을 멈추게(block) 하지 않으면서 렌더링하는 방법도 배울 겁니다. 마지막으로는 페이지 전체를 요청 시 렌더링(request-time rendering)으로 바꾸지 않고도, 특정 사용자에게만 보여지는 맞춤형 프로모션 배너를 멋지게 추가해 볼 거예요.

이 예제에서 사용된 실제 코드와 영상은 아래 링크에서 확인해 보실 수 있습니다:

1단계: 간단한 헤더 추가하기 (Step 1: Add a simple header)

가장 먼저 아주 심플한 헤더 컴포넌트를 추가해 보겠습니다.

//filename="app/products/page.tsx"
// Static component
function Header() {
  return <h1>Shop</h1>
}

export default async function Page() {
  return (
    <>
      <Header />
    </>
  )
}

정적 컴포넌트 (Static components)

방금 작성한 <Header /> 컴포넌트를 잘 살펴보세요. 외부 데이터나 요청 헤더, 라우트 파라미터, 현재 시간, 무작위 값(random values)처럼 매번 요청할 때마다 변할 수 있는 그 어떤 입력값에도 의존하지 않고 있죠?

이렇게 출력 결과가 절대 변하지 않고 사전에 100% 결정될 수 있는 컴포넌트를 우리는 정적(static) 컴포넌트라고 부릅니다. 렌더링을 위해 요청을 기다릴 이유가 전혀 없기 때문에, Next.js는 빌드 타임(build time)에 페이지를 아주 안전하게 사전 렌더링(prerender) 해둔답니다.

터미널에서 next build 명령어를 실행해보면 진짜 그렇게 동작하는지 확인할 수 있어요.

Route (app)      Revalidate  Expire
┌ ○ /products           15m      1y
└ ○ /_not-found

○  (Static)  prerendered as static content

결과를 보시면, 우리가 명시적으로 "정적으로 만들어줘!" 라고 설정하지 않았는데도 똑똑한 Next.js가 알아서 상품(/products) 라우트를 정적(static, 기호)으로 표시한 것을 볼 수 있습니다.

2단계: 상품 목록 추가하기 (Step 2: Add the product list)

이제 외부에서 진짜 상품 목록 데이터를 가져와서 화면에 그려볼 차례예요.

import db from '@/db'
import { List } from '@/app/products/ui'

function Header() {}

// Dynamic component
async function ProductList() {
  const products = await db.product.findMany()
  return <List items={products} />
}

export default async function Page() {
  return (
    <>
      <Header />
      <ProductList />
    </>
  )
}

항상 똑같았던 헤더와는 다르게, 이 상품 목록 컴포넌트는 외부 데이터베이스에 의존하고 있죠?

동적 컴포넌트 (Dynamic components)

이 상품 데이터는 시간이 지나면서 추가되거나 삭제되는 등 바뀔 수 있는 데이터입니다. 따라서 렌더링된 결과물이 언제나 똑같을 거라고 보장할 수 없게 되죠. 이런 이유로 상품 목록은 동적(dynamic) 컴포넌트가 됩니다.

우리가 프레임워크에 특별한 지시를 내리지 않으면, Next.js는 사용자의 요청이 있을 때마다 무조건 가장 최신의(fresh) 데이터를 가져오고 싶어한다고 스스로 판단합니다. 사실 이건 새로운 서버 요청이 들어오면 페이지를 새로 그리는 웹의 아주 표준적인 동작 방식이기도 해요.

하지만 잠깐, 여기서 문제가 하나 생깁니다. 만약 이 컴포넌트가 사용자가 접속할 때마다(요청 시) 렌더링된다면, 이 데이터를 다 가져올 때까지 전체 라우트의 응답 자체가 지연(delay)될 겁니다. 브라우저에서 페이지를 새로고침 해보면 화면이 멈칫하는 이 현상을 직접 확인하실 수 있을 거예요.

헤더 부분은 이미 즉시 렌더링될 준비가 다 끝났음에도 불구하고, 불쌍하게도 상품 목록 데이터를 다 가져올 때까지 브라우저로 전송되지 못하고 발이 묶이는 상황이 벌어지는 거죠.

이런 끔찍한 성능 저하를 막기 위해, Next.js는 우리가 처음으로 데이터를 가져오기 위해 await를 사용할 때 다음과 같은 경고 메시지(warning)를 친절하게 띄워줍니다: Blocking data was accessed outside of Suspense (번역하자면 'Suspense 밖에서 화면 렌더링을 막는 데이터에 접근했습니다' 라는 뜻이에요).

이 시점에서 우리는 꽉 막혀있는 응답을 뚫어줄(unblock) 방법을 결정해야 합니다. 다음 두 가지 중 하나를 선택해 볼 수 있어요:

  • 컴포넌트를 캐시(Cache)해서, 다시 안정적(stable)인 상태로 만들고 나머지 페이지들과 함께 묶어서 통째로 사전 렌더링되게 만듭니다.
  • 컴포넌트를 스트리밍(Stream)해서, 데이터를 가져오는 작업을 논블로킹(non-blocking) 상태로 만들고 페이지의 다른 부분들이 이 컴포넌트를 하염없이 기다리지 않도록 분리합니다.

지금 우리가 다루고 있는 상품 카탈로그는 특정 개인의 데이터가 아니라 모든 사용자에게 공통으로 보여지는 데이터잖아요? 따라서 굳이 매번 새로 가져올 필요가 없으니 캐싱(caching)이 아주 찰떡같은 선택이 될 겁니다.

캐시 컴포넌트 (Cache components)

자, 그럼 함수 안에 'use cache' 지시어(directive)를 마법의 주문처럼 추가해 보겠습니다. 이렇게 하면 해당 함수를 캐시하겠다고 Next.js에게 명시적으로 알려주는 거예요.

//filename="app/products/page.tsx"
import db from '@/db'
import { List } from '@/app/products/ui'

function Header() {}

// Cache component
async function ProductList() {
  'use cache'
  const products = await db.product.findMany()
  return <List items={products} />
}

export default async function Page() {
  return (
    <>
      <Header />
      <ProductList />
    </>
  )
}

짜잔! 지시어 한 줄만 추가했을 뿐인데, 이 컴포넌트는 완벽한 캐시 컴포넌트(cache component)로 변신했습니다. 함수가 처음 딱 한 번 실행될 때 반환하는 결과값을 어딘가에 저장(캐시)해두고, 이후에 요청이 올 때는 그 값을 계속 꺼내서 재사용하게 되는 구조죠.

만약 이 캐시 컴포넌트를 그리기 위한 입력값들이 사용자의 요청이 도착하기 전에 미리 다 준비되어 있다면 어떨까요? 맞습니다, 정적 컴포넌트와 똑같이 미리 화면을 만들어두는 사전 렌더링(prerender)이 가능해지는 거죠!

이제 브라우저를 다시 새로고침 해보세요. 캐시 컴포넌트가 더 이상 화면 렌더링을 가로막지(block) 않기 때문에 페이지가 번개처럼 즉시 로드되는 것을 볼 수 있을 겁니다. 게다가 next build를 다시 실행해보면, 훌륭하게도 페이지가 여전히 정적(Static) 상태로 잘 유지되고 있다는 걸 확인할 수 있어요:

Route (app)      Revalidate  Expire
┌ ○ /products           15m      1y
└ ○ /_not-found

○  (Static)  prerendered as static content

와, 완벽하네요! 하지만 실무 환경에서는 어떨까요? 페이지가 처음부터 끝까지 영원히 정적인 상태로만 남아있는 경우는 사실 거의 없답니다.

3단계: 동적 프로모션 배너 추가하기 (Step 3: Add a dynamic promotion banner)

아무리 단순하고 정적인 페이지라도 시간이 지나면 사용자 맞춤형 콘텐츠 같은 동적인(dynamic) 요소가 꼭 필요해지기 마련입니다. 이걸 테스트해보기 위해, 이번엔 사용자별로 다르게 보이는 프로모션 배너를 하나 추가해볼게요:

import db from '@/db'
import { List, Promotion } from '@/app/products/ui'
import { getPromotion } from '@/app/products/data'

function Header() {}

async function ProductList() {}

// Dynamic component
async function PromotionContent() {
  const promotion = await getPromotion()
  return <Promotion data={promotion} />
}

export default async function Page() {
  return (
    <>
      <PromotionContent />
      <Header />
      <ProductList />
    </>
  )
}

코드를 보시면, 이 배너는 처음부터 동적으로 동작하게 됩니다. 그리고 앞서 2단계에서 경험했던 것처럼, 비동기 처리가 렌더링을 다시 막아버리기(blocking behavior) 때문에 Next.js가 경고를 띄우며 잔소리를 시작할 거예요.

앞서 상품 목록의 경우엔 데이터가 만인에게 똑같이 보여지는 거라서 캐시라는 치트키를 쓸 수 있었죠. 하지만 이번 프로모션 배너는 상황이 다릅니다. 접속한 사용자의 현재 위치나 A/B 테스트 타겟 여부처럼 요청할 때마다 달라지는 특정 입력값에 의존해야 하거든요. 그래서 이번에는 단순히 캐싱만으로는 이 꽉 막힌 상황(blocking behavior)을 빠져나갈 수가 없어요.

부분 사전 렌더링 (Partial prerendering)

동적인 콘텐츠가 추가되었다고 해서 좌절할 필요는 없습니다! 다시 예전처럼 화면 전체 렌더링이 끝날 때까지 멍하니 기다려야 하는 완전 블로킹 상태로 돌아갈 필요가 없거든요. 우리는 스트리밍(streaming)이라는 기술을 활용해서 이 막힌 응답을 시원하게 뚫어줄 수 있습니다.

Next.js는 이 스트리밍 기능을 기본적으로 아주 훌륭하게 지원하고 있어요. 리액트의 기능인 Suspense 바운더리(Suspense boundary) 컴포넌트를 사용하면 프레임워크에게 두 가지를 명확하게 알려줄 수 있습니다. 첫째, 어느 부분을 잘라내서(slice) 스트리밍 조각(chunks) 으로 보낼 것인지. 둘째, 진짜 콘텐츠가 열심히 로딩되는 동안 사용자에게 대신 보여줄 임시 UI(fallback)는 무엇인지 말이죠. 코드를 볼까요?

import { Suspense } from 'react'
import db from '@/db'
import { List, Promotion, PromotionSkeleton } from '@/app/products/ui'
import { getPromotion } from '@/app/products/data'

function Header() {}

async function ProductList() {}

// Dynamic component (streamed)
async function PromotionContent() {
  const promotion = await getPromotion()
  return <Promotion data={promotion} />
}

export default async function Page() {
  return (
    <>
      <Suspense fallback={<PromotionSkeleton />}>
        <PromotionContent />
      </Suspense>
      <Header />
      <ProductList />
    </>
  )
}

위 코드에서 fallback으로 지정한 <PromotionSkeleton /> (스켈레톤 UI)은 헤더나 캐시를 적용한 상품 목록 같은 다른 정적인 콘텐츠들과 함께 묶여서 완벽하게 사전 렌더링(prerendered) 됩니다. 그리고 Suspense 내부에 감싸져 있는 진짜 컴포넌트인 <PromotionContent />는 서버에서 백그라운드로 비동기 작업을 열심히 수행하다가, 작업이 완료되면 나중에 스르륵 스트리밍되어 들어오게 되는 것이죠.

이렇게 코드를 수정하고 나면, Next.js는 미리 화면을 그려둘 수 있는 작업(정적)과 사용자의 요청이 들어와야만 처리할 수 있는 작업(동적)을 완벽하게 분리해냅니다. 결과적으로 이 라우트는 완전히 정적인 것도, 완전히 동적인 것도 아닌 부분적으로 사전 렌더링된(partially prerendered) 상태가 되는 거죠!

이쯤 되면 습관처럼 next build를 다시 실행해봐야겠죠?

Route (app)      Revalidate  Expire
┌ ◐ /products    15m      1y
└ ◐ /_not-found

◐  (Partial Prerender)  Prerendered as static HTML with dynamic server-streamed content

터미널 결과를 보세요! 기호가 완전히 칠해진 동그라미()에서 반만 칠해진 동그라미()로 변했어요! 부분 사전 렌더링이 적용되었다는 뜻입니다.

작동 원리를 다시 한번 정리해드릴게요:
빌드 타임(Build time)에는 헤더, 상품 목록, 그리고 프로모션 배너의 임시 뼈대(fallback)를 포함한 페이지의 대부분이 깔끔하게 렌더링되고 캐시된 뒤, 전 세계에 퍼져 있는 콘텐츠 전송 네트워크(CDN)로 쫙 뿌려집니다.

그리고 실제 사용자가 접속하는 요청 시간(Request time)이 되면, 앞서 미리 렌더링해 둔 정적인 부분들은 사용자와 가장 가까운 위치의 CDN 노드에서 0.1초 만에 아주 즉각적으로(instantly) 서빙되어 화면에 나타납니다.

이와 동시에 서버 뒤편에서는 특정 사용자만을 위한 맞춤형 프로모션을 렌더링하고, 완성이 되면 클라이언트(브라우저) 쪽으로 스트리밍해서 처음 보여줬던 임시 뼈대(fallback) 자리에 마법처럼 쏙 교체(swapped)해주는 겁니다.

💡 강사의 핵심 요약 팁!
부분 사전 렌더링(PPR, Partial Prerendering)은 Next.js가 밀고 있는 최고의 마법 같은 아키텍처입니다! 예전 개발자들은 '이 페이지는 정적 사이트, 저 페이지는 동적 사이트' 이렇게 이분법적으로만 나눴는데, 이제는 하나의 페이지 안에서 정적 사이트의 엄청난 로딩 속도와 동적 사이트의 맞춤형 데이터를 짬짜면처럼 동시에 가져갈 수 있게 된 거예요. 실무에서 UX를 끌어올리는 데 정말 유용하게 쓰일 겁니다. 꼭 내 것으로 만드세요!

마지막으로 브라우저에서 페이지를 새로고침 해보세요. 페이지의 뼈대와 대부분의 정보는 새로고침을 누르자마자 번쩍하고 나타나고, 동적인 프로모션 배너 부분만 데이터가 준비되는 대로 스르륵 스트리밍되어 나타나는 경이로운 속도를 눈으로 직접 확인하실 수 있을 거예요.

다음 단계 (Next steps)

수고하셨습니다! 지금까지 우리는 페이지 대부분이 정적이면서도 곳곳에 동적인(dynamic) 콘텐츠가 콕콕 박혀있는 매력적인 페이지를 만드는 방법을 훌륭하게 소화해냈습니다.

완전 정적인 페이지에서 출발해서, 비동기 처리를 추가해보고, 그 과정에서 화면이 멈추는(blocking) 문제를 마주했습니다. 하지만 곧바로 미리 렌더링할 수 있는 데이터는 캐싱(caching)하고 사용자마다 달라야 하는 데이터는 스트리밍(streaming)하는 명쾌한 방법론으로 이 문제를 멋지게 해결해냈죠.

앞으로 이어질 다음 가이드들에서는 현업에서 꼭 필요한 아래와 같은 고급 기술들을 더 깊이 파헤쳐 볼 예정입니다:

  • 미리 렌더링된 페이지나 캐시된 데이터를 어떻게 똑똑하게 다시 갱신(Revalidate)할 것인가.
  • 라우트 파라미터(route params)를 사용해 동일한 구조의 페이지를 수만 개의 버전으로 만들어내는 방법.
  • 개인화된 사용자 데이터를 활용하여 나만 볼 수 있는 강력한 프라이빗(private) 페이지를 만드는 방법.

Next.js 문서의 전체적인 의미론적 개요(semantic overview)를 한눈에 보시려면 https://nextjs.org/docs/sitemap.md 문서를 참고해 주세요.

제공되는 전체 문서의 인덱스(목록)를 검색하시려면 https://nextjs.org/docs/llms.txt 문서를 참고해 주시면 됩니다.

profile
프론트에_가까운_풀스택_개발자

0개의 댓글