Link Shortener 앱을 구축하며 Next.js App Router의 모든 기능을 실전에 적용합니다. 프로젝트 구조 설계부터 배포까지 전 과정을 다룹니다.
12장까지 Next.js App Router의 모든 핵심 기능을 개별적으로 다루었습니다. 이 마지막 장에서는 Link Shortener(단축 URL) 앱을 구축하며, 시리즈 전체에서 다룬 기능들을 하나의 프로젝트에 통합합니다.
Link Shortener는 긴 URL을 짧은 코드로 변환하고, 클릭 통계를 추적하는 서비스입니다. 이 프로젝트에서 활용하는 Next.js 기능은 다음과 같습니다.
| 기능 | 활용 장면 | 시리즈 참조 |
|---|---|---|
| App Router 파일 구조 | 라우트 설계 | 1-2장 |
| Route Groups | 레이아웃 분리 | 2장 |
| Parallel Routes | 대시보드 통계 | 3장 |
| Intercepting Routes | 링크 미리보기 모달 | 4장 |
| Server Components | 데이터 표시 | 5장 |
"use cache" | 인기 링크 캐싱 | 6장 |
| Streaming + Suspense | 점진적 UI 로딩 | 7장 |
| Server Actions | 링크 CRUD | 8장 |
| Turbopack | 빌드 도구 | 9장 |
| Middleware | 리디렉트 해석 | 10장 |
| next/image, generateMetadata | SEO | 11장 |
| instrumentation.ts, Docker | 배포 | 12장 |
link-shortener/
src/
app/
(public)/ # Route Group: 공개 페이지
page.tsx # 홈 (링크 생성 폼)
layout.tsx # 공개 레이아웃
[code]/
page.tsx # 리디렉트 처리
(dashboard)/ # Route Group: 대시보드
dashboard/
page.tsx # 대시보드 메인
layout.tsx # 대시보드 레이아웃
@stats/ # Parallel Route: 통계 패널
page.tsx
loading.tsx
@recent/ # Parallel Route: 최근 링크
page.tsx
loading.tsx
links/
page.tsx # 링크 목록
[id]/
page.tsx # 링크 상세
(.)[id]/
page.tsx # Intercepting: 링크 미리보기 모달
og/
[slug]/
route.tsx # OG 이미지 생성
api/
links/
route.ts # API: 링크 CRUD
sitemap.ts
robots.ts
layout.tsx # 루트 레이아웃
actions/
links.ts # Server Actions
lib/
db.ts # 데이터베이스
cache.ts # 캐시 레이어
metadata.ts # 메타데이터 유틸리티
analytics.ts # 분석 유틸리티
components/
LinkForm.tsx # 링크 생성 폼
LinkList.tsx # 링크 목록
LinkPreviewModal.tsx # 미리보기 모달
StatsCard.tsx # 통계 카드
DashboardShell.tsx # 대시보드 셸
proxy.ts # 미들웨어 (리디렉트 해석)
instrumentation.ts # 모니터링 초기화
next.config.ts
Dockerfile
공개 페이지와 대시보드의 레이아웃을 분리합니다. 2장에서 다룬 Route Groups를 활용합니다.
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-white
dark:from-gray-950 dark:to-gray-900">
<header className="mx-auto max-w-4xl px-6 py-8">
<h1 className="text-2xl font-bold">Link Shortener</h1>
</header>
<main className="mx-auto max-w-4xl px-6">
{children}
</main>
</div>
);
}import { DashboardShell } from '@/components/DashboardShell';
export default function DashboardLayout({
children,
stats,
recent,
}: {
children: React.ReactNode;
stats: React.ReactNode;
recent: React.ReactNode;
}) {
return (
<DashboardShell>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">{children}</div>
<aside className="space-y-6">
{stats}
{recent}
</aside>
</div>
</DashboardShell>
);
}대시보드 레이아웃은 stats와 recent를 Parallel Routes(3장)로 받아, 메인 콘텐츠와 사이드바를 독립적으로 로딩합니다.
대시보드의 통계 패널과 최근 링크 목록은 각각 독립적으로 데이터를 가져옵니다.
import { Suspense } from 'react';
import { StatsCard } from '@/components/StatsCard';
import { getStats } from '@/lib/analytics';
export default async function StatsPanel() {
const stats = await getStats();
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">통계 요약</h2>
<StatsCard title="총 링크" value={stats.totalLinks} />
<StatsCard title="총 클릭" value={stats.totalClicks} />
<StatsCard title="오늘 클릭" value={stats.todayClicks} />
</div>
);
}export default function StatsLoading() {
return (
<div className="space-y-4">
<div className="h-6 w-24 animate-pulse rounded bg-gray-200
dark:bg-gray-800" />
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="h-20 animate-pulse rounded-lg bg-gray-200
dark:bg-gray-800"
/>
))}
</div>
);
}import { getRecentLinks } from '@/lib/cache';
export default async function RecentLinks() {
const links = await getRecentLinks();
return (
<div className="space-y-3">
<h2 className="text-lg font-semibold">최근 생성</h2>
<ul className="space-y-2">
{links.map(link => (
<li key={link.id} className="rounded-lg border p-3">
<p className="font-mono text-sm text-blue-600 dark:text-blue-400">
/{link.code}
</p>
<p className="truncate text-xs text-gray-500">
{link.originalUrl}
</p>
</li>
))}
</ul>
</div>
);
}통계 패널의 데이터 로딩이 느려도 최근 링크 목록은 독립적으로 표시됩니다. 각 슬롯의 loading.tsx가 해당 영역만의 스켈레톤 UI를 제공합니다.
링크 목록에서 항목을 클릭하면 모달로 미리보기를 표시하고, URL을 직접 방문하면 전체 페이지를 렌더링합니다. 4장에서 다룬 Intercepting Routes를 활용합니다.
import { getLink } from '@/lib/db';
import { LinkPreviewModal } from '@/components/LinkPreviewModal';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function LinkInterceptedPage({ params }: PageProps) {
const { id } = await params;
const link = await getLink(id);
if (!link) return null;
return (
<LinkPreviewModal link={link} />
);
}'use client';
import { useRouter } from 'next/navigation';
interface Link {
id: string;
code: string;
originalUrl: string;
clicks: number;
createdAt: string;
}
export function LinkPreviewModal({ link }: { link: Link }) {
const router = useRouter();
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => router.back()}
role="dialog"
aria-modal="true"
aria-label="링크 미리보기"
>
<div
className="w-full max-w-lg rounded-xl bg-white p-6
shadow-2xl dark:bg-gray-900"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-bold">링크 상세</h2>
<dl className="mt-4 space-y-3">
<div>
<dt className="text-sm text-gray-500">단축 코드</dt>
<dd className="font-mono text-lg">/{link.code}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">원본 URL</dt>
<dd className="break-all text-sm">{link.originalUrl}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">클릭 수</dt>
<dd className="text-2xl font-bold">{link.clicks}</dd>
</div>
<div>
<dt className="text-sm text-gray-500">생성일</dt>
<dd className="text-sm">{link.createdAt}</dd>
</div>
</dl>
<button
onClick={() => router.back()}
className="mt-6 w-full rounded-lg bg-gray-100 py-2
text-sm font-medium hover:bg-gray-200
dark:bg-gray-800 dark:hover:bg-gray-700"
>
닫기
</button>
</div>
</div>
);
}8장에서 다룬 Server Actions로 링크 생성, 수정, 삭제를 구현합니다.
'use server';
import { z } from 'zod';
import { nanoid } from 'nanoid';
import { after } from 'next/server';
import { revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
import { trackEvent } from '@/lib/analytics';
const CreateLinkSchema = z.object({
url: z.string().url('올바른 URL을 입력하세요'),
customCode: z
.string()
.regex(/^[a-zA-Z0-9-]*$/, '영문, 숫자, 하이픈만 사용 가능합니다')
.max(20, '20자 이내로 입력하세요')
.optional()
.transform(val => val || undefined),
});
interface ActionState {
errors?: Record<string, string[]>;
message?: string;
data?: { code: string; shortUrl: string };
}
export async function createLink(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// 1. 입력 검증
const validatedFields = CreateLinkSchema.safeParse({
url: formData.get('url'),
customCode: formData.get('customCode'),
});
if (!validatedFields.success) {
return { errors: validatedFields.error.flatten().fieldErrors };
}
const { url, customCode } = validatedFields.data;
const code = customCode ?? nanoid(7);
// 2. 중복 확인
const existing = await db.links.findUnique({ where: { code } });
if (existing) {
return { message: '이미 사용 중인 코드입니다. 다른 코드를 입력하세요.' };
}
// 3. 링크 생성
try {
await db.links.create({
data: {
code,
originalUrl: url,
clicks: 0,
},
});
} catch {
return { message: '링크 생성에 실패했습니다.' };
}
// 4. 캐시 무효화
revalidateTag('links');
revalidateTag('stats');
// 5. 응답 후 분석 이벤트 (8장 after() API)
after(async () => {
await trackEvent('link_created', { code, url });
});
return {
data: {
code,
shortUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/${code}`,
},
};
}
export async function deleteLink(id: string): Promise<ActionState> {
try {
await db.links.delete({ where: { id } });
} catch {
return { message: '링크 삭제에 실패했습니다.' };
}
revalidateTag('links');
revalidateTag('stats');
after(async () => {
await trackEvent('link_deleted', { id });
});
redirect('/dashboard/links');
}'use client';
import { useActionState } from 'react';
import { createLink } from '@/actions/links';
export function LinkForm() {
const [state, formAction, isPending] = useActionState(createLink, {});
return (
<form action={formAction} className="space-y-4">
{state.message && (
<div className="rounded-lg bg-red-50 p-3 text-sm text-red-600
dark:bg-red-900/20 dark:text-red-400">
{state.message}
</div>
)}
{state.data && (
<div className="rounded-lg bg-green-50 p-4 dark:bg-green-900/20">
<p className="text-sm text-green-600 dark:text-green-400">
링크가 생성되었습니다
</p>
<p className="mt-1 font-mono text-lg font-bold text-green-700
dark:text-green-300">
{state.data.shortUrl}
</p>
</div>
)}
<div>
<label htmlFor="url" className="block text-sm font-medium">
URL
</label>
<input
id="url"
name="url"
type="url"
required
placeholder="https://example.com/very-long-url-that-needs-shortening"
className="mt-1 w-full rounded-lg border px-4 py-3
focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{state.errors?.url && (
<p className="mt-1 text-sm text-red-600">{state.errors.url[0]}</p>
)}
</div>
<div>
<label htmlFor="customCode" className="block text-sm font-medium">
커스텀 코드 (선택)
</label>
<input
id="customCode"
name="customCode"
type="text"
placeholder="my-link"
className="mt-1 w-full rounded-lg border px-4 py-3
focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{state.errors?.customCode && (
<p className="mt-1 text-sm text-red-600">
{state.errors.customCode[0]}
</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="w-full rounded-lg bg-blue-600 py-3 font-medium text-white
hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPending ? '생성 중...' : '단축 링크 생성'}
</button>
</form>
);
}6장에서 다룬 Cache Components로 자주 조회되는 데이터를 캐싱합니다.
'use cache';
import { cacheLife, cacheTag } from 'next/cache';
import { db } from '@/lib/db';
export async function getPopularLinks(limit: number = 10) {
cacheLife('hours');
cacheTag('links', 'popular');
return db.links.findMany({
orderBy: { clicks: 'desc' },
take: limit,
select: {
id: true,
code: true,
originalUrl: true,
clicks: true,
},
});
}
export async function getRecentLinks(limit: number = 5) {
cacheLife('minutes');
cacheTag('links', 'recent');
return db.links.findMany({
orderBy: { createdAt: 'desc' },
take: limit,
select: {
id: true,
code: true,
originalUrl: true,
createdAt: true,
},
});
}
export async function getLinkByCode(code: string) {
cacheLife('days');
cacheTag(`link-${code}`);
return db.links.findUnique({
where: { code },
select: {
id: true,
code: true,
originalUrl: true,
},
});
}7장에서 다룬 Streaming으로 대시보드의 각 섹션을 점진적으로 로딩합니다.
import { Suspense } from 'react';
import { LinkList } from '@/components/LinkList';
export default function LinksPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">내 링크</h1>
</div>
<Suspense fallback={<LinkListSkeleton />}>
<LinkList />
</Suspense>
</div>
);
}
function LinkListSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-16 animate-pulse rounded-lg bg-gray-200
dark:bg-gray-800"
/>
))}
</div>
);
}10장에서 다룬 미들웨어로 단축 코드를 원본 URL로 리디렉트합니다. after() API로 클릭 통계를 비동기로 기록합니다.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { after } from 'next/server';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 시스템 경로는 통과
if (
pathname.startsWith('/dashboard') ||
pathname.startsWith('/api') ||
pathname.startsWith('/_next') ||
pathname.startsWith('/og') ||
pathname === '/' ||
pathname === '/favicon.ico'
) {
return NextResponse.next();
}
// 단축 코드 추출 (예: /abc123)
const code = pathname.slice(1);
if (!code || code.includes('/')) {
return NextResponse.next();
}
// 링크 조회
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_SITE_URL}/api/links?code=${code}`,
{ next: { tags: [`link-${code}`] } }
);
if (!response.ok) {
return NextResponse.next(); // 404 페이지로 fallback
}
const link = await response.json();
// 응답 후 클릭 통계 기록
after(async () => {
await fetch(
`${process.env.NEXT_PUBLIC_SITE_URL}/api/links/${link.id}/click`,
{ method: 'POST' }
);
});
return NextResponse.redirect(link.originalUrl, 307);
} catch {
return NextResponse.next();
}
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};11장에서 다룬 메타데이터 생성으로 공유 시 미리보기를 제공합니다.
import type { Metadata } from 'next';
import { LinkForm } from '@/components/LinkForm';
export const metadata: Metadata = {
title: 'Link Shortener - URL 단축 서비스',
description: '긴 URL을 짧고 기억하기 쉬운 링크로 변환합니다. 클릭 통계와 분석을 제공합니다.',
openGraph: {
title: 'Link Shortener',
description: 'URL 단축 서비스',
images: [{ url: '/og/default', width: 1200, height: 630 }],
},
};
export default function HomePage() {
return (
<div className="py-16">
<div className="text-center">
<h2 className="text-4xl font-bold tracking-tight">
URL을 짧게, 분석은 깊게
</h2>
<p className="mt-4 text-lg text-gray-600 dark:text-gray-400">
긴 URL을 단축하고 클릭 통계를 추적합니다.
</p>
</div>
<div className="mx-auto mt-12 max-w-xl">
<LinkForm />
</div>
</div>
);
}12장에서 다룬 모니터링 초기화를 설정합니다.
import type { Instrumentation } from 'next';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initOpenTelemetry } = await import('./lib/telemetry');
initOpenTelemetry();
}
}
export const onRequestError: Instrumentation.onRequestError = async (
error,
request,
context
) => {
// 에러 리포팅 서비스로 전송
if (process.env.NODE_ENV === 'production') {
await fetch(process.env.ERROR_REPORTING_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: error.message,
url: request.url,
routeType: context.routeType,
timestamp: new Date().toISOString(),
}),
});
}
};이 프로젝트에서 사용한 모든 Next.js 기능과 그 역할을 정리합니다.
| 파일/기능 | 역할 | 장 |
|---|---|---|
(public)/, (dashboard)/ | Route Group으로 레이아웃 분리 | 2장 |
@stats/, @recent/ | Parallel Routes로 독립적 데이터 로딩 | 3장 |
(.)[id]/page.tsx | Intercepting Routes로 모달 미리보기 | 4장 |
| 서버 컴포넌트 | DB 직접 접근, 클라이언트 번들 최소화 | 5장 |
"use cache" + cacheLife | 인기/최근 링크 캐싱 | 6장 |
<Suspense> + loading.tsx | 점진적 스트리밍, 스켈레톤 UI | 7장 |
createLink, deleteLink | Server Actions CRUD + Zod 검증 | 8장 |
next.config.ts turbopack | 빌드 도구 설정 | 9장 |
proxy.ts | 단축 코드 리디렉트, 클릭 추적 | 10장 |
generateMetadata, OG 이미지 | SEO, 소셜 미디어 미리보기 | 11장 |
instrumentation.ts, Dockerfile | 모니터링, 배포 | 12장 |
13장에 걸쳐 Next.js App Router의 모든 핵심 기능을 다루었습니다. 각 장의 핵심 내용을 정리합니다.
1장: 등장과 진화 -- Next.js 15부터 16.2까지의 타임라인을 조망하고, 비동기 Request API, 캐싱 기본값 변경, Turbopack 전환이라는 세 가지 대전환을 개괄했습니다.
2장: 파일 시스템 아키텍처 -- App Router의 파일 컨벤션(page, layout, loading, error, not-found)과 Route Groups, 중첩 레이아웃 패턴을 다루었습니다.
3장: Parallel Routes -- @slot 기반의 병렬 라우팅으로 하나의 URL에서 여러 독립적 콘텐츠를 동시에 렌더링하는 패턴을 살펴보았습니다.
4장: Intercepting Routes -- (.), (..) 컨벤션으로 현재 레이아웃을 유지하면서 다른 라우트를 인터셉트하는 모달 패턴을 구현했습니다.
5장: 데이터 패칭 -- 서버 컴포넌트에서의 async/await 패턴, fetch 캐싱 기본값 변경(no-store), 요청 중복 제거를 다루었습니다.
6장: Cache Components -- Next.js 16의 "use cache" 디렉티브와 cacheLife, cacheTag로 세밀한 캐싱 전략을 구성하는 방법을 살펴보았습니다.
7장: Streaming과 Suspense -- React Suspense와 Next.js의 loading.tsx를 활용한 점진적 스트리밍, 중첩 Suspense 경계 설계를 다루었습니다.
8장: Server Actions -- "use server" 디렉티브, useActionState, useOptimistic, after() API를 활용한 뮤테이션과 폼 처리 패턴을 구현했습니다.
9장: Turbopack -- Rust 기반 증분 계산 엔진의 아키텍처, 성능 벤치마크, Webpack 마이그레이션 전략을 다루었습니다.
10장: 미들웨어 -- Edge Runtime의 middleware.ts에서 Node.js Runtime의 proxy.ts로의 진화, 인증, i18n, A/B 테스팅 패턴을 살펴보았습니다.
11장: 최적화 -- next/image, next/font, generateMetadata, OG 이미지 생성, Core Web Vitals 최적화 전략을 다루었습니다.
12장: 프로덕션 -- instrumentation.ts, CSP 보안, Docker 배포, Build Adapters, 셀프 호스팅 전략을 살펴보았습니다.
13장: 실전 프로젝트 -- Link Shortener 앱을 구축하며 시리즈 전체의 기능을 하나의 프로젝트에 통합했습니다.
Next.js App Router는 React의 서버 우선 아키텍처를 가장 완성도 높게 구현한 프레임워크입니다. 이 시리즈에서 다룬 기능들은 각각 독립적이면서도 유기적으로 연결되어, 현대적인 웹 애플리케이션의 모든 측면을 다룹니다. 이론적 이해를 넘어 실제 프로젝트에 적용하면서, 각 기능의 트레이드오프를 직접 경험하는 것이 가장 효과적인 학습 방법입니다.
"use cache"로 CRUD와 캐싱을 구현하고, after() API로 응답 후 분석 이벤트를 기록합니다.proxy.ts에서 단축 코드를 해석하여 원본 URL로 리디렉트하고, instrumentation.ts에서 모니터링 인프라를 초기화합니다.이 글이 도움이 되셨나요?
관련 주제 더 보기
Next.js의 프로덕션 배포를 다룹니다. instrumentation.ts, OpenTelemetry, 보안 헤더, Docker 배포, Build Adapters, 셀프 호스팅 전략을 살펴봅니다.
Next.js의 이미지, 폰트, 메타데이터 최적화를 다룹니다. next/image, next/font, generateMetadata, OG 이미지 생성, Core Web Vitals 전략을 살펴봅니다.
Next.js의 미들웨어 진화를 다룹니다. middleware.ts에서 proxy.ts로의 전환, 인증, i18n, A/B 테스팅, 속도 제한 등 고급 패턴을 살펴봅니다.