Dev Thinking
21완료

서버/클라이언트 컴포넌트 – 경계(Boundary)로 사고하기

2025-09-21
11분 읽기

공식문서 기반의 Next.js 입문기

들어가며

리액트로만 개발해오면, 컴포넌트는 대부분 브라우저에서 실행되는 전제를 갖습니다.
반면 Next.js에서는 컴포넌트가 서버에서 실행될 수도 있고, 브라우저에서 실행될 수도 있습니다.

이번 글은 서버 컴포넌트와 클라이언트 컴포넌트를 알아보곡 더 나아가 경계(Boundary)를 어디에 두면 좋은지, 그리고 그 결정이 **번들(브라우저로 보내는 코드 묶음)**과 데이터 접근 방식에 어떤 영향을 주는지 정리해보겠습니다.

다음 글(3-2편)에서 “서버 컴포넌트에서 데이터 패칭을 어떻게 병렬로 할까?”로 이어갈 예정이라,
3-1편에서는 그 질문이 성립하는 전제인 “경계 사고”를 먼저 다져보려 합니다.

서버/클라이언트 컴포넌트의 경계

서버 컴포넌트와 클라이언트 컴포넌트는 이름만 보면 렌더링 위치의 차이처럼 느껴집니다.
하지만 실제로는 실행 환경을 분리하는 설계 도구에 가깝습니다.

  • 서버 컴포넌트: 서버에서 실행되는 컴포넌트입니다. 데이터 준비, 서버 자원 접근(예: DB/비밀키), 무거운 연산 같은 일을 이쪽에 두기 쉽습니다.
  • 클라이언트 컴포넌트: 브라우저에서 실행되는 컴포넌트입니다. 상태 관리, 이벤트 핸들러, 브라우저 API 접근 같은 상호작용을 담당합니다.

Part 2에서도 잠깐 언급했듯이, usePathname 같은 훅을 쓰거나 클릭 이벤트가 필요하면 use client가 등장합니다.
이번 글에서는 여기서 한 발 더 나아가, 이런 질문으로 넘어가고 싶습니다.

왜 Next.js는 “기본은 서버 컴포넌트, 필요한 곳만 use client”라는 방향을 권할까요?
제가 이해한 핵심은 다음 두 가지였습니다.

  • 번들 영향: use client가 붙은 파일과 그 의존성은 브라우저 번들의 후보가 됩니다. 경계를 잘못 두면 "작은 상호작용 하나 때문에 큰 트리가 통째로 클라이언트"가 되기 쉽습니다. (참고: 브라우저 번들이란? 브라우저에서 실행될 JavaScript 코드를 하나로 묶고 압축한 파일로, 번들이 커질수록 앱 로딩 속도가 느려집니다)
  • 데이터 접근 차이: 서버에서는 더 직접적인 방식으로 데이터를 준비할 수 있지만, 브라우저에서는 결국 네트워크 요청(API 호출)을 통해서만 서버 자원에 닿을 수 있습니다.

참고: 이 글에서 말하는 “서버/클라이언트”는 단순히 “SSR vs CSR”이 아니라, 컴포넌트 단위로 실행 환경이 갈린다는 의미에 더 가깝습니다. 그래서 “경계”라는 단어가 계속 중요해집니다.

경계가 코드에 생기는 방식(제가 이해한 그림)

개념을 문장으로만 읽으면 “서버에서 실행된다/브라우저에서 실행된다” 정도로 끝나기 쉽습니다.
저는 실제로는 다음 두 줄이 더 중요한 힌트라고 느꼈습니다.

  • 서버 컴포넌트는 서버에서 실행되고, 브라우저로 “코드 자체”를 보내지 않으려는 방향을 갖습니다.
  • 클라이언트 컴포넌트는 브라우저에서 실행되어야 하니, 그 파일과 연결된 코드가 번들 후보가 됩니다.

