C
Next.js/렌더링/Lesson 04

Server Component — DB 직접 접근, 0KB 클라이언트 번들

40분·theory
이 챕터
1/5
TypeScript

Server Component — DB 직접 접근, 0KB 클라이언트 번들

💡 왜 배워야 할까요? — '서버에서 끝내고 HTML 만 보낸다' 가 기본값

🎯 Server Component 의 함수 자체는 **클라이언트 번들에 포함되지 않습니다** — 렌더링된 HTML 만 전송. 페이지 가중치가 극단적으로 줄어듭니다.
💼 DB·파일 시스템·환경변수에 직접 접근할 수 있습니다. 브라우저로 절대 새어나가지 않으니 API 키·시크릿을 안전하게 사용 가능.
fetch 가 같은 렌더링 안에서 자동 dedupe·캐시됩니다 — 같은 URL 호출이 여러 번 있어도 실제 네트워크 요청은 한 번.
🔗 Pages Router 의 `getServerSideProps`·`getStaticProps`·`SWR` 같은 데이터 패칭 추상화가 전부 사라집니다. 그냥 `await fetch()`.
📈 단점: `useState`·`onClick`·`useEffect` 같은 브라우저 hook 사용 불가. 그건 Client Component 영역.
🏢 실무에서는
codemaster40 의 `src/app/study/[category]/page.tsx` 도 Server Component 일 가능성이 높습니다. 학습 데이터 JSON 을 서버에서 읽어 HTML 로 그대로 렌더링하므로 초기 로딩이 빠르고, 클라이언트로 가는 JS 는 상호작용 부분(`useState` 가 있는 컴포넌트) 만입니다.

Server vs Client — 경계와 규칙

1. 기본값은 Server Component

파일 맨 위에 'use client' 가 없으면 자동으로 Server Component.

tsx
// app/users/page.tsx — 'use client' 없음 → Server
export default async function UsersPage() {
  const users = await db.user.findMany(); // DB 직접 접근 OK
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

2. Server Component 의 능력

기능ServerClient
async function 컴포넌트
await fetch() 직접△ (useEffect 안에서만)
DB·파일 시스템·secret 접근
useState·useEffect
onClick·onChange
브라우저 API (window·localStorage)
Context Provider

3. fetch 자동 dedupe + 캐시

같은 렌더링 안에서 같은 URL 을 여러 컴포넌트가 호출해도, 실제 fetch 는 한 번:

tsx
async function Header() {
  const user = await fetch('/api/me').then(r => r.json()); // 첫 호출
  return <div>{user.name}</div>;
}
async function Sidebar() {
  const user = await fetch('/api/me').then(r => r.json()); // 같은 fetch — 자동 dedupe
  return <div>{user.email}</div>;
}

캐시 정책:

tsx
fetch(url);                              // 기본 SSG (force-cache)
fetch(url, { cache: 'no-store' });       // 매번 새로
fetch(url, { next: { revalidate: 60 } });// 60초 ISR
fetch(url, { next: { tags: ['user'] } });// revalidateTag('user') 로 무효화

4. Server → Client 경계

tsx
// app/page.tsx (Server) — Client 컴포넌트를 import 해서 끼울 수 있음
import { Counter } from './Counter';

export default async function Home() {
  const users = await db.user.findMany();
  return (
    <div>
      <h1>유저: {users.length}명</h1>
      <Counter />  {/* Client Component */}
    </div>
  );
}
tsx
// app/Counter.tsx
'use client';
import { useState } from 'react';
export function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>{n}</button>;
}
  • Server 가 Client 를 import 해서 끼우는 건 OK.
  • Client 가 Server 를 import 해서 끼우는 건 ❌ (브라우저에서 DB 못 부르니까).
  • 단, Server Component 를 children 으로 받아서 Client Component 안에 끼우는 건 OK (composition).
💻 🅰️ Pages Router — getServerSideProps 보일러플레이트
// ❌ Pages Router — 데이터 조회와 렌더가 분리됨

// 📁 pages/users/[id].tsx
import type { GetServerSideProps, InferGetServerSidePropsType } from 'next';

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

