Skip to main content
핵심React 공통 모델 · 18

React 서버·클라이언트 경계: use client는 어디에 두어야 하는가

상품 본문 전체를 클라이언트 코드로 보내지 않고, 보기 상태와 localStorage가 필요한 작은 진입점만 분리해 import 방향과 prop 전송 계약을 검증한다.

마지막 검증 재검증 정책: 제품 버전 의존: 새 주요 버전마다 재검증
목차
표준·구현·측정·해석 표시는 무엇인가요?
  • 표준웹 표준이나 언어 명세가 정한 동작
  • 구현특정 기술이나 브라우저가 실제로 구현한 동작
  • 측정명시한 환경에서 직접 실행해 관찰한 결과
  • 해석앞선 근거에서 도출한 설계 판단
  • 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용

30초 요약

'use client'는 함수 하나의 표지가 아니라 브라우저에서 실행할 import 연결의 시작 파일이다. 상태, 사건 처리, Effect나 브라우저 기능이 처음 필요한 작은 파일에 두고, 정적인 본문은 서버 컴포넌트가 렌더한 결과로 전달한다. 서버에서 클라이언트로 넘는 prop은 React가 전송할 수 있는 값이어야 한다.

이 글을 관통하는 상황: 보기 설정 하나 때문에 상품 본문 전체를 보내야 할까

서버에서 상품을 읽어 제목·설명·가격을 보여 주고, 사용자는 보기 밀도를 브라우저에 저장한다고 하자. 처음에는 상태가 필요한 화면 전체에 'use client'를 붙이기 쉽다.

localStorage는 브라우저가 같은 출처의 다음 방문에서도 읽을 수 있도록 문자열 key와 값을 보관하는 저장소다. 예제는 별도 서버 없이 실행되도록 demo 상품을 파일 안에 둔다.

import ProductScreen from './product-screen'
 
const PRODUCTS = {
  demo: {
    id: 'demo',
    name: '불변 업데이트 노트',
    description: '변경된 경로에만 새 객체를 만드는 방법을 설명합니다.',
    priceLabel: '무료',
  },
}
 
export default async function ProductPage({ params }) {
  const { id } = await params
  const product = PRODUCTS[id]
 
  if (!product) return <p>상품을 찾을 수 없습니다.</p>
 
  return <ProductScreen product={product} />
}

/products/demo를 열면 클라이언트 화면은 문자열로만 이루어진 일반 객체를 받아 보기 상태와 본문을 함께 소유한다.

'use client'
 
import { useEffect, useState } from 'react'
import ProductCopy from './product-copy'
 
export default function ProductScreen({ product }) {
  const storageKey = `product-view:${product.id}:compact`
  const [compact, setCompact] = useState(false)
 
  useEffect(() => {
    setCompact(window.localStorage.getItem(storageKey) === '1')
  }, [storageKey])
 
  function handleToggle() {
    const next = !compact
    setCompact(next)
    window.localStorage.setItem(storageKey, next ? '1' : '0')
  }
 
  return (
    <section data-density={compact ? 'compact' : 'comfortable'}>
      <button onClick={handleToggle}>보기 밀도 바꾸기</button>
      <p>현재 보기: {compact ? 'compact' : 'comfortable'}</p>
      <ProductCopy product={product} />
    </section>
  )
}

정적인 본문 파일에는 확인용 표식을 둔다.

const PRODUCT_COPY_STATIC_MARKER = 'PRODUCT_COPY_STATIC_MARKER'
 
export default function ProductCopy({ product }) {
  return (
    <article data-module={PRODUCT_COPY_STATIC_MARKER}>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <strong>{product.priceLabel}</strong>
    </article>
  )
}

이 코드는 동작할 수 있지만 경계가 너무 높다. ProductScreenProductCopy를 import하므로 정적인 본문 파일도 클라이언트 JavaScript에 포함된다. 프로덕션 빌드 뒤 다음 검색에서 경로가 출력된다.

pnpm build
rg -n 'PRODUCT_COPY_STATIC_MARKER' .next/static/chunks

보기 설정 하나 때문에 상품 본문 구현까지 브라우저가 내려받고 해석해야 한다는 관찰 가능한 실패다.

use client는 import 방향을 따라간다

화면 트리에서 위·아래에 있다는 사실보다 import 방향이 코드 경계를 결정한다. 먼저 어느 파일이 상태·사건 처리·Effect·브라우저 기능을 실제로 필요로 하는지 표시한 뒤, 그 파일이 불필요하게 큰 하위 그래프를 가져오지 않는지 확인한다.

서버 결과를 클라이언트 래퍼의 children으로 전달한다

