학습 자료

클라이언트 컴포넌트도 서버에서 한 번 실행된다


Article

3강에서 이 에러를 봤다.

콘솔
  • Error: useState only works in Client Components. Add the "use client" directive at the top of the file to use it.

에러 메시지가 시키는 대로 한 줄을 붙이면 해결된다.

"use client";

import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

동작한다. 그래서 많은 사람이 여기서 멈춘다. 에러가 나면 "use client"를 붙이고, 또 나면 또 붙인다. 몇 주 지나면 프로젝트의 모든 파일 맨 위에 그 한 줄이 있다. 서버 컴포넌트를 쓰겠다고 Next.js를 골랐는데 서버 컴포넌트가 하나도 없다.

이 한 줄이 정확히 무엇을 켜는지 알아야 한다.

"use client" 는 브라우저 전용을 뜻하지 않는다

이름이 오해를 부른다. "use client"가 붙으면 그 컴포넌트는 브라우저에서만 도는 것 같다.

아니다. 클라이언트 컴포넌트도 서버에서 한 번 실행된다.

직접 확인한다. 방금 만든 Counterconsole.log를 넣고 새로고침한다.

"use client";

import { useState } from "react";

export default function Counter() {
  console.log("나는 어디서 도는가");

  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
터미널
  • 나는 어디서 도는가
  • GET / 200 in 38ms
브라우저 콘솔
  • 나는 어디서 도는가

양쪽 다 찍힌다. 3강에서 서버 컴포넌트는 터미널에만 찍혔다. 클라이언트 컴포넌트는 터미널에도 찍히고 브라우저에도 찍힌다.

서버가 먼저 실행해서 HTML을 만들고, 브라우저가 그 HTML을 받은 뒤 같은 코드를 다시 실행해 이벤트를 붙인다. 3강에서 배운 하이드레이션이 바로 이 두 번째 실행이다.

클라이언트 컴포넌트의 일생
  1. 서버에서 1회HTML 을 만든다. 버튼이 소스에 박힌다
  2. HTML 전송사용자는 즉시 버튼을 본다
  3. JS 전송이 컴포넌트의 코드가 간다
  4. 브라우저에서 1회하이드레이션 — onClick 이 붙는다
  5. 상태가 바뀔 때마다브라우저에서만 반복 실행

window is not defined

이 오해가 만드는 에러가 이것이다.

"use client";

export default function Banner() {
  const isMobile = window.innerWidth < 768;

  return <p>{isMobile ? "모바일" : "데스크탑"}</p>;
}

"use client"를 분명히 붙였는데 터진다.

콘솔
  • ReferenceError: window is not defined

서버에서 실행되는 그 1회 때문이다. Node.js에는 window가 없다.

브라우저에만 있는 것은 화면이 그려진 뒤에 만진다. React 편 15강의 useEffect가 그 자리다.

"use client";

import { useState, useEffect } from "react";

export default function Banner() {
  const [isMobile, setIsMobile] = useState(false);

  useEffect(() => {
    setIsMobile(window.innerWidth < 768);
  }, []);

  return <p>{isMobile ? "모바일" : "데스크탑"}</p>;
}

useEffect는 브라우저에서만 실행된다. 서버에서는 건너뛴다. 첫 렌더는 false로 서버와 브라우저가 일치하고, 그다음에 진짜 값으로 바뀐다.

3강의 new Date() 하이드레이션 불일치와 같은 뿌리다. 서버와 브라우저가 같은 결과를 내야 하고, 다를 수밖에 없는 값은 나중에 채워 넣는다.

서버가 보내는 것은 HTML 하나가 아니다

서버 컴포넌트는 브라우저로 코드가 가지 않는다고 3강에서 배웠다. 그런데 클라이언트 컴포넌트는 코드가 간다. 서버는 이 둘을 어떻게 구분해서 보낼까.

두 가지를 보낸다.

서버가 보내는 두 덩어리

HTML

첫 화면

  • 즉시 눈에 보인다
  • 크롤러가 읽는다
  • 아직 눌리지 않는다

RSC Payload

구멍 뚫린 설계도

  • 서버 컴포넌트의 결과
  • 클라이언트 컴포넌트가 있던 자리 표시
  • 그 자리에 넘길 props

RSC Payload에는 서버 컴포넌트의 코드가 아니라 결과가 담긴다. 그리고 클라이언트 컴포넌트 자리에는 "여기에 Counter를 넣어라, props는 이거다"라는 표시만 있다.

브라우저는 그 표시를 보고 Counter의 자바스크립트를 내려받아 끼운다.

브라우저가 받는 것
  • RSC Payload
    • <article>서버 컴포넌트의 결과 — 코드 없음
    • <h1>글 제목</h1>문자열로 박혀 있다
    • [Counter 자리]코드를 따로 받아야 한다
      • props: { start: 0 }함께 전달된다
    • </article>

이 구조가 props 규칙을 결정한다.

props 는 문자로 바뀌어 건너간다

서버 컴포넌트에서 클라이언트 컴포넌트로 props를 넘긴다. 그런데 그 값은 네트워크를 건너야 한다. 문자로 바꿀 수 있어야 한다.

export default async function Post({ params }) {
  const { id } = await params;
  const post = await getPost(id);

  return <LikeButton postId={post.id} onLike={() => save(post.id)} />;
}

onLike가 문제다. 함수는 문자로 바꿀 수 없다.

콘솔
  • Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".

에러 메시지 끝에 "use server"가 보인다. 함수를 넘기는 합법적인 길이 하나 있다는 뜻이다. Server Actions이고, 11강에서 다룬다.

props 로 넘길 수 있는가
된다문자열 · 숫자 · 불리언 · null
된다배열 · 일반 객체
된다Date · Map · Set
된다JSX (children 으로)
안 된다함수 (Server Action 은 예외)
안 된다클래스 인스턴스 · Symbol

자바스크립트 편 18강에서 깊은 복사를 다룰 때 structuredClone은 함수를 만나면 에러가 난다고 했다. 여기 규칙도 거의 같다. DateMap은 건너가지만 함수는 못 건넌다.

그래서 무엇을 클라이언트로 만드는가

기준은 하나다. 브라우저가 필요한가.

필요한 것어느 쪽
데이터 fetch · DB 조회서버
API 키 · 접속 정보서버
무거운 라이브러리로 가공서버
useState · useReducer클라이언트
useEffect · 훅 전반클라이언트
onClick · onChange클라이언트
window · localStorage클라이언트
애니메이션 · 드래그클라이언트

6강에서 usePathname을 쓴 Nav에, 7강에서 router.back()을 쓴 모달에, 4강에서 reset 버튼을 단 error.js"use client"가 붙어 있던 이유가 이제 다 설명된다. 전부 브라우저가 필요한 것들이다.

잎으로 밀어낸다

핵심은 "use client"붙이느냐 마느냐가 아니라 어디에 붙이느냐다.

게시글 페이지에 좋아요 버튼 하나가 있다고 하자.

같은 화면, 다른 경계

페이지에 붙인다

"use client" 를 page.js 맨 위에

  • 글 본문까지 브라우저로 간다
  • 마크다운 라이브러리도 따라간다
  • await getPost() 를 못 쓴다
  • 번들이 커진다

버튼에만 붙인다

LikeButton.jsx 맨 위에

  • 본문은 서버에서 HTML 로
  • 브라우저는 버튼 코드만 받는다
  • 페이지는 그대로 async
  • 번들이 작다
// app/posts/[id]/page.js — 서버 컴포넌트
import LikeButton from "./LikeButton";

export default async function Post({ params }) {
  const { id } = await params;
  const post = await getPost(id);

  return (
    <article>
      <h1>{post.title}</h1>
      <section>{post.body}</section>
      <LikeButton postId={post.id} count={post.likes} />
    </article>
  );
}
// app/posts/[id]/LikeButton.jsx — 클라이언트 컴포넌트
"use client";

import { useState } from "react";

export default function LikeButton({ postId, count }) {
  const [likes, setLikes] = useState(count);

  return <button onClick={() => setLikes(likes + 1)}>♥ {likes}</button>;
}

서버 컴포넌트가 클라이언트 컴포넌트를 import 해서 쓴다. 이게 정상적인 방향이다.

브라우저가 받는 자바스크립트
page.js 에 use client180KB
LikeButton 에만12KB
본문을 그리는 코드는 브라우저에 있을 이유가 없다

컴포넌트 트리에서 "use client"뿌리가 아니라 잎에 둔다. 상호작용이 필요한 가장 작은 조각까지 내려간다.

정리하면

  • "use client"브라우저 전용이라는 뜻이 아니다. **"여기부터 브라우저로 코드를 보내라"**는 경계 선언이다.
  • 클라이언트 컴포넌트도 서버에서 한 번 실행된다. console.log가 터미널과 브라우저 양쪽에 찍힌다.
  • 그래서 첫 렌더에는 window가 없다. 브라우저 전용 값은 useEffect에서 만진다.
  • 서버는 HTMLRSC Payload를 보낸다. 페이로드에는 서버 컴포넌트의 결과와 클라이언트 컴포넌트의 자리가 들어 있다.
  • props는 문자로 바뀌어 건너간다. 함수는 못 넘긴다. 예외가 Server Actions다.
  • 넘긴 props는 사용자가 들여다볼 수 있다. 객체를 통째로 넘기지 않는다.
  • "use client"는 트리의 잎에 붙인다. 페이지 맨 위에 붙이면 서버 컴포넌트를 쓰는 의미가 사라진다.

그런데 문제가 하나 남았다. "use client"를 붙인 파일이 다른 파일을 import 하면 그것도 클라이언트가 된다. 경계가 아래로 번진다.

그러면 클라이언트 컴포넌트 안에 서버 컴포넌트를 넣는 건 영영 불가능할까. 다음 강의에서 이 경계를 정확히 긋고, 번지는 것을 막는 방법을 배운다.

클라이언트 컴포넌트도 서버에서 한 번 실행된다 — 디코드랩(DCODELAB)