99

최근 30분간 동시 방문자 수를 표시합니다. (~)

최고 동시 방문자 수 -
어제: 0명 / 오늘: 0명

Next.js 16 핵심 정리

Next.js 16 핵심 정리

# 개요

이 글은 Next.js 16.3.0 버전을 기준으로 작성되었습니다.

다음은 Next.js 16의 주요 업데이트 내용입니다.

  • 캐싱 (중요)
    • 'use cache' 지시어를 사용해 페이지, 컴포넌트, 함수를 캐싱하는 부분 사전 렌더링(PPR)을 지원합니다.
    • revalidateTag()에서 캐시 프로필(Cache Profile)을 필수로 지정하도록 변경되었습니다.
    • 캐싱 API가 추가되었습니다.
      • updateTag(): 지정된 태그의 캐시를 만료하고 새로운 데이터를 가져옵니다.
      • refresh(): 캐시되지 않은 데이터만 새로 고치며, 캐시에는 전혀 영향을 주지 않습니다.
  • 미들웨어 파일 이름 변경 (중요)
    • middleware.tsproxy.ts로 변경되었습니다.
  • 비동기 요청 API 강제 (중요)
    • Next.js 15에서 도입된 비동기 API가 이제 완전히 강제됩니다.
    • params, searchParams, cookies(), headers() 등은 모두 비동기로만 접근 가능합니다.
  • React Compiler 지원 안정화 (중요)
    • 자동 메모이제이션을 제공하는 React Compiler가 안정화되었습니다.
    • React Compiler 사용을 적극 추천합니다.
  • Next.js DevTools MCP (중요)
    • Next.js 정보, 통합 로그, 자동 에러 확인, 페이지 인식 등의 기능을 위한 AI MCP를 지원합니다.
    • 최신 정보의 접근성 향상으로 레거시 정보로 인한 문제를 크게 줄일 것으로 기대합니다.
  • Turbopack 기본값
    • Turbopack이 안정화되어 Webpack 대신 기본 번들러가 되었습니다.
    • 필요한 경우 --webpack 플래그로 옵트아웃 할 수 있습니다.
  • React 19.2 지원
    • useEffectEvent(), <Activity/> 등의 React 최신 기능을 사용할 수 있습니다.
  • 경로 병렬 처리 default 필수
    • 모든 병렬 경로 슬롯에 명시적인 default 컴포넌트가 꼭 필요합니다.
  • 로깅 개선
    • 개발 로그에 빌드 프로세스, 컴파일, 렌더링 등의 소요 시간이 표시됩니다.
  • 프리패치 성능 개선
    • 공통의 레이아웃을 가지는 여러 URL을 프리패치할 때, 레이아웃을 한 번만 다운로드합니다.
    • 이미 캐시된 부분을 제외하고 필요한 부분만 프리패치합니다.
  • 이미지 구성의 기본값 변경
    • images 구성의 minimumCacheTTL, qualities, maximumRedirects 등의 옵션 기본값이 변경되었습니다.
  • ESLint Flat Config 기본값
    • ESLint v10에서 레거시 설정 지원을 중단하고, 린팅 속도나 관리 용이성 향상 등을 위해 ESLint Flat Config 형식을 기본값으로 사용합니다.
  • 개발 환경 성능 개선
    • Turbopack 파일 시스템 캐시(File System Cache)가 v16.1의 next dev, v16.3의 next build에서 차례로 기본값이 되어, 컴파일 결과를 디스크에 재사용합니다.
    • 개발 서버 재시작 후 첫 컴파일은 5~14배, 반복 빌드는 최대 5.5배 빨라집니다.
    • v16.2에서 next dev의 시작 시간이 약 87% 단축되었고, v16.3에서 메모리 제거(Memory Eviction)가 기본값이 되어 오래 실행하는 개발 서버의 메모리 사용량이 최대 90% 감소합니다.
  • 런타임 성능 개선
    • v16.2에서 서버 컴포넌트 페이로드의 역직렬화가 개선되어, HTML 렌더링이 25~60% 빨라졌습니다.
    • v16.3에서 App Router의 렌더링 계층이 Node.js 네이티브 스트림으로 교체되어, 부하 상황의 요청 처리량이 최대 22% 증가했습니다.

다음은 Next.js 15의 주요 업데이트 내용입니다.

  • 자동화된 업그레이드 CLI(@next/codemod)
    • Next.js 버전을 쉽게 업그레이드할 수 있는 CLI가 제공됩니다.
    • npx @next/codemod@canary upgrade latest 명령어로 업그레이드할 수 있습니다.
    • 상세한 코드 변경까지는 되지 않아, 추가 확인 작업이 필요합니다.
  • 비동기 요청 API (중요)
    • 데이터 요청이 필요 없는 컴포넌트를 비동기로 처리하여 초기 로드 속도를 높이는 방향으로 변경되었습니다.
    • paramssearchParams 등의 주요 API가 비동기 사용으로 전환되었습니다.
  • 캐싱 기본값 변경 (중요)
    • GET 요청 및 클라이언트 내비게이션의 기본 캐싱 설정이 해제되었습니다.
    • TanStack Query 같은 라이브러리의 캐싱 기능과 중복되지 않아서 좋습니다.
    • cache: 'force-cache' 옵션으로 다시 캐싱할 수 있습니다.
  • React 19 지원 (중요)
    • React 19와의 호환성을 지원하며, React 18과도 일부 하위 호환이 유지됩니다.
  • <Form> 컴포넌트
    • <form> 요소를 확장한 최적화 컴포넌트로, 제출 경로를 프리패칭(Prefetching)하고 제출 데이터를 쿼리스트링으로 보존합니다.
  • Turbopack Dev 안정화
    • next dev --turbo 모드가 안정화되어, 더 빠른 개발 환경과 반응 속도를 제공합니다.
  • 정적 라우트 표시기
    • 개발 중에 경로가 사전 렌더링되는지 여부를 화면 하단 모서리에 표시(⚡ Static route)합니다.
  • 비동기 처리 후 코드 실행 API (unstable_after)
    • 스트리밍이 완료된 후 비동기적으로 추가 작업을 처리할 수 있는 새로운 API입니다.
    • 아직 안정화 전이니 참고만 하는 것이 좋겠습니다.
  • /instrumentation.js API 안정화
    • 서버 생명주기를 관찰하여 성능을 모니터링하고 오류를 추적할 수 있습니다.
  • TypeScript 구성 지원
    • TypeScript를 사용하는 설정 파일을 지원하며 자동 완성과 타입 안전성도 보장됩니다.
  • 자체 호스팅 개선
    • 캐시 제어 헤더에 대한 더 많은 제어와 이미지 최적화 성능이 향상되었습니다.
  • 보안 강화
    • 서버 액션이 공개 엔드포인트가 되는 것을 방지하기 위한 기능이 추가되어 보안이 강화되었습니다.
  • 외부 패키지 번들링 최적화
    • 외부 패키지를 번들링할 수 있는 설정 옵션이 추가되었습니다.
  • ESLint 9 지원
    • ESLint 9를 지원하여 최신 규칙을 반영하고 호환성을 유지합니다.
  • 빌드 및 개발 성능 개선
    • 정적 페이지 생성 최적화와 서버 컴포넌트의 HMR 기능이 강화되었습니다.

# Next.js란?

Next.jsVercel에서 개발한 React 프레임워크로, 서버 사이드 렌더링(SSR), 클라이언트 사이드 렌더링(CSR), API 라우팅 등의 다양한 최적화 기능을 제공합니다.
Next.js를 사용하면, React의 기본 기능을 확장해, 보다 빠르고 안정적으로 웹 애플리케이션을 개발할 수 있습니다.

# 설치 및 구성

다음 명령으로 Next.js 프로젝트를 설치합니다.
각 질문에 Yes 또는 No로 답변합니다.

BASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
npx create-next-app@latest 프로젝트이름 ## v16 Would you like to use the recommended Next.js defaults? No, customize settings Would you like to use TypeScript? No / Yes # 타입스크립트 사용 여부 Which linter would you like to use? ESLint # ESLint / Biome / None 중 선택 Would you like to use React Compiler? No / Yes # React Compiler 사용 여부 Would you like to use Tailwind CSS? No / Yes # Tailwind CSS 사용 여부 Would you like your code inside a `src/` directory? No / Yes # src/ 디렉토리 사용 여부 Would you like to use App Router? (recommended) … No / Yes # App Router 사용 여부 Would you like to customize the import alias (`@/*` by default)? … No / Yes # `@/*` 외 경로 별칭 사용 여부 Would you like to include AGENTS.md to guide coding agents to write up-to-date Next.js code? No / Yes # AGENTS.md 포함 여부 ## v15 Would you like to use TypeScript? Yes # 타입스크립트 사용 여부 Would you like to use ESLint? Yes # ESLint 사용 여부 Would you like to use Tailwind CSS? Yes # Tailwind CSS 사용 여부 Would you like your code inside a `src/` directory? No # src/ 디렉토리 사용 여부 Would you like to use App Router? (recommended) … Yes # App Router 사용 여부 Would you like to customize the import alias (@/* by default)? … No # `@/*` 외 경로 별칭 사용 여부

'App Router'는 Next.js 13버전부터 사용할 수 있게 된 방식으로, 보다 복잡한 라우팅 요구사항과 애플리케이션 상태 관리를 위해 설계되었습니다.
일부 장단점이 있지만, 대부분의 경우 Pages Router 보다 최신의 App Router를 사용하는 것을 추천합니다!

# Prettier

다음 VS Code 확장 프로그램이 설치되어 있어야 합니다.

  • ESLint: 코드 품질 확인 및 버그, 안티패턴(Anti-pattern)을 감지
  • Prettier - Code formatter: 코드 스타일 및 포맷팅 관리, 일관된 코드 스타일을 적용 가능

Prettier 관련 패키지들을 설치합니다.

BASH
1
npm i -D prettier eslint-config-prettier eslint-plugin-prettier prettier-plugin-tailwindcss

ESLint 구성을 다음과 같이 수정합니다.

/eslint.config.js
JS
1
2
3
4
5
6
7
import prettierRecommended from 'eslint-config-prettier' const eslintConfig = defineConfig([ { extends: [prettierRecommended] } ]) export default eslintConfig

추가로, 프로젝트 루트 경로에 .prettierrc 파일을 생성하고 다음처럼 원하는 규칙을 추가합니다.
자세한 규칙은 Prettier / Options 에서 확인할 수 있습니다.

/.prettierrc
JSON
1
2
3
4
5
6
7
8
9
10
{ "semi": false, "singleQuote": true, "singleAttributePerLine": true, "bracketSameLine": true, "endOfLine": "lf", "trailingComma": "none", "arrowParens": "avoid", "plugins": ["prettier-plugin-tailwindcss"] }
# 자동 포맷팅 설정

프로젝트의 루트 경로에 .vscode/settings.json 폴더와 파일을 생성해 다음과 같이 내용을 추가할 수 있습니다.

/.vscode/settings.json
JSON
1
2
3
4
{ "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode" }

# 스킬 활용

Claude Code, Cursor 등의 AI 코딩 도구에서 Skills 기능을 사용하면, 대화형으로 보다 쉽게 자동 설치 및 구성할 수 있습니다.
다음 명령으로 스킬을 설치합니다.

BASH
1
npx skills add ParkYoungWoong/skills --skill react-next-scaffold

설치 후 AI 코딩 도구에서 Next.js 프로젝트를 시작하면, 스킬이 자동으로 활성화되어 위 구성(ESLint, Prettier, 경로 별칭 등)을 포함한 프로젝트를 생성합니다.
혹은 다음 클로드 코드 예시와 같이 각 도구에서 스킬을 수동으로 호출할 수도 있습니다.

1
❯ /react-next-scaffold
Claude Code

# AGENTS.md

