je개발 복습/Next.js

Next.js 서버 컴포넌트 가이드

Je-chan 2025. 11. 12. 10:57

Next.js 서버 컴포넌트 가이드

Next.js에서 서버 컴포넌트는 성능 최적화와 개발 경험 개선의 핵심입니다. 이 글에서는 서버 컴포넌트의 개념부터 실전 활용법까지 상세히 다룹니다.

1. 서버 컴포넌트 개념

클라이언트 컴포넌트와의 차이점

클라이언트 컴포넌트에서는 모든 렌더링이 클라이언트에서 발생합니다. 반면 Next.js는 기본적으로 서버에서 렌더링을 수행하고 HTML 결과만 클라이언트로 전송합니다.

// app/page.tsx - 기본이 서버 컴포넌트
export default async function Page() {
  const response = await fetch('https://api.example.com/posts');
  const data = await response.json();
  return <div>{data}</div>;
}
핵심 포인트
서버 컴포넌트는 async/await를 사용할 수 있고 서버에서만 실행됩니다.

2. 정적 생성 vs 동적 생성

서버 컴포넌트가 렌더링되는 시점은 데이터 페칭 방식에 따라 결정됩니다.

빌드 타임 (정적 생성 - SSG)

빌드 시점에 한 번 렌더링되고 정해진 시간 동안 캐시된 HTML을 재사용합니다.

// app/blog/[id]/page.tsx
export default async function BlogPost({ params }: { params: { id: string } }) {
  const response = await fetch(`https://api.example.com/posts/${params.id}`, {
    next: { revalidate: 3600 } // 1시간마다 다시 생성
  });
  const post = await response.json();
  return <article>{post.title}</article>;
}
1
빌드 타임에 페이지 생성
2
.next 폴더에 정적 HTML 저장
3
서빙 속도가 매우 빠름
4
블로그나 문서 등 자주 변하지 않는 콘텐츠에 적합

요청 타임 (동적 생성 - SSR)

매 요청마다 서버에서 렌더링합니다.

// app/dashboard/page.tsx
export default async function Dashboard() {
  const response = await fetch('https://api.example.com/user/me', {
    next: { revalidate: 0 } // 캐시 없음 = 매 요청마다 새로 생성
  });
  const data = await response.json();
  return <div>{data}</div>;
}
  • 매 요청마다 렌더링: 항상 최신 데이터를 제공합니다
  • 서빙 속도: 상대적으로 느립니다
  • 적합한 경우: 대시보드나 개인화된 페이지

렌더링 타이밍 비교

빌드 타임 vs 요청 타임
빌드 타임 (SSG)
npm run build 실행
빌드 프로세스 시작
RSC 렌더링 + 데이터 페칭
서버에서 컴포넌트 실행
정적 HTML 생성
HTML 파일로 저장
.next 폴더에 저장
디스크에 파일 저장
⚡ 빠른 서빙
즉시 응답 가능
요청 타임 (SSR)
유저 요청 발생
HTTP 요청 수신
RSC 렌더링 + 데이터 페칭
실시간으로 처리
동적 HTML 생성
요청마다 생성
메모리에서 처리
파일 저장 없음
응답 전송
상대적으로 느림

3. Props를 통한 클라이언트 컴포넌트 데이터 전달

서버 컴포넌트에서 페칭한 데이터를 클라이언트 컴포넌트에 전달하는 가장 기본적인 방식입니다.

기본 패턴

// app/page.tsx - 서버 컴포넌트
async function Page() {
  const response = await fetch('https://api.example.com/posts');
  const posts = await response.json();

  // 클라이언트 컴포넌트에 Props로 전달
  return <PostList posts={posts} />;
}
// app/components/PostList.tsx - 클라이언트 컴포넌트
'use client';
import { useState } from 'react';

interface Post {
  id: string;
  title: string;
}

export default function PostList({ posts }: { posts: Post[] }) {
  const [filter, setFilter] = useState('');

  const filteredPosts = posts.filter(post =>
    post.title.includes(filter)
  );

  return (
    <div>
      <input
        onChange={(e) => setFilter(e.target.value)}
        placeholder="필터링"
      />
      {filteredPosts.map(post => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  );
}

데이터 흐름

서버에서 클라이언트로 데이터 전달
서버 컴포넌트
1. fetch 데이터
API에서 데이터를 가져옵니다
↓ Props로 전달
클라이언트 컴포넌트
2. 상태 초기화
받은 데이터로 상태를 설정합니다
useState
3. 렌더링
화면에 데이터를 표시합니다
💡 필터링이나 정렬 등을 관리합니다
중요한 제약사항
Props로 전달되는 데이터는 JSON 직렬화 가능해야 합니다. 함수나 Date 객체는 변환이 필요합니다. 또한 클라이언트 컴포넌트에서는 서버 컴포넌트를 직접 import하여 props로 전달할 수 없습니다.

4. Tanstack Query와의 연동

Tanstack Query는 클라이언트 전용

Tanstack Query는 클라이언트 상태 관리 라이브러리입니다. 따라서 서버 컴포넌트와 직접 연동할 수 없습니다.

// ❌ 불가능 - 서버 컴포넌트에서 Tanstack Query 사용 불가
async function Page() {
  const { data } = useQuery({...}); // Error!
}

올바른 연동 방식: initialData 패턴

서버에서 초기 데이터를 페칭하고 클라이언트에서 Tanstack Query의 initialData로 활용합니다.

// app/page.tsx - 서버 컴포넌트
async function Page() {
  const response = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 } // 캐시된 데이터 즉시 반환
  });
  const initialPosts = await response.json();

  return <PostList initialPosts={initialPosts} />;
}
// components/PostList.tsx - 클라이언트 컴포넌트
'use client';
import { useQuery } from '@tanstack/react-query';

