본문으로 건너뛰기 Next.js 15 완벽 가이드 2026 | App Router·Server Actions·Turbopack

Next.js 15 완벽 가이드 2026 | App Router·Server Actions·Turbopack

Next.js 15 완벽 가이드 2026 | App Router·Server Actions·Turbopack

이 글의 핵심

Next.js 15의 새로운 기능과 변경사항을 실전 예제로 완벽 정리. App Router, Server Actions, Turbopack, Partial Prerendering, React 19 지원까지. Start now.

이 글의 핵심

Next.js 15의 새로운 기능과 변경사항을 실전 예제로 완벽 정리합니다. App Router, Server Actions, Turbopack, Partial Prerendering, React 19 지원까지 실무에 바로 적용할 수 있는 가이드입니다.

실무 경험 공유: 대규모 웹 애플리케이션의 관리 대시보드를 Next.js 15로 마이그레이션하면서, Turbopack으로 개발 빌드 시간을 28초에서 6초로 단축하며, Server Actions로 API 코드를 40% 줄인 경험을 바탕으로 작성했습니다.

들어가며: “Next.js 15, 뭐가 달라졌나요?”

Next.js 15의 주요 변화

2024년 10월 출시된 Next.js 15는 성능, 개발자 경험, 안정성에서 큰 도약을 이뤘습니다. 주요 변경사항

  • React 19 정식 지원
  • Turbopack 안정화 (개발 빌드 속도 최대 76% 향상)
  • Partial Prerendering (PPR) 실험적 지원
  • Server Actions 안정화
  • Caching 전략 변경 (기본값이 no-cache로)
  • Async Request APIs (cookies, headers, params)
flowchart TB
    subgraph v14[Next.js 14]
        A1[Pages Router 중심]
        A2[fetch 기본 캐싱]
        A3[Webpack 기본]
    end
    subgraph v15[Next.js 15]
        B1[App Router 완성]
        B2[fetch 기본 no-cache]
        B3[Turbopack 안정화]
        B4[React 19 지원]
        B5[PPR 실험적 지원]
    end
    v14 --> v15

실무에서 마주치는 문제들

개발 빌드가 너무 느려요

대규모 프로젝트에서 npm run dev 시작이 30초 이상 걸립니다. Turbopack으로 전환하면 5초 이내로 단축됩니다. 정적 페이지인데 매번 서버에서 렌더링돼요

Next.js 14에서는 fetch가 기본 캐싱되어 예상치 못한 동작이 발생했습니다. Next.js 15는 명시적 캐싱으로 예측 가능합니다. 폼 제출 시 클라이언트 코드가 너무 많아요

Server Actions를 사용하면 클라이언트 번들 크기를 줄이고 서버에서 직접 데이터를 처리할 수 있습니다.

App Router vs Pages Router: 라우팅·데이터 페칭의 근본 차이

Pages Router(pages/)는 Next.js 12 이전부터 이어진 모델입니다. 각 페이지는 단일 React 트리로 묶이며, getServerSideProps·getStaticProps·getInitialProps페이지 단위에서만 데이터를 주입합니다. 레이아웃은 _app.tsx·_document.tsx로 전역이 한 번에 고정되고, 경로별 중첩 레이아웃·공유 UI 상태를 프레임워크가 구조적으로 표현하기 어렵습니다.

App Router(app/)는 React Server Components(RSC)를 전제로 합니다. layout.tsx경로 세그먼트마다 중첩되고, page.tsx는 그 트리 안의 리프입니다. 데이터는 서버 컴포넌트 본문에서 직접 async 함수로 페칭하거나, Route Handler·Server Actions와 조합합니다. 즉 “페이지 한 방에 props”가 아니라 컴포넌트 트리 단위로 서버·클라이언트 경계를 나눕니다.

