Next.js에서 Server Component와 Client Component의 경계는 어떻게 정하나요?
App Router의 page와 layout은 기본적으로 서버에서 처리하는 Server Component입니다. 클릭·입력·state가 필요한 부분만 use client 경계로 나누고, 서버는 브라우저에 전달할 수 있는 초기 값을 보냅니다.
적용 조건·범위: React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.
독립 실습: 서버 문구와 클릭 UI 경계 나누기
React 컴포넌트·TypeScript·async/await를 알고 시작합니다. Next.js 환경 준비의 루트 app/ TypeScript App Router 프로젝트를 별도 연습 폴더에서 사용합니다. 이 글의 기존 실습 기준은 Next.js 16.3.5이며 Cache Components는 끈 구성입니다. 생성된 app/layout.tsx는 유지합니다. 아래 파일은 TaskFlow·인증·DB 없이 실행하는 독립 예제입니다.
npm run dev 후 /practice를 엽니다. page에는 use client를 넣지 않습니다.
app/practice/counter.tsx 전체 코드
"use client";
import { useState } from 'react';
export default function Counter({ initial }: { initial: number }) {
const [count, setCount] = useState(initial);
return <button onClick={() => setCount(value => value + 1)}>횟수 {count}</button>;
}
app/practice/page.tsx 전체 코드
import Counter from './counter';
export default function Page() {
return (
<main>
<h1>서버가 만든 제목</h1>
<Counter initial={2} />
</main>
);
}
기대 결과: 제목 아래 횟수 2 버튼이 보이고 클릭하면 3입니다. 서버에서 전달한 숫자는 직렬화 가능한 초기 props입니다.
자가 확인: onClick을 서버 page에서 Counter로 직접 전달하려는 경우와 숫자 전달의 차이를 설명합니다.
Server Component는 기본값입니다
다음 page.tsx에는 ‘use client’가 없습니다.
Server Component는 서버에서 조회한 결과를 JSX로 만들며 브라우저 이벤트 handler를 직접 정의하지 않습니다. 아래 독립 page는 Counter에 숫자 초기값만 전달하고 이벤트는 Client 파일이 맡습니다. 하단 프로젝트 적용은 이 경계를 실제 작업 목록과 입력 컴포넌트에 연결합니다.
서버에서 데이터를 읽고 HTML을 만드는 데 적합합니다.
Client Component가 필요한 경우
다음 기능은 브라우저 상호작용이 필요합니다.
- useState
- useEffect
- onClick
- onChange
- window
- localStorage
파일 최상단에 지시문을 추가합니다.
'use client';
import { useState } from 'react';
export default function TaskSearch() {
const [keyword, setKeyword] = useState('');
return (
<input
value={keyword}
onChange={(event) =>
setKeyword(event.target.value)
}
/>
);
}
상위 전체를 Client Component로 만들 필요는 없습니다
잘못된 방향:
'use client';
export default function TasksPage() {
// 전체 페이지가 Client boundary 안으로 들어감
}
상호작용 부분만 분리합니다.
TasksPage Server
├─ TaskSummary Server
├─ TaskList Server
└─ TaskSearch Client
Server에서 Client로 props 전달
직렬화 가능한 데이터는 props로 넘길 수 있습니다.
<TaskSearch initialKeyword="" />
함수, DB 연결 객체처럼 브라우저로 직렬화할 수 없는 값을 넘기는 구조는 피합니다.
자주 발생하는 오류
useState를 Server Component에서 사용합니다
‘use client’ 경계를 만들거나 상호작용 부분을 별도 컴포넌트로 분리합니다.
window를 page.tsx 최상단에서 읽습니다
Server Component에서는 브라우저 전용 API가 없습니다.
모든 파일에 use client를 붙입니다
Client JS 범위가 커지고 Server Component 장점을 잃습니다.
use client는 클라이언트 경계를 선언하며 처음 HTML을 만들 때 서버에서 렌더링될 수 있습니다. 따라서 Client 파일이라는 이유만으로 모듈 최상단이나 렌더 중 window·localStorage를 안전하게 읽는 것은 아닙니다. 브라우저 전용 접근은 이벤트나 마운트 이후 Effect처럼 실행 시점까지 구분합니다.
공식 원리: Next.js Server Component와 Client Component 구분 관련 문서 (2026-10-12 확인). 아래 결과는 실행 전 기대 기준입니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 Next.js로 페이지와 서버 기능을 확장합니다.
목록 조회는 Server Component에 남기고 입력 상태만 Client Component로 분리합니다. 이 단계의 검색 입력은 입력 상태 연습이며 목록 필터링은 21편에서 URL과 연결합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow Next.js 프로젝트 적용
시작 체크포인트: 이전: Next.js package.json scripts dependencies 이해의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: app/tasks/page.tsx, app/ui/task-search.tsx, package.json. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
개념 설명에서 사용한 프로젝트 부분 코드
다음은 TaskFlow getTasks 결과를 출력하는 부분 조각입니다. 조회 함수·타입·TaskSearch의 전체 연결은 아래 완료 파일을 사용합니다. 독립 실습의 page·Counter와 섞지 않습니다.
export default async function TasksPage() {
const tasks = await getTasks();
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>
{task.title}
</li>
))}
</ul>
);
}
직접 확인: 서버 목록과 브라우저 입력의 경계
독자 질문: 검색창에 글자를 입력하면 목록도 즉시 필터링될까요? 완료 코드의 app/tasks/page.tsx는 서버에서 목록을 조회하고, app/ui/task-search.tsx는 입력 상태만 관리합니다. 검색어를 입력해도 목록은 아직 필터링되지 않습니다. URL과 검색의 연결은 21회차에서 다룹니다.
app/tasks/page.tsx의await getTasks(),<TaskSearch />, 목록 출력을 찾습니다. 이 파일에는'use client'가 없습니다.app/ui/task-search.tsx의 첫 줄"use client",useState,onChange를 확인합니다.npm run dev뒤/tasks를 엽니다. 예상 화면은 ‘작업 목록’ 제목, ‘작업 검색’ 입력, 각 작업 제목과 ‘완료’ 또는 ‘진행 중’ 상태입니다. 글자를 입력하면 입력값만 달라지고 목록 개수와 URL은 그대로입니다./tasks를 직접 새로고침합니다. 예상 결과는 서버 목록과 빈 검색 입력입니다. 현재 코드는 검색어를 저장하지 않습니다.npm run typecheck와npm run build결과를 따로 기록합니다. 성공 여부를 실제 종료 코드로 확인합니다.
오류 점검
useState 관련 서버 컴포넌트 오류는 지시문이 app/ui/task-search.tsx 첫 문장인지 확인합니다. window is not defined는 서버 파일이나 모듈 최상단의 브라우저 API 접근을 찾습니다. 직렬화 불가능한 props 오류라면 Server → Client 경계로 넘기는 값을 점검합니다. 서버 목록 페이지 전체에 'use client'를 붙여 해결하려고 하지 말고 상호작용 컴포넌트의 경계를 먼저 확인합니다.
검증 상태: 문서와 공개 글의 코드를 대조했습니다. 이번 편집에서는 개발 서버·브라우저·typecheck·build를 실행하지 않아 실제 화면과 빌드 결과는 미확인입니다.
스스로 확인할 문제와 다음 학습
검색어를 입력해도 목록이 줄지 않으면 고장일까요? 아닙니다. 지금은 입력 상태와 서버 목록을 분리하는 단계입니다. 다음 5회차에서는 .next 빌드 산출물을 확인합니다.
완료 코드 ZIP 다운로드 — next-step-04-server-client
이전 체크포인트: next-step-03-package-json
이번 회차의 변경
목록 조회는 Server Component에 남기고 입력 상태만 Client Component로 분리합니다. 이 단계의 검색 입력은 입력 상태 연습이며 목록 필터링은 21편에서 URL과 연결합니다.
변경·추가 파일:
app/tasks/page.tsxapp/ui/task-search.tsx
전체 파일·코드 보기
실행과 검증
Node.js 22 LTS 이상 권장(Next.js 최소 20.9). 루트 app/ 구조, CSS 사용.
npm install
npm run dev
npm run build
npm run start
전체 코드와 ZIP은 같은 완료 체크포인트입니다. 자동 생성 worker는 ZIP에 포함하며 라이브러리 파일은 코드 뷰어에서 생략합니다.
완료 체크포인트: next-step-04-server-client
실습 프로젝트 전체 코드
.gitignore
node_modules
.next
.env*
!.env.example
*.tsbuildinfo
app/_components/SiteHeader.tsx
import Link from "next/link";
export default function SiteHeader() {
return (
<header>
<nav aria-label="주 메뉴">
<Link href="/">홈</Link>
<Link href="/tasks">작업</Link>
<Link href="/settings">설정</Link>
</nav>
</header>
);
}
app/globals.css
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #171717;
background: #fafafa;
font-family: Arial, sans-serif;
line-height: 1.6;
}
header,
main {
width: min(880px, calc(100% - 32px));
margin: 24px auto;
}
nav {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
a {
color: inherit;
text-underline-offset: 3px;
}
button,
input,
select {
min-height: 44px;
padding: 8px 12px;
font: inherit;
border: 1px solid #aaa;
background: #fff;
color: #171717;
max-width: 100%;
}
button {
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: wait;
}
input {
display: block;
width: 100%;
}
label {
display: block;
}
form,
section,
aside {
margin: 20px 0;
}
ul {
padding: 0;
list-style: none;
}
li {
padding: 12px;
margin: 12px 0;
border: 1px solid #ccc;
overflow-wrap: anywhere;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.controls form {
margin: 0;
}
.modal {
width: min(640px, calc(100% - 32px));
border: 1px solid #777;
padding: 24px;
background: #fff;
color: #171717;
}
.modal::backdrop {
background: rgb(0 0 0 / 55%);
}
:focus-visible {
outline: 3px solid #555;
outline-offset: 3px;
}
[data-theme="dark"] {
background: #202020;
color: #fafafa;
padding: 16px;
}
[data-density="compact"] li {
padding: 8px;
margin: 8px 0;
}
img {
max-width: 100%;
height: auto;
}
app/layout.tsx
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
import SiteHeader from "./_components/SiteHeader";
export const metadata: Metadata = {
title: { default: "TaskFlow Next.js", template: "%s | TaskFlow" },
description: "서버와 브라우저 역할을 배우는 TaskFlow 학습 프로젝트",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko">
<body>
<SiteHeader />
{children}
</body>
</html>
);
}
app/lib/data.ts
import type { Task } from "./types";
const tasks: Task[] = [
{
id: "task-1",
title: "App Router 공부",
description: "서버와 브라우저의 역할을 구분합니다.",
completed: false,
ownerId: "demo",
},
{
id: "task-2",
title: "TaskFlow 만들기",
description: "누적 실습을 완성합니다.",
completed: true,
ownerId: "demo",
},
];
export async function getTasks() {
return tasks;
}
export async function getTask(id: string) {
return tasks.find((t) => t.id === id);
}
export async function getTaskStats() {
const ts = await getTasks();
return { total: ts.length, completed: ts.filter((t) => t.completed).length };
}
app/lib/types.ts
export type Task = {
id: string;
title: string;
description: string;
completed: boolean;
ownerId: string;
};
app/page.tsx
import Link from "next/link";
export default function HomePage() {
return (
<main>
<h1>TaskFlow Next.js</h1>
<p>App Router 기반 작업 관리 서비스입니다.</p>
<Link href="/tasks">작업 목록 보기</Link>
</main>
);
}
app/settings/page.tsx
export default function SettingsPage() {
return (
<main>
<h1>설정</h1>
<p>TaskFlow 설정 화면입니다.</p>
</main>
);
}
app/tasks/page.tsx
import Link from "next/link";
import { getTasks } from "@/app/lib/data";
import TaskSearch from "@/app/ui/task-search";
export default async function TasksPage() {
const tasks = await getTasks();
return (
<main>
<h1>작업 목록</h1>
<TaskSearch />
<ul>
{tasks.map((task) => (
<li key={task.id}>
{task.title}
<span> · {task.completed ? "완료" : "진행 중"}</span>
</li>
))}
</ul>
</main>
);
}
app/ui/task-search.tsx
"use client";
import { useState } from "react";
export default function TaskSearch() {
const [keyword, setKeyword] = useState("");
return (
<label>
작업 검색
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="검색어 입력"
/>
</label>
);
}
next-env.d.ts
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = { cacheComponents: false };
export default config;
package.json
{
"name": "taskflow-next",
"version": "1.0.4",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build --webpack",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"typescript": "5.9.3",
"@types/node": "24.13.6",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
글 소개·학습 준비·전체 목차

Next.js · 4/38 · TaskFlow 누적 실습
Next.js Server Component와 Client Component 구분
Next.js · 4 / 38 · 필수 학습 · Server/Client Component
서버 조회와 클릭·입력 UI를 어디에서 나눌까요? App Router의 page와 layout은 기본적으로 Server Component이며 state·이벤트가 필요한 작은 부분을 use client 경계로 분리합니다. 서버는 직렬화 가능한 초기 값을 전달하고 Client가 클릭 뒤 값을 바꾸는 두 파일 예제로 확인합니다.
이번 글에서 배우는 내용
- Server Component 기본값
- ‘use client’
- Client Component가 필요한 경우
- 브라우저 API 경계
- Server → Client props
- Client boundary를 작게 유지하는 이유
목차
다음 학습
다음 글에서는 dev와 build 과정에서 생성되는 .next 폴더의 역할을 확인합니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 39개 · 필수 38개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.