interface Post {
  id: string;
  title: string;
}

export default function PostList({ initialPosts }: { initialPosts: Post[] }) {
  const { data: posts = initialPosts, isLoading } = useQuery({
    queryKey: ['posts'],
    queryFn: () => fetch('/api/posts').then(r => r.json()),
    initialData: initialPosts, // ✅ 초기값으로 사용
    staleTime: 60000, // 60초 동안 신선함
    gcTime: 5 * 60 * 1000, // 캐시 유지 시간
  });

  if (isLoading && !posts) return <div>로딩 중...</div>;

  return (
    <div>
      {posts.map(post => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  );
}

동작 흐름

서버 컴포넌트와 Tanstack Query 연동 과정
서버 컴포넌트
1️⃣ API Route에 요청
fetch('/api/posts') 실행
API Route
2️⃣ 데이터 획득
외부 API 또는 DB에서 데이터 가져오기
↓ initialPosts 반환
서버 컴포넌트
3️⃣ HTML + Props 전송
클라이언트로 렌더링 결과와 데이터 전달
클라이언트
4️⃣ initialData 사용 즉시 렌더링 ⚡
로딩 없이 바로 화면에 표시
💡 클라이언트 마운트 후 useQuery 백그라운드 실행
useQuery
5️⃣ staleTime 지나면 재페칭
백그라운드에서 최신 데이터 확인
API Route
6️⃣ 새 데이터 반환
업데이트된 데이터 제공
UI 렌더링
7️⃣ 데이터 업데이트
화면에 최신 데이터 반영

이 방식의 장점

✅ 장점
  • 초기 렌더링 속도: 서버에서 데이터를 준비하므로 로딩 시간이 없습니다
  • 자동 상태 관리: Tanstack Query가 캐싱과 재시도를 자동으로 처리합니다
  • 최신 데이터: 백그라운드에서 자동으로 새 데이터를 페칭합니다
  • 오류 처리: Tanstack Query의 재시도 메커니즘을 활용합니다
⚠️ 주의사항
  • 서버와 클라이언트의 캐시 정책을 일치시켜야 합니다
  • initialData는 JSON 직렬화 가능해야 합니다
  • staleTime 설정이 중요합니다

5. 캐싱 정책

Next.js의 캐싱은 여러 레이어에서 동작합니다.

5.1 외부 API 캐싱 (Request Deduplication)

동일한 URL에 대한 여러 요청을 자동으로 합칩니다.

// 다음 코드는 하나의 요청으로 통합됨
const posts1 = await fetch('/api/posts'); // 1번 요청
const posts2 = await fetch('/api/posts'); // 중복 제거 (같은 요청)

5.2 Data Cache (시간 기반 캐싱)

next 옵션의 revalidate 값으로 캐시 시간을 제어합니다.

// 1시간마다 갱신
const data = await fetch('https://api.example.com/posts', {
  next: { revalidate: 3600 }
});

// 매 요청마다 새로 페칭 (캐시 없음)
const data = await fetch('https://api.example.com/user', {
  next: { revalidate: 0 }
});
revalidate 값 동작
정수 (예: 3600) 지정된 초 동안 캐시 후 재생성
0 또는 false 캐시 없음 - 매 요청마다 새로 페칭
생략 무한 캐시 (기본값)

5.3 실제 캐싱 시나리오

시나리오: 서버 컴포넌트 + API Route + 클라이언트 Tanstack Query

// app/api/posts/route.ts
export async function GET() {
  const response = await fetch('https://external-api.com/posts', {
    next: { revalidate: 60 } // 외부 API 캐시: 60초
  });
  const data = await response.json();
  return Response.json(data);
}
// app/page.tsx - 서버 컴포넌트 (빌드/요청 타임)
async function Page() {
  const response = await fetch('http://localhost:3000/api/posts');
  const posts = await response.json();
  // API Route 호출 → 외부 API 캐시 확인 → 60초 내면 캐시된 값 반환
  return <PostList initialPosts={posts} />;
}
// components/PostList.tsx - 클라이언트
'use client';
export default function PostList({ initialPosts }: { initialPosts: unknown[] }) {
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: () => fetch('/api/posts').then(r => r.json()),
    initialData: initialPosts,
    staleTime: 0, // 즉시 stale 취급
  });
  
  return <div>{/* 렌더링 로직 */}</div>;
}