AI 코딩 에이전트(Agent)는 학습한 데이터를 기준으로 코드를 작성하기 때문에, 이미 사라진 Next.js API를 사용하는 경우가 있습니다.
이를 막기 위해 Next.js 16.2부터는 설치한 버전과 일치하는 문서가 패키지 안에 함께 포함됩니다.
문서는 node_modules/next/dist/docs/ 경로에 있어서, 네트워크 요청 없이 바로 읽을 수 있습니다.

그리고 16.3부터 next dev를 실행하면, 환경에서 AI 코딩 에이전트가 감지될 때 프로젝트 루트에 AGENTS.mdCLAUDE.md 파일을 생성하고 유지합니다.
AGENTS.md에는 다음과 같은 관리 블록이 기록되어, 에이전트가 코드를 작성하기 전에 번들 문서를 먼저 읽도록 안내합니다.

/AGENTS.md
MARKDOWN
1
2
3
4
5
6
7
<!-- BEGIN:nextjs-agent-rules --> # This is NOT the Next.js you know <!-- 생략.. 설치된 버전의 문서를 먼저 읽으라는 안내 --> <!-- END:nextjs-agent-rules -->

프로젝트에서 직접 작성할 지침은 BEGINEND 표시 영역 바깥에 작성합니다.
Next.js가 관리 블록을 갱신할 때, 바깥 영역 내용은 그대로 유지됩니다.

create-next-app으로 새 프로젝트를 시작할 때 두 파일이 자동으로 생성되고, --no-agents-md 옵션으로 제외할 수 있습니다.
이미 만든 프로젝트에서 자동 생성을 끄고 싶다면, 다음과 같이 구성 옵션(agentRules)을 추가할 수 있습니다.

/next.config.ts
TS
1
2
3
4
5
6
7
import type { NextConfig } from 'next' const nextConfig: NextConfig = { agentRules: false } export default nextConfig

# Dev Indicator

개발 서버(next dev)를 실행하면, 브라우저 화면 왼쪽 아래에 Next.js 로고 모양의 Dev Indicator가 나타납니다.
현재 경로가 정적(사전 렌더링)인지 동적(요청 시 렌더링)인지 등을 알려주는 표시로, 개발 환경에서만 동작합니다.

Next.js Dev Indicator

화면을 가려서 불편하다면, 다음과 같이 devIndicators 옵션에 false를 지정해 표시하지 않을 수 있습니다.
표시기만 숨기는 것이므로, 컴파일이나 런타임 에러는 이전과 동일하게 화면에 표시됩니다.

/next.config.ts
TS
1
2
3
4
5
6
7
import type { NextConfig } from 'next' const nextConfig: NextConfig = { devIndicators: false } export default nextConfig

숨기는 대신 위치만 옮기고 싶다면 devIndicators: { position: 'bottom-right' }처럼 지정합니다.
'bottom-left'(기본값), 'bottom-right', 'top-left', 'top-right' 중에서 선택할 수 있습니다.
appIsrStatus, buildActivity, buildActivityPosition 옵션은 v16에서 제거되었습니다.

# Server vs Client

Next.js에서는 서버 컴포넌트(Server Component)와 클라이언트 컴포넌트(Client Component)를 구분해 코드 일부가 서버 혹은 클라이언트에서 출력될 수 있도록 만들 수 있습니다.
기본적으로 생성하는 모든 컴포넌트는 서버 컴포넌트입니다!
클라이언트 컴포넌트로 변경/사용하려면 다음과 같이 컴포넌트 최상단에 'use client' 선언이 필요하고, 해당 선언이 없으면 서버 컴포넌트입니다.

클라이언트 컴포넌트 또한 일부 정적 요소는 서버에서 렌더링합니다.
따라서 클라이언트 컴포넌트는 '서버 + 클라이언트'의 하이브리드 컴포넌트로 이해해야 합니다!

TSX
1
2
3
4
5
'use client' export default function Component() { return <></> }

서버 컴포넌트와 클라이언트 컴포넌트는 다음과 같이 사용할 수 있는 일부 API가 다릅니다.
이런 구분을 통해, 서버 컴포넌트에서는 보안이나 캐싱, 성능, SEO 등의 이점을, 클라이언트 컴포넌트에서는 상호작용('click', 'load' 이벤트 등), 브라우저 API(window, document 등) 활용 등의 이점을 얻을 수 있습니다.

서버 컴포넌트만 사용:

  • cookies
  • headers
  • redirect
  • generateMetadata
  • revalidatePath
  • ...

클라이언트 컴포넌트만 사용:

  • useState
  • useEffect
  • onClick
  • onChange
  • useRouter
  • useParams
  • useSearchParams
  • useFormState
  • useOptimistic
  • ...

각 서버와 클라이언트 컴포넌트에서 서로 맞지 않는 API를 사용하면 다음 이미지와 같이, 바로 에러를 표시하기 때문에 금방 구분할 수 있게 됩니다.

"에러: 'next/headers'는 서버 컴포넌트에서만 작동하지만, 클라이언트 컴포넌트에서 사용되고 있습니다."

# 라우팅

라우팅 기능을 사용하기 위해선 Next.js의 파일 규칙(File Conventions)을 이해해야 합니다.
기본 파일은 아래 표에서 layout부터 순서대로 계층 구조를 나타내며, 각 페이지를 출력하기 위해 기능에 맞게 사용합니다.
이러한 명시적 파일 규칙을 통해, 프로젝트 구조를 명확하게 유지할 수 있습니다.

명시적 컴포넌트 계층 구조(Component Hierarchy)

기본 파일 확장자 설명
error .js, .jsx, .tsx 에러 페이지
layout .js, .jsx, .tsx 고정 레이아웃
loading .js, .jsx, .tsx 로딩 페이지
not-found .js, .jsx, .tsx 찾을 수 없는(404) 페이지
page .js, .jsx, .tsx 기본 페이지
template .js, .jsx, .tsx 변화 레이아웃(탐색 시)
추가 파일 확장자 설명
default .js, .jsx, .tsx 병렬 경로(Parallel)의 대체 페이지
global-error .js, .jsx, .tsx 전역 에러 UI
route .js, .ts API 엔드포인트

# 페이지

Next.js는 폴더를 사용해 경로를 정의하는 파일 시스템 기반 라우터 방식을 사용하기 때문에, /app 폴더 내에 생성하는 각 폴더는 기본적으로 URL 경로를 의미합니다.
예를 들어, 프로젝트에 /app/movies 폴더를 생성하면 /movies 경로 즉, http://localhost:3000/movies로 접근할 수 있습니다.
그리고 접근한 그 경로에서 출력할 내용은 기본적으로 각 폴더의 page.tsx 컴포넌트에 작성합니다.

이렇게 매핑되는 각 경로 구간을 세그먼트(Segment)라고 합니다.

세그먼트의 이해

1
2
3
4
├─app/ │ ├─movies/ │ │ └─page.tsx │ └─page.tsx
프로젝트 구조
/app/page.tsx
TSX
1
2
3
export default function Home() { return <h1>Home page!</h1> }
http://localhost:3000/ 경로의 페이지 내용
/app/movies/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
export default function Movies() { return ( <> <h1>Movies page!</h1> <ul> <li>Avengers</li> <li>Avatar</li> <li>Frozen</li> </ul> </> ) }
http://localhost:3000/movies 경로의 페이지 내용

또한 위에서 살펴본 라우팅 파일 규칙에 해당하는 이름이 아닌 파일은, 경로로 정의되지 않기 때문에 같은 폴더 안에서 자유롭게 추가해 사용할 수 있습니다.
다음 이미지에서 page.js, route.js 파일을 제외한 나머지 파일은 경로로 정의되지 않습니다.(Not Routable)

라우팅 파일과 폴더 공유(Colocation)

# 레이아웃

여러 하위 경로에서 공통으로 사용하는 UI는, 각 라우팅 폴더의 layout.tsx 컴포넌트에 작성합니다.
슬롯(Slot) 방식으로 children Prop을 사용하며, {children} 부분에는 같은 레벨에 있는 page.tsx 컴포넌트를 출력합니다.
또한 레이아웃은 중첩해서 사용할 수 있습니다.

1
2
3
4
5
6
7
8
├─app/ │ ├─movies/ │ │ ├─layout.tsx │ │ └─page.tsx │ ├─layout.tsx │ └─page.tsx ├─components/ │ └─Header.tsx
프로젝트 구조

앞서 우리는 Next.js의 설치 질문에서 ✔ Would you like to customize the import alias (@/* by default)? … No 입력을 통해, @ 경로 별칭이 프로젝트의 루트 경로를 의미하도록 구성했습니다.

다음 코드의 {children} 부분에는 /app/page.tsx 컴포넌트가 출력됩니다.

/app/layout.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import './globals.css' import Header from '@/components/Header' export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( <html lang="ko"> <body className="antialiased"> <Header /> <main className="p-2">{children}</main> </body> </html> ) }
http://localhost:3000/ 경로의 레이아웃

다음 코드의 {children} 부분에는 /app/movies/page.tsx 컴포넌트가 출력됩니다.

/app/movies/layout.tsx
TSX
1
2
3
4
5
export default function MoviesLayout({ children }: Readonly<{ children: React.ReactNode }>) { return <section>{children}</section> }
http://localhost:3000/movies 경로의 레이아웃

# 컴포넌트 방식의 탐색

Next.js에서는 페이지 이동을 위해 <a> 태그가 아닌 <Link> 컴포넌트를 사용합니다.
<Link> 컴포넌트는 이동하는 페이지 전체를 새로고침하지 않고 최적화된 번들만 일부 로드하거나 서버 렌더링 가능 등의 Next.js 프로젝트 내에서 최적화된 페이지 탐색을 제공합니다.
위에서 확인한, /components/Header.tsx 컴포넌트에서, 각 페이지로 이동할 수 있는 링크를 추가해봅시다.

1
2
3
4
5
6
7
8
├─app/ │ ├─movies/ │ │ ├─layout.tsx │ │ └─page.tsx │ ├─layout.tsx │ └─page.tsx ├─components/ │ └─Header.tsx
프로젝트 구조
/components/Header.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import Link from 'next/link' export default function Header() { return ( <header> <nav className="flex"> {links.map(({ href, label }) => ( <Link key={href} href={href} className="px-2"> {label} </Link> ))} </nav> </header> ) }

usePathname 훅을 사용해 반환되는 현재 경로(pathname)와 각 <Link> 컴포넌트의 경로를 비교해 현재 페이지인 경우 활성화 스타일을 추가할 수 있습니다.

use 접두사로 시작하는 훅은 클라이언트 컴포넌트('use client')에서만 사용할 수 있습니다.

/components/Header.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
'use client' import { usePathname } from 'next/navigation' import Link from 'next/link' const links = [ { href: '/', label: 'Home' }, { href: '/movies', label: 'Movies' } ] export default function Header() { const pathname = usePathname() return ( <header> <nav className="flex"> {links.map(({ href, label }) => ( <Link key={href} href={href} className={`px-2 ${pathname === href ? 'bg-blue-600 text-white' : ''} `}> {label} </Link> ))} </nav> </header> ) }

# 미리 가져오기

<Link /> 컴포넌트는 prefetch 옵션을 통해 뷰포트에 보여질 때, 연결된 경로(href)의 데이터를 미리 가져와 탐색 성능을 크게 향상시킬 수 있습니다.

미리 가져오기 기능은 제품(Production) 모드에서만 활성화됩니다!

  • null(기본값): 정적 경로인 경우 모든 하위 경로를, 동적 경로인 경우 loading.tsx가 있는 가장 가까운 세그먼트까지만 미리 가져옵니다.
  • true: 정적 경로와 동적 경로 모두 미리 가져옵니다.
  • false: 미리 가져오지 않습니다.
TSX
1
2
3
4
5
6
7
8
9
export default function Links() { return ( <> <Link href={someLink}>null</Link> <Link prefetch={true} href={someLink}>true</Link> <Link prefetch={false} href={someLink}>false</Link> </> ) }

v16은 경로 트리의 세그먼트마다 프리패치 요청을 나눠 보냅니다.
공통 레이아웃을 한 번만 받아 여러 경로에서 재사용할 수 있어 캐시 효율은 좋아지지만, 링크 하나당 발생하는 요청 수는 늘어납니다.

v16.3부터는 일정 크기 이하의 응답을 하나로 묶어 보내는 것이 기본 동작이 되어, 요청 수가 다시 줄었습니다.
여러 경로에서 재사용할 수 있는 큰 공유 세그먼트는 그대로 분리해 두기 때문에 캐시 효율은 유지됩니다.
따로 수정할 코드는 없지만, 필요하다면 prefetchInlining 옵션으로 동작을 조정할 수 있습니다.
false를 지정하면 묶지 않고, 세그먼트마다 개별 요청을 보냅니다.

/next.config.ts
TS
1
2
3
4
5
6
7
8
9
import type { NextConfig } from 'next' const nextConfig: NextConfig = { experimental: { prefetchInlining: false // 기본값: true } } export default nextConfig

# 프로그래밍 방식의 탐색

상황에 따라 <Link> 컴포넌트를 사용하지 않고, 프로그래밍 방식으로 페이지를 이동해야 할 때가 있습니다.
그 때는 Next.js에서 제공하는 useRouter 훅(Hook)을 사용해 다음과 같이 페이지 이동을 구현할 수 있습니다.

useRouter 훅은 클라이언트 컴포넌트에서만 사용할 수 있습니다.
따라서 컴포넌트 상단에 'use client 선언이 필요합니다.

/components/Header.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
'use client' import { usePathname } from 'next/navigation' import { useRouter } from 'next/navigation' import Link from 'next/link' const links = [ { href: '/', label: 'Home' }, { href: '/movies', label: 'Movies' } ] export default function Header() { const pathname = usePathname() const router = useRouter() return ( <header className="flex items-center"> <nav className="flex"> {links.map(({ href, label }) => ( <Link key={href} href={href} className={`px-2 ${pathname === href ? 'bg-blue-600 text-white' : ''} `}> {label} </Link> ))} </nav> <button className="rounded bg-gray-800 px-2 py-1 text-sm text-white transition-colors hover:bg-gray-700" onClick={() => router.push('/movies')}> Movies(Push) </button> </header> ) }

router 객체에서는 다음과 같은 메서드를 사용할 수 있습니다.

  • push(url): 페이지 이동
  • replace(url): 페이지 이동(히스토리에 남지 않음)
  • back(): 이전 페이지로 이동
  • forward(): 다음 페이지로 이동
  • refresh(): 페이지 새로고침
  • prefetch(url): 페이지 미리 가져오기

# 미리 가져오기

기본적인 미리 가져오기가 자동으로 동작하는 <Link> 컴포넌트와 달리, 프로그래밍 방식의 탐색에서는 useEffect 훅과 router.prefetch() 메서드를 사용해 미리 가져오기를 구현할 수 있습니다.

/components/Header.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
'use client' import { useEffect } from 'react' import { usePathname } from 'next/navigation' import { useRouter } from 'next/navigation' import Link from 'next/link' // 생략.. export default function Header() { const pathname = usePathname() const router = useRouter() useEffect(() => { router.prefetch('/movies') }, [router]) return ( <header className="flex items-center"> {/* 생략.. */} <button className="rounded bg-gray-800 px-2 py-1 text-sm text-white transition-colors hover:bg-gray-700" onClick={() => router.push('/movies')}> Movies(Push) </button> </header> ) }