그래서 경계를 세운다는 말은 결국 이런 뜻으로 이어졌습니다.

  • “상호작용 때문에 브라우저로 보내야 하는 코드”가 어디부터 어디까지인지 정한다.
  • “서버에서만 다루고 싶은 정보/연산”을 어디에 남겨둘지 정한다.

큰 틀과 데이터 준비는 서버에 두고, 상호작용이 필요한 부분만 클라이언트에 두는 방식입니다.

기능 구현 및 비교

이번 섹션에서는 하나의 목표를 두고, 먼저 리액트(CSR 가정)로 구현한 뒤 Next.js 방식으로 다시 구성해보겠습니다.

상황은 간단합니다.

  • 서버에서 “상품 목록”을 가져온다.
  • 각 상품마다 “즐겨찾기” 토글 버튼이 있다.

데이터는 서버와 가깝게 두고 싶고, 버튼은 브라우저 상호작용이 필요합니다.
이 조합이 딱 “경계를 어디에 두느냐”를 설명하기에 좋았습니다.

리액트 단독 구성 – 데이터와 상호작용이 한 컴포넌트에 모일 때

리액트(CSR)에서는 아래 같은 구조로 시작하는 경우가 많습니다.

src/
├── components/
│   └── ProductList.jsx
├── App.jsx
└── main.jsx

예시는 최대한 단순화해서, useEffect로 목록을 가져오고 useState로 즐겨찾기를 관리해보겠습니다.

// src/components/ProductList.jsx
import { useEffect, useMemo, useState } from "react";
 