캐싱 타이밍 다이어그램

캐싱 생명주기 (revalidate: 60초)
시간 0초
✅ 서버 컴포넌트 실행
✅ fetch('/api/posts')
✅ API Route 호출
✅ 외부 API 요청
✅ 데이터 캐시 시작
시간 0.5초
✅ 클라이언트 마운트
✅ useQuery 실행
✅ fetch('/api/posts')
✅ 캐시된 값 반환
시간 35초
✅ 사용자 페이지 이동
✅ useQuery 재실행
✅ fetch('/api/posts')
✅ 캐시 남은 시간: 25초
시간 65초+
❌ 외부 API 캐시 만료
다음 요청 시 새로운 데이터 페칭

캐시 레이어 구조

3단계 캐시 레이어
1️⃣ 클라이언트 메모리
Tanstack Query 캐시
브라우저 메모리에 저장되는 클라이언트 캐시입니다
🔹 staleTime과 gcTime으로 제어
🔹 페이지 새로고침 시 초기화
🔹 가장 빠른 응답 속도
⬇️ 캐시 미스 시
2️⃣ Next.js 서버 메모리
Data Cache
서버 메모리에 저장되는 fetch 결과 캐시입니다
🔹 revalidate 정책으로 제어
🔹 서버 재시작 시 초기화
🔹 빠른 응답 속도
⬇️ 캐시 미스 시
3️⃣ 외부 API
실제 데이터 소스
실제 데이터베이스나 외부 API입니다
🔹 네트워크 요청 발생
🔹 가장 느린 응답 속도
🔹 항상 최신 데이터
💡 중요 포인트
각 레이어의 캐시는 독립적으로 작동합니다. 클라이언트 캐시가 만료되어도 서버 캐시가 유효하면 빠르게 응답받을 수 있습니다.

5.4 Preload vs Prefetch 비교

Next.js에서 성능 최적화를 위해 두 가지 메커니즘을 제공합니다. Preload (서버 컴포넌트)와 Prefetch (클라이언트 컴포넌트)입니다.

Preload (서버 컴포넌트)

서버 컴포넌트로 렌더링된 페이지의 Link 컴포넌트는 viewport에 보이는 순간 자동으로 해당 페이지를 미리 로드합니다.

// app/page.tsx - 서버 컴포넌트
export default async function HomePage() {
  const response = await fetch('https://api.example.com/posts');
  const posts = await response.json();

  return (
    <div>
      <h1>블로그</h1>
      {posts.map((post: { id: string; title: string }) => (
        <Link key={post.id} href={`/blog/${post.id}`}>
          {/* ✅ 이 Link가 viewport에 보이면 자동 Preload */}
          {post.title}
        </Link>
      ))}
    </div>
  );
}
Preload 동작 방식
1
발생 시점
Link 태그가 viewport에 진입하는 순간
2
다운로드 내용
🗂️ RSC Payload (서버 렌더링 결과 + 데이터)
📄 HTML (마크업)
📦 JS 번들 (클라이언트 컴포넌트가 있으면)
3
자동성
개발자 개입 없이 자동으로 진행됩니다
4
특징
모든 것을 미리 준비하므로 Link 클릭 시 즉시 표시됩니다
Preload 동작 흐름
사용자
스크롤하여 Link 요소를 화면에 노출시킵니다
브라우저
Link가 viewport에 진입 감지!
Intersection Observer가 자동으로 페이지 요청을 시작합니다
서버
🔄 서버 컴포넌트 렌더링
• 데이터 fetch 실행
• RSC Payload 생성
• HTML 생성
캐시
✅ preload 완료
HTML + 데이터가 브라우저 캐시에 저장됩니다
사용자
⚡ Link 클릭 시 즉시 표시
이미 모든 데이터가 캐시에 준비되어 있습니다

Prefetch (클라이언트 컴포넌트)

클라이언트에서는 Link 컴포넌트의 prefetch 속성으로 명시적으로 미리 로드를 제어합니다.

'use client';
import Link from 'next/link';

interface Post {
  id: string;
  title: string;
}

export default function BlogList({ posts }: { posts: Post[] }) {
  return (
    <div>
      {posts.map(post => (
        <Link
          key={post.id}
          href={`/blog/${post.id}`}
          prefetch={true} // ✅ 명시적으로 prefetch 활성화 (기본값)
        >
          {post.title}
        </Link>
      ))}
    </div>
  );
}