구분Pages RouterApp Router
라우팅pages/foo.tsx/fooapp/foo/page.tsx/foo
레이아웃_app 단일 래퍼세그먼트별 layout.tsx 중첩
데이터GSSP/GSP 등 페이지 API서버 컴포넌트 async + fetch/DB
클라이언트 번들페이지 단위 하이드레이션 중심RSC 페이로드 + Client Component 청크
APIpages/api/*app/**/route.ts Route Handlers

언제 무엇을 쓰나: 신규 프로젝트는 App Router가 기본 권장입니다. 기존 pages/는 점진적 이전이 가능하지만, 두 라우터를 한 앱에서 혼용할 때는 URL·미들웨어·국제화 규칙을 명확히 나눠야 합니다.

서버 컴포넌트 아키텍처(RSC)

App Router에서 기본은 서버 컴포넌트입니다. 서버에서만 실행되며, useState·useEffect·브라우저 전용 API는 사용할 수 없습니다. 클라이언트 상호작용이 필요하면 파일 상단에 'use client'클라이언트 컴포넌트로 분리하며, 서버 컴포넌트는 그 자식으로 서버에서 렌더된 RSC 페이로드를 넘깁니다.

동작을 한 줄로 요약하면 다음과 같습니다.

  1. 서버에서 React 트리를 렌더링하며, 직렬화 가능한 RSC 페이로드(스트리밍 가능)를 생성합니다.
  2. 클라이언트는 Client Component 경계까지 하이드레이션하며, 서버 트리는 페이로드로 병합합니다.
  3. 'use client' 모듈과 그 하위만 번들에 포함되는 경향이 있어, 경계를 얕게 유지할수록 JS 전송량이 줄어듭니다.

실무 패턴: 데이터베이스·비밀 키·대용량 변환은 서버 컴포넌트에 두며, 폼·차트·모달 등은 클라이언트로 내립니다. Server Actions는 이 경계에서 서버만의 뮤테이션을 타입 안전하게 노출하는 수단입니다.

정적 렌더링 vs 동적 렌더링: 결정은 어디서 나오나

한 라우트가 빌드 타임에 HTML/RSC 결과를 고정할지, 요청마다 새로 그릴지는 다음 요인의 합으로 결정됩니다.

  • dynamic 세그먼트 설정: export const dynamic = 'force-static' | 'force-dynamic' | 'auto' 등.
  • 동적 API 사용: cookies(), headers(), searchParams를 읽는 방식, unstable_noStore() 호출 등은 동적 렌더링을 유발하기 쉽습니다(Next.js 15에서는 cookies/headers/params가 비동기이므로, “언제 await 하느냐”도 트리 분석 대상이 됩니다).
  • fetch 캐시 옵션: cache: 'no-store' 또는 next: { revalidate: 0 }은 데이터가 매 요청마다 신선해야 함을 의미해 전체 라우트가 동적이 될 수 있습니다. 반대로 force-cache·revalidate: N은 ISR/정적 쪽으로 기울게 합니다.
  • 라우트별 revalidate: 세그먼트에 export const revalidate = 3600처럼 두면 시간 기반 ISR 의도를 명시합니다.

결정 가이드: 마케팅·문서처럼 동일한 HTML이면 정적/ISR. 대시보드·개인화처럼 요청마다 달라지면 동적. “정적 껍데기 + 일부만 스트리밍”이면 Suspense 경계 + PPR(실험) 조합을 검토합니다.

캐싱 레이어: Router / React / Data / Full Route Cache

Next.js 문서가 설명하는 캐시 스택은 서로 다른 목적의 네 층입니다. 혼동하기 쉬우므로 역할을 분리해 이해하는 것이 좋습니다.