export function ProductList() {
  const [products, setProducts] = useState([]);
  const [favoriteIds, setFavoriteIds] = useState(() => new Set());
 
  useEffect(() => {
    let cancelled = false;
 
    async function load() {
      const res = await fetch("/api/products");
      const data = await res.json();
      if (!cancelled) setProducts(data);
    }
 
    load();
    return () => {
      cancelled = true;
    };
  }, []);
 
  const favoriteCount = useMemo(() => favoriteIds.size, [favoriteIds]);
 
  const toggleFavorite = (id) => {
    setFavoriteIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };
 
  return (
    <section>
      <h1>상품</h1>
      <p>즐겨찾기: {favoriteCount}개</p>
 
      <ul>
        {products.map((p) => (
          <li key={p.id}>
            <span>{p.name}</span>{" "}
            <button type="button" onClick={() => toggleFavorite(p.id)}>
              {favoriteIds.has(p.id) ? "즐겨찾기 해제" : "즐겨찾기"}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}

이 코드는 동작을 이해하기 쉽습니다.
브라우저에서 실행되니, 데이터를 가져오는 것도(fetch) 상태를 바꾸는 것도(useState) 같은 공간에 자연스럽게 들어갑니다.

하지만 한계도 보입니다.

  • 데이터는 결국 브라우저에서 가져옵니다. 서버에 가까운 데이터(예: 내부 시스템, 보호된 정보)는 API를 따로 만들고 거기를 경유해야 합니다.
  • 컴포넌트가 커질수록 “데이터 로딩/에러 처리/캐시” 같은 고민이 UI 코드와 섞이기 쉽습니다.

리액트는 기본적으로 실행 환경이 하나라서, 그 둘이 쉽게 같은 층으로 겹칩니다.

Next.js 구성 – 서버에서 준비하고, 필요한 곳만 클라이언트로

Next.js에서는 같은 목표를 다른 방식으로 쪼갤 수 있습니다.
핵심은 “즐겨찾기 버튼이 필요하다는 이유만으로, 상품 목록 전체를 클라이언트로 보낼 필요는 없다”는 점입니다.

아래처럼 파일을 나눠보겠습니다.

app/
└── products/
    ├── page.tsx         // 서버 컴포넌트: 데이터 준비와 화면 조립
    └── ProductList.tsx  // 클라이언트 컴포넌트: 상호작용(즐겨찾기)

먼저 page.tsx는 서버 컴포넌트로 두고, 목록 데이터를 준비합니다.

// app/products/page.tsx
import { ProductList } from "./ProductList";
 
type Product = {
  id: string;
  name: string;
};
 
async function getProducts(): Promise<Product[]> {
  // 예시는 단순화를 위해 fetch를 사용합니다.
  // 실제로는 DB/내부 API 등 서버에서만 가능한 접근을 여기에 둘 수 있습니다.
  const res = await fetch("https://example.com/api/products", {
    // 캐싱/재검증은 다음 글들(3-2, 3-3)에서 더 구체적으로 다룹니다.
    cache: "no-store",
  });
 
  if (!res.ok) {
    throw new Error("상품 목록을 불러오지 못했습니다.");
  }
 
  return res.json();
}
 
export default async function ProductsPage() {
  const products = await getProducts();
 
  return (
    <section>
      <h1>상품</h1>
      <ProductList products={products} />
    </section>
  );
}

여기서 중요한 점은 ProductsPage브라우저가 아니라 서버에서 실행된다는 점입니다.
그래서 “데이터 준비”를 컴포넌트의 자연스러운 일부로 둘 수 있습니다.

이제 실제 상호작용은 ProductList.tsx에만 담습니다.

// app/products/ProductList.tsx
"use client";
 
import { useMemo, useState } from "react";
 
type Product = {
  id: string;
  name: string;
};
 
type Props = {
  products: Product[];
};
 
export function ProductList({ products }: Props) {
  const [favoriteIds, setFavoriteIds] = useState(() => new Set<string>());
  const favoriteCount = useMemo(() => favoriteIds.size, [favoriteIds]);
 
  const toggleFavorite = (id: string) => {
    setFavoriteIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };
 
  return (
    <>
      <p>즐겨찾기: {favoriteCount}개</p>
 
      <ul>
        {products.map((p) => (
          <li key={p.id}>
            <span>{p.name}</span>{" "}
            <button type="button" onClick={() => toggleFavorite(p.id)}>
              {favoriteIds.has(p.id) ? "즐겨찾기 해제" : "즐겨찾기"}
            </button>
          </li>
        ))}
      </ul>
    </>
  );
}

use client는 “이 파일은 브라우저에서 실행되어야 한다”는 표시입니다.
그래서 useState, onClick 같은 코드가 자연스럽게 들어갑니다.

같은 UI를 만들었는데도, 코드의 결이 달라집니다.
리액트(CSR)에서는 “데이터 로딩과 상호작용”이 같은 실행 환경에서 만나고, Next.js에서는 둘이 경계로 분리됩니다.

이때 경계가 주는 효과를 저는 세 가지로 정리했습니다.

  1. 번들 크기 관점: 상호작용 코드만 클라이언트에 보내면 되니, 클라이언트 번들을 “작게 유지하려는 압력”이 생깁니다.
  2. 보안/권한 관점: 서버에서만 가능한 접근을 서버 컴포넌트로 감추고, 브라우저에는 결과만 넘기는 구성이 쉬워집니다.
  3. 설계 관점: UI 컴포넌트 분리가 “관심사 분리”를 넘어서 “실행 환경 분리”가 됩니다.

참고: 서버에서 준비한 데이터를 클라이언트 컴포넌트로 넘길 때는 "직렬화 가능한 값"이어야 합니다. (참고: 직렬화 가능한 값이란? 함수, 심볼, 클래스 인스턴스 등이 아닌, JSON으로 변환 가능한 기본 타입들 - 문자열, 숫자, 불리언, null, 객체, 배열 등을 의미합니다) 예제에서는 products를 단순한 배열/문자열로 유지했는데, 이런 제약이 설계를 더 단순하게 만들어주는 면도 있었습니다.

경계가 전파되는 방식 – 의존성과 import 방향

처음엔 “서버 컴포넌트와 클라이언트 컴포넌트가 섞여 있으면 복잡해지지 않을까?”라는 걱정이 들었습니다.
그런데 Next.js는 섞이는 방식에 분명한 방향성을 둡니다.

제가 이해한 핵심은 이렇게 정리할 수 있습니다.

  • 서버 컴포넌트는 클라이언트 컴포넌트를 자식으로 포함할 수 있습니다. (서버가 큰 틀을 잡고 클라이언트를 끼워 넣는 형태)
  • 반대로 클라이언트 컴포넌트가 서버 전용 코드를 직접 import하는 건 조심해야 합니다. (브라우저로 보낼 수 없는 코드가 섞일 수 있기 때문)

서버가 클라이언트를 포함하는 구조는 아래처럼 자연스럽습니다.

// app/products/page.tsx (서버 컴포넌트)
import { ProductList } from "./ProductList"; // ProductList는 "use client"
 
export default async function ProductsPage() {
  const products = await Promise.resolve([{ id: "1", name: "키보드" }]);
  return <ProductList products={products} />;
}

이 코드는 “페이지의 큰 뼈대와 데이터 준비는 서버에서”라는 흐름을 유지합니다.
그리고 ProductList만 브라우저에서 실행되면 되니, 경계도 작게 유지됩니다.

반대로 아래처럼 “클라이언트에서 서버 전용 일을 직접 끌어오려는 형태”는 설계가 흔들리기 쉽습니다.

// app/products/ProductList.tsx (클라이언트 컴포넌트)
"use client";
 
// 서버에서만 의미가 있는 모듈(예: DB 접근)을
// 클라이언트가 직접 import하려는 시도는 피하는 편이 안전합니다.
import { getProductsFromDb } from "../server/getProductsFromDb";

이 경우는 코드가 동작하기 어렵거나, 동작하더라도 “경계”를 흐리게 만들 수 있습니다.
이런 상황에서는 "서버에서 준비해서 props로 내려보낼 수 없을까?"를 먼저 고려하게 됩니다.

리액트 vs Next.js 비교표

구분리액트 (CSR 중심)Next.js (서버/클라이언트 혼합)
실행 환경 기본값브라우저 실행이 전제서버 컴포넌트가 기본, 필요한 곳만 use client
데이터 접근 모델브라우저에서 fetch 중심(대개 API 경유)서버에서 데이터 준비 후 UI 조립 가능
번들 관점사용한 코드가 번들 후보가 되기 쉬움use client 주변만 번들 후보가 되도록 설계 가능
컴포넌트 분리 의미UI 관심사 분리가 중심UI + 실행 환경(경계) 분리가 함께 따라옴
설계의 제약비교적 자유로움제약이 구조를 강제하고, 그게 기준이 됨

use client를 “어디에” 둘지 정하는 체크리스트

Part 2에서 use client를 “훅을 쓸 때 붙인다” 정도로 이해했다면, 3-1편에서는 한 단계 더 구체적으로 정리해보고 싶습니다.
저는 use client를 “기능의 스위치”가 아니라 “경계의 마커”로 보게 되었습니다.

그래서 다음처럼 체크리스트를 두고 판단하는 게 도움이 됐습니다.

1) use client가 필요한 신호

  • 상태/이벤트가 필요하다: useState, useReducer, onClick 같은 상호작용이 있다.
  • 브라우저 API가 필요하다: localStorage, window, document 같은 환경이 필요하다.
  • 클라이언트 전용 훅을 쓴다: usePathname처럼 브라우저 상태를 읽는 훅이 필요하다.

2) 서버 컴포넌트로 두고 싶은 신호

  • 데이터를 “준비”하는 일이 핵심이다: 화면을 그리기 전에 데이터를 가져오고 조립해야 한다.
  • 비밀을 숨겨야 한다: 토큰, 내부 시스템 접근, 권한 체크 같은 민감한 일이 있다.
  • 무거운 작업이 있다: 브라우저로 보내고 싶지 않은 계산/변환/가공이 있다.

경계 설계 패턴 – 제가 자주 쓰게 된 3가지

1) 서버 컨테이너 + 클라이언트 위젯

서버 컴포넌트는 화면의 큰 틀을 만들고, 데이터를 준비합니다.
클라이언트 컴포넌트는 그 틀 안에서 상호작용만 담당합니다.

예를 들어 “상품 목록” 페이지라면, 서버가 목록을 준비하고 목록 자체를 렌더링합니다.
그리고 즐겨찾기 버튼, 정렬 드롭다운, 검색 입력창처럼 “사용자가 만지는 조각”만 클라이언트 위젯으로 둡니다.

이 패턴의 장점은 경계가 설명 가능한 형태로 남는다는 점이었습니다.
“이건 데이터 준비라서 서버”, “이건 입력/클릭이라서 클라이언트”처럼 팀에서도 합의하기가 쉽습니다.

2) 클라이언트 컴포넌트의 props는 ‘작고 단순하게’