// prefetch={false} 로 설정하면 미리 로드하지 않습니다
// <Link href={`/blog/${post.id}`} prefetch={false}>
Prefetch 동작 방식
1
발생 시점
Link 태그가 viewport에 진입하는 순간
2
다운로드 내용
📦 JS 번들만 (클라이언트 실행 코드)
❌ RSC Payload 없음
❌ 데이터 없음
❌ HTML 없음
3
자동성
prefetch 속성으로 제어 가능합니다
4
특징
Link 클릭 후 페이지 이동 시 데이터를 fetch합니다
Prefetch 동작 흐름
사용자
스크롤하여 Link 요소를 화면에 노출시킵니다
브라우저
Link가 viewport에 진입 감지!
prefetch={"{true}"}일 때 JS 번들 다운로드를 시작합니다
네트워크
JS 번들 다운로드 요청
클라이언트 컴포넌트 코드만 다운로드합니다
브라우저
📦 번들만 캐시
데이터는 포함되지 않습니다
사용자
Link 클릭 → 페이지 이동
클라이언트에서 렌더링을 시작합니다
브라우저
🔄 클라이언트 렌더링
API에서 데이터를 fetch하기 시작합니다
API
데이터 응답
실제 데이터를 전송합니다
브라우저
⚡ 상대적으로 빠름
JS는 이미 로드되어 있어 Preload보다는 빠릅니다

실제 비교: 타이밍

Preload vs Prefetch 타이밍 비교
Preload (서버)
Link viewport 진입
자동 페이지 요청 시작
서버 렌더링 + 데이터 fetch
HTML + 데이터 캐시
유저 클릭: 즉시 표시 ⚡⚡
Prefetch (클라이언트)
Link viewport 진입
자동 JS 번들 다운로드
번들 캐시만 완료
유저 클릭: 페이지 이동
클라이언트 데이터 fetch 시작
상대적으로 느림 ⏱️

5.5 3가지 핵심 개념: JS 번들 vs RSC Payload vs HTML

Preload와 Prefetch를 정확히 이해하려면 다운로드되는 것들의 차이를 알아야 합니다.

개념 정의

1️⃣ JavaScript 번들 (JS Bundle)

// 번들에 포함되는 것들
- React 컴포넌트 로직
- useState, useEffect 등 클라이언트 훅
- 이벤트 핸들러
- 상태 관리 라이브러리
- 라우팅 로직

// 예: LikeButton.tsx - 클라이언트 컴포넌트
'use client';
import { useState } from 'react';

export function LikeButton() {
  const [liked, setLiked] = useState(false);  // ← 번들에 포함

  return (
    <button onClick={() => setLiked(!liked)}>  {/* ← 번들에 포함 */}
      {liked ? '❤️' : '🤍'}
    </button>
  );
}

// 번들 크기: 보통 30KB ~ 100KB
// 저장 위치: 브라우저 캐시 (CDN에서 다운로드)

2️⃣ RSC Payload (React Server Component Payload)

// RSC Payload에 포함되는 것들
- 서버 렌더링 결과
- 데이터
- 클라이언트 컴포넌트 참조
- Props

// 예: BlogDetail.tsx - 서버 컴포넌트
async function BlogDetail({ params }: { params: { id: string } }) {
  const response = await fetch(`/api/posts/${params.id}`);
  const post = await response.json();  // ← 데이터

  return (
    <article>
      <h1>{post.title}</h1>  {/* ← RSC Payload에 포함 */}
      <LikeButton />  {/* ← 클라이언트 컴포넌트 참조만 포함 */}
    </article>
  );
}

// RSC Payload 내용:
{
  "type": "Component",
  "name": "BlogDetail",
  "props": {
    "title": "Next.js 완벽 가이드",  // ← 데이터
    "content": "..."
  },
  "children": [
    {
      "type": "ClientComponent",
      "jsModuleId": "123"  // ← JS 번들 참조
    }
  ]
}

// 페이로드 크기: 보통 5KB ~ 50KB
// 저장 위치: 클라이언트 메모리 (라우터 캐시)

3️⃣ HTML

<!-- 서버에서 미리 렌더링한 마크업 -->
<article>
  <h1>Next.js 완벽 가이드</h1>
  <p>이 글에서는...</p>
  <button>🤍</button>  <!-- 클라이언트 컴포넌트 자리 -->
</article>

<!-- HTML은 RSC Payload에 포함됨 -->
<!-- 크기: 1KB ~ 20KB -->
<!-- 저장 위치: 클라이언트 메모리 -->

4가지 시나리오 비교

시나리오 1: 서버 컴포넌트만 있는 페이지

// app/blog/[id]/page.tsx - 모두 서버 컴포넌트
async function BlogDetail({ params }: { params: { id: string } }) {
  const response = await fetch(`/api/posts/${params.id}`);
  const post = await response.json();
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}
📦 JS 번들?
❌ 불필요
클라이언트 코드 없음
🗂️ RSC Payload?
✅ 받음
렌더링 결과 + 데이터
📄 HTML?
✅ 포함
RSC Payload에

시나리오 2: 클라이언트 컴포넌트만 있는 페이지

// app/blog/[id]/page.tsx - 모두 클라이언트 컴포넌트
'use client';
import { useQuery } from '@tanstack/react-query';

interface Post {
  id: string;
  title: string;
  content: string;
}