flowchart TB
  subgraph per["1. Request Memoization (React)"]
    M["동일 요청 내 동일 fetch URL 중복 제거"]
  end
  subgraph data["2. Data Cache"]
    D["fetch 결과의 영속 캐시 (설정 시)"]
  end
  subgraph full["3. Full Route Cache"]
    F["정적 라우트의 HTML + RSC 페이로드"]
  end
  subgraph router["4. Router Cache (클라이언트)"]
    R["소프트 내비게이션 시 레이아웃·로딩 상태 재사용"]
  end
  per --> data
  data --> full
  router --> R
  • Request Memoization: 한 번의 서버 요청 처리 중 같은 fetch 입력이 반복되면 React가 합쳐 줍니다. 요청이 끝나면 사라지는 짧은 수명의 중복 제거입니다.
  • Data Cache: fetchcache: 'force-cache' 또는 next: { revalidate } 등을 줄 때 서버 쪽에 유지되는 결과 저장소입니다. revalidatePath/revalidateTag로 무효화합니다.
  • Full Route Cache: 정적으로 빌드된 라우트의 산출물(HTML·RSC)을 CDN/서버에 캐시하는 층입니다. 동적 라우트는 여기에 들어가지 않습니다.
  • Router Cache: 브라우저 클라이언트가 클라이언트 내비게이션할 때 레이아웃 세그먼트를 잠시 재사용해 체감 속도를 높입니다. Next.js 15에서는 기본 동작이 보수적으로 바뀌었으므로, 항상 최신 데이터가 필요하면 fetch/router.refresh/cache: 'no-store' 등으로 의도를 드러내야 합니다.

추가로 서버에서 동일 함수의 DB 조회 중복을 막으려면 React의 cache(fn)으로 래핑하는 패턴이 흔합니다. 이는 fetch 메모이제이션과는 별개로 서버 로직 재사용에 가깝습니다.

프로덕션에서 통하는 패턴

  • 에러·로딩 경계: error.tsx·loading.tsx·not-found를 세그먼트에 두어 부분 실패를 격리합니다.
  • 환경 분리: NEXT_PUBLIC_*만 브라우저에 노출하며, 시크릿은 Server Actions·Route Handler·서버 전용 모듈에만 둡니다.
  • 관측: unstable_after(또는 로깅 큐)로 응답 후 분석·감사 로그를 연결하여 TTFB를 막지 않습니다.
  • 런타임 선택: Edge vs Node는 제약(일부 Node API, 실행 시간)이 다릅니다. 인증·지역 라우팅은 Middleware와 조합해 결정합니다.
  • 캐시 계약: “기본 no-store” 전제하에 어떤 데이터를 얼마나 stale 허용할지를 팀 규약으로 문서화하며, revalidateTag 단위로 무효화합니다.

1. Next.js 15 설치 및 마이그레이션

새 프로젝트 생성

npx create-next-app@latest my-app
cd my-app
npm run dev

설치 시 선택사항

  • TypeScript: Yes (권장)
  • ESLint: Yes
  • Tailwind CSS: Yes (선택)
  • src/ directory: Yes (권장)
  • App Router: Yes (필수)
  • Turbopack: Yes (권장)

기존 프로젝트 업그레이드

npm install next@latest react@latest react-dom@latest

package.json:

{
  "dependencies": {
    "next": "^15.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

주요 Breaking Changes

1. Async Request APIs

Before (Next.js 14):

// app/page.tsx
import { cookies, headers } from 'next/headers';
export default function Page() {
  const cookieStore = cookies();
  const headersList = headers();
  // ...
}

After (Next.js 15):

// app/page.tsx
import { cookies, headers } from 'next/headers';
export default async function Page() {
  const cookieStore = await cookies();
  const headersList = await headers();
  // ...
}

2. fetch 기본 캐싱 변경

Before (Next.js 14):

// 기본값: cache: 'force-cache'
const res = await fetch('https://api.example.com/data');

After (Next.js 15):

// 기본값: cache: 'no-store'
const res = await fetch('https://api.example.com/data');
// 캐싱을 원하면 명시적으로
const res = await fetch('https://api.example.com/data', {
  cache: 'force-cache'
});

3. Route Handlers의 GET 메서드

Before (Next.js 14):

// app/api/data/route.ts
export async function GET(request: Request) {
  // 기본 캐싱됨
  return Response.json({ data: 'cached' });
}

After (Next.js 15):

// app/api/data/route.ts
export async function GET(request: Request) {
  // 기본 캐싱 안 됨
  return Response.json({ data: 'not cached' });
}
// 캐싱을 원하면
export const dynamic = 'force-static';

2. Turbopack으로 개발 속도 향상

Turbopack이란?

Rust로 작성된 차세대 번들러로, Webpack보다 훨씬 빠릅니다. 성능 비교 (Next.js 공식 벤치마크):

  • 초기 컴파일: 76% 빠름
  • HMR (Hot Module Replacement): 96% 빠름
  • 메모리 사용량: 30% 감소

Turbopack 활성화

# 개발 모드에서 Turbopack 사용
npm run dev --turbo
# 또는 package.json에 추가
{
  "scripts": {
    "dev": "next dev --turbo"
  }
}

실전 예제: 대규모 프로젝트

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  // Turbopack 설정 (기본값으로 활성화됨)
  experimental: {
    turbo: {
      // Turbopack 옵션
      rules: {
        '*.svg': {
          loaders: ['@svgr/webpack'],
          as: '*.js',
        },
      },
    },
  },
};
module.exports = nextConfig;

실측 결과 (300개 컴포넌트 프로젝트):

  • Webpack: 초기 시작 28초
  • Turbopack: 초기 시작 6초
  • HMR: 0.1초 이내

3. Server Actions 완벽 가이드

Server Actions란?

서버에서 실행되는 함수를 클라이언트에서 직접 호출할 수 있는 기능입니다. API 라우트 없이 폼 제출, 데이터 변경이 가능합니다.

sequenceDiagram
    participant Client as 클라이언트
    participant SA as Server Action
    participant DB as 데이터베이스
    Client->>SA: 폼 제출 (POST)
    SA->>DB: 데이터 저장
    DB-->>SA: 성공
    SA-->>Client: 리디렉션/재검증

기본 사용법

// app/actions.ts
'use server';
export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
  // 데이터베이스 저장
  await db.post.create({
    data: { title, content }
  });
  // 캐시 재검증
  revalidatePath('/posts');
  redirect('/posts');
}
// app/new-post/page.tsx
import { createPost } from '../actions';
export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">작성</button>
    </form>
  );
}

고급 패턴: useFormState + useFormStatus

// app/actions.ts
'use server';
import { z } from 'zod';
const schema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().min(10),
});
export async function createPost(prevState: any, formData: FormData) {
  const validatedFields = schema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  });
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    };
  }
  await db.post.create({
    data: validatedFields.data
  });
  revalidatePath('/posts');
  return { success: true };
}
// app/new-post/page.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createPost } from '../actions';
function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? '작성 중...' : '작성'}
    </button>
  );
}
export default function NewPostPage() {
  const [state, formAction] = useFormState(createPost, null);
  return (
    <form action={formAction}>
      <input name="title" required />
      {state?.errors?.title && <p>{state.errors.title}</p>}
      
      <textarea name="content" required />
      {state?.errors?.content && <p>{state.errors.content}</p>}
      
      <SubmitButton />
    </form>
  );
}

Server Actions 보안

// app/actions.ts
'use server';
import { auth } from '@/lib/auth';
export async function deletePost(postId: string) {
  const session = await auth();
  
  if (!session?.user) {
    throw new Error('Unauthorized');
  }
  const post = await db.post.findUnique({
    where: { id: postId }
  });
  if (post.authorId !== session.user.id) {
    throw new Error('Forbidden');
  }
  await db.post.delete({
    where: { id: postId }
  });
  revalidatePath('/posts');
}

4. Partial Prerendering (PPR)

PPR이란?

정적 부분동적 부분을 하나의 페이지에서 결합하는 기술입니다. 정적 콘텐츠는 즉시 보여주으며, 동적 콘텐츠는 스트리밍으로 로드합니다.

flowchart LR
    subgraph Page[페이지]
        Static["정적 콘텐츠\n즉시 표시"]
        Dynamic["동적 콘텐츠\nSuspense로 스트리밍"]
    end
    Static --> User[사용자]
    Dynamic --> User