클라이언트 컴포넌트에 props를 넘길 때, 저는 가능한 한 모양을 단순하게 유지하려고 합니다.
직렬화 제약은 처음엔 불편하게 느껴지지만, 결과적으로 “넘겨야 할 것만 넘기는 습관”을 만들어주었습니다.

예를 들어 목록 UI가 필요로 하는 값이 id, name 정도라면, 그 이상을 굳이 클라이언트로 넘기지 않습니다.
데이터가 커질수록 “클라이언트가 들고 있어야 할 책임”도 함께 커진다는 느낌이 들었기 때문입니다.

또 한 가지는 “클라이언트에서 꼭 알아야 하는가?”를 자주 되묻는 것입니다.
예를 들어 권한이나 가격 정책 같은 로직은, 클라이언트로 넘어가는 순간 노출과 변조에 더 민감해질 수 있습니다.

3) ‘상호작용’을 경계로 확장시키지 않기

현장에서 가장 흔한 유혹은 이겁니다.
버튼 하나가 필요한데, 버튼이 있는 컴포넌트가 이미 여러 역할을 하고 있어서 “그 컴포넌트에 use client를 붙여버리는” 선택을 할 수도 있습니다.

이 선택은 빠르게 끝날 수 있지만, 대가도 함께 따라옵니다.
그 컴포넌트가 의존하고 있던 다른 코드들까지 클라이언트 번들 후보가 될 수 있기 때문입니다.

