본문 바로가기
web

Next.js 15 서버 컴포넌트 완벽 가이드 - App Router 실전 활용 (2026)

by bamsik 2026. 2. 19.
반응형

Next.js 15 App Router란? — 서버 컴포넌트 시대의 개막

Next.js 15가 안정 버전으로 자리잡은 2026년, React 서버 컴포넌트(RSC)App Router는 이제 선택이 아닌 필수가 되었습니다. Vercel의 조사에 따르면 새로 시작하는 Next.js 프로젝트의 87%가 App Router를 채택하고 있습니다.

하지만 Pages Router에서 넘어오는 개발자들이 App Router의 개념적 전환에 혼란을 겪는 경우가 많습니다. 이 가이드에서는 서버 컴포넌트의 핵심 개념부터 실전 패턴까지 완벽하게 정리합니다.

서버 컴포넌트 vs 클라이언트 컴포넌트

핵심 차이점

구분 서버 컴포넌트 클라이언트 컴포넌트
실행 위치 서버 (빌드/요청 시) 브라우저
번들 크기 0 (JS 없음) 번들에 포함
데이터 패칭 직접 가능 (DB, API) useEffect, SWR 필요
상태(State) 불가 가능 (useState)
이벤트 핸들러 불가 가능 (onClick 등)
브라우저 API 불가 가능 (window, localStorage)

기본 규칙

App Router에서 모든 컴포넌트는 기본적으로 서버 컴포넌트입니다. 클라이언트 컴포넌트가 필요할 때만 파일 상단에 'use client'를 선언합니다.

// 서버 컴포넌트 (기본값, 선언 불필요)
async function ProductList() {
  const products = await db.query('SELECT * FROM products LIMIT 20');
  
  return (
    <ul>
      {products.map(p => (
        <li key={p.id}>
          {p.name} - {p.price}원
          <AddToCartButton productId={p.id} /> {/* 클라이언트 컴포넌트 */}
        </li>
      ))}
    </ul>
  );
}

// 클라이언트 컴포넌트 (상호작용 필요)
'use client';

function AddToCartButton({ productId }: { productId: string }) {
  const [loading, setLoading] = useState(false);
  
  return (
    <button onClick={() => handleAddToCart(productId, setLoading)}>
      {loading ? '추가 중...' : '장바구니 담기'}
    </button>
  );
}

App Router 파일 구조 완벽 이해

특수 파일들의 역할

app/
├── layout.tsx          # 루트 레이아웃 (HTML, body 포함)
├── page.tsx            # 홈 페이지 (/)
├── loading.tsx         # 로딩 UI (Suspense 자동 래핑)
├── error.tsx           # 에러 UI (Error Boundary)
├── not-found.tsx       # 404 페이지
├── global-error.tsx    # 루트 레이아웃 에러
├── route.ts            # API 엔드포인트
├── template.tsx        # 매 네비게이션마다 새 인스턴스
├── (auth)/             # 라우트 그룹 (URL에 영향 없음)
│   ├── login/page.tsx
│   └── register/page.tsx
├── shop/
│   ├── layout.tsx      # 중첩 레이아웃
│   ├── page.tsx        # /shop
│   └── [id]/
│       └── page.tsx    # /shop/123 (동적 라우트)
└── api/
    └── products/
        └── route.ts    # GET /api/products

데이터 패칭 전략

1. 서버 컴포넌트에서 직접 패칭

// app/posts/page.tsx
async function PostsPage() {
  // fetch는 자동으로 캐시됨 (Next.js 확장 fetch)
  const posts = await fetch('https://api.example.com/posts', {
    next: { revalidate: 3600 } // 1시간마다 재검증
  }).then(r => r.json());

  return <PostList posts={posts} />;
}

// DB 직접 쿼리도 가능
import { db } from '@/lib/database';

async function UserProfile({ userId }: { userId: string }) {
  const user = await db.select().from(users).where(eq(users.id, userId));
  return <ProfileCard user={user[0]} />;
}

2. 병렬 데이터 패칭