PPR 활성화

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    ppr: true,
  },
};
module.exports = nextConfig;

실전 예제: 블로그 포스트

// app/posts/[id]/page.tsx
import { Suspense } from 'react';
// 정적 부분: 빌드 시 생성
async function PostContent({ id }: { id: string }) {
  const post = await db.post.findUnique({
    where: { id },
    select: { title: true, content: true, createdAt: true }
  });
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{post.createdAt.toLocaleDateString()}</time>
      <div>{post.content}</div>
    </article>
  );
}
// 동적 부분: 요청 시 생성
async function PostComments({ id }: { id: string }) {
  const comments = await db.comment.findMany({
    where: { postId: id },
    orderBy: { createdAt: 'desc' }
  });
  return (
    <section>
      <h2>댓글 ({comments.length})</h2>
      {comments.map(comment => (
        <div key={comment.id}>{comment.content}</div>
      ))}
    </section>
  );
}
export default async function PostPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return (
    <>
      {/* 정적: 즉시 표시 */}
      <PostContent id={id} />
      
      {/* 동적: 스트리밍 */}
      <Suspense fallback={<div>댓글 로딩 중...</div>}>
        <PostComments id={id} />
      </Suspense>
    </>
  );
}

PPR 최적화 팁

// 동적 부분을 명시적으로 표시
export const dynamic = 'force-static'; // 기본값
// 특정 부분만 동적으로
import { unstable_noStore as noStore } from 'next/cache';
async function DynamicData() {
  noStore(); // 이 함수는 캐싱하지 않음
  const data = await fetchRealtimeData();
  return <div>{data}</div>;
}

5. React 19 통합

React 19의 새로운 기능

Next.js 15는 React 19를 정식 지원합니다. 주요 기능

  • Actions: 폼 제출 간소화
  • useOptimistic: 낙관적 업데이트
  • use: 프로미스/컨텍스트 읽기
  • ref as prop: ref를 일반 prop처럼 사용

useOptimistic 예제

'use client';
import { useOptimistic } from 'react';
import { likePost } from './actions';
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (state, amount: number) => state + amount
  );
  async function handleLike() {
    addOptimisticLike(1); // 즉시 UI 업데이트
    await likePost(postId); // 서버 요청
  }
  return (
    <button onClick={handleLike}>
      좋아요 {optimisticLikes}
    </button>
  );
}

use Hook

'use client';
import { use } from 'react';
export function PostContent({ postPromise }: { postPromise: Promise<Post> }) {
  const post = use(postPromise); // 프로미스를 직접 읽음
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

6. 캐싱 전략 완벽 가이드

Next.js 15의 캐싱 레이어

flowchart TB
    subgraph Caching[캐싱 레이어]
        A[Request Memoization]
        B[Data Cache]
        C[Full Route Cache]
        D[Router Cache]
    end
    A --> B --> C --> D

위 다이어그램은 의존 관계를 단순화한 것이며, 실제로는 (1) 요청 단위 메모이제이션 → (2) 데이터 캐시 → (3) 정적 라우트 산출물 캐시가 서버 쪽에서 연결되고, (4) Router Cache는 클라이언트 내비게이션 전용입니다.

레이어수명·범위무엇을 줄이나
Request Memoization단일 요청 내같은 fetch URL 반복 호출
Data Cache빌드/요청 간(설정 시)원격 API·동일 fetch 결과 재사용
Full Route Cache정적 라우트 산출물HTML·RSC 페이로드 재생성 비용
Router Cache클라이언트 세션 내(소프트 네비)레이아웃 등 재요청·깜빡임

React cache(): fetch가 아닌 Prisma·ORM 호출을 서버 컴포넌트 트리에서 여러 번 호출할 때, import { cache } from 'react'로 감싸면 동일 렌더 패스에서 결과를 재사용합니다. Data Cache와는 목적이 다릅니다.

fetch 캐싱

// 캐싱 안 함 (기본값)
const res = await fetch('https://api.example.com/data');
// 캐싱 (재검증 시간 지정)
const res = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 } // 1시간
});
// 영구 캐싱
const res = await fetch('https://api.example.com/data', {
  cache: 'force-cache'
});
// 캐싱 안 함 (명시적)
const res = await fetch('https://api.example.com/data', {
  cache: 'no-store'
});