서버 page.jsx는 상품을 읽고, 정적인 본문을 렌더한다. 클라이언트 래퍼는 ProductCopy 모듈을 import하지 않고 children 결과만 받는다.

import ProductCopy from './product-copy'
import ProductReadingFrame from './product-reading-frame'
 
const PRODUCTS = {
  demo: {
    id: 'demo',
    name: '불변 업데이트 노트',
    description: '변경된 경로에만 새 객체를 만드는 방법을 설명합니다.',
    priceLabel: '무료',
  },
}
 
export default async function ProductPage({ params }) {
  const { id } = await params
  const product = PRODUCTS[id]
 
  if (!product) return <p>상품을 찾을 수 없습니다.</p>
 
  return (
    <ProductReadingFrame storageKey={`product-view:${id}:compact`}>
      <ProductCopy product={product} />
    </ProductReadingFrame>
  )
}

보기 설정 파일에만 경계를 둔다. 이 예제는 저장소 접근이 실패해도 현재 컴포넌트가 유지되는 동안의 보기 상태는 바꾸되, 새로고침이나 재마운트 뒤에는 유지되지 않는다는 오류 문구를 보여 준다. 실패 경로는 실험 상수로 재현한다.

'use client'
 
import { useEffect, useState } from 'react'
 
const CLIENT_FRAME_MARKER = 'CLIENT_FRAME_MARKER'
const FORCE_STORAGE_FAILURE = false
 
function readCompact(storageKey) {
  try {
    if (FORCE_STORAGE_FAILURE) throw new Error('storage test failure')
    return {
      compact: window.localStorage.getItem(storageKey) === '1',
      succeeded: true,
    }
  } catch {
    return { compact: false, succeeded: false }
  }
}
 
function writeCompact(storageKey, compact) {
  try {
    if (FORCE_STORAGE_FAILURE) throw new Error('storage test failure')
    window.localStorage.setItem(storageKey, compact ? '1' : '0')
    return true
  } catch {
    return false
  }
}
 
export default function ProductReadingFrame({ storageKey, children }) {
  const [compact, setCompact] = useState(false)
  const [storageError, setStorageError] = useState(false)
 
  useEffect(() => {
    console.log(CLIENT_FRAME_MARKER)
    const stored = readCompact(storageKey)
    setCompact(stored.compact)
    setStorageError(!stored.succeeded)
  }, [storageKey])
 
  function handleToggle() {
    const next = !compact
    setCompact(next)
    setStorageError(!writeCompact(storageKey, next))
  }
 
  return (
    <section data-density={compact ? 'compact' : 'comfortable'}>
      <button onClick={handleToggle}>보기 밀도 바꾸기</button>
      <p>현재 보기: {compact ? 'compact' : 'comfortable'}</p>
      {storageError ? (
        <p role="status">저장할 수 없어 현재 화면이 유지되는 동안만 적용됩니다.</p>
      ) : null}
      {children}
    </section>
  )
}

교정 후 다시 빌드한다.

pnpm build
rg -n 'CLIENT_FRAME_MARKER' .next/static/chunks
rg -n 'PRODUCT_COPY_STATIC_MARKER' .next/static/chunks

첫 검색은 클라이언트 파일을 출력하고, 두 번째 검색은 아무것도 출력하지 않고 종료 코드 1을 반환해야 한다. 상품 제목과 설명은 여전히 첫 화면과 RSC 결과에 보일 수 있지만, 본문 컴포넌트의 원래 모듈 코드를 브라우저가 실행한다는 뜻은 아니다.

브라우저 기능은 브라우저에서 읽는다

따라서 저장값이 1이면 첫 화면 뒤에 밀도가 한 번 바뀔 수 있다. 이 전환이 제품상 허용되지 않는다면 서버가 읽을 수 있는 쿠키 같은 입력으로 초기값을 만들거나, 첫 표시 전에 적용하는 별도 전략을 설계해야 한다. “브라우저에서만 읽으면 된다”와 “화면 전환이 전혀 없다”는 같은 요구가 아니다.

경계를 지나는 prop은 React가 전송할 수 있어야 한다

따라서 서버 page.jsx에서 다음처럼 일반 callback을 만들면 경계 오류가 난다.

<ProductReadingFrame
  storageKey={`product-view:${id}:compact`}
  onPreferenceChange={(compact) => console.log(id, compact)}
>
  <ProductCopy product={product} />
</ProductReadingFrame>

이 예제에서는 함수 prop을 제거하고, localStorage 쓰기와 클릭 처리를 클라이언트 컴포넌트 안에 둔다. 서버 기록이 실제 요구라면 서버 함수나 HTTP 요청처럼 명시적인 서버 통신 계약을 별도로 설계해야 한다. 오류를 재현하려면 callback을 추가한 상태로 pnpm dev를 실행하고 /products/demo를 연다. 동적 경로를 실제로 렌더하는 시점에 서버에서 클라이언트로 일반 함수를 전달할 수 없다는 오류가 나타난다.

