본문으로 건너뛰기
Tech Blog

`use client` 한 줄이 끌고 오는 것들

글 복사 완료!

파일 맨 위에 `use client`를 쓰면 경계가 그어지고, 그 아래 모듈까지 전부 클라이언트로 딸려가요.

·7분·

App Router 프로젝트에서 에러가 뜨면 반사적으로 use client를 한 줄 붙이게 돼요. 저도 한동안 그랬어요. useState가 안 돈다고 하면 붙이고, CSS-in-JS 라이브러리가 서버에서 막힌다고 하면 또 붙이고. 근데 어느 순간 번들 사이즈를 열어보니 생각보다 훨씬 뚱뚱해져 있었거든요. 범인을 따라가 보니 use client 한 줄이 혼자서 끌고 온 것들이 많았어요.

use client가 실제로 하는 일

공식 문서 표현을 그대로 읽어보면 오해가 덜해져요.

"Add 'use client' at the top of a file to mark the module and its transitive dependencies as client code." - React use client reference

이 파일과 전이 의존성 전체를 클라이언트 코드로 표시한다는 뜻이에요.

핵심은 "이 파일만"이 아니라는 거예요. 파일 하나를 찍었는데, 그 파일이 import하는 것들, 그 import가 또 import하는 것들이 전부 클라이언트 쪽으로 따라가요. Next.js 문서는 이걸 "boundary(경계)"라고 부르고요.

"'use client' is used to declare a boundary between the Server and Client module graphs (trees)." - Next.js Server and Client Components

서버 모듈 그래프와 클라이언트 모듈 그래프를 갈라놓는 경계선 선언이라는 거죠.

그러니까 use client는 "이 파일이 클라이언트가 된다"는 스위치가 아니에요. "여기서부터 아래로는 전부 클라이언트 번들에 들어간다"는 선언에 가까워요. 경계를 어디에 긋느냐에 따라 전혀 다른 결과가 나와요.

경계 아래로 전부 딸려와요

구체적으로 어떻게 딸려오는지 간단한 예로 따라가 볼게요. Navbar 안에 검색창이 있고, 그 검색창만 인터랙티브하게 만들고 싶다고 해봐요.

// Navbar.tsx (서버 컴포넌트)
import { Search } from './Search';
import { UserMenu } from './UserMenu';
 
export default function Navbar() {
  return (
    <nav>
      <Search />
      <UserMenu />
    </nav>
  );
}

"검색창이 돌아가게 하려면 일단 여기 붙이자"고 Navbar.tsx 맨 위에 use client를 써버리면, Next.js 문서 표현대로 이런 일이 벌어져요.

"Once a file is marked with 'use client', all its imports and child components are considered part of the client bundle." - Next.js Server and Client Components

해당 파일이 import하는 것과 자식 컴포넌트 전부가 클라이언트 번들에 포함된다는 거예요.

Search만 필요했는데 UserMenu까지, 그리고 UserMenu가 내부에서 끌어오는 포맷터, 날짜 라이브러리, 헬퍼 함수까지 전부 클라이언트로 같이 넘어가요. 이 전파는 모듈 그래프 차원에서 일어나는 일이라, import 한 줄만 새로 생겨도 범위가 넓어질 수 있어요.

남발이 돌려받는 비용

경계를 위쪽으로 올릴수록 세 가지 비용이 같이 올라가요.

제일 먼저 서버 전용 자원에 손댈 수 없게 돼요. 파일 시스템 읽기, 데이터베이스 직접 쿼리, 환경 변수 접근은 클라이언트 코드에서 동작하지 않거든요. Next.js는 process.env.API_KEY처럼 NEXT_PUBLIC_ 접두어가 없는 env를 클라이언트 번들에서 빈 문자열로 치환해버려요. 서버 쪽에서 키가 있다고 믿고 코드를 짰다가 런타임에 조용히 깨지는 경우가 여기서 나와요.

경계를 넘는 props에도 제약이 붙어요. 서버에서 클라이언트로 건너가는 props는 직렬화 가능한 값이어야 해요. primitives, Date, plain object, JSX, Promise까지는 허용되는데 class instance나 일반 함수는 넘기면 바로 예외가 던져지거든요. 서버에서 만든 풍부한 객체를 그대로 아래로 흘려보내던 습관은 경계 위에서 다시 짜야 해요.

그리고 서버 렌더가 주던 이점이 조금씩 빠져나가요. Server Component는 RSC Payload로 직렬화돼서 내려가기 때문에 해당 컴포넌트의 코드 자체는 브라우저에 거의 안 실려요. 경계를 위로 올릴수록 "여긴 클라이언트에서 hydrate해야 한다"고 판단되는 영역이 커지고, 번들 다운로드와 파싱 비용이 그만큼 따라옵니다.

경계는 leaf에 긋기

잘 쓰는 쪽은 경계를 가능한 한 깊게, 잎사귀 쪽에 긋습니다. Next.js 문서도 이 패턴을 두 가지로 보여주고 있어요.

첫 번째는 인터랙티브한 부분만 별도 파일로 떼어내는 방식이에요. Navbar는 서버 컴포넌트로 두고, 검색창 하나만 Search.tsx라는 별도 파일로 분리한 뒤 그 파일 상단에만 use client를 둡니다. NavbarSearch를 import할 때 경계가 거기서 생기기 때문에, 나머지 UserMenu나 날짜 표시는 서버에서 렌더된 채로 남아있어요.

두 번째는 children slot을 활용하는 composition 패턴이에요.

// Modal.tsx
'use client';
 
import { useState } from 'react';
 
export function Modal({ children }) {
  const [open, setOpen] = useState(false);
  return open ? <div>{children}</div> : null;
}
// Page.tsx (서버 컴포넌트)
import { Modal } from './Modal';
import { Cart } from './Cart';
 
export default function Page() {
  return (
    <Modal>
      <Cart />
    </Modal>
  );
}

Cart가 서버에서 데이터를 가져와 렌더된 결과물이 Modalchildren에 담긴 채로 내려가요. Modal은 클라이언트에서 열고 닫는 상태만 담당하고, Cart의 데이터 페칭은 서버에서 끝난 채로 보존됩니다. 경계가 위로 올라가지 않도록 막아주는 장치죠.

같은 발상이 Context Provider에도 적용돼요. ThemeProvider를 클라이언트 컴포넌트로 만들되 트리 깊숙한 곳에서 children만 감싸게 두면, 트리 상단의 Server Component들은 여전히 서버에 남아있을 수 있어요.

한 줄 앞에서 멈춰서 묻기

use client를 파일 상단에 쓰기 전에, 저는 요즘 두세 가지를 먼저 묻고 있어요. 지금 이 파일 전체가 꼭 클라이언트여야 하는지, 아니면 실제로 훅이 필요한 한 덩어리만 그런 건지. 클라이언트로 넘어가야 할 부분을 밖으로 떼어낼 수 있는지, 아니면 반대로 서버 렌더된 JSX를 children으로 받아서 감쌀 수 있는지. 그리고 이 경계가 내 아래로 어떤 import까지 끌고 들어가는지도요.

답이 불확실하면 적어도 경계를 지금보다 한 단계 더 아래로 내릴 여지가 있다는 신호예요. 당장 에러를 지우려고 맨 위에 붙이는 것보다, 인터랙티브가 정말로 필요한 leaf를 찾는 쪽이 대부분의 경우 훨씬 싸게 먹혀요.

참고 자료

관련 글