본문으로 건너뛰기

React 19 실행 위치와 데이터 소유권 정리

·10 min read

React 19와 Next.js App Router를 공부하는 동안 Server Component, Client Component, RSC, Server State, Client State, TanStack Query가 계속 같이 등장했다. 전부 서버와 얽힌 개념이라 한 덩어리처럼 보였다. 하나씩 뜯어보니 서로 다른 질문에 답하는 개념이었다.

질문을 둘로 쪼개고 나서야 용어가 갈라졌다

이 코드는 어디에서 실행되는가, 그리고 이 데이터의 원본은 어디에 있는가. 두 질문은 서로 다른 축이다.

앞의 질문에 서버라고 답하면 Server Component, 브라우저라고 답하면 Client Component다. 뒤의 질문에 서버나 데이터베이스라고 답하면 Server State, 브라우저라고 답하면 Client State다.

컴포넌트의 실행 위치와 데이터의 소유권은 각각 따로 결정된다.

Server Component에는 붙일 지시어가 없었다

Server Component는 말 그대로 서버에서 실행되는 React Component다. Next.js App Router에서는 layout과 page가 기본적으로 Server Component고, Server Component임을 표시하는 지시어는 따로 없다.

page 컴포넌트에서 DB를 직접 조회하는 코드가 그대로 동작한다.

export default async function Page() {
  const users = await db.user.findMany();
 
  return (
    <div>
      {users.map((user) => (
        <div key={user.id}>
          {user.name}
        </div>
      ))}
    </div>
  );
}

이 컴포넌트는 서버에서 실행된다. 서버가 DB를 조회하고, React가 그 결과로 UI를 만들어 브라우저로 보낸다.

덕분에 서버에서만 접근할 수 있는 자원과 바로 연결된다. Database, ORM, 서버 전용 API, 환경변수, 서버 파일 시스템 같은 것들이다.

대신 브라우저에서 동작해야 하는 상태와 이벤트는 직접 쓸 수 없다. useState를 호출하거나 onClick 핸들러를 붙이는 건 Server Component에서 막힌다. 그런 상호작용이 필요하면 Client Component로 내려야 한다.

"use client"를 붙여도 첫 로드는 서버를 거쳤다

Client Component는 브라우저에서 상호작용을 담당하는 React Component다. 이름 때문에 서버를 전혀 거치지 않는다고 오해하기 쉬운데, 그렇지 않다.

첫 로드에서는 Client Component도 서버에서 HTML로 미리 렌더링되고, 브라우저에서 JavaScript로 hydrate(서버가 만든 HTML에 이벤트 핸들러와 상태를 붙여 상호작용 가능하게 만드는 과정)되면서 동작하기 시작한다. 브라우저에서만 렌더링되는 건 그 이후의 클라이언트 내비게이션이다.

Client Component로 만들려면 파일 최상단에 "use client"를 적는다.

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

hydrate가 끝나면 이 버튼은 브라우저에서 동작한다. 클릭이 useState를 건드리고, 값이 바뀌면 다시 렌더된다.

여기서는 useState, useEffect, 이벤트 핸들러, window, document, localStorage 같은 브라우저 기능을 쓸 수 있다.

RSC는 특정 컴포넌트가 아니라 모델이었다

RSC는 React Server Components의 약자다. 이름만 보면 Server Component의 다른 표기 같지만, 특정 컴포넌트의 이름이 아니라 React의 Server Component 아키텍처를 가리킨다.

그래서 개별 컴포넌트를 가리킬 때는 "이 컴포넌트는 Server Component다"라고 쓰고, 프레임워크 차원을 가리킬 때 "Next.js App Router는 React Server Components(RSC)를 사용한다"고 쓴다.

상호작용이 필요한 부분만 Client Component로 떼어낸다

Next.js에서는 Server Component 안에 Client Component를 포함할 수 있다. 서버에서 사용자 정보를 조회하고, 버튼만 Client Component로 넘기는 식이다.

import Counter from "./Counter";
 
export default async function Page() {
  const user = await getUser();
 
  return (
    <div>
      <h1>{user.name}</h1>
 
      <Counter />
    </div>
  );
}

Counter는 별도 파일이고, 최상단에 "use client"가 붙어 있다.

"use client";
 
export default function Counter() {
  // "use client" 컴포넌트
}

구조를 그려보면 서버 쪽 작업과 브라우저 쪽 작업이 한 트리 안에서 갈라진다.

페이지 전체를 Client Component로 만들 필요가 없다. 상호작용이 필요한 부분만 떼어내 Client Component로 두는 게 RSC의 장점이다.

두 상태를 가른 기준은 Source of Truth였다

Client State는 브라우저가 관리하는 상태다.

const [isOpen, setIsOpen] = useState(false);

이 값의 원본은 서버가 아니라 브라우저에 있다. 모달 열림 여부, 현재 선택된 탭, input 값, 드롭다운과 사이드바 상태, 드래그 앤 드롭 상태, UI 애니메이션 상태가 전부 여기 해당한다. 서버에서 가져올 이유가 없는 값들이다.

Server State는 반대로 서버가 원본(Source of Truth, 그 데이터의 진짜 기준이 되는 저장소)을 쥐고 있는 데이터다. 사용자 정보, 상품 목록, 게시글, 댓글, 주문 정보, 결제 상태, 좋아요 수, 알림 목록이 그렇다. DB에서 가져온 user 객체가 브라우저에 들어와 있다면, 그건 서버 원본의 복사본이다.

서버에 있냐 브라우저에 있냐보다 중요한 건 Source of Truth의 위치였다.

기준을 이렇게 잡으니 Client Component가 Server State를 쓰는 조합도 자연스럽게 설명됐다. 아래 useQuery는 서버 데이터를 가져오는 TanStack Query의 훅이다.

"use client";
 
function UserProfile() {
  const { data } = useQuery({
    queryKey: ["user"],
    queryFn: fetchUser,
  });
 
  return <div>{data.name}</div>;
}

이 컴포넌트는 Client Component지만 data는 Server State다. 실행 위치와 데이터의 성격은 별개로 결정된다.

TanStack Query가 맡는 건 Server State의 생명주기다

Server State는 관리가 까다롭다. 브라우저가 API를 통해 DB의 사용자 목록을 받아오면, 그때부터 브라우저가 쥔 건 원본이 아니라 복사본이다.

복사본을 들고 있으니 질문이 따라붙는다.

  • 데이터가 오래됐는가
  • 다시 가져와야 하는가
  • 캐시해야 하는가
  • 다른 페이지에서 변경됐다면 그걸 어떻게 아는가
  • 언제 refetch 해야 하는가
  • loading, error, retry는 어떻게 다루는가

TanStack Query는 이 질문들을 Server State의 생명주기로 묶어서 관리한다.

Client State를 담당하는 도구가 아니라 Server State를 관리하기 위한 라이브러리다.

Server State를 쓴다고 Server Component가 되는 건 아니었다

Client Component에서 Server State를 가져다 쓸 수 있다.

"use client";
 
const { data } = useQuery({
  queryKey: ["users"],
  queryFn: fetchUsers,
});

Server Component에서도 Server State를 쓴다. 서버에서 직접 조회해 결과를 자식 컴포넌트로 넘기는 형태다.

export default async function Page() {
  const users = await getUsers();
 
  return <UserList users={users} />;
}

"Server State를 쓰는가"와 "Server Component인가"는 다른 질문이다. 두 질문의 답은 서로를 제약하지 않는다.