본문으로 건너뛰기

렌더링 실행 위치와 결과 시점

이번 주차에서 Server Component, Client Component, Cache Components, PPR을 함께 살펴봤습니다.

처음에는 이 개념들이 섞여 보여서, 이번에는 코드가 어디에서 실행되는지와 결과를 언제 만들고 다시 쓰는지로 나눠서 정리해봤습니다.

1. 코드는 어디에서 실행할까?
Server Component ───────── Client Component

2. 결과는 언제 만들고 얼마나 다시 쓸까?
Static ───────── Cache ───────── Request-time

1. Server / Client: 코드는 어디에서 실행될까

App Router의 page와 layout은 기본적으로 Server Component입니다.

상태, 이벤트, 브라우저 API가 필요한 부분에는 'use client'가 들어갑니다.

구분Server ComponentClient Component
주 역할데이터 조회, 서버 자원 접근상태, 이벤트, 브라우저 API
대표 예시DB/API 호출, 서버 환경 변수useState, onClick, window
브라우저 JavaScript컴포넌트 코드가 그대로 전달되지 않음Client bundle에 포함됨
선언기본값파일 상단에 'use client'
ShopPage Server
├─ Header Server
│ └─ ThemeToggle Client
├─ ProductList Server
└─ UserCart Server

'use client'는 해당 파일 하나만 바꾸는 표시가 아닙니다. 그 파일이 import하는 모듈도 Client 쪽 모듈 그래프에 포함됩니다.

상태나 이벤트가 시작되는 컴포넌트 가까이에 'use client'를 두면 브라우저로 전달되는 JavaScript 범위도 줄어듭니다. 반대로 너무 잘게 나누면 props 전달이나 파일 구조가 복잡해질 수 있습니다.

Client Component와 CSR도 같은 의미는 아닙니다.

첫 방문에서는 Client Component도 서버가 만든 HTML에 포함될 수 있습니다. 이후 JavaScript가 로드되면서 이벤트와 상태를 연결하는데, 이 과정을 Hydration이라고 합니다.

HTML 화면을 먼저 표시
RSC Payload Server/Client 컴포넌트 트리를 연결
Client JavaScript 이벤트와 상태를 활성화

RSC Payload는 Server Component의 렌더링 결과, Client Component가 들어갈 위치, 전달할 props 등을 담은 데이터입니다.

Server Component에서 Client Component로 보내는 props는 React가 직렬화할 수 있어야 합니다. 문자열, 숫자, 배열, 일반 객체는 전달할 수 있지만, 일반 함수나 class instance를 그대로 넘길 수는 없습니다.

2. Static / Cache / Request-time: 결과는 언제 만들어지는가

여기서는 코드가 어디에서 실행되는지와 별개로, 만들어진 결과를 얼마나 다시 쓸 수 있는지도 같이 봤습니다.

구분처리 시점예시
Static요청 전에 만들 수 있음고정 레이아웃, 순수 계산
Cache한 번 만든 결과를 여러 요청에서 재사용상품 목록, 게시글
Request-time실제 요청이 들어온 뒤 생성쿠키, 헤더, 사용자별 데이터

Cache Components를 켜면 한 Route 안에서도 이 세 경우가 같이 나올 수 있습니다.

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
cacheComponents: true,
}

export default nextConfig

외부 데이터 조회처럼 사전 렌더링 중 바로 끝나지 않는 작업은, 이번 예시에서는 아래처럼 나눠봤습니다.

여러 요청에서 같은 결과를 써도 된다
→ 'use cache'

실제 요청이 들어와야 값을 알 수 있다
→ Suspense 안에서 Request-time 렌더링

'use cache'는 특정 fetch 요청 하나를 저장하는 기능보다는, 함수나 컴포넌트가 반환한 결과를 재사용하는 경계로 이해했습니다.

cacheLife()는 캐시 수명을 정하고, cacheTag()는 관련 캐시를 갱신할 때 사용할 이름을 붙입니다.

3. 예시: 쇼핑몰 홈 화면

한 페이지에 다음 요구사항이 있다고 가정했습니다.

  • Header는 사용자마다 달라지지 않습니다.
  • ProductList는 모든 사용자가 같이 보며, 상품이 수정될 때만 갱신하면 됩니다.
  • UserCart는 쿠키에 따라 사용자마다 달라집니다.
  • ThemeToggle은 클릭 이벤트와 브라우저 API가 필요합니다.

앞에서 나눈 내용을 이 화면에 같이 놓아보면 다음과 같습니다.

ShopPage Server Component

├─ Header Static
│ └─ ThemeToggle Client Component

├─ ProductList Server Component
│ └─ 'use cache' Cache

└─ Suspense
├─ CartSkeleton Static Shell
└─ UserCart Request-time
└─ cookies()로 사용자 확인

