본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 13장: 실전 프로젝트 - Next.js 풀스택 앱 구축
2026년 2월 13일·웹 개발·

13장: 실전 프로젝트 - Next.js 풀스택 앱 구축

Link Shortener 앱을 구축하며 Next.js App Router의 모든 기능을 실전에 적용합니다. 프로젝트 구조 설계부터 배포까지 전 과정을 다룹니다.

21분1,770자15개 섹션
nextjsreacttypescriptfrontend
공유
nextjs-app-router13 / 13
12345678910111213
이전12장: 모니터링, 보안, 프로덕션 배포

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링크 CRUD8장
Turbopack빌드 도구9장
Middleware리디렉트 해석10장
next/image, generateMetadataSEO11장
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

Route Groups로 레이아웃 분리

공개 페이지와 대시보드의 레이아웃을 분리합니다. 2장에서 다룬 Route Groups를 활용합니다.

src/app/(public)/layout.tsx
typescript
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>
  );
}
src/app/(dashboard)/dashboard/layout.tsx
typescript
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장)로 받아, 메인 콘텐츠와 사이드바를 독립적으로 로딩합니다.

Parallel Routes: 대시보드 통계

대시보드의 통계 패널과 최근 링크 목록은 각각 독립적으로 데이터를 가져옵니다.

src/app/(dashboard)/dashboard/@stats/page.tsx
typescript
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>
  );
}
src/app/(dashboard)/dashboard/@stats/loading.tsx
typescript
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>
  );
}
src/app/(dashboard)/dashboard/@recent/page.tsx
typescript
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를 제공합니다.

Intercepting Routes: 링크 미리보기 모달

링크 목록에서 항목을 클릭하면 모달로 미리보기를 표시하고, URL을 직접 방문하면 전체 페이지를 렌더링합니다. 4장에서 다룬 Intercepting Routes를 활용합니다.

src/app/(dashboard)/dashboard/links/(.)[id]/page.tsx
typescript
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} />
  );
}
src/components/LinkPreviewModal.tsx
typescript
'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>
  );
}

Server Actions: 링크 CRUD

8장에서 다룬 Server Actions로 링크 생성, 수정, 삭제를 구현합니다.

src/actions/links.ts
typescript
'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');
}

링크 생성 폼

src/components/LinkForm.tsx
typescript
'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>
  );
}

"use cache": 인기 링크 캐싱

6장에서 다룬 Cache Components로 자주 조회되는 데이터를 캐싱합니다.

src/lib/cache.ts
typescript
'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,
    },
  });
}

Streaming과 Suspense

7장에서 다룬 Streaming으로 대시보드의 각 섹션을 점진적으로 로딩합니다.

src/app/(dashboard)/dashboard/links/page.tsx
typescript
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>
  );
}

Middleware: 리디렉트 해석

10장에서 다룬 미들웨어로 단축 코드를 원본 URL로 리디렉트합니다. after() API로 클릭 통계를 비동기로 기록합니다.

src/proxy.ts
typescript
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).*)'],
};

generateMetadata: SEO

11장에서 다룬 메타데이터 생성으로 공유 시 미리보기를 제공합니다.

src/app/(public)/page.tsx
typescript
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>
  );
}

instrumentation.ts: 모니터링

12장에서 다룬 모니터링 초기화를 설정합니다.

src/instrumentation.ts
typescript
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.tsxIntercepting Routes로 모달 미리보기4장
서버 컴포넌트DB 직접 접근, 클라이언트 번들 최소화5장
"use cache" + cacheLife인기/최근 링크 캐싱6장
<Suspense> + loading.tsx점진적 스트리밍, 스켈레톤 UI7장
createLink, deleteLinkServer 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의 서버 우선 아키텍처를 가장 완성도 높게 구현한 프레임워크입니다. 이 시리즈에서 다룬 기능들은 각각 독립적이면서도 유기적으로 연결되어, 현대적인 웹 애플리케이션의 모든 측면을 다룹니다. 이론적 이해를 넘어 실제 프로젝트에 적용하면서, 각 기능의 트레이드오프를 직접 경험하는 것이 가장 효과적인 학습 방법입니다.

핵심 요약

  • Link Shortener 앱을 통해 App Router의 파일 구조, Route Groups, Parallel Routes, Intercepting Routes, Server Actions, 캐싱, 스트리밍, 미들웨어, 최적화, 모니터링을 하나의 프로젝트에 통합했습니다.
  • Route Groups로 공개 페이지와 대시보드의 레이아웃을 분리하고, Parallel Routes로 대시보드의 통계와 최근 링크를 독립적으로 로딩합니다.
  • Server Actions와 "use cache"로 CRUD와 캐싱을 구현하고, after() API로 응답 후 분석 이벤트를 기록합니다.
  • proxy.ts에서 단축 코드를 해석하여 원본 URL로 리디렉트하고, instrumentation.ts에서 모니터링 인프라를 초기화합니다.
  • 13장에 걸친 시리즈를 통해 Next.js App Router의 모든 핵심 기능을 체계적으로 학습했습니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#nextjs#react#typescript#frontend

관련 글

웹 개발

12장: 모니터링, 보안, 프로덕션 배포

Next.js의 프로덕션 배포를 다룹니다. instrumentation.ts, OpenTelemetry, 보안 헤더, Docker 배포, Build Adapters, 셀프 호스팅 전략을 살펴봅니다.

2026년 2월 11일·15분
웹 개발

11장: 이미지, 폰트, 메타데이터 최적화

Next.js의 이미지, 폰트, 메타데이터 최적화를 다룹니다. next/image, next/font, generateMetadata, OG 이미지 생성, Core Web Vitals 전략을 살펴봅니다.

2026년 2월 9일·17분
웹 개발

10장: 미들웨어와 Proxy 고급 패턴

Next.js의 미들웨어 진화를 다룹니다. middleware.ts에서 proxy.ts로의 전환, 인증, i18n, A/B 테스팅, 속도 제한 등 고급 패턴을 살펴봅니다.

2026년 2월 7일·17분
이전 글12장: 모니터링, 보안, 프로덕션 배포

댓글

목차

약 21분 남음
  • 프로젝트 개요
  • 프로젝트 구조
  • Route Groups로 레이아웃 분리
  • Parallel Routes: 대시보드 통계
  • Intercepting Routes: 링크 미리보기 모달
  • Server Actions: 링크 CRUD
  • 링크 생성 폼
  • "use cache": 인기 링크 캐싱
  • Streaming과 Suspense
  • Middleware: 리디렉트 해석
  • generateMetadata: SEO
  • instrumentation.ts: 모니터링
  • 아키텍처 종합 정리
  • 시리즈 총정리
  • 핵심 요약