export default function BlogDetail({ params }: { params: { id: string } }) {
  const { data: post } = useQuery<Post>({
    queryKey: ['post', params.id],
    queryFn: () => fetch(`/api/posts/${params.id}`).then(r => r.json()),
  });

  return (
    <article>
      <h1>{post?.title}</h1>
      <p>{post?.content}</p>
    </article>
  );
}
📦 JS 번들?
✅ 받음
useQuery 및 컴포넌트 로직
🗂️ RSC Payload?
❌ 안 받음
클라이언트에서 처리
📄 HTML?
❌ 안 받음
클라이언트에서 만듦

시나리오 3: 혼합 페이지 (서버 + 클라이언트)

// app/blog/[id]/page.tsx - 서버 컴포넌트
async function BlogDetail({ params }: { params: { id: string } }) {
  const response = await fetch(`/api/posts/${params.id}`);
  const post = await response.json();

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <LikeButton /> {/* ← 클라이언트 컴포넌트 */}
    </article>
  );
}

// components/LikeButton.tsx - 클라이언트 컴포넌트
'use client';
export function LikeButton() {
  const [liked, setLiked] = useState(false);
  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? '❤️' : '🤍'}
    </button>
  );
}
📦 JS 번들?
✅ 받음
LikeButton 코드
🗂️ RSC Payload?
✅ 받음
post 데이터
📄 HTML?
✅ 포함
RSC Payload에

Network 탭에서 실제로 보는 것

// 서버 컴포넌트 Preload/Prefetch
GET /blog/123?_rsc=abc123
Status: 200
Size: 15KB
Content-Type: application/octet-stream
// ↑ RSC Payload (이진 형식)

// 클라이언트 컴포넌트 Prefetch
GET /_next/static/chunks/blog-[id]-abc123.js
Status: 200
Size: 45KB
Content-Type: application/javascript
// ↑ JS 번들 (JavaScript 코드)

종합 비교 표

항목 JS 번들 RSC Payload HTML
뭔가? 실행 코드 렌더링 결과 + 데이터 마크업
크기 30-100KB 5-50KB 1-20KB
포함 대상 클라이언트 컴포넌트 서버 컴포넌트 결과 서버 렌더링 결과
Prefetch 시 다운로드 ✅ 클라이언트만 ✅ 서버만 ✅ 서버만
언제 사용 페이지 이동 후 렌더링 페이지 이동 후 렌더링 즉시 표시
저장 위치 브라우저 캐시 (CDN) 클라이언트 메모리 클라이언트 메모리

5.5.5 혼합 구성 패턴: 최적의 선택

실제로는 순수 서버 컴포넌트순수 클라이언트 컴포넌트보다 혼합 구성이 가장 좋은 경우가 많습니다.

패턴: 서버에서 initialData 페칭 → 클라이언트에서 관리

// app/blog/[id]/page.tsx - 서버 컴포넌트
// 역할: 초기 데이터 페칭 + 레이아웃
async function BlogPage({ params }: { params: { id: string } }) {
  // ✅ 서버에서 초기 데이터 fetch
  const response = await fetch(`/api/posts/${params.id}`, {
    next: { revalidate: 60 }
  });
  const initialPost = await response.json();

  return <BlogDetail initialPost={initialPost} />;
}

// components/BlogDetail.tsx - 클라이언트 컴포넌트
// 역할: 상호작용 + 데이터 동기화
'use client';
import { useQuery } from '@tanstack/react-query';

interface Post {
  id: string;
  title: string;
  content: string;
}

export default function BlogDetail({ initialPost }: { initialPost: Post }) {
  const { data: post = initialPost, refetch } = useQuery({
    queryKey: ['post', initialPost.id],
    queryFn: () => fetch(`/api/posts/${initialPost.id}`).then(r => r.json()),
    initialData: initialPost,
    staleTime: 60000,
  });

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <button onClick={() => refetch()}>새로고침</button>
      <LikeButton postId={post.id} />
    </article>
  );
}

이 방식의 장점

✅ 초기 로딩 빠름
서버에서 데이터를 미리 준비합니다
✅ 상호작용 가능
클라이언트에서 처리합니다
✅ 백그라운드 동기화
Tanstack Query가 자동으로 처리합니다
✅ 최적의 번들
필요한 것만 포함합니다

3가지 구성 비교

구성 초기 로딩 상호작용 번들 크기 데이터 동기화 추천도
순수 서버 ⚡⚡ 매우 빠름 ❌ 불가 작음 ❌ 불가 ⭐⭐
순수 클라이언트 ⏱️ 느림 ✅ 우수 ✅ 우수 ⭐⭐
혼합 (권장) ⚡ 빠름 ✅ 우수 중간 ✅ 우수 ⭐⭐⭐⭐⭐

실제 시나리오별 최적 선택