# 동적 경로

미리 정의할 수 없는 동적인 경로는, 대괄호([])를 사용해 폴더 이름을 작성합니다.
그러면 URL의 세그먼트 값이, params Prop으로 전달되고, 대괄호 사이의 폴더 이름이 속성 이름이 됩니다.
만약 쿼리스트링(Query String)을 사용하는 경우, searchParams Prop으로 전달됩니다.

1
2
3
4
├─app/ │ ├─movies/ │ │ ├─[movieId]/ │ │ │ └─page.tsx
프로젝트 구조

paramssearchParams는 모두 Promise 객체입니다.
서버 컴포넌트인 경우, await 키워드를 사용해 필요한 값을 추출합니다.

v16부터 params, searchParams, cookies(), headers() 등이 더 이상 동기 접근이 불가하고, 모두 비동기로만 접근 가능합니다.

/app/movies/[movieId]/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
interface Movie { Title: string Plot: string } export default async function MovieDetails({ params, // 동적 세그먼트 searchParams // 쿼리스트링 }: { params: Promise<{ movieId: string }> searchParams: Promise<{ plot?: 'short' | 'full' }> }) { const { movieId } = await params const { plot } = await searchParams const res = await fetch(`https://omdbapi.com/?apikey=7035c60c&i=${movieId}&plot=${plot || 'short'}`) const movie: Movie = await res.json() return ( <> <h1>{movie.Title}</h1> <p>{movie.Plot}</p> </> ) }
서버 컴포넌트인 경우

클라이언트 컴포넌트인 경우, use 함수를 사용해 필요한 값을 추출합니다.

/app/movies/[movieId]/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
'use client' import { use, useState, useEffect } from 'react' interface Movie { Title: string Plot: string } export default function MovieDetails({ params, // 동적 세그먼트 searchParams // 쿼리스트링 }: { params: Promise<{ movieId: string }> searchParams: Promise<{ plot?: 'short' | 'full' }> }) { const { movieId } = use(params) const { plot } = use(searchParams) const [movie, setMovie] = useState<Movie | null>(null) useEffect(() => { const fetchMovie = async () => { const res = await fetch(`https://omdbapi.com/?apikey=7035c60c&i=${movieId}&plot=${plot || 'short'}`) const movie: Movie = await res.json() setMovie(movie) } fetchMovie() }, [movieId, plot]) return ( <> <h1>{movie?.Title}</h1> <p>{movie?.Plot}</p> </> ) }
클라이언트 컴포넌트인 경우

<Header> 컴포넌트에서 영화 상세 페이지로 이동할 수 있는 링크를 추가해봅시다.

/components/Header.tsx
TSX
1
2
3
4
5
6
7
8
9
10
// 생략.. const links = [ { href: '/', label: 'Home' }, { href: '/movies', label: 'Movies' }, { href: '/movies/tt4154796', label: 'Movie(Avengers)' } ] export default function Header() { // 생략.. }

혹은 다음과 같이 직접 URL을 입력해 영화 상세 페이지로 이동해 보세요.

1
2
3
http://localhost:3000/movies/tt4520988?plot=full http://localhost:3000/movies/tt4154796 http://localhost:3000/movies/tt1630029
위 URL로 접근해보세요!

앞서 살펴본 것처럼 [이름] 폴더로 단순한 동적 경로 일치도 가능하고, 다음 예시와 같이 모든 하위 경로의 동적 일치([...이름])나 선택적 동적 일치([[...이름]]) 패턴도 사용할 수 있습니다.

폴더 구조 예시 URL 예시 params
app/movies/[hello]/page.tsx /movies/foo { hello: 'foo' }
app/movies/[hello]/page.tsx /movies/bar { hello: 'bar' }
app/movies/[hello]/[world]/page.tsx /movies/foo/bar { hello: 'foo', world: 'bar' }
app/movies/[...hello]/page.tsx /movies/foo { hello: ['foo'] }
app/movies/[...hello]/page.tsx /movies/foo/bar { hello: ['foo', 'bar'] }
app/movies/[...hello]/page.tsx /movies/foo/bar/baz { hello: ['foo', 'bar', 'baz'] }
app/movies/[[...hello]]/page.tsx /movies {}
app/movies/[[...hello]]/page.tsx /movies/foo { hello: ['foo'] }
app/movies/[[...hello]]/page.tsx /movies/foo/bar { hello: ['foo', 'bar'] }

# 로딩

페이지 출력을 준비하는 동안, 먼저 로딩 상태를 표시할 수 있습니다.
출력할 페이지와 같은 경로(폴더)에 loading.tsx 파일을 생성합니다.

1
2
3
4
5
├─app/ │ ├─movies/ │ │ ├─[movieId]/ │ │ │ ├─loading.tsx │ │ │ └─page.tsx
프로젝트 구조
/app/movies/loading.tsx
TSX
1
2
3
4
5
import Loader from '@/components/Loader' export default function Loading() { return <Loader /> }
페이지 출력 전 로딩 UI

애니메이션 로딩 UI를 구현하기 위해, 다음과 같이 <Loader> 컴포넌트를 작성합니다.

/components/Loader.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
interface LoaderProps { size?: number weight?: number color?: string duration?: number className?: string } export default function Loader({ size = 40, weight = 4, color = '#e96900', duration = 1, className = '' }: LoaderProps) { return ( <div className={`animate-spin rounded-full ${className} `} style={{ width: size, height: size, borderWidth: weight, borderStyle: 'solid', borderColor: color, borderTopColor: 'transparent', animationDuration: `${duration}s` }} /> ) }

지연 시간을 추가하도록 대기(Delay) 유틸 함수를 작성합니다.

/utils/wait.ts
TSX
1
2
3
export default function wait(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) }
대기 유틸 함수

다음과 같이 영화 상세 정보 가져오기를 2초 동안 지연해서 확실히 로딩 UI를 확인하려고 합니다.
이제 http://localhost:3000/movies/tt4520988 페이지로 접근해보세요!

/app/movies/[movieId]/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
import wait from '@/utils/wait' export default async function MovieDetails({ // 생략.. }) { const { movieId } = await params const { plot } = await searchParams await wait(2000) const res = await fetch(`https://omdbapi.com/?apikey=7035c60c&i=${movieId}&plot=${plot || 'short'}`) // 생략.. }
최소 3초 후에 페이지를 출력

# 에러

페이지 출력 중 에러가 발생하면, 에러 상태를 표시할 수 있습니다.
출력할 페이지와 같은 폴더에 error.tsx 파일을 생성합니다.

사용자 입력의 유효성 검사나 잘못된 API 요청 등 클라이언트에서 발생하는 에러 상황까지 처리하기 위해, error.tsx는 클라이언트 컴포넌트여야 합니다.
따라서 컴포넌트 상단에 'use client' 선언이 필요합니다.

1
2
3
4
5
6
├─app/ │ ├─movies/ │ │ ├─[movieId]/ │ │ │ ├─error.tsx │ │ │ ├─loading.tsx │ │ │ └─page.tsx
프로젝트 구조
/app/movies/error.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'use client' export default function Error({ error, retry }: { error: Error & { digest?: string } retry: () => void }) { return ( <> <h2>{error.message}</h2> <button onClick={() => retry()}>다시 시도</button> </> ) }
페이지 출력 중 에러 발생 시 UI

retry()는 v16.3에서 안정화되었습니다.
router.refresh()reset()startTransition() 안에서 함께 호출하는데, 서버에서 데이터를 다시 가져와 해당 세그먼트를 렌더링하기 때문에, 대부분의 에러 복구 상황에서 권장됩니다.

/app/movies/[movieId]/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
import wait from '@/utils/wait' export default async function MovieDetails({ // 생략.. }) { // 생략.. await wait(2000) throw new Error('뭔가 문제가 있어요..') // 생략.. }
2초 후에 에러 페이지를 출력

# 찾을 수 없는 페이지

프로젝트에서 정의하지 않은 경로로 접근하면, not-found.tsx 파일로 별도의 페이지를 출력할 수 있습니다.

1
2
├─app/ │ └─not-found.tsx
프로젝트 구조
/app/not-found.tsx
TSX
1
2
3
4
5
6
7
8
9
10
import Link from 'next/link' export default function NotFound() { return ( <> <h1 className="text-2xl font-bold">404, 찾을 수 없는 페이지입니다.</h1> <Link href="/">메인 페이지로 이동~</Link> </> ) }
404 페이지 내용
1
http://localhost:3000/helloworld12345678
위 URL로 접근해보세요!

# 비동기 컴포넌트 스트리밍

다음 예제에서 async/page.tsx 파일은 1초 후에 페이지를 출력하는 비동기 컴포넌트이고, AbcXyz 컴포넌트 또한 각각 2초와 3초 후에 내용을 출력하는 비동기 컴포넌트입니다.
그러면 http://localhost:3000/async 주소로 접근했을 때, 로딩 애니메이션은 4초 동안 표시되고 그 후에 AbcXyz 컴포넌트가 동시에 출력됩니다.
Abc 컴포넌트는 2초 만에 출력할 수 있지만, Xyz 컴포넌트의 영향으로 3초 후에 같이 출력됩니다.

1
2
3
4
5
6
├─app/ │ ├─async/ │ │ ├─Abc.tsx │ │ ├─loading.tsx │ │ ├─page.tsx │ │ └─Xyz.tsx
프로젝트 구조
/app/async/loading.tsx
TSX
1
2
3
4
5
import Loader from '@/components/Loader' export default function Loading() { return <Loader /> }
페이지 출력 전 로딩 UI
/app/async/Abc.tsx
TSX
1
2
3
4
5
6
import wait from '@/utils/wait' export default async function Abc() { await wait(2000) return <h2>Abc 컴포넌트!</h2> }
2초 후에 페이지를 출력하는 컴포넌트 Abc
/app/async/Xyz.tsx
TSX
1
2
3
4
5
6
import wait from '@/utils/wait' export default async function Xyz() { await wait(3000) return <h2>Xyz 컴포넌트!</h2> }
3초 후에 페이지를 출력하는 컴포넌트 Xyz
/app/async/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import wait from '@/utils/wait' import Abc from './Abc' import Xyz from './Xyz' export default async function Page() { await wait(1000) return ( <> <h1>비동기 페이지!</h1> <Abc /> <Xyz /> </> ) }
1초 후에 비동기 컴포넌트 Abc와 Xyz를 출력하는 페이지

<Header> 컴포넌트에서 비동기 컴포넌트 스트리밍 테스트 페이지로 이동할 수 있게 링크를 추가해봅시다.

/components/Header.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
// 생략.. const links = [ { href: '/', label: 'Home' }, { href: '/movies', label: 'Movies' }, { href: '/movies/tt4154796', label: 'Movie(Avengers)' }, { href: '/async', label: 'Async' } ] export default function Header() { // 생략.. }

<Suspense> 컴포넌트를 사용해 비동기 컴포넌트를 스트리밍하면, 각 비동기 컴포넌트가 준비되는 대로 출력할 수 있습니다.
<Suspense> 컴포넌트에서 fallback Prop을 사용해 각 비동기 컴포넌트의 로딩 UI를 출력할 수도 있습니다.
다음 예제는 기본 로딩 애니메이션이 1초 동안 표시되고 그 후에 빨간색과 파란색 로딩 애니메이션이 각각 2초와 3초 동안 표시된 후에 AbcXyz 컴포넌트가 출력됩니다.

/app/async/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Suspense } from 'react' import Loader from '@/components/Loader' import wait from '@/utils/wait' import Abc from './Abc' import Xyz from './Xyz' export default async function Page() { await wait(1000) return ( <> <h1>비동기 페이지!</h1> <Suspense fallback={<Loader color="red" />}> <Abc /> </Suspense> <Suspense fallback={<Loader color="blue" />}> <Xyz /> </Suspense> </> ) }
비동기 컴포넌트 Abc와 Xyz를 출력하는 페이지

# 고급 라우팅 패턴

# 경로 그룹

/app 폴더 내 기본적인 폴더는 항상 URL 경로로 매핑되지만, 소괄호(())를 사용해 URL 경로에 영향을 주지 않는 폴더(경로) 그룹을 만들 수 있습니다.
이 그룹은 특히, 각자의 레이아웃(layout.tsx)을 가질 수 있기 때문에, 경로에 맞는 여러 레이아웃 제공을 제공할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
├─app/ │ ├─(movies)/ │ │ ├─movies/ │ │ │ ├─[movieId]/ │ │ │ │ ├─error.tsx │ │ │ │ ├─loading.tsx │ │ │ │ └─page.tsx │ │ └─layout.tsx <== (movies) 그룹에서만 동작하는 레이아웃 │ ├─layout.tsx <== 루트 레이아웃 │ └─page.tsx
프로젝트 구조
/app/(movies)/layout.tsx
TSX
1
2
3
4
5
6
7
8
export default function Layout({ children }: { children: React.ReactNode }) { return ( <div className="border-2 px-3 py-2"> <p className="text-gray-500">(movies) 경로 그룹의 레이아웃</p> {children} </div> ) }

# 경로 병렬 처리

@ 접두사의 폴더는 URL 경로에 영향을 주지 않는 페이지로, 하나의 레이아웃에서 병렬로 경로를 처리(Parallel Routes)할 수 있습니다.
이를 통해 여러 페이지나 컴포넌트를 병렬로 로드하고 렌더링할 수 있어, 순차적 로딩 대비 전체 로딩 시간이 단축되고 각 컴포넌트의 로딩 상태를 독립적으로 표시할 수 있어 더 나은 사용자 경험을 제공합니다.

경로 병렬 처리(Parallel Routes)

만약 경로 병렬 처리 작업이 개발 서버에서 적용되지 않는 경우, .next 폴더 삭제 후 개발 서버를 재시작하는 것을 추천합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
├─app │ ├─async │ │ ├─@abc │ │ │ ├─default.tsx │ │ │ ├─loading.tsx │ │ │ └─page.tsx │ │ ├─@xyz │ │ │ ├─default.tsx │ │ │ ├─loading.tsx │ │ │ └─page.tsx │ │ ├─layout.tsx │ │ ├─loading.tsx │ │ └─page.tsx
프로젝트 구조

default 컴포넌트는 슬롯이 현재 URL과 일치하지 않을 때 대신 출력할 내용입니다.
<Link> 컴포넌트를 사용하는 탐색에서는 Next.js가 각 슬롯이 어떤 하위 페이지를 보여주고 있었는지 기억합니다.
하지만 새로고침이나 주소 직접 입력 같은 전체 페이지 로드에서는 그 상태를 복구할 수 없습니다.
이때 URL과 일치하는 슬롯은 자신의 page.tsx를 출력하지만, 일치하지 않는 나머지 슬롯은 출력할 내용이 없습니다.
예를 들어 @abc 슬롯에만 /async/detail 하위 경로가 있다면, 그 주소에서 새로고침했을 때 @xyz 슬롯은 출력할 페이지를 찾지 못합니다.
이런 경우에 대비해 각 슬롯마다 대체 페이지를 두는 것입니다.

v16부터 자식 경로를 가진 세그먼트의 명명된(@) 슬롯에는 명시적인 default 컴포넌트가 필요하며, 없으면 빌드가 실패합니다.
명시할 내용이 없다면, notFound() 호출이나 null을 반환하면 됩니다.
다만 위 예제의 /async처럼 자식 경로가 없는 세그먼트나 모든 하위 경로 일치([...이름]) 아래의 슬롯은 예외라서, default 컴포넌트가 없어도 빌드가 통과합니다.
나중에 자식 경로가 추가되면 그때부터 필요하니, 미리 작성해 두는 것을 추천합니다.

/app/async/@abc/default.tsx
TSX
1
2
3
4
5
6
7
import { notFound } from 'next/navigation' export default function Default() { notFound() // 혹은 return null }

프로젝트 구조의 컴포넌트 순서대로 아래와 같이 내용을 작성합니다.

/app/async/@abc/loading.tsx
TSX
1
2
3
4
5
import Loader from '@/components/Loader' export default function Loading() { return <Loader color="red" /> }
/app/async/@abc/page.tsx
TSX
1
2
3
4
5
6
import wait from '@/utils/wait' export default async function Abc() { await wait(2000) return <h2>Abc 컴포넌트!</h2> }
/app/async/@xyz/loading.tsx
TSX
1
2
3
4
5
import Loader from '@/components/Loader' export default function Loading() { return <Loader color="blue" /> }
/app/async/@xyz/page.tsx
TSX
1
2
3
4
5
6
import wait from '@/utils/wait' export default async function Xyz() { await wait(3000) return <h2>Xyz 컴포넌트!</h2> }

layout.tsx 컴포넌트와 같은 레벨의 page.tsx 컴포넌트는 layout.tsxchildren, @abc/page.tsx 컴포넌트는 abc, @xyz/page.tsx 컴포넌트는 xyz Prop으로 각각 전달됩니다.
그리고 전달된 각 컴포넌트를 {children}과 같이 JSX 보간으로 출력합니다.

/app/async/layout.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export default function Layout({ children, abc, xyz }: { children: React.ReactNode abc: React.ReactNode xyz: React.ReactNode }) { return ( <> {children} {abc} {xyz} </> ) }
/app/async/loading.tsx
TSX
1
2
3
4
5
import Loader from '@/components/Loader' export default function Loading() { return <Loader /> }
/app/async/page.tsx
TSX
1
2
3
4
5
6
import wait from '@/utils/wait' export default async function Page() { await wait(1000) return <h1>비동기 페이지!</h1> }

http://localhost:3000/async 주소로 접근하면, 앞서 '비동기 컴포넌트 스트리밍'에서 살펴본 <Suspense> 컴포넌트 활용 예제와 같은 로딩 애니메이션 및 페이지 결과가 출력됩니다.
결과는 동일하지만, 이처럼 병렬 경로 처리 방식을 사용하면 각 페이지 컴포넌트가 독립적으로 로딩하고 로딩/에러 상태를 각 컴포넌트별로 쉽게 분리할 수 있어 복잡한 컴포넌트의 분기 처리가 줄어들 수 있습니다.

/app/async/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Suspense } from 'react' import Loader from '@/components/Loader' import wait from '@/utils/wait' import Abc from './Abc' import Xyz from './Xyz' export default async function Page() { await wait(1000) return ( <> <h1>비동기 페이지!</h1> <Suspense fallback={<Loader color="red" />}> <Abc /> </Suspense> <Suspense fallback={<Loader color="blue" />}> <Xyz /> </Suspense> </> ) }
앞서 작성했던 비동기 컴포넌트 스트리밍 예제

# 경로 가로채기

Next.js에서는 경로 가로채기(Intercepting Routes) 기능을 통해 현재 레이아웃에서 다른 URL 경로를 출력할 수 있습니다.

경로 가로채기의 (..) 같은 이름 규칙은, 상대 경로(../, ./)와 유사하지만, 폴더가 아닌 세그먼트를 기준으로 합니다.
예를 들어 '경로 그룹'은 URL에 매핑되지 않으므로, /app/a/b/(group)/(..)x 폴더 경로는 /a/x URL 경로와 일치합니다.

폴더 경로 URL 일치 설명
/app/a/b/(.)x /a/b/x 같은 레벨 세그먼트
/app/a/b/(..)x /a/x 상위 레벨 세그먼트
/app/a/b/(...)x /x 루트 레벨 세그먼트

경로 가로채기(Intercepting Routes)

만약 경로 가로채기 작업이 개발 서버에서 적용되지 않는 경우, .next 폴더 삭제 후 개발 서버를 재시작하는 것을 추천합니다.

1
2
3
4
5
6
7
8
9
├─app │ ├─a │ │ └─b │ │ └─c │ │ ├─(...)x │ │ │ └─page.tsx │ │ └─page.tsx │ └─x │ └─page.tsx
프로젝트 구조
/app/a/b/c/(...)x/page.tsx
TSX
1
2
3
export default function XPage() { return <h1>Intercepted X Page!!</h1> }
/app/a/b/c/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
import Link from 'next/link' export default function CPage() { return ( <> <h1>C Page</h1> <Link href="/x">가로채기!</Link> </> ) }
/app/x/page.tsx
TSX
1
2
3
export default function XPage() { return <h1>X Page..</h1> }
1
http://localhost:3000/a/b/c
위 URL로 접근해서, '가로채기!' 링크를 클릭해보세요!
1
2
3
4
5
6
7
8
9
10
11
12
├─app │ ├─a │ │ └─b │ │ └─c │ │ ├─@xWrap │ │ │ ├─(...)x │ │ │ │ └─page.tsx │ │ │ └─page.tsx │ │ ├─layout.tsx │ │ └─page.tsx │ └─x │ └─page.tsx
프로젝트 구조

..@xWrap/page.tsxnull을 반환해 화면에 따로 표시하지 않고, 가로챈 경로의 페이지(@xWrap/(...)x/page.tsx)를 출력하는 용도로 사용합니다.

/app/a/b/c/@xWrap/page.tsx
TSX
1
2
3
export default function xWrap() { return null }
/app/a/b/c/layout.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export default function CLayout({ children, xWrap }: { children: React.ReactNode xWrap: React.ReactNode }) { return ( <> {children} {xWrap} </> ) }

# Proxy

루트 경로에 생성하는 단일 /proxy.ts 파일을 통해, 특정 경로로 이동하기 전에 서버 측에서 실행되는 코드를 제공할 수 있습니다.
주로 인증 및 권한 확인이 필요한 페이지를 구분하는 데 사용되며, 응답 헤더 및 쿠키 설정, Redirect, Rewrite 등의 작업도 가능합니다.

Next.js 16부터 middleware.tsproxy.ts로 변경되었습니다.
proxy.ts는 네트워크 경계를 명확히 하고 Node.js 런타임에서 실행됩니다.

Proxy는 다음과 같은 과정에서 실행됩니다.

  1. Next.js Node.js Runtime 초기화
  2. 요청된 경로와 매칭되는 Proxy 실행(config.matcher)
  3. 정적/동적 경로 매칭
  4. 레이아웃과 페이지 렌더링

Proxy는 호출이 끝나야 경로 접근이 가능하기 때문에, 복잡하거나 오래 걸리는 작업은 피해야 합니다.
next/headers의 쿠키나 헤더는 Proxy 실행 후에만 수정 가능하며, 외부 패키지 사용이 제한될 수 있습니다.

/proxy.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export function proxy(request: NextRequest) { return NextResponse.next() } // matcher 속성에 일치하는 경로에서만 Proxy가 호출됩니다. // `config` 내보내기를 생략하면, 모든 경로에서 Proxy가 호출됩니다. export const config = { matcher: ['/dashboard/:path*', '...'] // 특정 경로만 일치 }
Proxy 기본 구조

Proxy는 기본적으로 모든 경로 요청에서 호출됩니다.
만약 특정한 경로를 제외하고 싶다면, source 속성에 정규표현식(RegExp)을 사용해 특정 패턴을 제외할 수 있습니다.
(?!) 표현은 특정 패턴을 제외하는 부정 전방 일치(Negative Lookahead)를 의미합니다.
즉, 명시된 경로를 제외한 나머지 경로에서 Proxy가 호출되며, 다음 경로가 Proxy에서 제외됩니다.

  • api/*: API 라우트
  • _next/static/*: 정적 파일
  • _next/image/*: 이미지 최적화 파일
  • favicon.ico: 파비콘 파일
/proxy.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
// ... export const config = { matcher: [ { source: '/((?!api|_next/static|_next/image|favicon.ico).*)', // Prefetch 요청을 Proxy에서 제외! missing: [ { type: 'header', key: 'next-router-prefetch' }, { type: 'header', key: 'purpose', value: 'prefetch' } ] } ] }

# API

/app/api 폴더 내 구조를 통해 API 엔드포인트를 정의할 수 있고, 'GET'이나 'POST' 등의 여러 HTTP 메서드 요청을 처리할 수 있습니다.
이 폴더 구조는 page.tsx 등의 기본 파일 규칙이 아닌, route.ts 파일을 사용합니다.

1
2
3
4
5
6
7
├─app │ ├─api │ │ ├─movies │ │ │ └─[movieId] │ │ │ └─route.ts │ │ └─users │ │ └─route.ts
프로젝트 구조
/app/api/movies/[movieId]/route.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' type Context = { params: Promise<{ movieId: string }> } export async function GET(request: NextRequest, context: Context) { const { movieId } = await context.params // 동적 경로 const res = await fetch(`https://omdbapi.com/?apikey=7035c60c&i=${movieId}`) const data = await res.json() return NextResponse.json(data) }
영화 상세 정보 API
/app/api/users/route.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' interface ResponseValue { total: number users: User[] } interface User { id: string name: string age: number isValid: boolean // ... } export async function POST(request: NextRequest) { // const body = await request.json() // 요청 바디 const searchParams = request.nextUrl.searchParams // 쿼리스트링 const sort = (searchParams.get('sort') || 'name') as keyof User // API: https://www.heropy.dev/p/5PlGxB const res = await fetch('https://api.heropy.dev/v0/users') const { users } = (await res.json()) as ResponseValue users.sort((a, b) => { const av = a[sort] || 0 const bv = b[sort] || 0 return av > bv ? 1 : -1 }) return NextResponse.json(users) }
사용자 목록 API
1
2
http://localhost:3000/api/movies/tt4520988 http://localhost:3000/api/users?sort=age
위 URL로 접근해보세요!

# 서버 액션

Next.js는 서버에서만 실행되는 함수(Server Actions)를 작성할 수 있습니다.
서버 액션은 데이터를 변경하는 것에서 끝나지 않고, 필요하면 변경이 반영된 화면까지 서버에서 다시 그려 응답에 함께 담아 보냅니다.
데이터 변경과 화면 갱신이 하나로 묶여 있다는 점에서 단순한 API 호출과 다르며, 양식 제출이나 이벤트 핸들러 등으로 호출해야 합니다.

다음과 같이, 모듈 상단에 'use server' 지시어를 추가하고 서버 액션을 추가합니다.

/serverActions/index.ts
TS
1
2
3
4
5
6
7
'use server' export async function wait(duration = 1000): Promise<{ message: string }> { console.log(`Run 'wait' function`) return new Promise(resolve => setTimeout(() => resolve({ message: `Waited for ${duration}ms` }), duration) ) }

다음과 같이, 클라이언트 컴포넌트에서도 서버 액션를 가져와 사용할 수 있습니다.
역시, Run 'wait' function 메시지는 서버 콘솔에만 출력됩니다.

/app/client/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'use client' import { wait } from '@/serverActions' import { useState, useEffect } from 'react' export default function ClientPage() { const [message, setMessage] = useState('') const [loading, setLoading] = useState(true) useEffect(() => { wait(3000).then(({ message }) => { setMessage(message) setLoading(false) }) }, []) return <h1>{loading ? 'Loading...' : message}</h1> }

클라이언트 컴포넌트에서는 서버 액션을 인라인으로 정의할 수 없습니다.
'use server' 선언이 있는 별도의 파일(모듈)에서 서버 액션을 정의해 내보내야 사용할 수 있습니다.

특히, 서버 액션은 <form> 요소의 action 속성으로 호출하는 것이 가능해, 양식(Forms) 작업에서 유용합니다.
다음 예제는 양식과 서버 액션을 연결하는 방법만 보여 줍니다.
실제 로그인 처리는 인증 섹션을 참고하세요.

/app/signin/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { signIn } from '@/serverActions' export default function Page() { return ( <> <form action={signIn} className="flex gap-4"> <input name="email" type="email" placeholder="이메일" className="rounded px-2 py-1 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500" /> <input name="password" type="password" placeholder="비밀번호" className="rounded px-2 py-1 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500" /> <button type="submit" className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-400"> 로그인 </button> </form> </> ) }
/serverActions/index.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
'use server' import { redirect } from 'next/navigation' export async function signIn(formData: FormData) { const email = formData.get('email') const password = formData.get('password') console.log(email, password) // await 로그인 처리.. redirect('/') // 로그인 성공 시, 메인 페이지로 이동! } // ...

다음과 같이 서버 컴포넌트에서 직접 호출하는 방식은 서버에서 실행되는 일반 함수 호출일 뿐이고 서버 액션으로는 동작하지 않습니다.
서버 액션은 <form> 요소의 action 속성이나 이벤트 핸들러 등 React의 액션 메커니즘을 통해, 페이지 렌더링과는 별도의 요청으로 호출될 때 성립합니다.

/app/server/page.tsx
TSX
1
2
3
4
5
6
import { wait } from '@/serverActions' export default async function ServerPage() { const { message } = await wait(3000) return <h1>{message}</h1> }

# 인증

Next.js에서 사용자 인증을 구현할 때 가장 먼저 정할 것은 세션 토큰을 어디에 저장하느냐입니다.
서버 컴포넌트와 서버 액션, Proxy가 모두 읽을 수 있어야 하므로 httpOnly 쿠키에 저장합니다.

그러면 브라우저가 인증 API와 직접 통신하지 않고, Next.js 서버가 중간에서 대신 호출하는 구조가 됩니다.
API Key처럼 노출되면 안 되는 값을 클라이언트 번들에 포함하지 않아도 된다는 이점이 함께 따라옵니다.

소셜 로그인이나 여러 인증 제공자(Provider)를 함께 사용해야 한다면, next-auth 라이브러리를 사용할 수 있습니다.
자세한 내용은 Auth.js(NextAuth.js) 핵심 정리를 참고하세요.

Auth.js(NextAuth.js)

라이브러리 없이 외부 인증 API를 직접 연동해 봅시다.
사용할 API는 다음과 같고, 모든 요청 헤더에는 발급받은 API Key가 필요하지만 글에서는 생략합니다.

엔드포인트 설명
POST /auth/signup 회원가입
POST /auth/signin 로그인
POST /auth/refresh 세션 갱신
POST /auth/me 사용자 정보 확인

엔드포인트 모두 응답 본문으로 { token, user, message }를 반환하며, token은 수명이 10분인 액세스 토큰(Access Token)입니다.
그리고 회원가입과 로그인, 세션 갱신의 응답에는 리프레시 토큰(Refresh Token)이 _refresh라는 이름의 httpOnly 쿠키로 함께 포함됩니다.

# 세션 관리

인증과 관련된 서버 전용 코드를 한 파일에 모읍니다.
함께 사용하는 server-only 패키지는 해당 모듈이 클라이언트 번들에 포함되면 빌드 에러를 발생시켜, 토큰을 다루는 코드가 브라우저 측으로 노출되는 것을 막아 줍니다.

BASH
1
npm i server-only
/lib/auth.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import 'server-only' import { cache } from 'react' import { cookies } from 'next/headers' import { redirect } from 'next/navigation' export const SESSION = 'session' const BASE_URL = 'https://api.heropy.dev/v1' const baseHeaders = { 'Content-Type': 'application/json', apikey: process.env.AUTH_API_KEY! } export interface User { id: string email: string name: string photo: string | null } // 세션 쿠키 저장 export async function setSessionToken(token: string) { const cookieStore = await cookies() cookieStore.set(SESSION, token, { httpOnly: true, // 클라이언트에서 JS로 접근할 수 없습니다! secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 3 // 리프레시 토큰의 수명과 동일하게 설정! }) } // 세션 쿠키 삭제 export async function clearSessionToken() { const cookieStore = await cookies() cookieStore.delete(SESSION) } // 응답에서 리프레시 토큰 추출 export function readRefreshToken(res: Response) { const setCookie = res.headers.getSetCookie().find(c => c.startsWith('_refresh=')) return setCookie?.split(';')[0].replace('_refresh=', '') } // 세션 조회 export const getSession = cache(async () => { const refreshToken = (await cookies()).get(SESSION)?.value if (!refreshToken) return null const res = await fetch(`${BASE_URL}/auth/refresh`, { method: 'POST', // 서버의 fetch는 쿠키를 자동으로 보내지 않으므로 직접 첨부합니다. headers: { ...baseHeaders, Cookie: `_refresh=${refreshToken}` }, cache: 'no-store' }) if (!res.ok) return null const { token, user }: { token: string; user: User } = await res.json() return { accessToken: token, user } }) // 세션 조회 + 미인증 시 로그인 페이지로 이동 export async function requireSession() { const session = await getSession() if (!session) redirect('/signin') return session } // 인증이 필요한 API 요청 export async function api<T>(path: string, init: RequestInit = {}): Promise<T> { const { accessToken } = await requireSession() const res = await fetch(`${BASE_URL}${path}`, { ...init, headers: { ...baseHeaders, Authorization: `Bearer ${accessToken}`, ...init.headers }, cache: 'no-store' // 사용자마다 다른 응답이므로 캐시하지 않습니다! }) if (!res.ok) throw new Error(`API Error: ${res.status}`) return res.json() }

setSessionTokenclearSessionToken 함수는 next/headerscookies 함수로 세션 쿠키를 저장하고 삭제합니다.
readRefreshToken 함수는 API 응답의 Set-Cookie 헤더에서 리프레시 토큰을 꺼냅니다.
서버의 fetch는 브라우저처럼 쿠키를 자동으로 보관하지 않아서 직접 읽어야 합니다.
로그인과 로그아웃에서 호출합니다.

쿠키 수정은 서버 액션과 라우트 핸들러, Proxy에서만 가능합니다.
서버 컴포넌트를 렌더링하는 중에는 쿠키를 수정할 수 없습니다.

getSession 함수는 저장한 리프레시 토큰으로 /auth/refresh를 호출해, 세션이 유효한지 확인하면서 사용자 정보와 새 액세스 토큰을 함께 받습니다.
받아 온 액세스 토큰은 반환 값으로만 존재하며, 요청 처리가 끝나면 사라집니다.
React의 cache 함수로 감싸면, 한 번의 렌더링에서 여러 번 호출해도 요청은 한 번만 발생합니다.
덕분에 같은 요청 안에서는 api를 몇 번 호출하든 같은 액세스 토큰을 재사용합니다.
여기에 미인증 처리까지 더한 requireSession을 페이지와 서버 액션에서 호출하게 됩니다.

api 함수는 인증이 필요한 API 요청을 처리합니다.
requireSession으로 받은 액세스 토큰을 Authorization 헤더에 담아 요청합니다.
getSession 함수가 요청마다 새 액세스 토큰을 받아 옵니다.

cookies 함수를 사용하는 경로는 정적 생성 대상에서 제외되고 동적 렌더링으로 전환됩니다.
인증이 필요한 페이지에서는 당연한 동작이지만, 캐싱 전략을 세울 때 인증 경계와 캐시 경계가 겹치지 않도록 나눠야 합니다.

# 로그인과 로그아웃

로그인은 서버 액션으로 처리합니다.
서버에서만 실행되므로 이메일과 비밀번호가 브라우저의 JS를 거치지 않습니다.

로그인에 성공하면 readRefreshToken 함수로 응답에서 리프레시 토큰을 꺼내 쿠키에 저장하고, 원래 가려던 경로로 이동합니다.
실패하면 로그인 페이지로 되돌려 보냅니다.

/serverActions/auth.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
'use server' import { redirect } from 'next/navigation' import { readRefreshToken, setSessionToken, clearSessionToken } from '@/lib/auth' export async function signIn(formData: FormData) { // 외부 주소로 이동하지 않도록 내부 경로인지 확인합니다! const redirectTo = String(formData.get('redirectTo') || '') const safe = redirectTo.startsWith('/') && !redirectTo.startsWith('//') ? redirectTo : '/' const res = await fetch('https://api.heropy.dev/v1/auth/signin', { method: 'POST', headers: { 'Content-Type': 'application/json', apikey: process.env.AUTH_API_KEY! }, body: JSON.stringify({ email: formData.get('email'), password: formData.get('password') }) }) const refreshToken = res.ok ? readRefreshToken(res) : null if (!refreshToken) { redirect(`/signin?error=1&redirectTo=${encodeURIComponent(safe)}`) } await setSessionToken(refreshToken) redirect(safe) } export async function signOut() { await clearSessionToken() redirect('/signin') }

'use server' 파일에서 내보낸 함수는 모두 서버 액션이 되어, 클라이언트가 네트워크로 호출할 수 있는 엔드포인트가 되기 때문에 readRefreshToken이나 setSessionToken 함수처럼 서버 내부에서만 쓰는 함수를 /lib/auth.ts에 두어야 합니다.

redirect 함수는 내부적으로 예외를 던지는 방식으로 동작합니다.
try 블록 안에서 호출하면 catch에 잡혀 리다이렉트가 동작하지 않습니다.

로그인 양식은 <form> 요소의 action 속성에 서버 액션을 연결하기만 하면 됩니다.
훅을 사용하지 않으므로 페이지는 서버 컴포넌트로 두고, searchParamsawait로 읽습니다.

/app/signin/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { signIn } from '@/serverActions/auth' export default async function SignInPage({ searchParams }: { searchParams: Promise<{ redirectTo?: string; error?: string }> }) { const { redirectTo, error } = await searchParams return ( <form action={signIn}> <input type="hidden" name="redirectTo" value={redirectTo || '/'} /> <input name="email" type="email" placeholder="이메일" /> <input name="password" type="password" placeholder="비밀번호" /> {error && <p>이메일 또는 비밀번호를 확인해주세요.</p>} <button type="submit">로그인</button> </form> ) }

# 경로 보호

인증이 필요한 페이지와 로그인한 사용자가 접근하면 안 되는 페이지를 Proxy에서 구분합니다.
쿠키가 있는지만 확인하므로 비동기 함수가 아니어도 되고, 네트워크 호출도 필요 없습니다.

/proxy.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' import { SESSION } from '@/lib/auth' const protectedRoutes = ['/dashboard', '/settings'] const guestOnlyRoutes = ['/signin', '/signup'] export default function proxy(request: NextRequest) { const hasSession = request.cookies.has(SESSION) const { pathname, search } = request.nextUrl // 인증이 필요한 경로인데 세션이 없으면, 로그인 페이지로 이동! if (!hasSession && protectedRoutes.some(route => pathname.startsWith(route))) { const url = new URL('/signin', request.url) url.searchParams.set('redirectTo', pathname + search) // 로그인 후 돌아올 경로 return NextResponse.redirect(url) } // 이미 로그인했는데 로그인 페이지로 접근하면, 메인 페이지로 이동! if (hasSession && guestOnlyRoutes.some(route => pathname.startsWith(route))) { return NextResponse.redirect(new URL('/', request.url)) } return NextResponse.next() } export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'] }

Proxy가 하는 일은 낙관적 검사(Optimistic Check)입니다.
낙관적 검사는 토큰이 유효할 것이라고 가정하고, 쿠키가 있는지만 빠르게 확인하는 방식입니다.
API 서버나 데이터베이스에 묻지 않으니 빠르지만, 쿠키는 위조되거나 만료된 값일 수 있어서 검사를 통과했다고 인증된 것은 아닙니다.
Proxy는 거의 모든 요청에서 실행되기 때문에, 여기서 매번 검증 요청을 보내면 부담이 큽니다.
그래서 Proxy는 명백히 인증되지 않은 요청을 미리 걸러내는 역할만 맡고, 실제 검증은 데이터에 가까운 requireSession 함수가 담당합니다.

마지막으로, 레이아웃(layout.tsx)에서 인증을 검사하지 않아야 합니다.
다음처럼 레이아웃 한 곳에서 하위 경로 전체를 막는 구조를 떠올리기 쉽지만, Next.js에서는 의도대로 동작하지 않습니다.

/app/dashboard/layout.tsx
TSX
1
2
3
4
5
6
7
import { getSession } from '@/lib/auth' export default async function DashboardLayout({ children }: { children: React.ReactNode }) { const session = await getSession() if (!session) return null // 하위 페이지의 렌더링을 막지 못합니다! return <div>{children}</div> }
권장하지 않는 방식

결국 인증 검사는 데이터를 실제로 사용하는 페이지(page.tsx)에서 수행해야 합니다.
requireSession 함수를 호출하면 미인증 처리 및 사용자 정보를 얻는 것도 가능합니다.

/app/dashboard/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
import { requireSession } from '@/lib/auth' export default async function DashboardPage() { const { user } = await requireSession() return ( <div> <h1>{user.name}님, 안녕하세요!</h1> <p>{user.email}</p> </div> ) }

# 캐싱

# 캐시 컴포넌트 및 함수

캐시 컴포넌트(Cache Component)는 'use cache' 지시어를 사용해 캐싱된 페이지 등의 컴포넌트를 말합니다.
혹은 서버 액션 같은 일반 함수에서도 사용할 수 있으며, 기본적으로 반환 값을 캐싱합니다.
이 기능을 사용하려면, Next.js 구성 옵션(cacheComponents)을 추가해야 합니다.

/next.config.ts
TS
1
2
3
4
5
6
7
8
import type { NextConfig } from 'next' const nextConfig: NextConfig = { // experimental: { ppr: true } // v15 (Partial Prerendering) cacheComponents: true // v16 } export default nextConfig

그리고 캐시 여부를 테스트하기 전에 브라우저 개발자 도구의 캐시 사용 중지(Disable cache) 옵션을 비활성화해야 합니다.
이 옵션이 켜져 있거나 강력 새로고침(Hard Reload)을 하면 브라우저가 Cache-Control: no-cache 헤더를 전송하는데, 개발 서버는 이 헤더를 받으면 캐시를 무시하고 캐시 컴포넌트나 함수를 다시 실행합니다.
이 동작은 개발 서버에만 있으며, 프로덕션에서는 이 헤더로 캐시를 우회할 수 없습니다.

캐시 사용 중지 비활성화

또한 개발 모드에서는 파일을 저장할 때마다 기존 캐시가 무효화되고, 기본 캐시 저장소가 메모리이므로 개발 서버를 재시작하면 캐시가 사라집니다.
코드를 수정하면서 테스트하면 매번 다시 실행되는 것이 정상이므로, 캐시 여부는 코드를 수정하지 않은 상태에서 일반 새로고침이나 페이지 이동으로 확인해야 합니다.

이제 'use cache' 지시어를 사용해 캐시 컴포넌트를 정의할 수 있습니다.
이 지시어는 모듈이나 함수 레벨의 최상단에 위치해야 하며, 캐시 컴포넌트는 비동기 함수로 정의해야 합니다!
모듈 레벨에서 지시어를 사용하면, 내보내는 모든 함수는 비동기 함수여야 합니다!

TSX
1
2
3
4
5
6
'use cache' // <= 모듈 레벨 export default async function Component() { 'use cache' // <= 함수 레벨 return <></> }

캐시 컴포넌트는 인수(Props)를 직렬화(Serialization)하여 자동으로 캐시 키를 생성하고, 반환 값을 직렬화하여 저장합니다.
캐시 키에는 인수 외에도 빌드 ID, 함수의 위치와 시그니처로 만든 해시, 함수 안에서 참조한 바깥 범위의 변수가 포함되며, 개발 모드에서는 HMR(Hot Module Replacement) 갱신 해시가 추가됩니다.
따라서 인수가 다르면 별개의 항목으로 캐싱되고, 새로 빌드하거나 개발 모드에서 파일을 저장하면 기존 캐시는 무효화됩니다.
직렬화 가능한 값은 다음과 같습니다.

  • String, Number, Boolean, Null, Undefined, Plain Objects, Arrays
  • Dates, Maps, Sets, TypedArrays, ArrayBuffers

직렬화할 수 없는 값을 함수 내부에서 읽거나 수정하면 캐시할 수 없습니다.
다만, 패스쓰루(Pass-through, 읽거나 수정하지 않고 단순히 통과)인 경우에는 사용할 수 있습니다.

TSX
1
2
3
4
5
6
7
8
9
10
'use cache' export async function Component({ children }: { children: ReactNode }) { // 여기에서 children을 읽거나 수정하지 않아야 합니다. return <div>{children}</div> } export async function Form({ action }: { action: () => Promise<void> }) { // 여기에서 action을 호출하지 않아야 합니다. return <form action={action}></form> }

캐시 컴포넌트가 저장하는 것은 함수 본문 안에서 실행한 작업의 결과입니다.
따라서 데이터를 가져오는 fetch가 캐시 컴포넌트 밖에 있으면, 캐시가 적용되어도 요청은 매번 발생합니다.
다음은 페이지에서 이미 가져온 영화 정보를 캐시 컴포넌트에 전달하는 예제입니다.

/app/movies/[movieId]/MovieInfo.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'use cache' interface Movie { Title: string Plot: string } export default async function MovieInfo({ movie }: { movie: Movie }) { return ( <> <h1>{movie.Title}</h1> <p>{movie.Plot}</p> </> ) }
/app/movies/[movieId]/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
import MovieInfo from './MovieInfo' export default async function MovieDetails({ params }: { params: Promise<{ movieId: string }> }) { const { movieId } = await params const res = await fetch(`https://omdbapi.com/?apikey=7035c60c&i=${movieId}`) const movie = await res.json() return <MovieInfo movie={movie} /> }
캐시 컴포넌트 밖에서 데이터를 가져오는 경우

위 예제는 문법에 문제가 없고 캐시도 정상적으로 동작하지만, 캐시 키가 movie 객체 전체라서 캐시를 찾으려면 먼저 fetchmovie를 가져와야 합니다.
결국 캐시가 절약하는 것은 JSX를 만드는 작업뿐이며, 네트워크 요청은 그대로 발생합니다.
캐시의 이점을 얻으려면 다음과 같이 식별자만 전달하고, fetch를 캐시 컴포넌트 안으로 옮겨야 합니다.

/app/movies/[movieId]/MovieInfo.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
'use cache' interface Movie { Title: string Plot: string } export default async function MovieInfo({ movieId }: { movieId: string }) { const res = await fetch(`https://omdbapi.com/?apikey=7035c60c&i=${movieId}`) const movie: Movie = await res.json() return ( <> <h1>{movie.Title}</h1> <p>{movie.Plot}</p> </> ) }
/app/movies/[movieId]/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
import MovieInfo from './MovieInfo' export default async function MovieDetails({ params }: { params: Promise<{ movieId: string }> }) { const { movieId } = await params return <MovieInfo movieId={movieId} /> }
식별자만 전달하는 경우

이제 캐시 키는 movieId 하나가 되고, 같은 영화를 두 번째 방문할 때부터는 fetch를 실행하지 않고 저장된 결과를 사용합니다.
paramssearchParams 같은 요청 시점의 값은 캐시 컴포넌트 안에서 읽을 수 없으므로, 위 예제처럼 페이지에서 읽어서 인수로 전달합니다.

캐시 컴포넌트나 함수를 사용할 때, 개발 모드에서는 캐시가 적용되어도 테스트용 로그가 반복 출력될 수 있습니다.
이는 단순히 개발용 로그일 뿐이며, 실제로 함수가 재실행되는 것은 아닙니다.

캐시 사용 여부 확인

브라우저 개발자 도구의 네트워크 탭에 표시되는 요청으로도 캐시 여부를 판단할 수 없습니다.
paramssearchParams를 읽는 동적 페이지는 이동할 때마다 Next.js 서버에 서버 컴포넌트 페이로드(RSC Payload)를 요청하며, 네트워크 탭에는 ?_rsc= 쿼리스트링이 붙은 요청으로 표시됩니다.
이 요청은 캐시와 관계없이 매번 발생하고, 응답에 영화 정보가 포함되어 있어서 데이터 요청처럼 보이지만 캐시 컴포넌트 안의 fetch와는 다른 요청입니다.
캐시 컴포넌트 안의 fetch는 서버에서 실행되므로 브라우저에는 표시되지 않습니다.
따라서 개발 서버 터미널에서 두 번째 방문부터 응답 시간이 크게 줄어드는지 확인하거나, 다음과 같이 캐시 디버그 로그를 활성화해서 확인해야 합니다.

BASH
1
NEXT_PRIVATE_DEBUG_CACHE=1 npm run dev
1
2
3
4
use-cache: Resume Data Cache entry not found ["development","c0be05f2...",[{"movieId":"tt6793710"}],"0"] GET /movies/tt6793710 200 in 803ms use-cache: Resume Data Cache entry found ["development","c0be05f2...",[{"movieId":"tt6793710"}],"0"] GET /movies/tt6793710 200 in 94ms
캐시 디버그 로그 예시

첫 방문은 캐시 항목이 없어서(not found) 함수를 실행하고, 두 번째 방문부터는 저장된 항목을 찾아서(found) 바로 응답합니다.
로그의 캐시 키에서 movieId 값이 인수로 포함된 것도 확인할 수 있습니다.

# 캐시 수명

캐시 컴포넌트는 기본적으로 클라이언트에서 5분, 서버에서는 15분 동안 유효하며, 따로 만료되지 않습니다.
필요한 경우, cacheLife 함수를 사용해 사전 구성 프로필이나 타이밍 속성으로 제어할 수 있습니다.

TS
1
2
3
4
5
6
7
cacheLife('hours') cacheLife('max') cacheLife({ stale: 60 * 10, revalidate: 60 * 60 * 24, expire: 60 * 60 * 24 * 30 })
TSX
1
2
3
4
5
6
7
'use cache' import { cacheLife } from 'next/cache' export async function Component() { cacheLife('hours') return <></> }

다음과 같은 사전 프로필을 사용할 수 있습니다.

사전 프로필 사용 예시 stale revalidate expire
default 표준 콘텐츠 5분 15분 만료 없음
seconds 실시간 데이터(주가, 라이브) 30초 1초 1분
minutes 자주 업데이트됨(소셜 피드, 뉴스) 5분 1분 1시간
hours 매일 여러 번 업데이트(제품 재고, 날씨) 5분 1시간 1일
days 매일 업데이트(블로그 게시물, 기사) 5분 1일 1주
weeks 주간 업데이트(팟캐스트, 뉴스레터) 5분 1주 30일
max 거의 변경되지 않음(법적 페이지, 보관된 콘텐츠) 5분 30일 1년

더 자세하게 제어하기 위한 다음과 같은 초(sec) 단위로 지정하는 타이밍 속성을 사용할 수 있습니다.

타이밍 속성 의미 기본값
stale 클라이언트가 캐시 데이터를 사용하는 시간 300
revalidate 서버 백그라운드에서 캐시된 데이터를 사용하는 시간 900
expire 서버가 캐시된 데이터를 언제 다시 생성해야 하는지의 시간
revalidate와 같이 설정하는 경우, expire가 더 길어야 함!
만료 없음

# 캐시 태그

cacheTag 함수를 사용해 필요에 따라 제어할 수 있도록 태그를 지정할 수 있습니다.
태그는 문자만 가능하며, 최대 256자 및 128개까지 지정할 수 있습니다.

TSX
1
2
3
4
5
6
7
'use cache' import { cacheTag } from 'next/cache' export async function Component() { cacheTag('hello', 'world', 'tag') return <></> }

updateTag 함수를 사용해 특정 태그를 포함하는 캐시를 만료하고 캐시 컴포넌트나 함수를 다시 실행(Read-Your-Writes)하고 결과를 캐싱합니다.
따라서 호출 후 화면에는 바로 최신 데이터가 표시되며, 변경사항을 즉시 표시해야 하는 경우에 유용합니다.
다중 태그를 지정할 수 있는 cacheTag 함수와는 다르게 updateTag 함수는 단일 태그만 지정할 수 있고, 서버 액션에서만 사용할 수 있습니다.

Read-Your-Writes(RYW)은 클라이언트가 자신이 방금 기록한 데이터를 즉시 읽을 수 있도록 보장하는 일관성 모델을 말합니다.

TSX
1
2
3
4
5
6
'use server' import { updateTag } from 'next/cache' export async function action() { updateTag('world') }

revalidateTag 함수도 특정 태그를 포함하는 캐시를 만료할 수 있지만, 캐시 컴포넌트나 함수는 추가 요청이 있을 때만 다시 실행(Stale-While-Revalidate)됩니다.
따라서 최신 데이터는 바로 표시되지 않으며, 게시물이나 기타 문서 업데이트 같이 지연 표시가 허용되는 상황에 유용합니다.
함수를 호출한 시점에 재검증이 시작되지는 않고, 해당 태그를 사용하는 페이지를 다시 방문할 때 비로소 갱신됩니다.
덕분에 하나의 태그에 많은 페이지가 연결돼 있어도 재검증이 한꺼번에 몰리지 않습니다.
서버 액션뿐만 아니라 경로 핸들러(GET, POST 등의 API 함수)에서도 사용할 수 있습니다.
그리고 두 번째 인수로 캐시 프로필을 필수 지정해야 하며, 일반적으로는 'max'가 권장됩니다.
캐시 프로필은 낡은 데이터를 언제까지 더 제공할지 결정하는 값이므로, 캐시를 즉시 만료하는 updateTag 함수에는 이 인수가 없습니다.

Stale-While-Revalidate(SWR)은 클라이언트가 캐시된 데이터를 사용하고 서버가 백그라운드에서 데이터를 재생성하는, 지연 시간을 최소화해 성능과 사용자 경험을 최적화하는 캐싱 전략입니다.

TSX
1
2
3
4
5
6
'use server' import { revalidateTag } from 'next/cache' export async function action() { revalidateTag('world', 'max') }

# 최적화

Next.js에는 애플리케이션 속도나 웹 바이탈을 향상시킬 수 있는 여러 최적화 기능이 내장되어 있습니다.
주로 사용하는 몇 가지 기능을 살펴봅시다.

# React Compiler

React Compiler는 자동으로 컴포넌트를 메모이제이션하여 불필요한 리렌더링을 줄입니다.
v16부터 React Compiler의 내장 지원이 안정화되었습니다.

/next.config.ts
TS
1
2
3
4
5
6
7
import type { NextConfig } from 'next' const nextConfig: NextConfig = { reactCompiler: true } export default nextConfig

# 이미지

<Image /> 컴포넌트를 사용해 지연 로딩, 브라우저 캐싱, 크기 최적화 등의 기능을 아주 간단하게 사용할 수 있습니다.
src, alt, width, height 속성은 필수이며, 동일 소스 경로의 이미지는 자동으로 캐싱됩니다.

/app/poster/[movieId]/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import Image from 'next/image' type Movie = { // 응답 결과 타이핑 Title: string Poster: string } export default async function MoviePoster({ params, searchParams // 쿼리스트링 }: { params: Promise<{ movieId: string }> searchParams: Promise<{ plot?: 'short' | 'full' }> }) { const { movieId } = await params const { plot } = await searchParams const res = await fetch(`https://omdbapi.com/?apikey=7035c60c&i=${movieId}&plot=${plot || 'short'}`) const movie: Movie = await res.json() return ( <Image src={movie.Poster} alt={movie.Title} width={300} height={450} /> ) }

만약 원격의 이미지 경로를 사용한다면, 애플리케이션 보호를 위해 remotePatterns 옵션을 프로젝트 구성으로 추가해야 합니다.
포트 번호(port)나 구체적인 하위 경로(pathname)를 명시하는 것도 가능합니다.

/next.config.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import type { NextConfig } from 'next' const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 'heropy.dev' }, { protocol: 'https', hostname: 'm.media-amazon.com' // `movie.Poster` 경로의 도메인 }, { protocol: 'https', hostname: '**.example.com', port: '80', pathname: '/account123/**', } ] } } export default nextConfig
원격 이미지 최적화 패턴 구성

onLoad 속성을 사용해 이미지 로딩이 완료되면 콜백을 호출할 수 있습니다.
단, onLoad 속성은 클라이언트 컴포넌트에서 사용해야 합니다.

TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
'use client' // ... export default function Page() { const [loaded, setLoaded] = useState(false) // ... return ( <Image src={image.src} alt={image.name} width={100} height={200} onLoad={() => setLoaded(true)} /> ) }

중요한 이미지로 판단해 우선 로드하거나 품질을 지정할 수도 있습니다.

TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
export default function Page() { // ... return ( <Image src={image.src} alt={image.name} width={100} height={200} quality={100} // 기본값: 75 priority // LCP(Largest Contentful Paint) 최적화 /> ) }

# 폰트

Next.js는 지원하는 모든 글꼴 파일에 대한 자체 호스팅(automatic self-hosting)이 내장되어 있습니다.
기본적으로 모든 Google Fonts를 자체 호스팅으로 지원하며, 구글 API로 별도 요청을 전송하지 않습니다.

다음과 예제와 같이 내장 폰트 함수를 가져와 초기화합니다.

/styles/fonts.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Roboto, Oswald } from 'next/font/google' export const roboto = Roboto({ subsets: ['latin'], // 사용할 폰트 집합 weight: ['400', '700'], // 사용할 폰트 두께 display: 'swap', // 폰트 다운로드 전까지 기본 폰트 표시(성능 최적화) variable: '--font-roboto' // 사용할 CSS 변수 이름 }) export const oswald = Oswald({ subsets: ['latin'], weight: ['500'], display: 'swap', variable: '--font-oswald' })

각 요소의 className 속성으로 폰트를 적용할 수 있습니다.

/components/Headline.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { roboto, oswald } from '@/styles/fonts' export default function Headline() { return ( <> <h1 className={oswald.className}> OMDb API </h1> <p className={roboto.className}> The OMDb API is a RESTful web service to obtain movie information, all content and images on the site are contributed and maintained by our users. If you find this service useful, please consider making a one-time donation or become a patron. </p> </> ) }

CSS 변수를 사용해 폰트를 적용하는 방법도 있습니다.
우선 각 폰트의 CSS 변수를 루트 요소 등록합니다.

/app/layout.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { roboto, oswald } from '@/styles/fonts' import '@/styles/global.scss' export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( <html lang="ko" className={`${roboto.variable} ${oswald.variable}`}> <body> {children} </body> </html> ) }

이제, CSS(SCSS)에서 CSS 변수를 사용할 수 있습니다.

/styles/global.scss
CSS
1
2
3
4
5
6
body { font-family: var(--font-roboto); } h1, h2, h3 { font-family: var(--font-oswald); }

# Pretendard

Pretendard 웹 폰트를 사용하는 경우, 다음과 같이 구성할 수 있습니다.

BASH
1
npm i pretendard
/app/layout.tsx
TS
1
2
3
import 'pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css' import '@/styles/global.scss' // ...
/styles/global.scss
SCSS
1
2
3
4
5
6
7
8
9
html { --font-pretendard: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', sans-serif; } body { font-family: var(--font-pretendard); }

# 메타데이터

검색 엔진 최적화(SEO)를 위한 메타데이터를 각 페이지마다 아주 쉽게 정의할 수 있습니다.

# 정적 데이터 생성

각 경로의 layout.tsx 혹은 page.tsx 파일에서 metadata 객체를 내보내면 됩니다.

/app/layout.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import Header from '@/components/Header' import type { Metadata } from 'next' export const metadata: Metadata = { // <title>페이지 제목</title> title: '페이지 제목', // <meta name="description" content="페이지 설명"> description: '페이지 설명', // Open Graph Protocol / <meta property="og:속성" content="값"> openGraph: { type: 'website', siteName: '사이트 이름', title: '페이지 제목', description: '페이지 설명', images: '페이지 대표 이미지' }, // Twitter Cards / <meta name="twitter:속성" content="값"> twitter: { card: 'summary_large_image', site: '사이트 이름', title: '페이지 제목', description: '페이지 설명', images: '페이지 대표 이미지' } } export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( <html lang="ko"> <body> <Header /> {children} </body> </html> ) }

# 동적 데이터 생성

동적 경로에서 메타데이터를 생성하려면, generateMetadata 함수를 사용해야 합니다.
generateMetadata 함수는 페이지와 같은 인수를 받아서 처리할 수 있기 때문에, API 요청으로 생성할 메타데이터를 가져올 수 있습니다.
다만, generateMetadata와 페이지 컴포넌트(MovieDetails)가 같은 API 요청을 사용하기 때문에 중복 요청을 방지하기 위해서 fetch 함수를 사용해 요청을 캐싱할 수 있습니다.
fetch 함수는 Next.js 기능으로 매핑되어 있어서, GET 요청과 함께 cache: 'force-cache' 옵션을 지정하면 캐싱이 가능합니다.

/app/movies/[movieId]/page.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import type DetailedMovie from '@/stores/movies' type Context = { params: Promise<{ movieId: string }> searchParams: Promise<{ plot?: 'short' | 'full' }> } async function fetchMovie(id: string, plot?: 'short' | 'full'): Promise<DetailedMovie> { const res = await fetch(`https://omdbapi.com/?apikey=7035c60c&i=${id}&plot=${plot || 'short'}`, { method: 'GET', cache: 'force-cache' }) return await res.json() } export async function generateMetadata({ params, searchParams }: Context) { const { movieId } = await params const { plot } = await searchParams const movie = await fetchMovie(movieId, plot) return { title: movie.Title, description: movie.Plot, openGraph: { type: 'website', siteName: process.env.NEXT_PUBLIC_SITE_NAME, title: movie.Title, description: movie.Plot, images: movie.Poster } } } export default async function MovieDetails({ params, searchParams }: Context) { const { movieId } = await params const { plot } = await searchParams const movie = await fetchMovie(movieId, plot) return ( <> <h1>{movie.Title}</h1> <p>{movie.Plot}</p> </> ) }

혹은 다음과 같이 'use cache' 지시어로 캐시 함수를 만들어 중복 요청을 방지할 수도 있습니다.

/lib/movie.ts
TS
1
2
3
4
5
6
7
'use cache' import type DetailedMovie from '@/stores/movies' export async function fetchMovie(id: string, plot?: 'short' | 'full'): Promise<DetailedMovie> { const res = await fetch(`https://omdbapi.com/?apikey=7035c60c&i=${id}&plot=${plot || 'short'}`) return await res.json() }

파일 상단에 'use cache'를 선언하면 내보내는 모든 함수가 캐시 함수가 되며, 이때 모든 함수는 async여야 합니다.
캐시 함수 안에서는 cookiesheaders, searchParams 같은 요청 시점 API를 사용할 수 없습니다.
위 예제처럼 필요한 값을 인수로 전달해야 하며, 전달한 인수는 캐시 키의 일부가 됩니다.

# 템플릿 제공

title은 객체 타입으로 지정해 템플릿(template)과 기본값(default)을 제공할 수 있습니다.
이는 하위 경로에 정의된 제목에 사이트 이름 등을 접두사나 접미사로 추가할 때 유용합니다.
%s 치환 문자에 동적으로 값이 삽입됩니다.

/app/layout.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import Header from '@/components/Header' import type { Metadata } from 'next' export const metadata: Metadata = { title: { template: '%s | 사이트이름', default: '사이트이름' }, description: '설명..', openGraph: { title: '제목', // ... }, twitter: { title: '제목', // ... } } // 생략..

# 정적 에셋

정적(Static) 에셋은 /public 폴더에 저장할 수 있습니다.
이 폴더에 저장된 파일은 / 경로로 접근할 수 있습니다.

1
2
3
4
5
6
├─public │ ├─images │ │ ├─logo.png │ │ └─main.jpg │ ├─next.svg │ ├─vercel.svg
프로젝트 구조
1
2
3
4
http://localhost:3000/images/logo.png http://localhost:3000/images/main.jpg http://localhost:3000/next.svg http://localhost:3000/vercel.svg
위 URL로 접근해보세요!

# 환경변수

1
2
3
4
5
├─.env ├─.eslintrc.json ├─.gitignore ├─.prettierrc ├─next.config.ts
프로젝트 구조

각 컴포넌트에서 process.env.변수이름으로 접근 가능한 환경변수는 /.env 파일에서 관리하며, 기본적으로 서버 컴포넌트에서만 접근할 수 있습니다.
만약 클라이언트 컴포넌트에서도 접근하도록 만들고 싶다면, 변수 이름에 NEXT_PUBLIC_을 접두사로 추가해야 합니다.

보안이 요구되는 API 키 등의 중요한 정보는 NEXT_PUBLIC_ 접두사를 사용하지 않아야 합니다.
.env 파일은 Next.js 프로젝트의 .gitignore에 추가되어 있어 원격 저장소에 업로드되지 않습니다.

1
2
OMDB_API_KEY=7035c60c NEXT_PUBLIC_SITE_NAME=Nextjs Movie App
.env 예시
/app/movies/[movieId]/page.tsx
TSX
1
2
3
4
5
6
7
8
// 생략.. async function fetchMovie(id: string, plot?: 'short' | 'full'): Promise<DetailedMovie> { const res = await fetch(`https://omdbapi.com/?apikey=${process.env.OMDB_API_KEY}&i=${id}&plot=${plot || 'short'}`) return await res.json() } // 생략..

만약 환경변수를 자동완성하려면, 다음과 같이 타이핑합니다.

/types/env.d.ts
TS
1
2
3
4
5
6
7
8
9
export declare global { namespace NodeJS { interface ProcessEnv { OMDB_API_KEY_KEY: string NEXT_PUBLIC_BASE_URL: string NEXT_PUBLIC_SITE_NAME: string } } }

환경변수 자동완성

# 배포

Next.js 프로젝트는 AWS, GCP, Azure 등의 다른 클라우드 서비스로도 배포할 수 있지만,
Next.js는 Vercel 팀에서 개발/관리하는 프레임워크이니, Vercel 서비스로 배포하는 것이 가장 효율적이며 추천되는 방법입니다.

우선 프로젝트를 원격 저장소(GitHub)에 업로드하고 Vercel에 로그인한 후 프로젝트를 추가합니다.

새로운 프로젝트 추가

연결할 GitHub 저장소 검색한 후 해당 저장소에서 가져오기(Import)를 선택합니다.

연결할 GitHub 저장소 검색 및 가져오기

특별히 추가하거나 수정할 내용 없이 배포를 진행하면, 약간의 시간이 지난 후 다음 이미지와 같이 배포가 완료됩니다.
바로 우측 상단에 Visit 버튼을 선택해 배포된 프로젝트를 확인할 수 있습니다.

배포 완료 직후

만약 프로젝트에서 환경변수를 사용하는 경우, Vercel 프로젝트의 Settings에서 직접 환경변수를 추가해야 합니다.
프로젝트 Settings > Environment Variables 페이지로 이동해, KeyValue로 환경변수 입력 후 저장(Save) 버튼을 선택합니다.

그리고 환경변수를 추가하거나 수정한 후 프로젝트에 반영하기 위해서는 다시 배포해야 합니다.
프로젝트 Deployments 페이지로 이동해 최신 배포 항목에서 Redeploy 메뉴를 선택합니다.

재배포(Redeploy)

나타나는 모달에서 Redeploy 버튼을 선택하면, 환경변수가 적용된 새로운 배포가 진행됩니다.
배포가 완료되면, 다시 Visit 버튼을 선택해 배포된 사이트를 확인할 수 있습니다.

재배포 진행!

# 영화 검색 예제

Nextjs Movie App

이해를 돕기 위해, Next.js를 활용한 영화 검색 예제를 준비했습니다.
배포된 예제 사이트에 접속할 수 있고, 예제 코드(GitHub 저장소)도 확인할 수 있습니다.