2024년 1월, Next.js 14를 실무 프로젝트에 도입한지 약 2개월이 지났습니다. 기존 pages 디렉토리 구조에서 app 디렉토리로 마이그레이션하면서 느낀 장점들과 주의할 점들을 공유하고자 합니다.
기존 pages 구조에서는 다음과 같은 불편함이 있었습니다:
pages/
index.tsx
about.tsx
users/
[id].tsx
api/
users.ts
posts.ts
src/
components/
hooks/
api/
types.ts
client.ts
API 정의와 타입이 여러 곳에 분산되어 있어 관리가 어려웠습니다. 특히 pages/api
와 src/api
폴더가 별도로 존재하면서 혼란스러웠죠.
app/
(routes)/
page.tsx
about/
page.tsx
users/
[id]/
page.tsx
api/
users/
route.ts
posts/
route.ts
_components/
_hooks/
_lib/
api/
client.ts
types.ts
이렇게 구조를 변경하면서 얻은 장점들:
라우팅 관련 코드 집중화
명확한 컨벤션
직관적인 API 구조
// app/api/users/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
// 핸들러 로직
return NextResponse.json({ users: [] });
}
export async function POST(request: Request) {
const body = await request.json();
// 처리 로직
return NextResponse.json({ success: true });
}
모든 컴포넌트는 기본적으로 서버 컴포넌트로 동작합니다. 이는 다음과 같은 이점을 제공했습니다:
초기 번들 사이즈 감소
데이터 페칭 간소화
// app/users/page.tsx
async function UsersPage() {
const users = await db.users.findMany(); // 직접 DB 접근
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
환경 변수 접근 용이성
필요한 부분만 선택적으로 클라이언트 컴포넌트로 전환:
// app/_components/UserForm.tsx
'use client';
export function UserForm() {
const [name, setName] = useState('');
return (
<form>
<input
value={name}
onChange={e => setName(e.target.value)}
/>
</form>
);
}
기존에는 API 라우트를 만들고, fetch로 요청하는 과정이 필요했습니다:
// Before: pages/api/submit.ts
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).end();
// ... 처리 로직
}
// Before: components/Form.tsx
const handleSubmit = async (data) => {
const res = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(data)
});
}
Server Actions를 사용하면 훨씬 간단해집니다:
// After: app/_lib/actions.ts
'use server'
export async function submitForm(data: FormData) {
const name = data.get('name');
// 직접 처리 로직 구현
}
// After: app/_components/Form.tsx
export function Form() {
return (
<form action={submitForm}>
<input name="name" />
<button type="submit">제출</button>
</form>
);
}
// app/api/posts/route.ts
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
// 조회 로직
return NextResponse.json({ posts: [] });
}
export async function POST(request: Request) {
const body = await request.json();
// 생성 로직
return NextResponse.json({ id: 'new-post' });
}
// app/api/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const post = await db.post.findUnique({
where: { id: params.id }
});
if (!post) {
return NextResponse.json(
{ error: 'Not found' },
{ status: 404 }
);
}
return NextResponse.json(post);
}
// 캐시 제어
fetch(url, { next: { revalidate: 3600 } }); // 1시간
fetch(url, { cache: 'no-store' }); // 항상 최신
{
"compilerOptions": {
"plugins": [
{
"name": "next"
}
]
}
}
더 섬세한 캐싱 전략
성능 모니터링
배포 파이프라인
Next.js 14의 App Router는 단순한 디렉토리 구조 변경이 아닌, 웹 개발 패러다임의 변화를 가져왔습니다. 서버와 클라이언트의 경계가 자연스럽게 섞이면서, 더 효율적인 개발이 가능해졌습니다.
특히 API 통합과 Server Components의 기본 채택은 코드베이스를 더 깔끔하고 관리하기 쉽게 만들어주었습니다. 마이그레이션이 쉽지는 않았지만, 투자할 만한 가치가 있었다고 확신합니다.
다음에는 Partial Prerendering을 도입하면서 겪은 경험을 공유하도록 하겠습니다.