<code">async function Dashboard() {
  // Promise.all로 병렬 실행 (워터폴 방지)
  const [user, stats, notifications] = await Promise.all([
    getUser(),
    getStats(),
    getNotifications()
  ]);

  return (
    <div>
      <UserCard user={user} />
      <StatsPanel stats={stats} />
      <NotificationBell count={notifications.length} />
    </div>
  );
}

3. Streaming과 Suspense

<code">import { Suspense } from 'react';

// 느린 컴포넌트를 Suspense로 래핑하면 스트리밍 가능
function ProductPage() {
  return (
    <div>
      <ProductHeader />  {/* 즉시 표시 */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <ReviewSection />  {/* 비동기 로딩, 나중에 스트리밍 */}
      </Suspense>
      <Suspense fallback={<RecommendationsSkeleton />}>
        <RecommendedProducts />
      </Suspense>
    </div>
  );
}

Server Actions — 폼 처리의 혁명

Next.js 15의 Server Actions는 API 라우트 없이 서버 측 로직을 직접 실행합니다. 폼 제출, 데이터 변경 작업에서 코드량을 대폭 줄일 수 있습니다.

<code">// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
  
  // 유효성 검사
  if (!title || title.length < 3) {
    return { error: '제목은 3자 이상이어야 합니다' };
  }
  
  // DB 저장
  await db.insert(posts).values({ title, content });
  
  // 캐시 무효화 후 리다이렉트
  revalidatePath('/posts');
  redirect('/posts');
}

// app/posts/new/page.tsx
import { createPost } from '@/app/actions';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="제목" />
      <textarea name="content" placeholder="내용" />
      <button type="submit">발행하기</button>
    </form>
  );
}

캐싱 전략 마스터하기

Next.js 15의 4가지 캐시

  • Request Memoization: 동일 요청 시 메모이제이션 (단일 요청 사이클)
  • Data Cache: fetch 결과 영구 캐싱 (revalidate로 제어)
  • Full Route Cache: 정적 라우트의 HTML+RSC 페이로드 캐싱
  • Router Cache: 클라이언트 사이드 RSC 페이로드 캐싱 (30초~5분)
<code">// 캐시 전략 예시
// 1. 정적 (빌드 시 생성)
fetch(url) // 기본값: 영구 캐시

// 2. ISR (주기적 재생성)
fetch(url, { next: { revalidate: 60 } }) // 60초마다

// 3. 동적 (매 요청마다)
fetch(url, { cache: 'no-store' })

// 4. 태그 기반 무효화
fetch(url, { next: { tags: ['posts'] } })
revalidateTag('posts') // 서버 액션에서 특정 태그 무효화

Pages Router에서 App Router 마이그레이션 가이드

단계별 접근법

  1. 공존 시작: app/ 디렉토리 생성, pages/와 동시 운영 가능
  2. 레이아웃 마이그레이션: _app.tsx, _document.tsx → app/layout.tsx
  3. 페이지별 전환: 우선순위 낮은 페이지부터 순차적으로 이동
  4. 데이터 패칭 전환: getServerSideProps → 서버 컴포넌트 async/await
  5. API 라우트 정리: 일부는 Server Actions로, 나머지는 route.ts로

흔한 마이그레이션 실수

  • 서버 컴포넌트에서 useState/useEffect 사용 시도 → 'use client' 추가
  • 클라이언트 컴포넌트에서 async/await 사용 → 서버에서 props로 전달
  • context를 서버 컴포넌트에서 사용 → Providers를 클라이언트로 이동

결론

Next.js 15 App Router는 처음에는 학습 곡선이 있지만, 익숙해지면 기존 방식보다 훨씬 직관적이고 성능 최적화가 쉽습니다. 서버 컴포넌트의 핵심은 "서버에서 실행되는 것"과 "브라우저에서 실행되는 것"을 명확히 분리하는 것입니다.

2026년 현재 App Router는 성숙기에 접어들었으며, 생태계 지원도 완벽합니다. 아직 Pages Router를 사용 중이라면 지금이 전환할 최적의 타이밍입니다.

반응형