페이지 특성에 따른 선택 가이드
상호작용이 필요하지 않은 경우
🟩 순수 서버 (Preload만)
• 정적 페이지
• 가격이나 기본 정보 표시
• 약관이나 문서
상호작용이 필요하고 데이터가 자주 변하지 않는 경우
🟨 혼합 구성 (초기값 + 동기화)
• 블로그나 제품 상세 페이지
• 좋아요나 댓글 기능
• 필터링이나 정렬 기능
상호작용이 필요하고 데이터가 자주 변하는 경우
🟦 클라이언트 (Prefetch + Query)
• 대시보드나 실시간 데이터
• 채팅이나 스트리밍
• 실시간 알림

혼합 구성이 더 나은 이유

1️⃣ 사용자 경험

순수 서버

사용자 클릭 → RSC Payload 완료될 때까지 대기 → 표시
(네트워크가 느리면 오래 걸립니다)

혼합

사용자 클릭 → initialData로 즉시 표시 ⚡
백그라운드 동기화 시작
(항상 빠릅니다)

2️⃣ 번들 크기 최적화

// 순수 클라이언트 (큰 번들)
'use client';
import axios from 'axios';        // 20KB
import lodash from 'lodash';      // 70KB
import dateFns from 'date-fns';  // 50KB
// ... 많은 라이브러리

// 혼합 (작은 번들)
'use client';
import { useState } from 'react';  // React 기본만
// 나머지는 서버에서 처리

3️⃣ SEO 최적화

// 순수 클라이언트 (SEO 안 좋음)
'use client';
export default function BlogDetail() {
  const { data: post } = useQuery({...});
  // 초기에는 HTML이 없음 → 검색 엔진이 못 봄
  return <h1>{post?.title}</h1>;
}

// 혼합 (SEO 좋음)
async function BlogPage({ params }: { params: { id: string } }) {
  const response = await fetch(`/api/posts/${params.id}`);
  const post = await response.json();
  // ✅ 서버 렌더링 HTML이 있음 → 검색 엔진이 볼 수 있음
  return <BlogDetail initialPost={post} />;
}

5.6 실전: 언제 뭘 쓸까?

가장 추천하는 패턴: 혼합 구성

대부분의 경우 서버에서 초기값 fetch → 클라이언트에서 상호작용이 최적입니다.

패턴 1: 블로그 + 댓글 (읽기 + 쓰기)

// app/blog/[id]/page.tsx - 서버 컴포넌트
async function BlogPage({ params }: { params: { id: string } }) {
  // 1️⃣ 초기 포스트 데이터 (변하지 않음)
  const response = await fetch(`/api/posts/${params.id}`, {
    next: { revalidate: 3600 }
  });
  const post = await response.json();

  return (
    <div>
      <BlogContent post={post} /> {/* 클라이언트 */}
      <Comments postId={params.id} /> {/* 클라이언트 */}
    </div>
  );
}

// components/BlogContent.tsx - 클라이언트
'use client';
interface Post {
  title: string;
  content: string;
}

export function BlogContent({ post }: { post: Post }) {
  const [liked, setLiked] = useState(false);

  return (
    <article>
      <h1>{post.title}</h1>
      <button onClick={() => setLiked(!liked)}>
        {liked ? '❤️' : '🤍'}
      </button>
    </article>
  );
}

// components/Comments.tsx - 클라이언트
'use client';
import { useQuery } from '@tanstack/react-query';

interface Comment {
  id: string;
  content: string;
}

export function Comments({ postId }: { postId: string }) {
  // 2️⃣ 댓글은 실시간으로 fetch (변함)
  const { data: comments = [] } = useQuery<Comment[]>({
    queryKey: ['comments', postId],
    queryFn: () => fetch(`/api/comments?postId=${postId}`).then(r => r.json()),
  });

  return (
    <div>
      {comments.map(c => <Comment key={c.id} comment={c} />)}
    </div>
  );
}
💡 장점
• 포스트는 서버에서 빠르게 로드됩니다
• 댓글은 백그라운드에서 동기화됩니다
• 좋아요는 즉시 반응합니다
• SEO가 최적화됩니다 (서버 렌더링 HTML)

패턴 2: 전자상거래 (상품 정보 + 사용자 상호작용)

// app/products/[id]/page.tsx - 서버 컴포넌트
async function ProductPage({ params }: { params: { id: string } }) {
  // 1️⃣ 상품 정보 (자주 안 바뀜)
  const response = await fetch(`/api/products/${params.id}`, {
    next: { revalidate: 86400 }
  });
  const product = await response.json();

  return (
    <div>
      <ProductDetail initialProduct={product} />
      <Reviews productId={params.id} />
    </div>
  );
}

// components/ProductDetail.tsx - 클라이언트
'use client';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';

interface Product {
  id: string;
  name: string;
  price: number;
}