ProductList는 서버에서 조회하고 결과를 캐시합니다

// app/product-list.tsx
import { cacheLife, cacheTag } from 'next/cache'

export default async function ProductList() {
'use cache'

cacheLife('hours')
cacheTag('products')

const products = await getProducts()

return (
<ul>
{products.map((product) => (
<li key={product.id}>
<strong>{product.name}</strong>
<span>{product.price.toLocaleString()}</span>
</li>
))}
</ul>
)
}

상품 목록은 서버에서 조회하지만, 사용자마다 다른 값은 아닙니다. 일정 시간 같은 결과를 제공해도 되므로 캐시해서 여러 요청에서 재사용합니다.

상품 수정 이후 products 태그를 갱신하면 상품 목록과 관련된 결과를 다시 갱신할 수 있습니다.

UserCart는 요청이 들어온 뒤 처리합니다

// app/page.tsx
import { Suspense } from 'react'
import ProductList from './product-list'
import UserCart from './user-cart'

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

<Suspense fallback={<CartSkeleton />}>
<UserCart />
</Suspense>
</>
)
}
// app/user-cart.tsx
import { cookies } from 'next/headers'

export default async function UserCart() {
const sessionId = (await cookies()).get('session')?.value

if (!sessionId) {
return <p>로그인하면 장바구니를 확인할 수 있습니다.</p>
}

const cart = await getCart(sessionId)

return <CartSummary cart={cart} />
}

UserCart는 사용자의 쿠키가 있어야 결과를 만들 수 있습니다.

이 예시에서는 UserCart를 Suspense 안에 두었습니다. 먼저 만들 수 있는 영역과 CartSkeleton이 표시되고, 장바구니 결과는 준비된 뒤 이어서 전달됩니다.

ThemeToggle은 브라우저에서 실행합니다

// app/theme-toggle.tsx
'use client'

export default function ThemeToggle() {
function toggleTheme() {
const root = document.documentElement
const nextTheme =
root.dataset.theme === 'dark' ? 'light' : 'dark'

root.dataset.theme = nextTheme
}

return <button onClick={toggleTheme}>테마 전환</button>
}

document와 클릭 이벤트를 사용하므로 Client Component가 필요합니다.

그래서 여기서는 Header 전체가 아니라 ThemeToggle만 Client Component로 두었습니다.

한 표에서 같이 보기

영역실행 위치결과 시점이유
HeaderServerStatic요청 데이터가 필요하지 않음
ProductListServerCache여러 사용자가 같은 결과를 재사용
UserCartServerRequest-time사용자 쿠키가 있어야 조회 가능
ThemeToggleClient초기 HTML 이후 Hydration클릭 이벤트와 document 사용

이 구성에서는 미리 만들 수 있는 부분과 Suspense의 fallback이 Static Shell에 들어갑니다.

사용자별 장바구니는 요청 시점에 렌더링되고, 완료되는 대로 Streaming됩니다. 이번 예시에서는 Static, Cache, Request-time이 한 페이지 안에서 어떻게 같이 동작하는지 이런 흐름으로 확인했습니다.

첫 화면
├─ Header
├─ ProductList
└─ CartSkeleton

│ UserCart 조회 완료

장바구니 영역 교체

4. 같이 섞이기 쉬운 용어

RSC와 SSR

RSC는 컴포넌트를 서버에서 실행하는 React의 모델입니다.

SSR은 요청 시점에 서버에서 HTML을 만드는 렌더링 방식입니다. 서로 관련은 있지만 같은 개념은 아닙니다.

Client Component와 CSR

Client Component는 브라우저 JavaScript가 필요한 컴포넌트입니다.

첫 방문에 HTML을 서버에서 만들 수 있으므로, 페이지 전체를 브라우저에서 그리는 CSR과 동일하지 않습니다.

PPR과 Streaming

Streaming은 준비된 결과부터 나누어 보내는 방식입니다.

PPR에서는 Static Shell을 먼저 보내고, 요청 시점에 만들어지는 영역을 Streaming으로 이어서 전달할 수 있습니다.

정리하면서 본 순서

상태·이벤트·브라우저 API가 필요한가?
├─ 예 → Client Component
└─ 아니오 → Server Component

같은 결과를 여러 요청에서 다시 써도 되는가?
├─ 예 → 'use cache'와 갱신 조건 검토
└─ 아니오 → Request-time 처리

실제 요청이 있어야 값을 알 수 있는가?
├─ 예 → 가까운 Suspense 안으로 분리
└─ 아니오 → Static으로 만들 수 있는지 확인

Server Component를 많이 쓰는 것보다, 코드가 어디에서 실행되는지와 결과를 언제 만들고 다시 쓸 수 있는지를 따로 보는 쪽으로 정리했습니다.

참고