Next.js 14 (App Router) ⚡️
React 위에 서버 렌더링·라우팅·번들링·배포 최적화를 얹은 풀스택 프론트엔드 프레임워크.
Next.js는 Vercel이 만든 React 메타 프레임워크다. 파일 시스템 기반 라우팅과 서버 컴포넌트를 1급으로 지원해, SPA처럼 코드를 짜면서도 SSR/SSG/ISR을 자연스럽게 섞을 수 있게 해준다.
📎 공식 문서: nextjs.org/docs
Next.js가 풀어준 문제 🔎
순수 React만 쓰면 다음과 같은 고민거리가 항상 따라온다.
- 라우팅을 어떻게 정의할 것인가 (
react-router-dom직접 설정) - 서버 사이드 렌더링은 어떻게 붙일 것인가 (Express + ReactDOMServer 직접 구현)
- 이미지·폰트·번들 최적화는 누가 책임지는가 (
webpack설정 노가다) - 환경 변수·API 키 보안은 어디서 처리하는가 (별도 백엔드 필요)
Next.js는 이 모든 결정을 관례(convention)로 흡수해버린다. 폴더를 만들면 라우트가 되고, 컴포넌트는 기본적으로 서버에서 렌더되며, <Image> 한 줄로 이미지 최적화가 끝난다.
두 가지 라우터 모델 🛤️
Next.js는 역사적으로 Pages Router와 App Router 두 모델을 지원한다.
| 항목 | Pages Router (~v12) | App Router (v13+) |
|---|---|---|
| 위치 | pages/ | app/ |
| 기본 컴포넌트 | 클라이언트 컴포넌트 | 서버 컴포넌트 (RSC) |
| 데이터 페칭 | getServerSideProps, getStaticProps | async 컴포넌트 + 확장 fetch |
| 레이아웃 | _app.tsx 단일 | 폴더별 layout.tsx 중첩 |
| 메타데이터 | <Head> 컴포넌트 | metadata export |
| 권장 | 유지보수 모드 | 신규 프로젝트 권장 |
본 문서는 App Router (v13+) 기준으로 작성한다.
설치와 프로젝트 생성 🛠️
# 공식 CLI로 새 프로젝트 생성
npx create-next-app@latest my-app
# 옵션 (대화형)
✔ TypeScript? Yes
✔ ESLint? Yes
✔ Tailwind CSS? Yes
✔ `src/` directory? Yes
✔ App Router? Yes
✔ Import alias `@/*`? Yes
생성 후 폴더 구조:
my-app/
├── src/
│ └── app/
│ ├── layout.tsx # 루트 레이아웃 (필수)
│ ├── page.tsx # 홈 페이지 (/)
│ ├── globals.css
│ └── favicon.ico
├── public/ # 정적 파일
├── next.config.mjs
├── tsconfig.json
└── package.json
파일 시스템 라우팅 📁
폴더가 곧 URL. 파일이 곧 페이지/레이아웃이다.
기본 라우트
app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
└── tournament/
└── page.tsx → /tournament
각 폴더의 page.tsx가 해당 경로의 화면이 된다. `page.tsx`가 없는 폴더는 라우트로 노출되지 않는다 (헬퍼 컴포넌트 폴더로 활용 가능).
동적 라우트
app/
└── game/
└── [id]/
└── page.tsx → /game/1, /game/foo 등
// app/game/[id]/page.tsx
interface IPageProps {
params: { id: string };
}
export default function GamePage({ params }: IPageProps) {
return <h1>Game {params.id}</h1>;
}
여러 세그먼트를 잡으려면 [...slug](catch-all), 옵셔널이면 [[...slug]]을 쓴다.
라우트 그룹 — (name)
URL에 영향을 주지 않으면서 폴더만 묶는다. 인증·비인증 영역 레이아웃을 분기할 때 유용.
app/
├── (auth)/
│ ├── login/page.tsx → /login
│ └── signup/page.tsx → /signup
└── (main)/
├── layout.tsx # 메인 레이아웃
└── page.tsx → /
특수 파일
| 파일 | 역할 |
|---|---|
layout.tsx | 자식 라우트 공통 레이아웃 (중첩 가능) |
page.tsx | 해당 경로의 화면 |
loading.tsx | Suspense 폴백 자동 적용 |
error.tsx | 에러 바운더리 (클라이언트 컴포넌트 필수) |
not-found.tsx | 404 화면 |
template.tsx | layout과 비슷하지만 매 네비게이션마다 재마운트 |
route.ts | API Route (Route Handler) |
서버 컴포넌트 vs 클라이언트 컴포넌트 🧩
App Router에서 모든 컴포넌트는 기본적으로 서버 컴포넌트(RSC)다. 클라이언트 동작이 필요한 컴포넌트만 명시적으로
"use client"를 선언한다.
서버 컴포넌트 (RSC, 기본값)
// app/page.tsx — 'use client' 없음 → 서버 컴포넌트
async function fetchGames() {
const res = await fetch("https://api.rawg.io/api/games");
return res.json();
}
export default async function Home() {
const games = await fetchGames(); // 서버에서 await
return <ul>{games.results.map(g => <li key={g.id}>{g.name}</li>)}</ul>;
}
- 서버에서만 실행 (브라우저 번들에 코드 포함 안 됨)
async/await직접 사용 가능- DB·파일시스템·환경 변수 직접 접근 가능
- 단, `useState`/`useEffect`/이벤트 핸들러 사용 불가
클라이언트 컴포넌트
"use client";
import { useState } from "react";
export default function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
- 파일 최상단에
"use client"지시어 - 브라우저 번들에 포함, 하이드레이션됨
- 훅·이벤트 핸들러·브라우저 API 사용 가능
사용 기준 (정리)
| 상황 | 선택 |
|---|---|
| 데이터 페칭, DB 접근 | 서버 |
| API 키·시크릿 사용 | 서버 |
useState, useEffect, useReducer | 클라이언트 |
onClick, onChange 등 이벤트 | 클라이언트 |
window, localStorage | 클라이언트 |
| 외부 라이브러리(상태/UI) 래핑 | 클라이언트 |
📝 서버 컴포넌트는 클라이언트 컴포넌트를 자식으로 가질 수 있다. 반대는 불가(클라이언트 컴포넌트 안에서는 서버 컴포넌트를 직접 import 못함. children prop으로 주입은 가능).
레이아웃 중첩 🪟
layout.tsx는 자식 라우트들의 공통 셸을 만든다. 네비게이션 시에도 리마운트되지 않아 상태가 유지된다.
app/
├── layout.tsx # 루트 (HTML/Body)
├── page.tsx # /
└── tournament/
├── layout.tsx # /tournament 하위 공통 레이아웃
└── page.tsx # /tournament
// app/layout.tsx — 루트 레이아웃 (필수)
import "./globals.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<header>나의 헤더</header>
{children}
<footer>푸터</footer>
</body>
</html>
);
}
데이터 페칭 📡
확장된 fetch
Next.js는 표준 fetch를 확장해 캐시 동작을 옵션으로 제어한다.
// 빌드 타임 캐시 (기본값, SSG와 유사)
const res = await fetch(url, { cache: "force-cache" });
// 매 요청마다 새로 (SSR과 유사)
const res = await fetch(url, { cache: "no-store" });
// N초마다 재검증 (ISR과 유사)
const res = await fetch(url, { next: { revalidate: 60 } });
// 태그 기반 무효화
const res = await fetch(url, { next: { tags: ["games"] } });
// 어디서든:
import { revalidateTag } from "next/cache";
revalidateTag("games");
동적 세그먼트 사전 생성 — generateStaticParams
// app/game/[id]/page.tsx
export async function generateStaticParams() {
const games = await fetch("...").then(r => r.json());
return games.map(g => ({ id: String(g.id) }));
}
빌드 타임에 위 함수의 반환값으로 가능한 경로들을 미리 생성한다.
메타데이터 API 🏷️
<Head> 컴포넌트 대신 export 형태로 정적/동적 메타데이터를 선언한다.
// app/layout.tsx — 정적 메타데이터
export const metadata = {
title: "툰로그",
description: "샛파란 개발자의 공부 블로그",
};
// app/game/[id]/page.tsx — 동적 메타데이터
export async function generateMetadata({ params }) {
const game = await fetch(`https://api.rawg.io/api/games/${params.id}`)
.then(r => r.json());
return {
title: game.name,
description: game.description_raw?.slice(0, 160),
openGraph: { images: [game.background_image] },
};
}
API Route (Route Handler) 🔌
app/api/<path>/route.ts 파일은 HTTP 엔드포인트가 된다.
// app/api/games/route.ts
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const q = searchParams.get("q");
const res = await fetch(
`https://api.rawg.io/api/games?search=${q}&key=${process.env.RAWG_API_KEY}`
);
const data = await res.json();
return NextResponse.json(data);
}
export async function POST(request: Request) {
const body = await request.json();
// ...
return NextResponse.json({ ok: true });
}
❗️ API Route는 API 키 프록시 용도로 자주 쓴다. 클라이언트에 키를 노출하지 않고 서버에서 외부 API를 호출한 뒤 응답만 전달.
환경 변수 🔐
# .env.local
DATABASE_URL=postgres://... # 서버에서만 접근 가능
NEXT_PUBLIC_GA_ID=G-XXXXX # NEXT_PUBLIC_ 접두사 → 클라이언트 번들에 포함
process.env.DATABASE_URL // 서버 컴포넌트·API Route에서만 OK
process.env.NEXT_PUBLIC_GA_ID // 어디서든 OK (브라우저에 노출됨)
⚠️ `NEXT_PUBLIC_` 접두사를 붙이면 빌드 결과물에 평문으로 박혀버린다. 진짜 시크릿은 절대 접두사를 붙이지 말 것.
이미지·폰트 최적화 🖼️
<Image>
import Image from "next/image";
<Image
src="/cover.jpg"
alt="cover"
width={1200}
height={630}
priority // LCP 후보면 priority
sizes="(max-width: 768px) 100vw, 50vw"
/>
자동으로 WebP/AVIF 변환, lazy load, 반응형 srcset 생성.
외부 도메인 이미지는 next.config.mjs에 등록 필요:
// next.config.mjs
export default {
images: {
remotePatterns: [
{ protocol: "https", hostname: "media.rawg.io" },
],
},
};
next/font
// app/layout.tsx
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });
export default function RootLayout({ children }) {
return (
<html lang="ko" className={inter.className}>
<body>{children}</body>
</html>
);
}
빌드 타임에 폰트 파일을 셀프 호스팅으로 가져와 CLS 0, 외부 요청 0을 달성.
네비게이션 🧭
import Link from "next/link";
<Link href="/tournament" prefetch>토너먼트 시작</Link>;
<Link>는 자동으로 백그라운드 prefetch (뷰포트 진입 시)- 프로그램적 이동:
"use client";
import { useRouter } from "next/navigation";
const router = useRouter();
router.push("/result");
router.refresh(); // 서버 컴포넌트 재실행
자주 쓰는 명령 📜
npm run dev # 개발 서버 (포트 3000)
npm run build # 프로덕션 빌드
npm run start # 프로덕션 서버 (빌드 후)
npm run lint # ESLint
Next.js 사용 시 함정과 팁 ⚠️
❗️ 클라이언트 컴포넌트 경계를 최대한 깊숙이 밀어넣기
페이지 전체에 "use client"를 박으면 RSC의 장점이 사라진다. 상태가 필요한 잎(leaf) 컴포넌트만 클라이언트로 격리하고, 그 위는 서버 컴포넌트로 유지하자.
❗️ fetch는 자동 중복 제거된다
같은 URL·옵션의 fetch를 한 렌더 트리에서 여러 번 호출해도 한 번만 실제 요청이 나간다. 굳이 중복 제거를 직접 안 해도 됨.
❗️ use client 안에서 fs/crypto 등 Node API 쓰지 말 것
번들에 포함되면 빌드 에러 발생. 서버 전용 코드는 서버 컴포넌트나 API Route에 격리.
❗️ searchParams는 props로 들어온다 (서버 컴포넌트에서)
// app/search/page.tsx
export default function Search({ searchParams }: {
searchParams: { q?: string };
}) {
return <p>검색어: {searchParams.q}</p>;
}
❗️ async 서버 컴포넌트는 자식만 가능
async function Page() {}는 OK. async function RootLayout() {}도 OK. 하지만 클라이언트 컴포넌트는 async로 만들 수 없다.
3계층 아키텍처와의 매칭 🏗️
Next.js App Router의 폴더 모델은 Presentation / Business / Data 3계층과 자연스럽게 분리된다.
| 계층 | 위치 (예시) |
|---|---|
| Presentation | src/app/**/page.tsx, src/components/** |
| Business | src/modules/** (검색·토너먼트·결과 로직) |
| Data | src/lib/externalApiClient.ts, src/app/api/**/route.ts |
- 서버 컴포넌트가 Business → Data 호출
- 클라이언트 컴포넌트는 Business를 hook으로 사용
- Data 모듈은 서버에서만 import (API 키 보호)