export function ProductDetail({ initialProduct }: { initialProduct: Product }) {
  const [quantity, setQuantity] = useState(1);

  // 2️⃣ 가격은 실시간으로 확인 (주식처럼 변할 수 있음)
  const { data: product = initialProduct } = useQuery<Product>({
    queryKey: ['product', initialProduct.id],
    queryFn: () => fetch(`/api/products/${initialProduct.id}`).then(r => r.json()),
    initialData: initialProduct,
    staleTime: 30000, // 30초
  });

  return (
    <div>
      <h1>{product.name}</h1>
      <p>₩{product.price}</p>
      <input 
        type="number" 
        value={quantity} 
        onChange={e => setQuantity(Number(e.target.value))} 
      />
      <button onClick={() => addToCart(product.id, quantity)}>
        장바구니 추가
      </button>
    </div>
  );
}
💡 장점
• 상품 기본 정보는 즉시 표시됩니다
• 가격 변동은 백그라운드에서 감시합니다
• 사용자 상호작용이 즉시 반응합니다
• 장바구니 추가 같은 서버 액션이 가능합니다

패턴 3: 대시보드 (초기값 있는 실시간 데이터)

// app/dashboard/page.tsx - 서버 컴포넌트
async function DashboardPage() {
  // 1️⃣ 초기 통계 (사용자가 보기 좋게)
  const response = await fetch('https://api.example.com/stats', {
    next: { revalidate: 0 } // 항상 새로운 데이터
  });
  const initialStats = await response.json();

  return <DashboardContent initialStats={initialStats} />;
}

// components/DashboardContent.tsx - 클라이언트
'use client';
import { useQuery } from '@tanstack/react-query';

interface Stats {
  users: number;
  revenue: number;
}

export function DashboardContent({ initialStats }: { initialStats: Stats }) {
  // 2️⃣ 실시간 통계 (자동 갱신)
  const { data: stats = initialStats } = useQuery<Stats>({
    queryKey: ['stats'],
    queryFn: () => fetch('/api/stats').then(r => r.json()),
    initialData: initialStats,
    staleTime: 10000, // 10초마다 갱신
    refetchInterval: 30000, // 30초마다 자동 갱신
  });

  return (
    <div>
      <Chart data={stats} /> {/* 초기값으로 즉시 표시 */}
      <Summary data={stats} />
    </div>
  );
}
💡 장점
• 초기 로딩이 빠릅니다 (초기값이 있음)
• 자동으로 갱신됩니다 (실시간 데이터)
• 사용자가 항상 최신 데이터를 봅니다

순수 서버 컴포넌트를 쓰는 경우

  • 상호작용이 없습니다
  • 데이터가 변하지 않습니다
  • 정적 페이지입니다 (블로그 글이나 약관 등)
// 예: 약관 페이지
async function TermsPage() {
  const response = await fetch('https://api.example.com/terms');
  const terms = await response.json();
  return <div>{terms.content}</div>;
}

순수 클라이언트 컴포넌트를 쓰는 경우

  • 데이터가 매우 자주 변합니다
  • 초기값이 필요하지 않습니다
  • SEO가 불필요합니다
// 예: 실시간 채팅
'use client';
interface Message {
  id: string;
  text: string;
}

export function ChatRoom({ roomId }: { roomId: string }) {
  const { data: messages } = useQuery<Message[]>({
    queryKey: ['chat', roomId],
    queryFn: () => fetch(`/api/chat/${roomId}`).then(r => r.json()),
    refetchInterval: 1000, // 1초마다
  });
  
  return <div>{messages?.map(m => <Message key={m.id} message={m} />)}</div>;
}

혼합 구성을 쓰는 경우 (대부분)

  • 일부 데이터는 정적입니다 (상품명이나 설명)
  • 일부 데이터는 동적입니다 (가격이나 재고)
  • 좋은 초기 로딩 시간이 필요합니다
  • SEO가 필요합니다

핵심 정리

항목 Preload Prefetch
사용 대상 서버 컴포넌트 클라이언트 컴포넌트
로드 내용 페이지 + 데이터 JS 번들만
제어 방식 자동 수동 (prefetch 속성)
사용자 경험 매우 빠름 ⚡⚡ 빠름 ⚡
적합한 경우 정적이나 준정적 페이지 동적이나 실시간 콘텐츠
캐싱 효율 매우 높음 중간

6. 서버 컴포넌트의 장점

6.1 초기 렌더링 속도 향상 (FCP 및 LCP)

렌더링 속도 비교
클라이언트만 사용 ⏱️
1. JS 다운로드
2. 파싱
3. fetch 시작
4. 렌더링
⏱️ 느림
서버 컴포넌트 ⚡
1. 사용자 요청
2. 서버에서 HTML + 데이터 준비
3. 즉시 반환
⚡ 빠름
💡 실제 효과
• Time to First Contentful Paint (FCP)가 단축됩니다
• Largest Contentful Paint (LCP)가 개선됩니다
• 사용자가 빠르게 콘텐츠를 확인할 수 있습니다

6.2 번들 크기 감소

서버 컴포넌트에서 사용하는 라이브러리는 클라이언트 번들에 포함되지 않습니다.

// 서버 컴포넌트 (번들에 미포함)
import axios from 'axios'; // ← 서버에서만 실행
import { expensiveFunction } from 'heavy-library';

async function ServerComponent() {
  const data = await expensiveFunction();
  return <div>{data}</div>;
}
// 클라이언트 컴포넌트 (번들에 포함)
'use client';
import axios from 'axios'; // ← 번들 크기 증가
import { smallFunction } from 'react-utils'; // ← 불필요하면 번들에 포함

