Step 05: 라우팅 아키텍처 — Express Router vs App Router
Step 05: 라우팅 아키텍처 — Express Router vs App Router
PM 요청 (김도연)
"안녕하세요! 준혁님, 지난번에 만든 SSR 페이지 잘 작동하고 있어요. 그런데 이제 페이지가 많아지면서 라우팅이 점점 복잡해지고 있어요. 블로그 글 목록 페이지, 상세 페이지, 사용자 프로필, 대시보드... 이런 식으로 페이지가 늘어나니까 라우트 관리가 어려워지더라고요.
특히 동적 라우트가 필요한데요. 예를 들어 /posts/123, /posts/456 이런 식으로 ID에 따라 다른 글을 보여줘야 하거든요. 그리고 대시보드는 사이드바가 있는 레이아웃, 블로그는 다른 레이아웃을 쓰고 싶은데... Express에서는 이걸 어떻게 구성해야 할까요? Next.js는 어떻게 다른가요?"
시니어 멘토링 (이준혁)
라우팅 철학의 근본적 차이
"좋은 질문이야. 라우팅은 사실 Express와 Next.js의 철학적 차이가 가장 극명하게 드러나는 부분이거든. 페이지가 3~4개일 때는 별 차이 없어 보이지만, 10개, 20개로 늘어나면 그 차이가 엄청나게 커져.
Express와 Next.js는 라우팅에 대한 접근 방식이 완전히 달라:
- Express: 명시적 등록 방식 — 모든 라우트를 코드로 직접 선언하고 등록해야 해.
app.get('/path', handler)이런 식으로. - Next.js: 파일시스템 기반 라우팅 — 파일을 만들면 그게 곧 라우트야. 파일 구조 = URL 구조.
이게 무슨 차이를 만드는지 실제 코드로 보자."
Express Router 패턴: 반복의 지옥
"Express에서 SSR을 하면서 라우트를 추가하면... 이게 진짜 반복 작업의 연속이야."
기본 라우터 구조
// routes/posts.js
import express from 'express';
import { QueryClient, dehydrate } from '@tanstack/react-query';
import { renderToString } from 'react-dom/server';
import { QueryClientProvider } from '@tanstack/react-query';
import { PostsListPage } from '../pages/PostsListPage.jsx';
import { PostDetailPage } from '../pages/PostDetailPage.jsx';
import { htmlTemplate } from '../utils/htmlTemplate.js';
import { fetchPosts, fetchPost } from '../api/posts.js';
const router = express.Router();
// 목록 페이지: /posts
router.get('/', async (req, res) => {
try {
// 1. QueryClient 생성
const queryClient = new QueryClient();
// 2. 데이터 prefetch
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: fetchPosts
});
// 3. React 컴포넌트를 HTML로 렌더링
const html = renderToString(
<QueryClientProvider client={queryClient}>
<PostsListPage />
</QueryClientProvider>
);
// 4. QueryClient 상태를 직렬화
const dehydratedState = dehydrate(queryClient);
// 5. HTML 템플릿에 주입
res.send(htmlTemplate(html, 'posts-bundle.js', dehydratedState, {
title: '블로그 글 목록',
layout: 'default'
}));
} catch (error) {
res.status(500).send('Server Error');
}
});
// 상세 페이지: /posts/:id
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
// 1. QueryClient 생성 (또 반복!)
const queryClient = new QueryClient();
// 2. 데이터 prefetch (또 반복!)
await queryClient.prefetchQuery({
queryKey: ['post', id],
queryFn: () => fetchPost(id)
});
// 3. React 컴포넌트를 HTML로 렌더링 (또 반복!)
const html = renderToString(
<QueryClientProvider client={queryClient}>
<PostDetailPage postId={id} />
</QueryClientProvider>
);
// 4. QueryClient 상태를 직렬화 (또 반복!)
const dehydratedState = dehydrate(queryClient);
// 5. HTML 템플릿에 주입 (또 반복!)
res.send(htmlTemplate(html, 'post-detail-bundle.js', dehydratedState, {
title: '블로그 글',
layout: 'default'
}));
} catch (error) {
res.status(500).send('Server Error');
}
});
export default router;"보이지? 목록 페이지와 상세 페이지가 거의 똑같은 구조를 반복하고 있어. 이게 SSR의 본질적인 문제는 아니고, Express에서 SSR을 할 때 생기는 보일러플레이트 문제야."
사용자 라우터도 똑같은 패턴
// routes/users.js
import express from 'express';
import { QueryClient, dehydrate } from '@tanstack/react-query';
import { renderToString } from 'react-dom/server';
import { QueryClientProvider } from '@tanstack/react-query';
import { UsersListPage } from '../pages/UsersListPage.jsx';
import { UserProfilePage } from '../pages/UserProfilePage.jsx';
import { htmlTemplate } from '../utils/htmlTemplate.js';
import { fetchUsers, fetchUser } from '../api/users.js';
const router = express.Router();
// /users - 목록
router.get('/', async (req, res) => {
try {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({ queryKey: ['users'], queryFn: fetchUsers });
const html = renderToString(
<QueryClientProvider client={queryClient}><UsersListPage /></QueryClientProvider>
);
const dehydratedState = dehydrate(queryClient);
res.send(htmlTemplate(html, 'users-bundle.js', dehydratedState, {
title: '사용자 목록',
layout: 'default'
}));
} catch (error) {
res.status(500).send('Server Error');
}
});
// /users/:id - 프로필
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const queryClient = new QueryClient();
await queryClient.prefetchQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) });
const html = renderToString(
<QueryClientProvider client={queryClient}><UserProfilePage userId={id} /></QueryClientProvider>
);
const dehydratedState = dehydrate(queryClient);
res.send(htmlTemplate(html, 'user-profile-bundle.js', dehydratedState, {
title: '사용자 프로필',
layout: 'default'
}));
} catch (error) {
res.status(500).send('Server Error');
}
});
export default router;"똑같지? 이게 Express SSR의 가장 큰 문제야. 라우트마다 동일한 보일러플레이트를 반복해야 해."
메인 서버에서 라우터 등록
// server.js
import express from 'express';
import postsRouter from './routes/posts.js';
import usersRouter from './routes/users.js';
import dashboardRouter from './routes/dashboard.js';
import apiPostsRouter from './routes/api/posts.js';
import apiUsersRouter from './routes/api/users.js';
const app = express();
// 페이지 라우트 (SSR)
app.use('/posts', postsRouter);
app.use('/users', usersRouter);
app.use('/dashboard', dashboardRouter);
// API 라우트
app.use('/api/posts', apiPostsRouter);
app.use('/api/users', apiUsersRouter);
// 홈 페이지
app.get('/', async (req, res) => {
// 또 똑같은 패턴...
const queryClient = new QueryClient();
await queryClient.prefetchQuery({ queryKey: ['home'], queryFn: fetchHomeData });
const html = renderToString(
<QueryClientProvider client={queryClient}><HomePage /></QueryClientProvider>
);
const dehydratedState = dehydrate(queryClient);
res.send(htmlTemplate(html, 'home-bundle.js', dehydratedState, {
title: '홈',
layout: 'default'
}));
});
app.listen(3000, () => console.log('Server running on port 3000'));"라우터를 하나하나 수동으로 등록해야 해. 파일을 만들었다고 자동으로 라우트가 생기는 게 아니야. 항상 app.use()로 명시적으로 연결해줘야 해."
레이아웃 시스템: 수동 조합의 고통
"레이아웃도 문제야. 대시보드는 사이드바가 있고, 블로그는 없고... 이런 걸 Express에서는 수동으로 만들어야 해."
// utils/htmlTemplate.js
export function htmlTemplate(html, bundlePath, dehydratedState, options = {}) {
const { layout = 'default', title = 'My App', sidebar = false } = options;
// 네비게이션 바 (모든 페이지 공통)
const navbar = `
<nav style="background: #333; color: white; padding: 1rem;">
<a href="/" style="color: white; margin-right: 1rem;">Home</a>
<a href="/posts" style="color: white; margin-right: 1rem;">Posts</a>
<a href="/users" style="color: white; margin-right: 1rem;">Users</a>
<a href="/dashboard" style="color: white;">Dashboard</a>
</nav>
`;
// 사이드바 (레이아웃에 따라 조건부)
const sidebarHtml = layout === 'dashboard' ? `
<aside style="width: 200px; background: #f5f5f5; padding: 1rem;">
<h3>Dashboard Menu</h3>
<ul>
<li><a href="/dashboard/stats">Stats</a></li>
<li><a href="/dashboard/users">Users</a></li>
<li><a href="/dashboard/settings">Settings</a></li>
</ul>
</aside>
` : '';
// 전체 HTML 구조
return `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<style>
body { margin: 0; font-family: sans-serif; }
.container { display: flex; min-height: calc(100vh - 60px); }
main { flex: 1; padding: 2rem; }
</style>
</head>
<body>
${navbar}
<div class="container">
${sidebarHtml}
<main>
<div id="root">${html}</div>
</main>
</div>
${dehydratedState ? `<script>window.__REACT_QUERY_STATE__ = ${JSON.stringify(dehydratedState)};</script>` : ''}
<script src="/public/${bundlePath}"></script>
</body>
</html>`;
}"이 함수로 모든 레이아웃을 처리해야 해. 새로운 레이아웃이 필요하면? 이 함수를 수정하거나 새로운 템플릿 함수를 만들어야 해. 중첩 레이아웃을 만들려면? 더 복잡해져."
// utils/advancedTemplate.js - 중첩 레이아웃 시도
export function nestedLayoutTemplate(html, bundlePath, dehydratedState, options = {}) {
const { layouts = ['root', 'default'], title = 'My App' } = options;
let wrappedHtml = html;
// 레이아웃을 안쪽부터 바깥쪽으로 감싸기
if (layouts.includes('posts')) {
wrappedHtml = `
<div class="posts-layout">
<h2>Posts Section</h2>
<div class="posts-content">${wrappedHtml}</div>
</div>
`;
}
if (layouts.includes('dashboard')) {
wrappedHtml = `
<div class="dashboard-layout" style="display: flex;">
<aside style="width: 200px; background: #f5f5f5;">Dashboard Sidebar</aside>
<div style="flex: 1;">${wrappedHtml}</div>
</div>
`;
}
// 루트 레이아웃
const navbar = `<nav>...</nav>`;
return `<!DOCTYPE html>
<html>
<head><title>${title}</title></head>
<body>
${navbar}
<main><div id="root">${wrappedHtml}</div></main>
${dehydratedState ? `<script>window.__REACT_QUERY_STATE__ = ${JSON.stringify(dehydratedState)};</script>` : ''}
<script src="/public/${bundlePath}"></script>
</body>
</html>`;
}"보이지? 중첩 레이아웃을 만들려면 순서를 계산해서 수동으로 감싸야 해. 코드가 엄청 복잡해지고, 유지보수가 어려워져."
Express SSR 라우트 반복 문제 시각화
"시각적으로 보면 이런 거야:"
Express: 라우트 5개 = SSR 보일러플레이트 5번 반복
┌─────────────────────────────────────────────────────────────┐
│ / │
│ QueryClient → prefetch → renderToString → dehydrate → │
│ htmlTemplate → send │
├─────────────────────────────────────────────────────────────┤
│ /posts │
│ QueryClient → prefetch → renderToString → dehydrate → │
│ htmlTemplate → send │
├─────────────────────────────────────────────────────────────┤
│ /posts/:id │
│ QueryClient → prefetch → renderToString → dehydrate → │
│ htmlTemplate → send │
├─────────────────────────────────────────────────────────────┤
│ /dashboard │
│ QueryClient → prefetch → renderToString → dehydrate → │
│ htmlTemplate → send │
├─────────────────────────────────────────────────────────────┤
│ /settings │
│ QueryClient → prefetch → renderToString → dehydrate → │
│ htmlTemplate → send │
└─────────────────────────────────────────────────────────────┘
총 라인 수: 약 150줄 (라우트당 30줄 × 5)"라우트가 5개면 동일한 패턴을 5번 반복해야 해. 10개면 10번. 이게 Express SSR의 근본적인 한계야."
번들링도 문제
"그리고 각 페이지마다 별도의 클라이언트 번들이 필요해:"
// webpack.config.js
module.exports = {
entry: {
'home-bundle': './client/home.js',
'posts-bundle': './client/posts.js',
'post-detail-bundle': './client/post-detail.js',
'users-bundle': './client/users.js',
'user-profile-bundle': './client/user-profile.js',
'dashboard-bundle': './client/dashboard.js',
},
output: {
path: path.resolve(__dirname, 'public'),
filename: '[name].js'
},
// ...
};"페이지마다 번들을 만들거나, 아니면 하나의 거대한 번들에 모든 페이지를 넣어야 해. 둘 다 비효율적이지."
Next.js App Router: 파일시스템이 곧 라우트
"이제 Next.js를 보자. 완전히 다른 세상이야."
폴더 구조 = URL 구조
app/
├── layout.tsx # 루트 레이아웃 (모든 페이지에 자동 적용)
├── page.tsx # / (홈 페이지)
├── posts/
│ ├── layout.tsx # posts 전용 레이아웃 (자동 중첩!)
│ ├── page.tsx # /posts (목록 페이지)
│ ├── loading.tsx # 로딩 UI (자동 Suspense!)
│ ├── error.tsx # 에러 UI (자동 Error Boundary!)
│ └── [id]/
│ ├── page.tsx # /posts/[id] (동적 라우트)
│ └── loading.tsx # 상세 페이지 로딩 UI
├── users/
│ ├── page.tsx # /users
│ └── [id]/
│ └── page.tsx # /users/[id]
├── dashboard/
│ ├── layout.tsx # 대시보드 레이아웃 (사이드바 포함)
│ ├── page.tsx # /dashboard
│ ├── stats/
│ │ └── page.tsx # /dashboard/stats
│ └── settings/
│ └── page.tsx # /dashboard/settings
└── api/
└── posts/
├── route.ts # GET/POST /api/posts
└── [id]/
└── route.ts # GET/PATCH/DELETE /api/posts/[id]"보이지? 파일을 만들면 그게 곧 라우트야. app.use() 같은 거 필요 없어. 폴더 구조를 보면 URL 구조가 바로 이해돼."
루트 레이아웃: 모든 페이지의 공통 구조
// app/layout.tsx
import './globals.css';
import { ReactNode } from 'react';
export const metadata = {
title: 'My App',
description: 'Express vs Next.js Tutorial',
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko">
<head />
<body>
<nav style={{ background: '#333', color: 'white', padding: '1rem' }}>
<a href="/" style={{ color: 'white', marginRight: '1rem' }}>Home</a>
<a href="/posts" style={{ color: 'white', marginRight: '1rem' }}>Posts</a>
<a href="/users" style={{ color: 'white', marginRight: '1rem' }}>Users</a>
<a href="/dashboard" style={{ color: 'white' }}>Dashboard</a>
</nav>
<main style={{ padding: '2rem' }}>
{children}
</main>
</body>
</html>
);
}"이 레이아웃이 모든 페이지에 자동으로 적용돼. Express처럼 htmlTemplate() 함수를 매번 호출할 필요 없어."
홈 페이지: 그냥 컴포넌트
// app/page.tsx
export default async function HomePage() {
const data = await fetch('https://api.example.com/home').then(r => r.json());
return (
<div>
<h1>홈 페이지</h1>
<p>{data.welcome}</p>
</div>
);
}"끝이야. 보일러플레이트가 하나도 없어. QueryClient, renderToString, dehydrate, htmlTemplate... 이런 거 다 필요 없어. 그냥 컴포넌트만 만들면 돼."
블로그 목록: 중첩 레이아웃 자동 적용
// app/posts/layout.tsx - posts 섹션 전용 레이아웃
export default function PostsLayout({ children }: { children: React.ReactNode }) {
return (
<div style={{ border: '2px solid #ddd', padding: '1rem' }}>
<h2>📝 Posts Section</h2>
<p>블로그 글 모음입니다</p>
<div style={{ marginTop: '1rem' }}>
{children}
</div>
</div>
);
}
// app/posts/page.tsx - 목록 페이지
export default async function PostsListPage() {
const posts = await fetch('https://jsonplaceholder.typicode.com/posts').then(r => r.json());
return (
<div>
<h3>블로그 글 목록</h3>
<ul>
{posts.map((post: any) => (
<li key={post.id}>
<a href={`/posts/${post.id}`}>{post.title}</a>
</li>
))}
</ul>
</div>
);
}"이 페이지는 자동으로 이런 구조가 돼:
RootLayout (app/layout.tsx)
└── PostsLayout (app/posts/layout.tsx)
└── PostsListPage (app/posts/page.tsx)중첩 레이아웃이 자동으로 적용돼! Express처럼 nestedLayoutTemplate() 같은 함수 만들 필요 없어."
동적 라우트: [id] 폴더
// app/posts/[id]/page.tsx
export default async function PostDetailPage({ params }: { params: { id: string } }) {
const post = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`).then(r => r.json());
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<a href="/posts">← 목록으로</a>
</article>
);
}
// 정적 경로 생성 (선택사항 - 성능 최적화)
export async function generateStaticParams() {
const posts = await fetch('https://jsonplaceholder.typicode.com/posts').then(r => r.json());
return posts.map((post: any) => ({ id: String(post.id) }));
}"Express의 /:id와 똑같은 기능이야. 하지만 타입 안전성이 있어. params.id가 자동으로 타입 추론돼."
로딩 상태: loading.tsx
// app/posts/loading.tsx
export default function Loading() {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div style={{ fontSize: '2rem' }}>⏳</div>
<p>블로그 글을 불러오는 중...</p>
</div>
);
}"이 파일을 만들면 자동으로 Suspense boundary가 생겨. Express에서는 수동으로 로딩 상태를 관리해야 하는데, Next.js는 파일 하나면 끝이야."
에러 처리: error.tsx
// app/posts/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div style={{ padding: '2rem', border: '2px solid red', borderRadius: '8px' }}>
<h2>❌ 에러가 발생했습니다</h2>
<p>{error.message}</p>
<button onClick={reset} style={{ padding: '0.5rem 1rem', cursor: 'pointer' }}>
다시 시도
</button>
</div>
);
}"자동 Error Boundary야. Express에서는 try/catch를 일일이 써야 하는데, Next.js는 error.tsx 파일 하나로 해당 라우트의 모든 에러를 잡아."
대시보드: 사이드바 레이아웃
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div style={{ display: 'flex', gap: '2rem' }}>
<aside style={{ width: '200px', background: '#f5f5f5', padding: '1rem', borderRadius: '8px' }}>
<h3>Dashboard Menu</h3>
<ul style={{ listStyle: 'none', padding: 0 }}>
<li><a href="/dashboard">Overview</a></li>
<li><a href="/dashboard/stats">Stats</a></li>
<li><a href="/dashboard/settings">Settings</a></li>
</ul>
</aside>
<div style={{ flex: 1 }}>
{children}
</div>
</div>
);
}
// app/dashboard/page.tsx
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<p>대시보드 메인 페이지입니다.</p>
</div>
);
}
// app/dashboard/stats/page.tsx
export default async function StatsPage() {
const stats = await fetch('https://api.example.com/stats').then(r => r.json());
return (
<div>
<h1>통계</h1>
<div>사용자 수: {stats.users}</div>
<div>글 수: {stats.posts}</div>
</div>
);
}"대시보드의 모든 페이지(/dashboard, /dashboard/stats, /dashboard/settings)가 자동으로 사이드바 레이아웃을 공유해. Express에서는 htmlTemplate(html, bundle, state, { layout: 'dashboard' }) 이런 식으로 매번 명시해야 하는데, Next.js는 폴더 구조만으로 자동이야."
API 라우트
// app/api/posts/route.ts
import { NextResponse } from 'next/server';
// GET /api/posts
export async function GET() {
const posts = await fetch('https://jsonplaceholder.typicode.com/posts').then(r => r.json());
return NextResponse.json(posts);
}
// POST /api/posts
export async function POST(request: Request) {
const body = await request.json();
// DB에 저장 로직...
return NextResponse.json({ success: true, data: body }, { status: 201 });
}
// app/api/posts/[id]/route.ts
import { NextResponse } from 'next/server';
// GET /api/posts/[id]
export async function GET(request: Request, { params }: { params: { id: string } }) {
const post = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`).then(r => r.json());
return NextResponse.json(post);
}
// PATCH /api/posts/[id]
export async function PATCH(request: Request, { params }: { params: { id: string } }) {
const body = await request.json();
// DB 업데이트 로직...
return NextResponse.json({ success: true, id: params.id, data: body });
}
// DELETE /api/posts/[id]
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
// DB 삭제 로직...
return NextResponse.json({ success: true, deleted: params.id });
}"API 라우트도 똑같은 파일시스템 방식이야. Express의 router.get(), router.post() 대신 export async function GET(), export async function POST()를 쓰면 돼."
Next.js의 마법: 라우트 5개 = 컴포넌트 5개
Next.js: 라우트 5개 = 컴포넌트 5개 (보일러플레이트 0)
┌─────────────────────────────────────────┐
│ app/page.tsx │
│ async function → fetch → return JSX │ (10줄)
├─────────────────────────────────────────┤
│ app/posts/page.tsx │
│ async function → fetch → return JSX │ (10줄)
├─────────────────────────────────────────┤
│ app/posts/[id]/page.tsx │
│ async function → fetch → return JSX │ (10줄)
├─────────────────────────────────────────┤
│ app/dashboard/page.tsx │
│ async function → fetch → return JSX │ (10줄)
├─────────────────────────────────────────┤
│ app/settings/page.tsx │
│ async function → fetch → return JSX │ (10줄)
└─────────────────────────────────────────┘
총 라인 수: 약 50줄 (라우트당 10줄 × 5)
Express 대비 1/3 수준!"보이지? Express는 150줄이 필요한 걸 Next.js는 50줄로 끝내. 3배 더 간결하고, 보일러플레이트가 없어."
비교표: 한눈에 보는 차이
| 기능 | Express Router | Next.js App Router |
|---|---|---|
| 라우트 정의 | app.get('/path', handler) 코드로 선언 | app/path/page.tsx 파일 생성 |
| 동적 라우트 | /posts/:id (req.params.id) | app/posts/[id]/page.tsx (params.id) |
| 라우트 등록 | 수동 import + app.use() | 자동 (파일 존재 = 라우트) |
| 레이아웃 | 수동 htmlTemplate 함수 | layout.tsx (자동 적용) |
| 중첩 레이아웃 | 수동 조합 (매우 복잡) | 폴더 중첩 (자동) |
| 로딩 상태 | 수동 구현 | loading.tsx (자동 Suspense) |
| 에러 처리 | try/catch + 에러 핸들러 | error.tsx (자동 Error Boundary) |
| API 라우트 | 별도 라우터 파일 + 수동 등록 | app/api/*/route.ts |
| 코드 반복 | 각 라우트마다 SSR 보일러플레이트 반복 | 없음 (자동 SSR) |
| 타입 안전성 | 없음 (URL 문자열, params 타입 미보장) | params 타입 자동 추론 |
| 번들링 | 수동 설정 (페이지별 번들 또는 하나의 거대 번들) | 자동 코드 스플리팅 |
| 개발 서버 | 수동 HMR 설정 (복잡) | 즉시 적용 (Fast Refresh) |
실전 시나리오: 페이지 10개 추가하기
"실제로 페이지를 10개 추가한다고 생각해보자."
Express의 경우
// 1. 10개의 라우터 파일 생성 (routes/a.js ~ routes/j.js)
// 2. 각 파일마다 30줄의 SSR 보일러플레이트 작성 → 300줄
// 3. 10개의 React 컴포넌트 생성
// 4. 10개의 클라이언트 번들 진입점 생성
// 5. webpack.config.js에 10개 진입점 추가
// 6. server.js에 10개 라우터 import + app.use() 등록
// server.js에 추가되는 코드:
import aRouter from './routes/a.js';
import bRouter from './routes/b.js';
import cRouter from './routes/c.js';
// ... 10개 import
app.use('/a', aRouter);
app.use('/b', bRouter);
app.use('/c', cRouter);
// ... 10개 등록총 작업량: 약 500줄 코드 + 30개 파일 + webpack 설정 + 수동 등록
Next.js의 경우
# 1. 10개의 폴더 생성
mkdir app/a app/b app/c app/d app/e app/f app/g app/h app/i app/j
# 2. 각 폴더에 page.tsx 생성 (10줄씩) → 100줄
# 3. 끝!총 작업량: 약 100줄 코드 + 10개 파일
"5배 차이야. 페이지가 많아질수록 이 차이는 기하급수적으로 커져."
라우트 구조 변경의 용이성
Express: 라우트 이동 = 코드 수정
// /posts/:id를 /blog/:slug로 변경하려면:
// 1. routes/posts.js → routes/blog.js 파일명 변경
// 2. 라우트 핸들러 수정:
router.get('/:slug', async (req, res) => {
const { slug } = req.params; // id → slug
// ...
});
// 3. server.js 수정:
- app.use('/posts', postsRouter);
+ app.use('/blog', blogRouter);
// 4. 모든 링크 수정 (템플릿, 컴포넌트, 리다이렉트...)
// 5. 클라이언트 번들 경로 수정Next.js: 폴더 이름만 변경
# /posts/[id]를 /blog/[slug]로 변경하려면:
mv app/posts app/blog
mv app/blog/[id] app/blog/[slug]
# 끝! (링크는 자동으로 404 → 개발 중 바로 알 수 있음)"폴더명만 바꾸면 끝이야. 등록 코드도, 설정 파일도 건드릴 필요 없어."
타입 안전성
// Next.js - params 타입 자동 추론
export default async function PostPage({ params }: { params: { id: string } }) {
const id = params.id; // ✅ 타입 안전
// TypeScript가 'id'가 string임을 알고 있음
}
// Express - 타입 보장 안 됨
router.get('/:id', (req, res) => {
const id = req.params.id; // ❌ any 타입
// id가 문자열인지 숫자인지 확인 불가
});개발자 경험
Express Router 개발 흐름:
1. routes/new-page.js 생성
2. SSR 보일러플레이트 작성 (30줄)
3. React 컴포넌트 생성
4. client/new-page.js 진입점 생성
5. webpack.config.js에 진입점 추가
6. server.js에 import + app.use() 추가
7. 서버 재시작
8. 테스트
소요 시간: 15~20분
Next.js App Router 개발 흐름:
1. app/new-page/page.tsx 생성
2. 컴포넌트 작성 (10줄)
3. 테스트
소요 시간: 2~3분"6배 빠르게 개발할 수 있어. 그리고 실수할 여지가 훨씬 적어."
깨달음 포인트
"정리하자면:
-
Express는 라우트마다 SSR 보일러플레이트를 반복해야 해.
QueryClient생성 → prefetch →renderToString→dehydrate→htmlTemplate→ send- 이 패턴을 라우트 개수만큼 반복. 5개면 5번, 10개면 10번.
-
레이아웃 시스템이 수동이야.
htmlTemplate()함수로 모든 걸 처리해야 함.- 중첩 레이아웃은 더욱 복잡.
-
라우트 등록이 수동이야.
- 파일을 만들고, import하고,
app.use()로 등록하는 3단계.
- 파일을 만들고, import하고,
-
Next.js는 파일 생성 = 라우트 생성이야.
- 보일러플레이트 없음.
- 레이아웃, 로딩, 에러 처리가 파일 하나로 자동.
- 중첩 레이아웃도 폴더 구조로 자동.
-
페이지가 많아질수록 Express의 부담이 기하급수적으로 증가해.
- 페이지 10개 추가: Express 500줄 vs Next.js 100줄
- 개발 시간: Express 15분 vs Next.js 3분
Express로 SSR을 하면서 라우팅을 제대로 관리하려면 엄청난 반복 작업이 필요해. 이게 Express의 잘못은 아니야. Express는 원래 SSR을 위해 설계된 게 아니거든. 반면 Next.js는 SSR을 위해 처음부터 설계됐고, 그래서 라우팅이 이렇게 간단한 거야.
라우트가 3~5개 정도면 Express도 나쁘지 않아. 하지만 10개, 20개, 50개로 늘어나면? Next.js가 압도적으로 유리해."
실습 과제
"직접 해보면서 차이를 느껴봐:
Express 실습
/products와/products/:id라우트를 추가해봐.- SSR 보일러플레이트 전체를 작성해야 해.
- 레이아웃을
htmlTemplate()에 추가해봐. - 몇 줄이 필요한지 세어봐.
Next.js 실습
app/products/page.tsx와app/products/[id]/page.tsx를 만들어봐.- 각 10줄 정도로 완성될 거야.
app/products/layout.tsx로 공통 레이아웃을 추가해봐.- 자동으로 중첩되는 걸 확인해봐.
비교
- 코드 라인 수 비교
- 개발 시간 비교
- 유지보수 용이성 비교
이 경험이 두 프레임워크의 차이를 가장 극명하게 보여줄 거야."
다음 단계
**Step 06: 빌드/배포 파이프라인**에서는 프로덕션 빌드 과정과 배포 전략의 차이를 비교합니다. Express는 Webpack 설정부터 수동 최적화까지 모든 걸 직접 해야 하지만, Next.js는
next build하나로 최적화된 프로덕션 빌드가 완성됩니다. 어떤 차이가 있는지 살펴보겠습니다.
읽기 시간: 약 20분 작성: Express SSR vs Next.js 튜토리얼 시리즈