저는 이때 “버튼만 따로 떼면 어떤가?”를 먼저 생각합니다.
상호작용이 필요한 부분을 작게 떼어내면, 경계가 넓어지는 걸 막을 수 있고, 문제를 찾기도 쉬웠습니다.

결국 이 세 패턴은 한 문장으로 정리됩니다.
서버는 준비하고, 클라이언트는 반응한다.
그리고 그 사이의 경계는 의도적으로 ‘작게’ 만든다.

예상 질문

마지막으로, 쓰면서 가장 자주 떠올랐던 질문을 짧게 정리해보겠습니다.

Q1. 클라이언트 컴포넌트에서도 fetch로 데이터를 가져오면 되지 않나요?
가능합니다. 다만 그러면 다시 “브라우저에서 데이터 로딩을 시작하고, 로딩 상태를 관리하고, API를 경유한다”는 리액트(CSR) 방식의 결로 돌아가기 쉽습니다.
반대로 서버 컴포넌트에서 준비하면 “화면 조립과 데이터 준비”가 한 흐름으로 이어지고, 브라우저에 보내는 코드도 줄일 여지가 생깁니다.

Q2. 그럼 page.tsx에 그냥 use client를 붙이면 단순해지지 않나요?
당장은 단순해질 수 있습니다. 하지만 그 선택은 “페이지 전체를 브라우저 실행으로 바꾸는 결정”이기도 해서, 번들과 책임이 커질 가능성이 있습니다.
저는 먼저 “잎으로 내릴 수 있는 상호작용이 무엇인지”를 찾고, 거기에만 use client를 두는 편이 납득이 쉬웠습니다.