// 1. 서버 데이터 조회 — 별도 함수
export const getServerSideProps: GetServerSideProps<{ user: User }> = async (ctx) => {
  const id = ctx.params?.id as string;
  const user = await db.user.findUnique({ where: { id: Number(id) } });
  if (!user) return { notFound: true };
  return { props: { user } };
};

// 2. 컴포넌트는 props 받는 그릇
export default function UserPage({
  user,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

// 클라이언트로 전송:
//  - 페이지 컴포넌트 JS (props 매핑 코드 포함)
//  - getServerSideProps 의 결과 직렬화 JSON
//  - React hydration 코드
// → 정적 콘텐츠인데도 클라이언트 번들에 페이지 함수가 들어감
💻 🅱️ App Router — async Server Component 한 덩어리
// ✅ App Router — Server Component 가 직접 fetch·DB

// 📁 app/users/[id]/page.tsx
import { notFound } from 'next/navigation';
import { Counter } from './Counter';

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

// 컴포넌트가 async — 안에서 await
export default async function UserPage({
  params,
}: {
  params: { id: string };
}) {
  const user: User | null = await db.user.findUnique({
    where: { id: Number(params.id) },
  });

  if (!user) notFound(); // 404 페이지로 전환

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>

      {/* Client Component 끼우기 — 상호작용 필요한 부분만 */}
      <Counter />
    </div>
  );
}

// 📁 app/users/[id]/Counter.tsx
'use client';
import { useState } from 'react';

export function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>좋아요: {n}</button>;
}

// 클라이언트로 전송:
//  - UserPage 의 HTML 만 (함수 본체는 X)
//  - Counter 컴포넌트 JS (이게 진짜 상호작용 필요)
//  - React runtime
// → 정적 부분은 HTML 로만, 상호작용은 별도 청크

💡 💡 Server Component 실전 5

1. async 컴포넌트는 Server 만 가능
Client 에 async 붙이면 빌드 에러. 데이터 패칭은 Server 에서, 결과를 props 로 Client 에 내려줘라.

2. 'use client' 는 전염되지 않는다
Server 가 Client 를 import 해도 부모는 여전히 Server. 경계가 명확.

3. Client → Server import 는 불가

Client Component 가 Server Component 를 import 해서 직접 끼우면 빌드 에러. 대신 children 으로 받아서 끼워라:

tsx
// ✅ composition 패턴
<ClientLayout><ServerContent /></ClientLayout>
// ClientLayout 의 props 로 children 만 받음

4. fetch 캐시 모드 4가지

tsx
fetch(url);                                    // force-cache (기본 SSG)
fetch(url, { cache: 'no-store' });             // SSR (매번 새로)
fetch(url, { next: { revalidate: 60 } });      // ISR (60초)
fetch(url, { next: { tags: ['user'] } });      // 태그 무효화

5. 환경변수 — Server 는 다 보임, Client 는 NEXT_PUBLIC_ 만

ts
process.env.DATABASE_URL          // Server: OK, Client: undefined
process.env.NEXT_PUBLIC_API_URL   // 양쪽 다 OK

Server Component 에 시크릿 박아도 브라우저로 안 새어나감.

⚡ 직접 실행해보기 — Server/Client 경계 시뮬레이션

각 컴포넌트가 어디서 실행될지, fetch 가 어디서 일어날지 시뮬레이션해봅니다.
✏️ JS 코드
📟 콘솔 출력
▶ 실행 버튼을 눌러보세요
⚠️ 브라우저 샌드박스에서 실행 — console.log()만 지원, alert/fetch 불가

확인 퀴즈

App Router 에서 Client Component 가 Server Component 를 직접 `import` 해서 JSX 로 끼우면?
💡 Client Component 의 코드는 브라우저에서 실행되므로 Server Component 의 본체(DB 호출 등) 를 직접 import 해서 끼울 수 없습니다. 대신 Server 부모가 Client Component 를 끼우면서 Server Component 를 `children` 으로 전달하는 **composition 패턴**을 씁니다. 이 규칙이 Server/Client 경계를 명확히 유지하는 핵심입니다.
Server Component — DB 직접 접근, 0KB 번들 - Next.js