캐시 재검증

// app/actions.ts
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function updatePost(id: string, data: any) {
  await db.post.update({ where: { id }, data });
  // 특정 경로 재검증
  revalidatePath('/posts');
  revalidatePath(`/posts/${id}`);
  // 태그로 재검증
  revalidateTag('posts');
}
// fetch에 태그 지정
const res = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] }
});

라우트 레벨 캐싱

// app/posts/page.tsx
// 정적 생성 (기본값)
export const dynamic = 'force-static';
// 동적 렌더링
export const dynamic = 'force-dynamic';
// 재검증 시간 지정
export const revalidate = 3600; // 1시간
// ISR (Incremental Static Regeneration)
export const revalidate = 60; // 60초마다 재생성

7. 성능 최적화 패턴

이미지 최적화

import Image from 'next/image';
export function ProductCard({ product }: { product: Product }) {
  return (
    <div>
      <Image
        src={product.image}
        alt={product.name}
        width={300}
        height={300}
        placeholder="blur"
        blurDataURL={product.blurDataURL}
        loading="lazy"
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
      />
      <h3>{product.name}</h3>
    </div>
  );
}

폰트 최적화

// app/layout.tsx
import { Inter, Noto_Sans_KR } from 'next/font/google';
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
});
const notoSansKR = Noto_Sans_KR({
  subsets: ['latin'],
  weight: ['400', '700'],
  display: 'swap',
  variable: '--font-noto-sans-kr',
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko" className={`${inter.variable} ${notoSansKR.variable}`}>
      <body>{children}</body>
    </html>
  );
}

스트리밍 SSR

// app/dashboard/page.tsx
import { Suspense } from 'react';
async function SlowComponent() {
  await new Promise(resolve => setTimeout(resolve, 3000));
  return <div>느린 컴포넌트</div>;
}
export default function DashboardPage() {
  return (
    <div>
      <h1>대시보드</h1>
      
      {/* 즉시 표시 */}
      <div>빠른 콘텐츠</div>
      
      {/* 스트리밍 */}
      <Suspense fallback={<div>로딩 중...</div>}>
        <SlowComponent />
      </Suspense>
    </div>
  );
}

번들 크기 최적화

// 동적 import
import dynamic from 'next/dynamic';
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
  loading: () => <p>로딩 중...</p>,
  ssr: false, // 클라이언트에서만 로드
});
export default function Page() {
  return (
    <div>
      <HeavyComponent />
    </div>
  );
}

8. 실전 예제: 풀스택 블로그

프로젝트 구조

app/
├── (auth)/
│   ├── login/
│   │   └── page.tsx
│   └── register/
│       └── page.tsx
├── (main)/
│   ├── page.tsx
│   ├── posts/
│   │   ├── page.tsx
│   │   ├── [id]/
│   │   │   └── page.tsx
│   │   └── new/
│   │       └── page.tsx
│   └── layout.tsx
├── api/
│   └── posts/
│       └── route.ts
├── actions.ts
└── layout.tsx

데이터베이스 (Prisma)

// prisma/schema.prisma
model Post {
  id        String   @id @default(cuid())
  title     String
  content   String
  published Boolean  @default(false)
  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  comments  Comment[]
}
model Comment {
  id        String   @id @default(cuid())
  content   String
  postId    String
  post      Post     @relation(fields: [postId], references: [id])
  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
}
model User {
  id       String    @id @default(cuid())
  email    String    @unique
  name     String
  posts    Post[]
  comments Comment[]
}

Server Actions

