Next.js 16 핵심 정리
# 개요
다음은 Next.js 16의 주요 업데이트 내용입니다.
- 캐싱 (중요)
'use cache'지시어를 사용해 페이지, 컴포넌트, 함수를 캐싱하는 부분 사전 렌더링(PPR)을 지원합니다.revalidateTag()에서 캐시 프로필(Cache Profile)을 필수로 지정하도록 변경되었습니다.- 캐싱 API가 추가되었습니다.
updateTag(): 지정된 태그의 캐시를 만료하고 새로운 데이터를 가져옵니다.refresh(): 캐시되지 않은 데이터만 새로 고치며, 캐시에는 전혀 영향을 주지 않습니다.
- 미들웨어 파일 이름 변경 (중요)
middleware.ts가proxy.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 (중요)
- 데이터 요청이 필요 없는 컴포넌트를 비동기로 처리하여 초기 로드 속도를 높이는 방향으로 변경되었습니다.
params나searchParams등의 주요 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.jsAPI 안정화- 서버 생명주기를 관찰하여 성능을 모니터링하고 오류를 추적할 수 있습니다.
- TypeScript 구성 지원
- TypeScript를 사용하는 설정 파일을 지원하며 자동 완성과 타입 안전성도 보장됩니다.
- 자체 호스팅 개선
- 캐시 제어 헤더에 대한 더 많은 제어와 이미지 최적화 성능이 향상되었습니다.
- 보안 강화
- 서버 액션이 공개 엔드포인트가 되는 것을 방지하기 위한 기능이 추가되어 보안이 강화되었습니다.
- 외부 패키지 번들링 최적화
- 외부 패키지를 번들링할 수 있는 설정 옵션이 추가되었습니다.
- ESLint 9 지원
- ESLint 9를 지원하여 최신 규칙을 반영하고 호환성을 유지합니다.
- 빌드 및 개발 성능 개선
- 정적 페이지 생성 최적화와 서버 컴포넌트의 HMR 기능이 강화되었습니다.
# Next.js란?
Next.js는 Vercel에서 개발한 React 프레임워크로, 서버 사이드 렌더링(SSR), 클라이언트 사이드 렌더링(CSR), API 라우팅 등의 다양한 최적화 기능을 제공합니다.
Next.js를 사용하면, React의 기본 기능을 확장해, 보다 빠르고 안정적으로 웹 애플리케이션을 개발할 수 있습니다.
# 설치 및 구성
다음 명령으로 Next.js 프로젝트를 설치합니다.
각 질문에 Yes 또는 No로 답변합니다.
12345678910111213141516171819npx 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 # `@/*` 외 경로 별칭 사용 여부
# Prettier
다음 VS Code 확장 프로그램이 설치되어 있어야 합니다.
- ESLint: 코드 품질 확인 및 버그, 안티패턴(Anti-pattern)을 감지
- Prettier - Code formatter: 코드 스타일 및 포맷팅 관리, 일관된 코드 스타일을 적용 가능
Prettier 관련 패키지들을 설치합니다.
1npm i -D prettier eslint-config-prettier eslint-plugin-prettier prettier-plugin-tailwindcss
ESLint 구성을 다음과 같이 수정합니다.
1234567import prettierRecommended from 'eslint-config-prettier' const eslintConfig = defineConfig([ { extends: [prettierRecommended] } ]) export default eslintConfig
추가로, 프로젝트 루트 경로에 .prettierrc 파일을 생성하고 다음처럼 원하는 규칙을 추가합니다.
자세한 규칙은 Prettier / Options 에서 확인할 수 있습니다.
12345678910{ "semi": false, "singleQuote": true, "singleAttributePerLine": true, "bracketSameLine": true, "endOfLine": "lf", "trailingComma": "none", "arrowParens": "avoid", "plugins": ["prettier-plugin-tailwindcss"] }
# 자동 포맷팅 설정
프로젝트의 루트 경로에 .vscode/settings.json 폴더와 파일을 생성해 다음과 같이 내용을 추가할 수 있습니다.
1234{ "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode" }
# Server vs Client
Next.js에서는 서버 컴포넌트(Server Component)와 클라이언트 컴포넌트(Client Component)를 구분해 코드 일부가 서버 혹은 클라이언트에서 출력될 수 있도록 만들 수 있습니다.
기본적으로 생성하는 모든 컴포넌트는 서버 컴포넌트입니다!
클라이언트 컴포넌트로 변경/사용하려면 다음과 같이 컴포넌트 최상단에 'use client' 선언이 필요하고, 해당 선언이 없으면 서버 컴포넌트입니다.
12345'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를 사용하면 다음 이미지와 같이, 바로 에러를 표시하기 때문에 금방 구분할 수 있게 됩니다.
zoom_out_map
# 라우팅
라우팅 기능을 사용하기 위해선 Next.js의 파일 규칙(File Conventions)을 이해해야 합니다.
기본 파일은 아래 표에서 layout부터 순서대로 계층 구조를 나타내며, 각 페이지를 출력하기 위해 기능에 맞게 사용합니다.
이러한 명시적 파일 규칙을 통해, 프로젝트 구조를 명확하게 유지할 수 있습니다.
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
1234├─app/ │ ├─movies/ │ │ └─page.tsx │ └─page.tsx
123export default function Home() { return <h1>Home page!</h1> }
123456789101112export default function Movies() { return ( <> <h1>Movies page!</h1> <ul> <li>Avengers</li> <li>Avatar</li> <li>Frozen</li> </ul> </> ) }
또한 위에서 살펴본 라우팅 파일 규칙에 해당하는 이름이 아닌 파일은, 경로로 정의되지 않기 때문에 같은 폴더 안에서 자유롭게 추가해 사용할 수 있습니다.
다음 이미지에서 page.js, route.js 파일을 제외한 나머지 파일은 경로로 정의되지 않습니다.(Not Routable)
zoom_out_map
# 레이아웃
여러 하위 경로에서 공통으로 사용하는 UI는, 각 라우팅 폴더의 layout.tsx 컴포넌트에 작성합니다.
슬롯(Slot) 방식으로 children Prop을 사용하며, {children} 부분에는 같은 레벨에 있는 page.tsx 컴포넌트를 출력합니다.
또한 레이아웃은 중첩해서 사용할 수 있습니다.
12345678├─app/ │ ├─movies/ │ │ ├─layout.tsx │ │ └─page.tsx │ ├─layout.tsx │ └─page.tsx ├─components/ │ └─Header.tsx
다음 코드의 {children} 부분에는 /app/page.tsx 컴포넌트가 출력됩니다.
1234567891011121314151617import './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> ) }
다음 코드의 {children} 부분에는 /app/movies/page.tsx 컴포넌트가 출력됩니다.
12345export default function MoviesLayout({ children }: Readonly<{ children: React.ReactNode }>) { return <section>{children}</section> }
# 컴포넌트 방식의 탐색
Next.js에서는 페이지 이동을 위해 <a> 태그가 아닌 <Link> 컴포넌트를 사용합니다.<Link> 컴포넌트는 이동하는 페이지 전체를 새로고침하지 않고 최적화된 번들만 일부 로드하거나 서버 렌더링 가능 등의 Next.js 프로젝트 내에서 최적화된 페이지 탐색을 제공합니다.
위에서 확인한, /components/Header.tsx 컴포넌트에서, 각 페이지로 이동할 수 있는 링크를 추가해봅시다.
12345678├─app/ │ ├─movies/ │ │ ├─layout.tsx │ │ └─page.tsx │ ├─layout.tsx │ └─page.tsx ├─components/ │ └─Header.tsx
123456789101112131415161718import 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> 컴포넌트의 경로를 비교해 현재 페이지인 경우 활성화 스타일을 추가할 수 있습니다.
1234567891011121314151617181920212223242526'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)의 데이터를 미리 가져와 탐색 성능을 크게 향상시킬 수 있습니다.
null(기본값): 정적 경로인 경우 모든 하위 경로를, 동적 경로인 경우loading.tsx가 있는 가장 가까운 세그먼트까지만 미리 가져옵니다.true: 정적 경로와 동적 경로 모두 미리 가져옵니다.false: 미리 가져오지 않습니다.
123456789export 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)을 사용해 다음과 같이 페이지 이동을 구현할 수 있습니다.
123456789101112131415161718192021222324252627282930313233'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() 메서드를 사용해 미리 가져오기를 구현할 수 있습니다.
123456789101112131415161718192021222324252627'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으로 전달됩니다.
1234├─app/ │ ├─movies/ │ │ ├─[movieId]/ │ │ │ └─page.tsx
params와 searchParams는 모두 Promise 객체입니다.
서버 컴포넌트인 경우, await 키워드를 사용해 필요한 값을 추출합니다.
v16부터 params, searchParams, cookies(), headers() 등이 더 이상 동기 접근이 불가하고, 모두 비동기로만 접근 가능합니다.
1234567891011121314151617181920212223interface 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 훅을 사용해 필요한 값을 추출합니다.
1234567891011121314151617181920212223242526272829303132333435'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> 컴포넌트에서 영화 상세 페이지로 이동할 수 있는 링크를 추가해봅시다.
12345678910// 생략.. const links = [ { href: '/', label: 'Home' }, { href: '/movies', label: 'Movies' }, { href: '/movies/tt4154796', label: 'Movie(Avengers)' } ] export default function Header() { // 생략.. }
혹은 다음과 같이 직접 URL을 입력해 영화 상세 페이지로 이동해 보세요.
123http://localhost:3000/movies/tt4520988?plot=full http://localhost:3000/movies/tt4154796 http://localhost:3000/movies/tt1630029
앞서 살펴본 것처럼 [이름] 폴더로 단순한 동적 경로 일치도 가능하고, 다음 예시와 같이 모든 하위 경로의 동적 일치([...이름])나 선택적 동적 일치([[...이름]]) 패턴도 사용할 수 있습니다.
| 폴더 구조 예시 | 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 파일을 생성합니다.
12345├─app/ │ ├─movies/ │ │ ├─[movieId]/ │ │ │ ├─loading.tsx │ │ │ └─page.tsx
12345import Loader from '@/components/Loader' export default function Loading() { return <Loader /> }
애니메이션 로딩 UI를 구현하기 위해, 다음과 같이 <Loader> 컴포넌트를 작성합니다.
123456789101112131415161718192021222324252627282930interface 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) 유틸 함수를 작성합니다.
123export default function wait(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) }
다음과 같이 영화 상세 정보 가져오기를 2초 동안 지연해서 확실히 로딩 UI를 확인하려고 합니다.
이제 http://localhost:3000/movies/tt4520988 페이지로 접근해보세요!
1234567891011import 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'}`) // 생략.. }
# 에러
페이지 출력 중 에러가 발생하면, 에러 상태를 표시할 수 있습니다.
출력할 페이지와 같은 폴더에 error.tsx 파일을 생성합니다.
123456├─app/ │ ├─movies/ │ │ ├─[movieId]/ │ │ │ ├─error.tsx │ │ │ ├─loading.tsx │ │ │ └─page.tsx
12345678'use client' export default function Error({ error }: { error: Error & { digest?: string } }) { return <h2>{error.message}</h2> }
12345678910import wait from '@/utils/wait' export default async function MovieDetails({ // 생략.. }) { // 생략.. await wait(2000) throw new Error('뭔가 문제가 있어요..') // 생략.. }
# 찾을 수 없는 페이지
프로젝트에서 정의하지 않은 경로로 접근하면, not-found.tsx 파일로 별도의 페이지를 출력할 수 있습니다.
12├─app/ │ └─not-found.tsx
12345678910import Link from 'next/link' export default function NotFound() { return ( <> <h1 className="text-2xl font-bold">404, 찾을 수 없는 페이지입니다.</h1> <Link href="/">메인 페이지로 이동~</Link> </> ) }
1http://localhost:3000/helloworld12345678
# 비동기 컴포넌트 스트리밍
다음 예제에서 async/page.tsx 파일은 1초 후에 페이지를 출력하는 비동기 컴포넌트이고, Abc와 Xyz 컴포넌트 또한 각각 2초와 3초 후에 내용을 출력하는 비동기 컴포넌트입니다.
그러면 http://localhost:3000/async 주소로 접근했을 때, 로딩 애니메이션은 4초 동안 표시되고 그 후에 Abc와 Xyz 컴포넌트가 동시에 출력됩니다.Abc 컴포넌트는 2초 만에 출력할 수 있지만, Xyz 컴포넌트의 영향으로 3초 후에 같이 출력됩니다.
123456├─app/ │ ├─async/ │ │ ├─Abc.tsx │ │ ├─loading.tsx │ │ ├─page.tsx │ │ └─Xyz.tsx
12345import Loader from '@/components/Loader' export default function Loading() { return <Loader /> }
123456import wait from '@/utils/wait' export default async function Abc() { await wait(2000) return <h2>Abc 컴포넌트!</h2> }
123456import wait from '@/utils/wait' export default async function Xyz() { await wait(3000) return <h2>Xyz 컴포넌트!</h2> }
1234567891011121314import 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 /> </> ) }
<Header> 컴포넌트에서 비동기 컴포넌트 스트리밍 테스트 페이지로 이동할 수 있게 링크를 추가해봅시다.
1234567891011// 생략.. 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초 동안 표시된 후에 Abc와 Xyz 컴포넌트가 출력됩니다.
1234567891011121314151617181920import { 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> </> ) }
# 고급 라우팅 패턴
# 경로 그룹
/app 폴더 내 기본적인 폴더는 항상 URL 경로로 매핑되지만, 소괄호(())를 사용해 URL 경로에 영향을 주지 않는 폴더(경로) 그룹을 만들 수 있습니다.
이 그룹은 특히, 각자의 레이아웃(layout.tsx)을 가질 수 있기 때문에, 경로에 맞는 여러 레이아웃 제공을 제공할 수 있습니다.
12345678910├─app/ │ ├─(movies)/ │ │ ├─movies/ │ │ │ ├─[movieId]/ │ │ │ │ ├─error.tsx │ │ │ │ ├─loading.tsx │ │ │ │ └─page.tsx │ │ └─layout.tsx <== (movies) 그룹에서만 동작하는 레이아웃 │ ├─layout.tsx <== 루트 레이아웃 │ └─page.tsx
12345678export 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)할 수 있습니다.
이를 통해 여러 페이지나 컴포넌트를 병렬로 로드하고 렌더링할 수 있어, 순차적 로딩 대비 전체 로딩 시간이 단축되고 각 컴포넌트의 로딩 상태를 독립적으로 표시할 수 있어 더 나은 사용자 경험을 제공합니다.
zoom_out_map
12345678910111213├─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을 반환하면 됩니다.
1234567import { notFound } from 'next/navigation' export default function Default() { notFound() // 혹은 return null }
프로젝트 구조의 컴포넌트 순서대로 아래와 같이 내용을 작성합니다.
12345import Loader from '@/components/Loader' export default function Loading() { return <Loader color="red" /> }
123456import wait from '@/utils/wait' export default async function Abc() { await wait(2000) return <h2>Abc 컴포넌트!</h2> }
12345import Loader from '@/components/Loader' export default function Loading() { return <Loader color="blue" /> }
123456import wait from '@/utils/wait' export default async function Xyz() { await wait(3000) return <h2>Xyz 컴포넌트!</h2> }
layout.tsx 컴포넌트와 같은 레벨의 page.tsx 컴포넌트는 layout.tsx의 children, @abc/page.tsx 컴포넌트는 abc, @xyz/page.tsx 컴포넌트는 xyz Prop으로 각각 전달됩니다.
그리고 전달된 각 컴포넌트를 {children}과 같이 JSX 보간으로 출력합니다.
1234567891011121314151617export default function Layout({ children, abc, xyz }: { children: React.ReactNode abc: React.ReactNode xyz: React.ReactNode }) { return ( <> {children} {abc} {xyz} </> ) }
12345import Loader from '@/components/Loader' export default function Loading() { return <Loader /> }
123456import wait from '@/utils/wait' export default async function Page() { await wait(1000) return <h1>비동기 페이지!</h1> }
http://localhost:3000/async 주소로 접근하면, 앞서 '비동기 컴포넌트 스트리밍'에서 살펴본 <Suspense> 컴포넌트 활용 예제와 같은 로딩 애니메이션 및 페이지 결과가 출력됩니다.
결과는 동일하지만, 이처럼 병렬 경로 처리 방식을 사용하면 각 페이지 컴포넌트가 독립적으로 로딩하고 로딩/에러 상태를 각 컴포넌트별로 쉽게 분리할 수 있어 복잡한 컴포넌트의 분기 처리가 줄어들 수 있습니다.
1234567891011121314151617181920import { 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 |
루트 레벨 세그먼트 |
zoom_out_map
123456789├─app │ ├─a │ │ └─b │ │ └─c │ │ ├─(...)x │ │ │ └─page.tsx │ │ └─page.tsx │ └─x │ └─page.tsx
123export default function XPage() { return <h1>Intercepted X Page!!</h1> }
12345678910import Link from 'next/link' export default function CPage() { return ( <> <h1>C Page</h1> <Link href="/x">가로채기!</Link> </> ) }
123export default function XPage() { return <h1>X Page..</h1> }
1http://localhost:3000/a/b/c
123456789101112├─app │ ├─a │ │ └─b │ │ └─c │ │ ├─@xWrap │ │ │ ├─(...)x │ │ │ │ └─page.tsx │ │ │ └─page.tsx │ │ ├─layout.tsx │ │ └─page.tsx │ └─x │ └─page.tsx
..@xWrap/page.tsx는 null을 반환해 화면에 따로 표시하지 않고, 가로챈 경로의 페이지(@xWrap/(...)x/page.tsx)를 출력하는 용도로 사용합니다.
123export default function xWrap() { return null }
1234567891011121314export default function CLayout({ children, xWrap }: { children: React.ReactNode xWrap: React.ReactNode }) { return ( <> {children} {xWrap} </> ) }
# Proxy
루트 경로에 생성하는 단일 /proxy.ts 파일을 통해, 특정 경로로 이동하기 전에 서버 측에서 실행되는 코드를 제공할 수 있습니다.
주로 인증 및 권한 확인이 필요한 페이지를 구분하는 데 사용되며, 응답 헤더 및 쿠키 설정, Redirect, Rewrite 등의 작업도 가능합니다.
Next.js 16부터 middleware.ts가 proxy.ts로 변경되었습니다.proxy.ts는 네트워크 경계를 명확히 하고 Node.js 런타임에서 실행됩니다.
Proxy는 다음과 같은 과정에서 실행됩니다.
- Next.js Node.js Runtime 초기화
- 요청된 경로와 매칭되는 Proxy 실행(
config.matcher) - 정적/동적 경로 매칭
- 레이아웃과 페이지 렌더링
123456789101112import { 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는 기본적으로 모든 경로 요청에서 호출됩니다.
만약 특정한 경로를 제외하고 싶다면, source 속성에 정규표현식(RegExp)을 사용해 특정 패턴을 제외할 수 있습니다.(?!) 표현은 특정 패턴을 제외하는 부정 전방 일치(Negative Lookahead)를 의미합니다.
즉, 명시된 경로를 제외한 나머지 경로에서 Proxy가 호출되며, 다음 경로가 Proxy에서 제외됩니다.
api/*: API 라우트_next/static/*: 정적 파일_next/image/*: 이미지 최적화 파일favicon.ico: 파비콘 파일
12345678910111213// ... 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 파일을 사용합니다.
1234567├─app │ ├─api │ │ ├─movies │ │ │ └─[movieId] │ │ │ └─route.ts │ │ └─users │ │ └─route.ts
123456789101112import 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) }
1234567891011121314151617181920212223242526272829import 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) }
12http://localhost:3000/api/movies/tt4520988 http://localhost:3000/api/users?sort=age
# 인증
Next.js 프로젝트에서 회원가입이나 로그인 등의 사용자 인증 및 세션 관리를 위해 next-auth 라이브러리를 사용할 수 있습니다.
인증과 관련된 자세한 내용은 Auth.js(NextAuth.js) 핵심 정리를 참고하세요.
zoom_out_map
# 서버 액션
Next.js는 서버에서만 실행되는 함수(Server Actions)를 작성할 수 있습니다.
다음과 같이, 모듈 상단에 'use server' 지시어를 추가하고 서버 액션을 추가합니다.
1234567'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 메시지는 서버 콘솔에만 출력됩니다.
123456import { wait } from '@/serverActions' export default async function ServerPage() { const { message } = await wait(3000) return <h1>{message}</h1> }
123export default function ServerLoading() { return <h1>Loading...</h1> }
다음과 같이, 클라이언트 컴포넌트에서도 서버 액션를 가져와 사용할 수 있습니다.
역시, Run 'wait' function 메시지는 서버 콘솔에만 출력됩니다.
123456789101112131415'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> }
특히, 서버 액션은 <form> 요소의 action 속성으로 호출하는 것이 가능해, 양식(Forms) 작업에서 유용합니다.
1234567891011121314151617181920212223242526272829import { 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> </> ) }
123456789101112'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)을 추가해야 합니다.
12345678import 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' 지시어를 사용해 캐시 컴포넌트를 정의할 수 있습니다.
이 지시어는 모듈이나 함수 레벨의 최상단에 위치해야 하며, 캐시 컴포넌트는 비동기 함수로 정의해야 합니다!
모듈 레벨에서 지시어를 사용하면, 내보내는 모든 함수는 비동기 함수여야 합니다!
123456'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, 읽거나 수정하지 않고 단순히 통과)인 경우에는 사용할 수 있습니다.
12345678910'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 함수를 사용해 사전 구성 프로필이나 타이밍 속성으로 제어할 수 있습니다.
1234567cacheLife('hours') cacheLife('max') cacheLife({ stale: 60 * 10 revalidate: 60 * 60 * 24 expire: 60 * 60 * 24 * 30 })
1234567'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개까지 지정할 수 있습니다.
1234567'use cache' import { cacheTag } from 'next/cache' export async function Component() { cacheTag('hello', 'world', 'tag') return <></> }
updateTag 함수를 사용해 특정 태그를 포함하는 캐시를 만료하고 캐시 컴포넌트나 함수를 다시 실행(Read-Your-Writes)하고 결과를 캐싱합니다.
따라서 호출 후 화면에는 바로 최신 데이터가 표시되며, 변경사항을 즉시 표시해야 하는 경우에 유용합니다.
다중 태그를 지정할 수 있는 cacheTag 함수와는 다르게 updateTag 함수는 단일 태그만 지정할 수 있고, 서버 액션에서만 사용할 수 있습니다.
123456'use server' import { updateTag } from 'next/cache' export async function action() { updateTag('world') }
revalidateTag 함수도 특정 태그를 포함하는 캐시를 만료할 수 있지만, 캐시 컴포넌트나 함수는 추가 요청이 있을 때만 다시 실행(Stale-While-Revalidate)됩니다.
따라서 최신 데이터는 바로 표시되지 않으며, 게시물이나 기타 문서 업데이트 같이 지연 표시가 허용되는 상황에 유용합니다.
서버 액션뿐만 아니라 경로 핸들러(GET, POST 등의 API 함수)에서도 사용할 수 있습니다.
그리고 두 번째 인수로 캐시 프로필을 필수 지정해야 하며, 일반적으로는 'max'가 권장됩니다.
123456'use server' import { revalidateTag } from 'next/cache' export async function action() { revalidateTag('world', 'max') }
# 최적화
Next.js에는 애플리케이션 속도나 웹 바이탈을 향상시킬 수 있는 여러 최적화 기능이 내장되어 있습니다.
주로 사용하는 몇 가지 기능을 살펴봅시다.
# React Compiler
React Compiler는 자동으로 컴포넌트를 메모이제이션하여 불필요한 리렌더링을 줄입니다.
v16부터 React Compiler의 내장 지원이 안정화되었습니다.
1234567import type { NextConfig } from 'next' const nextConfig: NextConfig = { reactCompiler: true } export default nextConfig
# 이미지
<Image /> 컴포넌트를 사용해 지연 로딩, 브라우저 캐싱, 크기 최적화 등의 기능을 아주 간단하게 사용할 수 있습니다.src, alt, width, height 속성은 필수이며, 동일 소스 경로의 이미지는 자동으로 캐싱됩니다.
123456789101112131415161718192021222324252627import 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)를 명시하는 것도 가능합니다.
123456789101112131415161718192021222324import 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 속성은 클라이언트 컴포넌트에서 사용해야 합니다.
123456789101112131415'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)} /> ) }
중요한 이미지로 판단해 우선 로드하거나 품질을 지정할 수도 있습니다.
12345678910111213export 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로 별도 요청을 전송하지 않습니다.
다음과 예제와 같이 내장 폰트 함수를 가져와 초기화합니다.
1234567891011121314import { 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 속성으로 폰트를 적용할 수 있습니다.
1234567891011121314151617import { 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 변수를 루트 요소 등록합니다.
123456789101112131415161718import { 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 변수를 사용할 수 있습니다.
123456body { font-family: var(--font-roboto); } h1, h2, h3 { font-family: var(--font-oswald); }
# Pretendard
Pretendard 웹 폰트를 사용하는 경우, 다음과 같이 구성할 수 있습니다.
1npm i pretendard
123import 'pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css' import '@/styles/global.scss' // ...
123456789html { --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 객체를 내보내면 됩니다.
1234567891011121314151617181920212223242526272829303132333435363738import 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' 옵션을 지정하면 캐싱이 가능합니다.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849import 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'를 사용하는 캐시 서버 액션을 사용하면 캐싱을 통해 중복 요청을 방지할 수 있습니다.
1234567'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 치환 문자에 동적으로 값이 삽입됩니다.
1234567891011121314151617181920import Header from '@/components/Header' import type { Metadata } from 'next' export const metadata: Metadata = { title: { template: '%s | 사이트이름', default: '사이트이름' }, description: '설명..', openGraph: { title: '제목', // ... }, twitter: { title: '제목', // ... } } // 생략..
# 정적 에셋
정적(Static) 에셋은 /public 폴더에 저장할 수 있습니다.
이 폴더에 저장된 파일은 / 경로로 접근할 수 있습니다.
123456├─public │ ├─images │ │ ├─logo.png │ │ └─main.jpg │ ├─next.svg │ ├─vercel.svg
1234http://localhost:3000/images/logo.png http://localhost:3000/images/main.jpg http://localhost:3000/next.svg http://localhost:3000/vercel.svg
# 환경변수
12345├─.env ├─.eslintrc.json ├─.gitignore ├─.prettierrc ├─next.config.ts
각 컴포넌트에서 process.env.변수이름으로 접근 가능한 환경변수는 /.env 파일에서 관리하며, 기본적으로 서버 컴포넌트에서만 접근할 수 있습니다.
만약 클라이언트 컴포넌트에서도 접근하도록 만들고 싶다면, 변수 이름에 NEXT_PUBLIC_을 접두사로 추가해야 합니다.
12OMDB_API_KEY=7035c60c NEXT_PUBLIC_SITE_NAME=Nextjs Movie App
12345678// 생략.. 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() } // 생략..
만약 환경변수를 자동완성하려면, 다음과 같이 타이핑합니다.
123456789export 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)를 선택합니다.
zoom_out_map
특별히 추가하거나 수정할 내용 없이 배포를 진행하면, 약간의 시간이 지난 후 다음 이미지와 같이 배포가 완료됩니다.
바로 우측 상단에 Visit 버튼을 선택해 배포된 프로젝트를 확인할 수 있습니다.
zoom_out_map
만약 프로젝트에서 환경변수를 사용하는 경우, Vercel 프로젝트의 Settings에서 직접 환경변수를 추가해야 합니다.
프로젝트 Settings > Environment Variables 페이지로 이동해, Key와 Value로 환경변수 입력 후 저장(Save) 버튼을 선택합니다.
그리고 환경변수를 추가하거나 수정한 후 프로젝트에 반영하기 위해서는 다시 배포해야 합니다.
프로젝트 Deployments 페이지로 이동해 최신 배포 항목에서 Redeploy 메뉴를 선택합니다.
zoom_out_map
나타나는 모달에서 Redeploy 버튼을 선택하면, 환경변수가 적용된 새로운 배포가 진행됩니다.
배포가 완료되면, 다시 Visit 버튼을 선택해 배포된 사이트를 확인할 수 있습니다.
zoom_out_map
# 영화 검색 예제
zoom_out_map
이해를 돕기 위해, Next.js를 활용한 영화 검색 예제를 준비했습니다.
배포된 예제 사이트에 접속할 수 있고, 예제 코드(GitHub 저장소)도 확인할 수 있습니다.
끝까지 읽어주셔서 감사합니다.
좋아요와 응원 댓글은 블로그 운영에 큰 힘이 됩니다!