Next.js App Router 구조: 파일 규약, 서버·클라이언트 컴포넌트 경계, 캐싱, 스트리밍, 고급 라우팅
이 글의 핵심
App Router의 파일 규약과 중첩 레이아웃, 서버·클라이언트 컴포넌트를 나누는 기준, fetch 캐싱 전략, Server Actions, 스트리밍, Parallel·Intercepting Routes 같은 고급 라우팅을 예제로 정리합니다.
App Router 소개
Next.js 13에서 도입된 App Router는 React Server Components를 기반으로 한 새로운 라우팅 시스템입니다. 기존 Pages Router와 병행 사용이 가능하며, 더 나은 성능, 개선된 개발자 경험, 그리고 강력한 레이아웃 시스템을 제공합니다.
App Router의 핵심 이점
서버 우선 아키텍처
기본적으로 모든 컴포넌트는 서버에서 렌더링됩니다. 이는 초기 로딩 속도를 개선하고 클라이언트 번들 크기를 줄여줍니다. 클라이언트 상호작용이 필요한 부분만 선택적으로 'use client' 지시어를 사용하여 클라이언트 컴포넌트로 만들 수 있습니다.
중첩 레이아웃 시스템
각 경로 세그먼트는 자체 layout.tsx를 가질 수 있으며, 이는 자동으로 중첩되어 적용됩니다. 이를 통해 공통 UI 요소를 재사용하고, 네비게이션 시 레이아웃이 유지되어 부드러운 사용자 경험을 제공할 수 있습니다.
스트리밍과 점진적 렌더링
Suspense와 통합되어 페이지의 일부분을 우선적으로 렌더링하고, 느린 데이터는 나중에 스트리밍할 수 있습니다. 이는 Time To First Byte(TTFB)를 개선하고 사용자 체감 성능을 향상시킵니다.
내장 데이터 페칭
getServerSideProps나 getStaticProps 대신, 서버 컴포넌트 내에서 직접 async/await를 사용하여 데이터를 가져올 수 있습니다. 같은 렌더링 안의 중복 fetch는 자동으로 합쳐지고, 캐싱 여부는 옵션으로 정합니다(버전에 따라 기본값이 다르므로 아래 캐싱 절을 꼭 확인해야 합니다).
App Router vs Pages Router
주요 차이점 비교
| 항목 | Pages Router (pages/) | App Router (app/) |
|---|---|---|
| 라우팅 방식 | 파일 = 페이지 | 폴더 기반, 특수 파일로 역할 정의 |
| 레이아웃 | _app.tsx로 전역 관리 | layout.tsx로 중첩 가능 |
| 데이터 페칭 | getServerSideProps, getStaticProps | async 서버 컴포넌트 |
| 기본 렌더링 | 클라이언트 컴포넌트 | 서버 컴포넌트 |
| 스트리밍 | 지원하지 않음 | loading.tsx, Suspense |
| 마이그레이션 | — | Pages Router와 병행 가능 |
언제 App Router를 사용해야 하는가?
App Router를 권장하는 경우:
- 새로운 프로젝트를 시작하는 경우
- 서버 컴포넌트의 이점을 활용하고 싶은 경우
- 복잡한 중첩 레이아웃이 필요한 경우
- 최신 React 기능(Suspense, Streaming 등)을 사용하고 싶은 경우
Pages Router를 유지해도 되는 경우:
- 레거시 프로젝트가 안정적으로 운영 중인 경우
- 점진적 마이그레이션 전략을 선택한 경우
- 특정 서드파티 라이브러리가 Server Components를 지원하지 않는 경우
파일 규약과 라우팅 구조
특수 파일 규약
App Router는 특정 파일명에 특별한 의미를 부여합니다:
| 파일명 | 목적 | 설명 |
|---|---|---|
layout.tsx | 레이아웃 | 여러 페이지에 공통으로 적용되는 UI |
page.tsx | 페이지 | 경로의 고유한 UI, 공개적으로 접근 가능 |
loading.tsx | 로딩 UI | Suspense 경계를 자동으로 생성 |
error.tsx | 에러 UI | Error Boundary를 자동으로 생성 |
template.tsx | 템플릿 | 네비게이션마다 새로 마운트되는 레이아웃 |
not-found.tsx | 404 UI | 리소스를 찾을 수 없을 때 표시 |
루트 레이아웃 설정
루트 레이아웃은 애플리케이션의 최상위 레이아웃으로, <html> 및 <body> 태그를 포함해야 합니다.
// app/layout.tsx
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: {
template: '%s | My App',
default: 'My App',
},
description: 'My awesome application',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<nav>
{/* 글로벌 네비게이션 */}
</nav>
<main>{children}</main>
<footer>
{/* 글로벌 푸터 */}
</footer>
</body>
</html>
);
}
폴더 구조 예시
app/
├── layout.tsx # 루트 레이아웃
├── page.tsx # 홈페이지 (/)
├── about/
│ └── page.tsx # About 페이지 (/about)
├── blog/
│ ├── layout.tsx # 블로그 레이아웃
│ ├── page.tsx # 블로그 목록 (/blog)
│ └── [slug]/
│ └── page.tsx # 블로그 상세 (/blog/[slug])
└── dashboard/
├── layout.tsx # 대시보드 레이아웃
├── page.tsx # 대시보드 홈
└── settings/
└── page.tsx # 설정 페이지
서버 컴포넌트 심화
React Server Components (RSC)란?
서버 컴포넌트는 서버에서만 실행되는 React 컴포넌트입니다. 이는 다음과 같은 이점을 제공합니다:
- 제로 번들 크기: 서버 컴포넌트의 코드는 클라이언트 번들에 포함되지 않음
- 직접 데이터 액세스: 데이터베이스, 파일 시스템 등에 직접 접근 가능
- 민감한 정보 보호: API 키, 토큰 등을 안전하게 서버에서만 사용 가능
- 자동 코드 분할: 클라이언트 컴포넌트만 별도로 분할됨
서버 컴포넌트에서 데이터 페칭
// app/dashboard/page.tsx
import { Suspense } from 'react';
// 데이터 페칭 함수 (서버에서만 실행)
async function getMetrics() {
// API 키를 안전하게 서버에서만 사용
const res = await fetch(`${process.env.API_URL}/metrics`, {
headers: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
// 캐시 전략 설정
next: {
revalidate: 60, // 60초마다 재검증
tags: ['metrics'], // 태그 기반 재검증
},
});
if (!res.ok) {
throw new Error(`Failed to fetch metrics: ${res.status}`);
}
return res.json();
}
// 서버 컴포넌트 (async 사용 가능)
export default async function DashboardPage() {
const metrics = await getMetrics();
return (
<div>
<h1>Dashboard</h1>
<MetricsDisplay metrics={metrics} />
</div>
);
}
// 서버 컴포넌트 내부의 컴포넌트도 서버에서 실행
function MetricsDisplay({ metrics }: { metrics: any }) {
return (
<div>
<p>Total Users: {metrics.totalUsers}</p>
<p>Active Sessions: {metrics.activeSessions}</p>
</div>
);
}
병렬 데이터 페칭
여러 데이터 소스에서 병렬로 데이터를 가져와 성능을 개선할 수 있습니다.
// app/dashboard/page.tsx
async function getUser() {
const res = await fetch(`${process.env.API_URL}/user`);
return res.json();
}
async function getPosts() {
const res = await fetch(`${process.env.API_URL}/posts`);
return res.json();
}
async function getComments() {
const res = await fetch(`${process.env.API_URL}/comments`);
return res.json();
}
export default async function DashboardPage() {
// 병렬로 데이터 페칭 (Promise.all 사용)
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments(),
]);
return (
<div>
<UserProfile user={user} />
<PostsList posts={posts} />
<CommentsList comments={comments} />
</div>
);
}
서버 컴포넌트의 제약사항
서버 컴포넌트에서는 다음을 사용할 수 없습니다:
- React Hooks (
useState,useEffect,useContext등) - 브라우저 전용 API (
window,document,localStorage등) - 이벤트 리스너 (
onClick,onChange등) - React Context의 Provider
이러한 기능이 필요한 경우 클라이언트 컴포넌트를 사용해야 합니다.
클라이언트 컴포넌트 전략
’use client’ 지시어
'use client' 지시어를 파일 최상단에 추가하면 해당 컴포넌트와 그 하위 트리가 클라이언트 컴포넌트로 표시됩니다.
// app/components/Counter.tsx
'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
클라이언트 경계 최소화 전략
클라이언트 번들 크기를 최소화하려면, 클라이언트 컴포넌트를 가능한 한 작게 유지하고 리프 노드 근처에 배치합니다.
잘못된 예 (상위 컴포넌트가 클라이언트)
// ❌ 전체 페이지가 클라이언트 컴포넌트가 됨
'use client';
import { useState } from 'react';
export default function Page() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<header>
<h1>My Page</h1>
<nav>{/* 많은 콘텐츠 */}</nav>
</header>
<main>
<article>{/* 많은 콘텐츠 */}</article>
</main>
<button onClick={() => setIsOpen(!isOpen)}>
Toggle
</button>
{isOpen && <Modal />}
</div>
);
}
올바른 예 (클라이언트 컴포넌트 최소화)
// ✅ 서버 컴포넌트를 기본으로 사용
export default function Page() {
return (
<div>
<header>
<h1>My Page</h1>
<nav>{/* 많은 콘텐츠 */}</nav>
</header>
<main>
<article>{/* 많은 콘텐츠 */}</article>
</main>
{/* 상호작용이 필요한 부분만 클라이언트 컴포넌트로 */}
<ToggleButton />
</div>
);
}
// components/ToggleButton.tsx
'use client';
import { useState } from 'react';
import { Modal } from './Modal';
export function ToggleButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(!isOpen)}>
Toggle
</button>
{isOpen && <Modal />}
</>
);
}
서버와 클라이언트 컴포넌트 조합
서버 컴포넌트는 클라이언트 컴포넌트를 자식으로 가질 수 있으며, 그 반대도 가능합니다(children prop을 통해).
// app/page.tsx (서버 컴포넌트)
import { ClientComponent } from './ClientComponent';
async function getData() {
const res = await fetch('https://api.example.com/data');
return res.json();
}
export default async function Page() {
const data = await getData();
return (
<div>
<h1>Server-side Data</h1>
<pre>{JSON.stringify(data, null, 2)}</pre>
{/* 클라이언트 컴포넌트에 서버 데이터 전달 */}
<ClientComponent initialData={data} />
</div>
);
}
// app/ClientComponent.tsx (클라이언트 컴포넌트)
'use client';
import { useState } from 'react';
export function ClientComponent({ initialData }: { initialData: any }) {
const [data, setData] = useState(initialData);
const handleUpdate = () => {
// 클라이언트에서 상태 업데이트
setData({ ...data, updated: true });
};
return (
<div>
<button onClick={handleUpdate}>Update</button>
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}
중첩 레이아웃과 템플릿
layout.tsx의 동작 방식
layout.tsx는 여러 페이지에 공통으로 적용되는 UI를 정의합니다. 레이아웃은 네비게이션 시 상태를 유지하며 리렌더링되지 않습니다.
// app/dashboard/layout.tsx
import { Sidebar } from './components/Sidebar';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="dashboard-container">
<Sidebar />
<main className="dashboard-content">
{children}
</main>
</div>
);
}
template.tsx의 사용
template.tsx는 레이아웃과 유사하지만, 네비게이션마다 새로운 인스턴스가 생성됩니다. 이는 다음과 같은 경우에 유용합니다:
- 페이지 진입/이탈 애니메이션
- 페이지뷰 로깅
- 폼 상태 초기화
// app/dashboard/template.tsx
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
export default function DashboardTemplate({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
useEffect(() => {
// 페이지 변경마다 실행됨
console.log('Page view:', pathname);
// 분석 도구에 페이지뷰 전송
}, [pathname]);
return <div className="fade-in">{children}</div>;
}
Route Groups로 레이아웃 조직화
Route Groups (folderName)를 사용하면 URL 구조에 영향을 주지 않고 라우트를 논리적으로 그룹화할 수 있습니다.
app/
├── (marketing)/
│ ├── layout.tsx # 마케팅 레이아웃
│ ├── about/
│ │ └── page.tsx # /about
│ └── contact/
│ └── page.tsx # /contact
└── (shop)/
├── layout.tsx # 쇼핑 레이아웃
├── products/
│ └── page.tsx # /products
└── cart/
└── page.tsx # /cart
데이터 페칭과 캐싱 전략
fetch() API 확장
Next.js는 네이티브 fetch() API를 확장하여 자동 캐싱, 중복 제거, 재검증을 지원합니다.
캐시된 데이터 페칭 (명시적으로 지정)
// 결과를 Data Cache에 저장해 재사용 (Next.js 15+에서는 명시해야 캐시됨)
async function getData() {
const res = await fetch('https://api.example.com/posts', {
cache: 'force-cache',
});
return res.json();
}
App Router의 캐싱은 버전에 따라 기본값이 뒤집혔다는 점을 먼저 알아야 합니다. Next.js 13·14에서는 옵션 없는 fetch가 기본적으로 캐시되어, “DB 값을 바꿨는데 화면이 안 바뀐다”는 문의가 가장 흔한 App Router 문제였습니다. Next.js 15부터는 반대로 fetch가 기본적으로 캐시되지 않고, 캐시하려면 위처럼 cache: 'force-cache'나 next.revalidate를 명시해야 합니다. GET Route Handler와 클라이언트 라우터 캐시의 기본값도 같은 방향으로 바뀌었습니다. 인터넷의 예제나 오래된 글을 볼 때는 어느 버전 기준인지 확인하지 않으면 정반대의 동작을 기대하게 됩니다. 페이지가 정적으로 빌드되었는지 동적으로 렌더링되는지는 next build 출력의 경로 옆 기호(정적 ○, 동적 ƒ)로 확인할 수 있습니다. 또 cookies(), headers(), searchParams 같은 요청 단위 API를 한 번이라도 쓰면 그 경로는 자동으로 동적 렌더링으로 바뀝니다.
동적 데이터 페칭
// 요청마다 새로운 데이터 페칭
async function getDynamicData() {
const res = await fetch('https://api.example.com/user', {
cache: 'no-store',
});
return res.json();
}
재검증 기반 캐싱 (ISR)
// 60초마다 백그라운드에서 재검증
async function getISRData() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 },
});
return res.json();
}
태그 기반 재검증
// app/lib/data.ts
export async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
});
return res.json();
}
// app/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
// 포스트 생성 로직...
// 'posts' 태그가 지정된 모든 캐시 재검증
revalidateTag('posts');
}
요청 중복 제거
Next.js는 동일한 렌더 패스 내에서 같은 URL과 옵션을 가진 fetch 요청을 자동으로 중복 제거합니다.
// app/page.tsx
async function getUser() {
const res = await fetch('https://api.example.com/user');
return res.json();
}
// 같은 렌더링 내에서 여러 번 호출해도 실제로는 한 번만 요청됨
export default async function Page() {
const user1 = await getUser(); // 실제 요청
const user2 = await getUser(); // 중복 제거됨
const user3 = await getUser(); // 중복 제거됨
return <div>{user1.name}</div>;
}
Server Actions
Server Actions란?
Server Actions는 서버에서 실행되는 비동기 함수로, 클라이언트와 서버 컴포넌트 모두에서 호출할 수 있습니다. 이를 통해 별도의 API 엔드포인트 없이 서버 측 로직을 실행할 수 있습니다.
기본 사용법
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
// 입력 검증
const title = formData.get('title') as string;
const content = formData.get('content') as string;
if (!title || !content) {
return { error: 'Title and content are required' };
}
// 데이터베이스 작업
let post: { id: string };
try {
const response = await fetch(`${process.env.API_URL}/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content }),
});
if (!response.ok) {
throw new Error('Failed to create post');
}
post = await response.json();
} catch (error) {
return { error: 'Failed to create post' };
}
// 캐시 재검증
revalidatePath('/blog');
// redirect()는 내부적으로 특수한 에러를 던지므로 try/catch 밖에서 호출해야 함
redirect(`/blog/${post.id}`);
}
redirect()를 try 블록 밖으로 옮긴 데는 이유가 있습니다. Next.js의 redirect()는 함수가 반환되는 대신 NEXT_REDIRECT라는 특수한 에러를 던지고, 프레임워크가 그 에러를 잡아 리다이렉트를 수행합니다. 그래서 try 안에서 호출하면 우리가 쓴 catch가 그 에러를 먼저 잡아 버려, 글은 저장되었는데 사용자에게는 “Failed to create post”가 보이는 버그가 됩니다. notFound()도 같은 방식으로 동작하므로 같은 주의가 필요합니다. 처음 Server Actions를 쓸 때 거의 모두가 한 번씩 밟는 함정입니다.
또 이 액션은 실패 시 { error: ... }를 반환하지만, 아래처럼 <form action={createPost}>에 직접 연결하면 반환값을 받을 곳이 없습니다. 에러 메시지를 화면에 보여 주려면 클라이언트 컴포넌트에서 React 19의 useActionState로 액션을 감싸 반환된 상태를 렌더링해야 합니다.
폼에서 Server Actions 사용
// app/blog/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPostPage() {
return (
<form action={createPost}>
<div>
<label htmlFor="title">Title</label>
<input type="text" id="title" name="title" required />
</div>
<div>
<label htmlFor="content">Content</label>
<textarea id="content" name="content" required />
</div>
<button type="submit">Create Post</button>
</form>
);
}
클라이언트 컴포넌트에서 Server Actions 사용
// app/components/LikeButton.tsx
'use client';
import { likePost } from '@/app/actions';
import { useTransition } from 'react';
export function LikeButton({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition();
const handleLike = () => {
startTransition(async () => {
await likePost(postId);
});
};
return (
<button onClick={handleLike} disabled={isPending}>
{isPending ? 'Liking...' : 'Like'}
</button>
);
}
Server Actions의 보안 고려사항
Server Actions는 서버에서 실행되지만, 클라이언트에서 호출할 수 있으므로 보안에 주의해야 합니다:
- 입력 검증: 모든 입력은 서버에서 검증해야 합니다
- 인증/인가: 사용자 권한을 확인해야 합니다
- Rate Limiting: 남용을 방지하기 위한 제한이 필요할 수 있습니다
- CSRF 보호: Server Actions는 POST로만 호출되고 Next.js가
Origin과Host헤더를 비교하므로 기본적인 CSRF 방어가 됩니다
특히 1·2번이 중요합니다. 'use server' 파일에서 export한 함수는 공개 HTTP 엔드포인트가 된다고 생각해야 합니다. 폼에서만 호출하려고 만든 함수라도, 누구나 개발자 도구에서 액션 ID를 찾아 임의의 인자로 직접 호출할 수 있습니다. 그래서 “버튼이 관리자 화면에만 있으니 안전하다”는 가정은 성립하지 않고, 각 액션 안에서 세션을 확인하고 입력을 Zod 같은 스키마로 검증해야 합니다. 페이지 단위로 권한을 검사하는 미들웨어만으로는 액션 호출을 막지 못한다는 점도 자주 놓치는 부분입니다.
스트리밍과 Suspense
loading.tsx를 사용한 즉시 로딩 상태
loading.tsx는 해당 세그먼트의 로딩 UI를 정의하며, 자동으로 Suspense 경계를 생성합니다.
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="loading-skeleton">
<div className="skeleton-header" />
<div className="skeleton-content" />
<div className="skeleton-content" />
</div>
);
}
Suspense를 사용한 세밀한 스트리밍
페이지 내에서 특정 컴포넌트만 지연 로딩하려면 Suspense를 직접 사용합니다.
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { SlowComponent } from './SlowComponent';
import { FastComponent } from './FastComponent';
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* 빠른 컴포넌트는 즉시 표시 */}
<FastComponent />
{/* 느린 컴포넌트는 로딩 상태로 스트리밍 */}
<Suspense fallback={<div>Loading slow data...</div>}>
<SlowComponent />
</Suspense>
</div>
);
}
// app/dashboard/SlowComponent.tsx
async function getSlowData() {
// 느린 데이터 페칭 시뮬레이션
await new Promise((resolve) => setTimeout(resolve, 3000));
return { message: 'Slow data loaded!' };
}
export async function SlowComponent() {
const data = await getSlowData();
return <div>{data.message}</div>;
}
병렬 데이터 로딩과 Suspense
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function Page() {
return (
<div>
{/* 각 컴포넌트가 독립적으로 로딩됨 */}
<Suspense fallback={<div>Loading stats...</div>}>
<Stats />
</Suspense>
<Suspense fallback={<div>Loading chart...</div>}>
<Chart />
</Suspense>
<Suspense fallback={<div>Loading table...</div>}>
<Table />
</Suspense>
</div>
);
}
고급 라우팅 패턴
Parallel Routes
Parallel Routes를 사용하면 동일한 레이아웃 내에서 여러 페이지를 동시에 렌더링할 수 있습니다.
app/
└── dashboard/
├── layout.tsx
├── @analytics/
│ └── page.tsx
├── @team/
│ └── page.tsx
└── page.tsx
// app/dashboard/layout.tsx
export default function Layout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div>
<div>{children}</div>
<div className="grid grid-cols-2 gap-4">
<div>{analytics}</div>
<div>{team}</div>
</div>
</div>
);
}
Parallel Routes에서 가장 흔한 문제는 새로고침 시 404입니다. 클라이언트 내비게이션 중에는 Next.js가 각 슬롯의 이전 상태를 기억해 유지하지만, /dashboard/settings 같은 하위 경로를 직접 새로고침하면 @analytics와 @team 슬롯에 그 경로에 맞는 페이지가 없어 무엇을 그려야 할지 모릅니다. 이때 각 슬롯에 default.tsx가 없으면 페이지 전체가 404가 됩니다. 슬롯마다 default.tsx(보통 return null 또는 기본 화면)를 두는 것이 사실상 필수입니다.
Intercepting Routes
Intercepting Routes를 사용하면 현재 레이아웃 내에서 다른 경로의 콘텐츠를 로드할 수 있습니다 (예: 모달).
app/
└── photos/
├── page.tsx
├── [id]/
│ └── page.tsx
└── (.)[id]/
└── page.tsx
// app/photos/(.)[id]/page.tsx (인터셉트된 라우트 - 모달로 표시)
import { Modal } from '@/components/Modal';
// Next.js 15부터 params는 Promise이므로 await해서 사용
export default async function PhotoModal({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<Modal>
<img src={`/photos/${id}.jpg`} alt="Photo" />
</Modal>
);
}
인터셉트는 목록에서 링크를 눌러 이동하는 소프트 내비게이션에서만 일어난다는 점이 핵심입니다. 사진 목록에서 클릭하면 (.)[id] 경로가 모달로 뜨지만, 그 URL을 복사해 새 탭에서 열거나 새로고침하면 인터셉트 없이 원래의 [id]/page.tsx가 전체 페이지로 렌더링됩니다. 공유 가능한 URL을 유지하면서 모달 UX를 제공하는 것이 이 기능의 목적이므로, 두 페이지가 모두 제대로 동작하도록 만들어야 합니다. 실무에서는 보통 @modal 같은 Parallel Route 슬롯 안에 인터셉트 경로를 두고, 슬롯의 default.tsx가 null을 반환하게 하는 조합으로 구성합니다.
마이그레이션 전략
Pages Router에서 App Router로 점진적 마이그레이션
- 공존 설정:
app디렉토리와pages디렉토리를 동시에 사용 - 경로별 마이그레이션: 한 번에 하나의 경로씩 이동
- 우선순위 결정: 정적 페이지부터 시작하여 동적 페이지로 진행
- 테스트: 각 마이그레이션 후 철저한 테스트 수행
마이그레이션 체크리스트
-
getServerSideProps를 서버 컴포넌트의async/await로 변경 -
getStaticProps를fetch의revalidate옵션으로 변경 -
_app.tsx를layout.tsx로 변경 -
_document.tsx를 루트layout.tsx로 통합 - 클라이언트 전용 코드에
'use client'추가 - API Routes를 Route Handlers로 마이그레이션 (선택사항)
실무 Best Practices
성능 최적화
- 서버 컴포넌트 우선: 기본적으로 서버 컴포넌트를 사용하고, 필요할 때만 클라이언트 컴포넌트 사용
- 코드 분할:
dynamicimport로 큰 컴포넌트 지연 로딩 - 이미지 최적화:
next/image컴포넌트 활용 - 적절한 캐싱: 데이터 특성에 맞는 캐싱 전략 선택
보안
- 환경 변수: 민감한 정보는 환경 변수로 관리
- 입력 검증: 모든 사용자 입력을 서버에서 검증
- 인증/인가: 보호가 필요한 경로에 미들웨어 사용
- CORS 설정: API Routes의 CORS 정책 명확히 설정
개발 경험
- 타입 안전성: TypeScript 적극 활용
- 컴포넌트 조직화: 재사용 가능한 컴포넌트 라이브러리 구축
- 에러 처리:
error.tsx로 일관된 에러 UI 제공 - 로딩 상태:
loading.tsx와 Suspense로 좋은 UX 제공