실행 결과를 순서대로 확인한다

  1. 잘못된 ProductScreen을 빌드해 PRODUCT_COPY_STATIC_MARKER가 클라이언트 chunk에 있는지 본다.
  2. 교정 코드로 바꾸고 CLIENT_FRAME_MARKER만 클라이언트 chunk에 남는지 본다.
  3. 화면의 현재 보기가 버튼을 누르면 바뀌고, 새로고침 뒤 저장한 값으로 다시 바뀌는지 확인한다.
  4. FORCE_STORAGE_FAILURE = true로 바꾸고 새로고침한다. 기본 보기와 오류 문구가 나타나며, 버튼을 누르면 현재 보기는 바뀌지만 새로고침하거나 다른 경로로 이동했다 돌아와 컴포넌트가 재마운트되면 기본 보기로 돌아오는지 확인한다.
  5. 함수 prop을 추가하고 pnpm dev에서 /products/demo를 열어 경계 오류를 확인한다. prop을 제거한 뒤 같은 경로가 열리는지 확인한다.
문제       보기 설정 하나 때문에 상품 본문 전체가 클라이언트 그래프에 포함됨
잘못된 판단 상태가 하나라도 있으면 가장 위 컴포넌트에 use client를 붙여야 함
관찰       정적 본문 표식이 브라우저 JavaScript 빌드 결과에서 발견됨
원인       use client가 import 의존성을 따라가는 모듈 경계임을 놓침
행동       작은 클라이언트 래퍼가 서버 렌더 children과 문자열 key만 받도록 분리
검증       클라이언트 chunk, 새로고침 저장값, 함수 prop 경계 실패를 각각 확인

PR에서 바로 확인할 항목

  • 상태·사건·Effect·브라우저 API가 처음 필요한 파일에 경계를 두었는가?
  • 'use client' 파일의 import가 불필요한 정적 화면과 서버 전용 모듈을 끌어오지 않는가?
  • 화면 중첩과 모듈 import 그래프를 같은 것으로 판단하지 않았는가?
  • 서버에서 클라이언트로 넘는 prop이 React 전송 가능 값인가?
  • 브라우저 저장값을 읽은 뒤 생기는 첫 화면 전환을 제품 요구와 함께 검증했는가?
  • 브라우저 기능 실패와 저장 불가 상태에 사용자 피드백이 있는가?

기억할 질문은 하나다.

브라우저에서 실행해야 하는 가장 작은 import 진입점은 어디이며, 그 경계를 지나는 값은 무엇인가?

Active recall

기억에서 꺼내 보기

답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.

  1. 01use client는 해당 컴포넌트 함수 하나만 브라우저 코드로 만드는가?

    정답

    아니다. 그 파일이 import로 가져오는 모듈도 클라이언트 모듈 그래프에 포함된다.

    왜 그런가

    정적인 자식 컴포넌트를 클라이언트 파일에서 import하면 상태를 사용하지 않아도 브라우저 JavaScript에 들어갈 수 있다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  2. 02서버 컴포넌트에서 만든 일반 callback을 클라이언트 컴포넌트 prop으로 넘길 수 있는가?

    정답

    아니다. 일반 함수는 React 전송 가능 값이 아니며 별도 서버 함수 계약과도 다르다.

    왜 그런가

    이 예제는 함수 대신 storageKey 문자열을 넘기고 브라우저 사건 처리는 클라이언트 경계 안에 둔다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  3. 03서버에서 만든 ProductCopy가 클라이언트 래퍼의 children이면 클라이언트 bundle에 들어가는가?

    정답

    아니다. 서버 부모가 렌더 결과를 prop으로 전달하고 클라이언트 래퍼가 ProductCopy를 import하지 않으면 그 모듈은 클라이언트 import 그래프에 들어가지 않는다.

    왜 그런가

    화면의 JSX 중첩 모양보다 어느 파일이 어느 모듈을 import하는지가 코드 경계를 결정한다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

출처와 검증 범위

아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.

  1. use clientReact · 공식 문서 · 확인 2026-08-09
  2. useEffectReact · 공식 문서 · 확인 2026-08-09
  3. Server and Client ComponentsNext.js · 공식 문서 · 확인 2026-08-09
  4. use clientNext.js · 공식 문서 · 확인 2026-08-09
  5. Web StorageWHATWG · 표준 · 확인 2026-08-09
이 문서의 마지막까지 읽었습니다.