Q3. 클라이언트의 fetch와 서버 컴포넌트의 fetch는 다른 건가요?
기본적인 API는 같습니다. Next.js에서 제공하는 서버 환경도 globalThis.fetch를 노출하니, 문법적으로는 브라우저에서 쓰던 것과 동일한 방식으로 호출할 수 있습니다.
다만 서버에서는 브라우저의 Same-Origin 제한이나 동시 연결 수 제한이 없어 다양한 API를 동시에 호출할 수 있고, cache/revalidate 같은 Next.js의 옵션도 여기서 작동합니다. 즉 “같은 fetch를 쓰지만, 실행 환경(브라우저 vs 서버)과 캐시/보안 제약이 다르다”고 이해하면 좋습니다.

Q4. 서버 컴포넌트가 훅/이벤트를 못 쓰는 제약이 불편한데요.
저는 오히려 그 제약이 “경계를 설계하도록 밀어주는 장치”처럼 느껴졌습니다.
서버에서 할 일을 먼저 정리하고, 그 다음에 꼭 필요한 상호작용만 클라이언트로 옮기면, 코드가 ‘어디서 실행되는지’가 더 명확해졌습니다.

경계가 주는 개발 경험 변화

경계를 기준으로 코드를 나누기 시작하면, 코드 리뷰에서 질문의 방향도 조금 달라집니다.
리액트(CSR)에서는 “이 컴포넌트가 어떤 UI를 담당하나?”가 중심이었다면, 여기서는 “이 코드는 어디에서 실행되나?”가 함께 따라옵니다.

제가 자주 스스로에게 던지는 질문은 이런 형태였습니다.

  • 이 코드는 브라우저로 가도 괜찮은가?
    민감한 로직이나 내부 구조가 섞여 있지 않은지, 번들로 보내도 되는 의존성인지 확인합니다.

  • 이 데이터는 어디에서 준비하는 게 자연스러운가?
    화면을 그리기 전에 결정해야 하는 값이라면 서버에서 준비하는 흐름이 단순해질 때가 많았습니다.
    반대로 사용자 입력과 함께 변하는 값이라면 클라이언트에 두는 편이 설명이 쉬웠습니다.

  • use client가 “필요 최소한”인가?
    저는 이 질문을 “이 컴포넌트가 잎(leaf)인가?”로 바꿔 생각했습니다.
    잎이 아니라면, 잎을 만들어낼 수 있는지(상호작용 부분만 분리할 수 있는지)를 먼저 찾게 됩니다.

이런 질문들이 쌓이면, 결국 글의 처음에서 던진 질문으로 돌아갑니다.
“이 코드는 어디에서 실행될까?”를 반복해서 묻는 습관이, 경계를 더 선명하게 만들었습니다.

요약

정리하면, 이 글은 “서버 컴포넌트/클라이언트 컴포넌트가 무엇인가”보다 “경계를 어떻게 설계할 것인가”에 집중했습니다.

저는 이걸 성능 최적화 기법이라기보다, 설계의 질문을 바꾸는 장치로 이해하게 됐습니다.
컴포넌트를 나눌 때 “UI가 어떻게 보일까?”뿐 아니라 “이 코드는 어디에서 실행될까?”를 같이 묻는 순간, 코드가 자연스럽게 더 작은 책임을 갖게 되는 경우가 많았습니다. 결국 경계는 성능보다 먼저, 코드의 책임을 정리하는 기준으로 작동했습니다. 이 습관이 이후 글의 기반이 됩니다.

  • Next.js 본질: 서버/클라이언트 컴포넌트는 UI 분리 도구이면서, 동시에 실행 환경(서버/브라우저) 분리 도구입니다.
  • 개발 방식 차이: 리액트(CSR)는 하나의 실행 환경을 전제로 UI를 쌓아가고, Next.js는 경계를 기준으로 책임을 나누게 됩니다.
  • 핵심 차별화: use client는 편의 기능이 아니라 경계의 선언이며, 번들과 데이터 접근 방식에 영향을 줍니다.

참조