// app/actions.ts
'use server';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
const postSchema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().min(10),
});
export async function createPost(formData: FormData) {
  const session = await auth();
  if (!session?.user) {
    throw new Error('Unauthorized');
  }
  const validatedFields = postSchema.parse({
    title: formData.get('title'),
    content: formData.get('content'),
  });
  const post = await prisma.post.create({
    data: {
      ...validatedFields,
      authorId: session.user.id,
    },
  });
  revalidatePath('/posts');
  redirect(`/posts/${post.id}`);
}
export async function deletePost(postId: string) {
  const session = await auth();
  if (!session?.user) {
    throw new Error('Unauthorized');
  }
  const post = await prisma.post.findUnique({
    where: { id: postId },
  });
  if (post?.authorId !== session.user.id) {
    throw new Error('Forbidden');
  }
  await prisma.post.delete({
    where: { id: postId },
  });
  revalidatePath('/posts');
  redirect('/posts');
}

포스트 목록 페이지

// app/(main)/posts/page.tsx
import { prisma } from '@/lib/prisma';
import Link from 'next/link';
export const revalidate = 60; // 60초마다 재생성
export default async function PostsPage() {
  const posts = await prisma.post.findMany({
    where: { published: true },
    include: {
      author: {
        select: { name: true },
      },
      _count: {
        select: { comments: true },
      },
    },
    orderBy: { createdAt: 'desc' },
  });
  return (
    <div>
      <h1>블로그 포스트</h1>
      <Link href="/posts/new">새 글 작성</Link>
      
      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <Link href={`/posts/${post.id}`}>
              <h2>{post.title}</h2>
              <p>
                {post.author.name} · {post.createdAt.toLocaleDateString()}
                · 댓글 {post._count.comments}
              </p>
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

포스트 상세 페이지 (PPR)

// app/(main)/posts/[id]/page.tsx
import { Suspense } from 'react';
import { prisma } from '@/lib/prisma';
import { notFound } from 'next/navigation';
// 정적 부분
async function PostContent({ id }: { id: string }) {
  const post = await prisma.post.findUnique({
    where: { id },
    include: {
      author: {
        select: { name: true },
      },
    },
  });
  if (!post) notFound();
  return (
    <article>
      <h1>{post.title}</h1>
      <p>
        {post.author.name} · {post.createdAt.toLocaleDateString()}
      </p>
      <div>{post.content}</div>
    </article>
  );
}
// 동적 부분
async function PostComments({ id }: { id: string }) {
  const comments = await prisma.comment.findMany({
    where: { postId: id },
    include: {
      author: {
        select: { name: true },
      },
    },
    orderBy: { createdAt: 'desc' },
  });
  return (
    <section>
      <h2>댓글 ({comments.length})</h2>
      {comments.map(comment => (
        <div key={comment.id}>
          <p>{comment.content}</p>
          <small>
            {comment.author.name} · {comment.createdAt.toLocaleDateString()}
          </small>
        </div>
      ))}
    </section>
  );
}
export default async function PostPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return (
    <>
      <PostContent id={id} />
      
      <Suspense fallback={<div>댓글 로딩 중...</div>}>
        <PostComments id={id} />
      </Suspense>
    </>
  );
}

9. 자주 하는 실수와 해결법

문제 1: “cookies is not a function”

원인: Next.js 15에서 cookies()가 async 함수로 변경됩니다.

// ❌ 잘못된 코드
import { cookies } from 'next/headers';
export default function Page() {
  const cookieStore = cookies(); // 에러!
}
// ✅ 올바른 코드
import { cookies } from 'next/headers';
export default async function Page() {
  const cookieStore = await cookies();
}

문제 2: fetch 캐싱 예상과 다름

원인: Next.js 15는 기본값이 no-store로 변경됩니다.

// ❌ 캐싱 안 됨 (기본값)
const res = await fetch('https://api.example.com/data');
// ✅ 캐싱하려면 명시적으로
const res = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 }
});

문제 3: Server Actions에서 redirect 안 됨

