99

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

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

Next.js 16 핵심 정리

Next.js 16 핵심 정리

# 개요

info

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

다음은 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 형식을 기본값으로 사용합니다.

다음은 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
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 사용 여부 ✔ 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 # `@/*` 외 경로 별칭 사용 여부 ## 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 # `@/*` 외 경로 별칭 사용 여부

info

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

# Prettier

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

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

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

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

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

/eslint.config.js
JS
content_copy
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
content_copy
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
content_copy
1
2
3
4
{ "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode" }

# Server vs Client

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

info

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

TSX
content_copy
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'는 서버 컴포넌트에서만 작동하지만, 클라이언트 컴포넌트에서 사용되고 있습니다." zoom_out_map

# 라우팅

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

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

기본 파일 확장자 설명
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
global-error .js, .jsx, .tsx
route .js, .ts

# 페이지

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

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

세그먼트의 이해 zoom_out_map

content_copy
1
2
3
4
├─app/ │ ├─movies/ │ │ └─page.tsx │ └─page.tsx
프로젝트 구조
/app/page.tsx
TSX
content_copy
1
2
3
export default function Home() { return <h1>Home page!</h1> }
http://localhost:3000/ 경로의 페이지 내용
/app/movies/page.tsx
TSX
content_copy
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) zoom_out_map

# 레이아웃

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

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

info

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

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

/app/layout.tsx
TSX
content_copy
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
content_copy
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 컴포넌트에서, 각 페이지로 이동할 수 있는 링크를 추가해봅시다.

content_copy
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
content_copy
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> 컴포넌트의 경로를 비교해 현재 페이지인 경우 활성화 스타일을 추가할 수 있습니다.

info

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

/components/Header.tsx
TSX
content_copy
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)의 데이터를 미리 가져와 탐색 성능을 크게 향상시킬 수 있습니다.

info

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

  • null(기본값): 정적 경로인 경우 모든 하위 경로를, 동적 경로인 경우 loading.tsx가 있는 가장 가까운 세그먼트까지만 미리 가져옵니다.
  • true: 정적 경로와 동적 경로 모두 미리 가져옵니다.
  • false: 미리 가져오지 않습니다.
TSX
content_copy
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> </> ) }

# 프로그래밍 방식의 탐색

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

warning

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

/components/Header.tsx
TSX
content_copy
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
content_copy
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으로 전달됩니다.

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

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

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

/app/movies/[movieId]/page.tsx
TSX
content_copy
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
content_copy
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
content_copy
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을 입력해 영화 상세 페이지로 이동해 보세요.

content_copy
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 파일을 생성합니다.

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

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

/components/Loader.tsx
TSX
content_copy
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
content_copy
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
content_copy
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 파일을 생성합니다.

warning

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

content_copy
1
2
3
4
5
6
├─app/ │ ├─movies/ │ │ ├─[movieId]/ │ │ │ ├─error.tsx │ │ │ ├─loading.tsx │ │ │ └─page.tsx
프로젝트 구조
/app/movies/error.tsx
TSX
content_copy
1
2
3
4
5
6
7
8
'use client' export default function Error({ error }: { error: Error & { digest?: string } }) { return <h2>{error.message}</h2> }
페이지 출력 중 에러 발생 시 UI
/app/movies/[movieId]/page.tsx
TSX
content_copy
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 파일로 별도의 페이지를 출력할 수 있습니다.

content_copy
1
2
├─app/ │ └─not-found.tsx
프로젝트 구조
/app/not-found.tsx
TSX
content_copy
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 페이지 내용
content_copy
1
http://localhost:3000/helloworld12345678
위 URL로 접근해보세요!

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

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

content_copy
1
2
3
4
5
6
├─app/ │ ├─async/ │ │ ├─Abc.tsx │ │ ├─loading.tsx │ │ ├─page.tsx │ │ └─Xyz.tsx
프로젝트 구조
/app/async/loading.tsx
TSX
content_copy
1
2
3
4
5
import Loader from '@/components/Loader' export default function Loading() { return <Loader /> }
페이지 출력 전 로딩 UI
/app/async/Abc.tsx
TSX
content_copy
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
content_copy
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
content_copy
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
content_copy
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
content_copy
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)을 가질 수 있기 때문에, 경로에 맞는 여러 레이아웃 제공을 제공할 수 있습니다.

content_copy
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
content_copy
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) zoom_out_map

warning

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

content_copy
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
프로젝트 구조

v16부터 모든 병렬 경로 슬롯에 명시적인 default 컴포넌트가 필요하며, 컴포넌트가 없으면 빌드가 실패합니다.
명시할 내용이 없다면, notFound() 호출이나 null을 반환하면 됩니다.

/app/async/@abc/default.tsx
TSX
content_copy
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
content_copy
1
2
3
4
5
import Loader from '@/components/Loader' export default function Loading() { return <Loader color="red" /> }
/app/async/@abc/page.tsx
TSX
content_copy
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
content_copy
1
2
3
4
5
import Loader from '@/components/Loader' export default function Loading() { return <Loader color="blue" /> }
/app/async/@xyz/page.tsx
TSX
content_copy
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
content_copy
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
content_copy
1
2
3
4
5
import Loader from '@/components/Loader' export default function Loading() { return <Loader /> }
/app/async/page.tsx
TSX
content_copy
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
content_copy
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) zoom_out_map

warning

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