function ClientComponent() {
  return <div>...</div>;
}
결과
• 클라이언트가 다운로드하는 JavaScript 크기가 감소합니다
• 페이지 로딩 속도가 향상됩니다
• 모바일 사용자 경험이 개선됩니다

6.3 민감한 정보 보호

API 키나 데이터베이스 연결 정보 등 민감한 정보를 안전하게 처리합니다.

// 서버 컴포넌트 (안전 ✅)
async function Dashboard() {
  const response = await fetch('https://api.example.com/admin', {
    headers: {
      'Authorization': `Bearer ${process.env.SECRET_KEY}`
      // ✅ 서버에서만 실행되고 클라이언트에 노출되지 않음
    }
  });
  const data = await response.json();
  return <div>{data}</div>;
}
// 클라이언트 컴포넌트 (위험 ❌)
'use client';
useEffect(() => {
  fetch('https://api.example.com/admin', {
    headers: {
      'Authorization': `Bearer ${process.env.NEXT_PUBLIC_KEY}`
      // ❌ 클라이언트에서 실행되므로 사용자 브라우저에 노출됨
    }
  });
});

6.4 데이터베이스에 직접 접근

서버 컴포넌트에서는 REST API를 거치지 않고 데이터베이스에 직접 접근할 수 있습니다.

// 서버 컴포넌트
import { db } from '@/lib/db';

interface User {
  id: string;
  name: string;
}

async function UserList() {
  // API를 거치지 않고 직접 DB 쿼리
  const users = await db.query<User>('SELECT * FROM users');
  return <div>{users.map(u => <p key={u.id}>{u.name}</p>)}</div>;
}
💡 이점
• N+1 쿼리 문제를 해결합니다
• 네트워크 왕복 시간을 절감합니다
• 데이터 접근 로직을 단순화합니다

6.5 비교표

항목 서버 컴포넌트 클라이언트 컴포넌트
초기 로딩 ⚡ 빠름 ⏱️ 느림
번들 크기 📦 작음 📦 큼
보안 🔒 우수 ⚠️ 취약
상호작용 ❌ 불가 ✅ 가능
데이터 페칭 📡 직접 가능 📡 API를 거쳐야 함
상태 관리 ❌ 불가 ✅ 가능

모범 사례

전체 아키텍처 패턴

// app/page.tsx - 서버 컴포넌트 (데이터 페칭)
async function Page() {
  const response = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 }
  });
  const initialData = await response.json();

  return <PostContainer initialData={initialData} />;
}

// components/PostContainer.tsx - 서버 컴포넌트 (레이아웃)
function PostContainer({ initialData }: { initialData: unknown[] }) {
  return (
    <div className="container">
      <PostFilters /> {/* 클라이언트 컴포넌트 */}
      <PostList initialData={initialData} /> {/* 클라이언트 컴포넌트 */}
    </div>
  );
}

// components/PostList.tsx - 클라이언트 컴포넌트 (인터랙션)
'use client';
import { useQuery } from '@tanstack/react-query';

function PostList({ initialData }: { initialData: unknown[] }) {
  const { data: posts = initialData } = useQuery({
    queryKey: ['posts'],
    queryFn: () => fetch('/api/posts').then(r => r.json()),
    initialData,
    staleTime: 60000,
  });

  return (
    <div>
      {/* 렌더링 로직 */}
    </div>
  );
}

// components/PostCard.tsx - 클라이언트 컴포넌트 (상호작용)
'use client';
interface Post {
  id: string;
  title: string;
}

function PostCard({ post }: { post: Post }) {
  const [liked, setLiked] = useState(false);

  return (
    <div>
      <h3>{post.title}</h3>
      <button onClick={() => setLiked(!liked)}>
        {liked ? '❤️' : '🤍'}
      </button>
    </div>
  );
}

역할 분담

🖥️ 서버 컴포넌트
• 데이터 페칭
• 보안 정보 처리
• SEO 최적화
💻 클라이언트 컴포넌트
• 상호작용
• 상태 관리
• 이벤트 처리
🔌 API Route
• 캐싱 제어
• 공통 로직
• 재사용 가능한 엔드포인트

결론

Next.js 16에서 서버 컴포넌트는 성능과 보안 그리고 사용자 경험을 모두 개선하는 핵심 기능입니다. 올바른 캐싱 전략과 함께 Tanstack Query를 활용하면 최고의 개발 경험을 얻을 수 있습니다.

핵심 원칙
• 가능한 한 서버 컴포넌트를 사용합니다
• 상호작용이 필요한 부분만 클라이언트로 분리합니다
• 캐싱 정책을 명확히 정의합니다
• Props로 초기 데이터를 전달한 후 Tanstack Query로 동기화합니다

'je개발 복습 > Next.js' 카테고리의 다른 글

[ Next.js 공식문서 ] Cache Componet, PPR  (0) 2025.11.12