원인: redirect()는 예외를 던지므로 try-catch로 감싸면 안 됨.

// ❌ 잘못된 코드
export async function createPost(formData: FormData) {
  try {
    await db.post.create({ data: {...} });
    redirect('/posts'); // 예외가 catch됨!
  } catch (error) {
    return { error: 'Failed' };
  }
}
// ✅ 올바른 코드
export async function createPost(formData: FormData) {
  try {
    await db.post.create({ data: {...} });
  } catch (error) {
    return { error: 'Failed' };
  }
  redirect('/posts'); // try-catch 밖에서
}

문제 4: Turbopack에서 특정 패키지 에러

원인: 일부 패키지가 Turbopack과 호환되지 않음.

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    turbo: {
      // 특정 패키지는 Webpack으로 처리
      resolveAlias: {
        'problematic-package': 'problematic-package/dist/index.js',
      },
    },
  },
};
module.exports = nextConfig;

10. 배포 및 프로덕션 최적화

Vercel 배포

# Vercel CLI 설치
npm i -g vercel
# 배포
vercel
# 프로덕션 배포
vercel --prod

환경 변수

# .env.local
DATABASE_URL="postgresql://..."
NEXTAUTH_SECRET="..."
NEXTAUTH_URL="http://localhost:3000"
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  env: {
    NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
  },
};
module.exports = nextConfig;

성능 모니터링

// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next';
import { Analytics } from '@vercel/analytics/react';
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        {children}
        <SpeedInsights />
        <Analytics />
      </body>
    </html>
  );
}

취업·면접과 연결하기

Next.js·App Router·캐싱·Server Actions를 JD와 맞춰 말할 수 있게 키워드별로 정리해 두면 개발자 이력서·서류·면접 가이드기술 면접 완벽 대비 가이드 준비에 바로 붙습니다. 포트폴리오·배포 맥락은 개발 취업 실전 팁의 프로젝트 절과 함께 보세요.

정리 및 체크리스트

핵심 요약

  • Next.js 15는 React 19, Turbopack, PPR로 성능과 개발 경험이 크게 향상됨
  • Turbopack으로 개발 빌드 속도 최대 76% 향상
  • Server Actions로 API 라우트 없이 서버 로직 실행
  • Partial Prerendering으로 정적+동적 콘텐츠 최적 결합
  • 캐싱 전략 변경으로 더 예측 가능한 동작

마이그레이션 체크리스트

  • cookies(), headers(), params를 async로 변경
  • fetch 캐싱 동작 확인 및 명시적 설정
  • Route Handlers의 GET 메서드 캐싱 확인
  • Turbopack 활성화 및 테스트
  • Server Actions로 폼 처리 마이그레이션
  • PPR 실험적 기능 테스트
  • 성능 벤치마크 측정

같이 보면 좋은 글


이 글에서 다루는 키워드

Next.js, React, App Router, Server Actions, Turbopack, Partial Prerendering, SSR, ISR, 캐싱, 성능 최적화

자주 묻는 질문 (FAQ)

Q. Next.js 14에서 15로 업그레이드해야 하나요?

A. 새 프로젝트는 15를 권장합니다. 기존 프로젝트는 Turbopack과 PPR의 이점이 크다면 업그레이드하세요. Breaking changes가 있으므로 테스트 후 적용하세요.

Q. Turbopack은 프로덕션에서도 사용할 수 있나요?

A. 아직 개발 모드에서만 안정화되었습니다. 프로덕션 빌드는 여전히 Webpack을 사용합니다.

Q. Server Actions vs API Routes, 언제 뭘 쓰나요?

A. 폼 제출, 데이터 변경은 Server Actions를 권장합니다. 외부 API 제공, 복잡한 인증 로직은 API Routes를 사용하세요.

Q. PPR을 사용하면 무조건 빠른가요?

A. 정적 콘텐츠가 많고 일부만 동적일 때 효과적입니다. 전체가 동적이면 일반 SSR이 나을 수 있습니다.