content_copy
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
content_copy
1
2
3
export default function XPage() { return <h1>Intercepted X Page!!</h1> }
/app/a/b/c/page.tsx
TSX
content_copy
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
content_copy
1
2
3
export default function XPage() { return <h1>X Page..</h1> }
content_copy
1
http://localhost:3000/a/b/c
위 URL로 접근해서, '가로채기!' 링크를 클릭해보세요!
content_copy
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
content_copy
1
2
3
export default function xWrap() { return null }
/app/a/b/c/layout.tsx
TSX
content_copy
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. 레이아웃과 페이지 렌더링

warning

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

/proxy.ts
TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export default 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
content_copy
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 파일을 사용합니다.

content_copy
1
2
3
4
5
6
7
├─app │ ├─api │ │ ├─movies │ │ │ └─[movieId] │ │ │ └─route.ts │ │ └─users │ │ └─route.ts
프로젝트 구조
/app/api/movies/[movieId]/route.ts
TS
content_copy
1
2
3
4
5
6
7
8
9
10
11
12
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 Response.json(data) }
영화 상세 정보 API
/app/api/users/route.ts
TS
content_copy
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 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 Response.json(users) }
사용자 목록 API
content_copy
1
2
http://localhost:3000/api/movies/tt4520988 http://localhost:3000/api/users?sort=age
위 URL로 접근해보세요!

# 인증

Next.js 프로젝트에서 회원가입이나 로그인 등의 사용자 인증 및 세션 관리를 위해 next-auth 라이브러리를 사용할 수 있습니다.
인증과 관련된 자세한 내용은 Auth.js(NextAuth.js) 핵심 정리를 참고하세요.

Auth.js(NextAuth.js) zoom_out_map

# 서버 액션

Next.js는 서버에서만 실행되는 함수(Server Actions)를 작성할 수 있습니다.
다음과 같이, 모듈 상단에 'use server' 지시어를 추가하고 서버 액션을 추가합니다.

/serverActions/index.ts
TS
content_copy
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/server/page.tsx
TSX
content_copy
1
2
3
4
5
6
import { wait } from '@/serverActions' export default async function ServerPage() { const { message } = await wait(3000) return <h1>{message}</h1> }
/app/server/loading.tsx
TSX
content_copy
1
2
3
export default function ServerLoading() { return <h1>Loading...</h1> }

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

/app/client/page.tsx
TSX
content_copy
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> }

warning

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

특히, 서버 액션은 <form> 요소의 action 속성으로 호출하는 것이 가능해, 양식(Forms) 작업에서 유용합니다.

/app/signin/page.tsx
TSX
content_copy
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
content_copy
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('/') // 로그인 성공 시, 메인 페이지로 이동! } // ...

# 캐싱

# 캐시 컴포넌트 및 함수

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

/next.config.ts
TS
content_copy
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) 옵션을 비활성화가 필요할 수 있습니다.

캐시 사용 중지 비활성화 zoom_out_map

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

캐시 사용 여부 확인 zoom_out_map

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

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

캐시 컴포넌트는 인수(Props)나 반환 값을 직렬화(Serialization)하여 자동으로 캐시 키를 생성합니다.
이 키를 기반으로 캐시를 관리하고, 키가 변경되면 캐시를 무효화하고 새로운 값을 가져올 수 있습니다.
직렬화 가능한 값은 다음과 같습니다.

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

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

TSX
content_copy
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> }

# 캐시 수명

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

TS
content_copy
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
content_copy
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분 1년
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가 더 길어야 함!
1년

# 캐시 태그

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

TSX
content_copy
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 함수는 단일 태그만 지정할 수 있고, 서버 액션에서만 사용할 수 있습니다.

info

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

TSX
content_copy
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'가 권장됩니다.

info

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

TSX
content_copy
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
content_copy
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
content_copy
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
content_copy
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
content_copy
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
content_copy
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
content_copy
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 속성으로 폰트를 적용할 수 있습니다.

content_copy
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
content_copy
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
content_copy
1
2
3
4
5
6
body { font-family: var(--font-roboto); } h1, h2, h3 { font-family: var(--font-oswald); }

# Pretendard

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

BASH
content_copy
1
npm i pretendard
/app/layout.tsx
TS
content_copy
1
2
3
import 'pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css' import '@/styles/global.scss' // ...
/styles/global.scss
SCSS
content_copy
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
content_copy
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
content_copy
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' }> } function fetchMovie(id: string, plot?: 'short' | 'full'): 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'를 사용하는 캐시 서버 액션을 사용하면 캐싱을 통해 중복 요청을 방지할 수 있습니다.

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

# 템플릿 제공

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

/app/layout.tsx
TSX
content_copy
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 폴더에 저장할 수 있습니다.
이 폴더에 저장된 파일은 / 경로로 접근할 수 있습니다.

content_copy
1
2
3
4
5
6
├─public │ ├─images │ │ ├─logo.png │ │ └─main.jpg │ ├─next.svg │ ├─vercel.svg
프로젝트 구조
content_copy
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로 접근해보세요!

# 환경변수

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

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

warning

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

content_copy
1
2
OMDB_API_KEY=7035c60c NEXT_PUBLIC_SITE_NAME=Nextjs Movie App
.env 예시
/app/movies/[movieId]/page.tsx
TSX
content_copy
1
2
3
4
5
6
7
8
// 생략.. function fetchMovie(id: string, plot?: 'short' | 'full'): 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
content_copy
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 } } }

환경변수 자동완성 zoom_out_map

# 배포

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

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

새로운 프로젝트 추가 zoom_out_map

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

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

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

배포 완료 직후 zoom_out_map

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

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

재배포(Redeploy) zoom_out_map

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

재배포 진행! zoom_out_map

# 영화 검색 예제

Nextjs Movie App zoom_out_map

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