메인 콘텐츠로 이동

React 19의 Server Component는 어떻게 동작하는가

·-
링크 복사 완료!
React Server Components 표지

1편과 2편에서는

1편에서 사용자가 직접 짜던 보일러플레이트(forwardRef, Context.Provider, 폼 상태 관리, useEffect Promise unwrapping)를, 2편에선 useMemo/useCallback 같은 메모이제이션을 다뤘어요. 두 편 모두 사용자가 직접 판단해 적재적소에 적용하던 걸 React 코어가 내부에서 대신 해주고 사용자는 신경 쓰지 않아도 되는 방향이었어요.

이번 편에선 Server Component에 대해 다뤄볼게요. React 19는 server와 client에서 다른 모습으로 동작해요. 같은 react를 import해도 server build에선 useState가 없어요. 어떻게 한 패키지가 두 가지 모습으로 나눠지는지, 그리고 그렇게 나누어진 뒤 client는 server에서 온 component를 어떻게 다시 React tree로 복원하는지를 소스 코드를 살펴보면서 정리해볼게요.

인용한 소스는 React v19.2.0 기준이니 참고해주세요.

1. 같은 react, 다른 진입점

react/package.json을 보면 흥미로운 게 있어요.

{
  "main": "index.js",
  "exports": {
    ".": {
      "react-server": "./react.react-server.js",
      "default": "./index.js"
    },
    "./jsx-runtime": {
      "react-server": "./jsx-runtime.react-server.js",
      "default": "./jsx-runtime.js"
    },
    "./compiler-runtime": {
      "react-server": "./compiler-runtime.js",
      "default": "./compiler-runtime.js"
    }
  }
}

packages/react/package.json

exports["."]에 두 개의 키가 있어요. react-server라는 condition이 켜져 있으면 react.react-server.js로, 그렇지 않으면 defaultindex.js로 연결돼요. import는 똑같이 import React from "react"인데 번들러 condition에 따라 실제로 읽히는 entry file이 달라져요.

이 condition은 누가 켤까요? Next.js나 Webpack RSC 어댑터 같은 RSC 도구가 server build를 만들 때 명시적으로 enable해요. 결과적으로 server build와 client build는 같은 react를 import하지만 서로 다른 entry file을 시작점으로 잡아요.

react.react-server.js는 한 줄짜리 re-export이고 본체는 ReactServer.js예요.

import {use, useId, useCallback, useDebugValue, useMemo} from './ReactHooks';
import {forwardRef} from './ReactForwardRef';
import {lazy} from './ReactLazy';
import {memo} from './ReactMemo';
import {cache, cacheSignal} from './ReactCacheServer';
// ...
 
export {
  Children,
  REACT_FRAGMENT_TYPE as Fragment,
  REACT_SUSPENSE_TYPE as Suspense,
  cloneElement,
  createElement,
  use,
  forwardRef,
  lazy,
  memo,
  cache,
  cacheSignal,
  useId,
  useCallback,
  useDebugValue,
  useMemo,
  // ...
};

packages/react/src/ReactServer.js

여기서 export되는 hook 목록을 잘 봐주세요. use, useId, useCallback, useDebugValue, useMemo 다섯 개뿐이에요. useState, useEffect, useReducer, useRef, useTransition 같은 클라이언트 hook은 import조차 되지 않아요. 대신 cache, cacheSignal 같은 server 전용 함수가 추가로 등장해요.

즉 'Server Component에선 useState가 안 된다'는 건 런타임 에러로 막힌 게 아니라 진입점에서 export 자체를 안 한다는 거예요. 빌드 단계에서 import 자체가 실패해요. 타입 시스템과 번들러 양쪽에서 일관되게 차단돼요.

2. 두 갈래의 dispatcher

같은 hook도 server와 client에서 동작이 같지 않아요. hook 함수 본체는 어떻게 분기될까요?

ReactHooks.js를 보면 hook은 사실 거의 빈 껍데기예요.

import ReactSharedInternals from 'shared/ReactSharedInternals';
 
function resolveDispatcher() {
  const dispatcher = ReactSharedInternals.H;
  // ...
  return ((dispatcher: any): Dispatcher);
}
 
export function use<T>(usable: Usable<T>): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.use(usable);
}

packages/react/src/ReactHooks.js#L19-L210

resolveDispatcherReactSharedInternals.H(현재 dispatcher)를 그대로 반환해요. 즉 hook 호출은 그 시점의 dispatcher 슬롯에 담겨 있는 함수를 호출하는 것일 뿐이에요. 동작 차이를 만들어내는 건 dispatcher 자체예요.

dispatcher가 어떻게 다른지 직접 비교해볼게요. server 쪽은 이렇게 되어 있어요.

export type SharedStateServer = {
  H: null | Dispatcher,        // Hooks
  A: null | AsyncDispatcher,   // Cache
  // ...
};
 
const ReactSharedInternals: SharedStateServer = ({
  H: null,
  A: null,
}: any);

packages/react/src/ReactSharedInternalsServer.js

H(Hooks)와 A(Async/Cache) 두 슬롯뿐이에요.

반면 client 쪽은:

export type SharedStateClient = {
  H: null | Dispatcher,                       // Hooks
  A: null | AsyncDispatcher,                  // Cache
  T: null | Transition,                       // Transition
  S: null | onStartTransitionFinish,
  G: null | onStartGestureTransitionFinish,   // Gesture
  // ...
};
 
const ReactSharedInternals: SharedStateClient = ({
  H: null,
  A: null,
  T: null,
  S: null,
}: any);

packages/react/src/ReactSharedInternalsClient.js

H, A에 추가로 T(Transition), S(onStartTransitionFinish), G(Gesture) 슬롯이 있어요. transition과 gesture를 위한 슬롯들이에요. server에는 transition 개념이 적용되지 않으니 슬롯도 자료구조 단계에서 빠져 있어요.

같은 hook이라도 어느 dispatcher가 적용되느냐에 따라 동작이 달라져요. server build에선 server dispatcher의 hook이, client build에선 client dispatcher의 hook이 호출돼요. 진입점이 conditional exports로 한 번 갈라지고, 그 안의 hook 동작이 dispatcher 슬롯으로 한 번 더 갈라지는 두 단계 분기예요.

3. Server에서 useState가 있을 수 없는 진짜 이유

이제 "Server Component에선 왜 useState가 안 될까?"란 질문에 답을 할 수 있어요.

표면적인 답은 ReactServer.js가 export하지 않아서예요. 하지만 더 근본적인 이유가 있어요.

useState는 fiber 노드와 hooks linked list에 의존해요. 같은 컴포넌트가 다시 render돼서 같은 fiber를 만나야 이전 state를 꺼낼 수 있어요. 그런데 Server Component는 한 번 render되고 그 결과(직렬화된 RSC payload)를 client로 보낸 뒤 끝이에요. 같은 컴포넌트 인스턴스가 다시 server에서 render되는 일이 없어요. server에는 "다음 render"라는 개념 자체가 없어요.

useEffect도 마찬가지예요. effect는 commit 이후 실행되는 부수 효과인데 server에는 commit 단계 자체가 없어요. server는 component를 evaluate해서 RSC payload를 만들고 끝이에요.

대신 server에는 다른 종류의 기억이 필요해요. 같은 데이터를 여러 server component가 요청할 때 한 번만 fetch하는 게 좋겠죠. 그래서 server 전용 cache가 export돼요. component 인스턴스 단위가 아니라 요청(request) 단위로 결과를 공유하는 캐시예요.

결과적으로 dispatcher 슬롯이 server에서 H, A 두 개인 게 자연스러워요. server에선 hook이 fiber 사이의 state를 보존할 필요가 없고 한 번의 evaluate 동안만 사용할 수 있으면 되니까요.

4. Server Component가 async function일 수 있는 이유

async function PostList() {
  const posts = await fetchPosts();
  return (
    <ul>
      {posts.map((p) => <li key={p.id}>{p.title}</li>)}
    </ul>
  );
}

일반 client component에선 위와 같이 동작할 수 없어요. 왜 그럴까요?

Client render path는 fiber render loop을 돌아요. 하나의 동기 함수 호출로 component를 evaluate해서 React element를 받고, fiber 트리로 commit해요. 함수가 async라면 React element 대신 Promise를 받게 되고, fiber render loop은 그 Promise를 element로 처리할 수 없어요. 그래서 정식으로 막혀 있어요.

Server render path는 다른 경로를 통해요. server에선 component evaluate 결과를 곧장 fiber로 commit하는 게 아니라 RSC chunk로 직렬화해서 client에게 보내요. 그래서 component가 Promise를 반환해도 stream을 일시 중단하고 await한 뒤 결과로 다시 chunk를 이어 쓸 수 있어요.

같은 React 코어가 두 개의 진입점으로 나눠진 덕분이에요.

5. Client가 RSC chunk를 React tree로 복원하는 흐름

서버는 완성된 화면을 통째로 주지 않고 컴포넌트 정보를 텍스트로 바꿔 한 줄씩 보내요. 각 줄 앞에는 번호(id)와 종류(tag)가 붙어 있어서 클라이언트는 이걸 보고 어떤 데이터인지 판단해 순서대로 트리를 복원해요. 이때 줄이 도착하는 순서가 뒤섞이거나 한 줄이 아직 안 온 다른 줄을 참조할 수 있는데 이걸 매끄럽게 이어붙이는 게 이 과정의 핵심이에요.

Next.js 시리즈 3편에서 RSC payload의 wire format을 다뤘어요. row 단위로 newline 구분된 텍스트 스트림이고 각 row는 <id>:<tag><payload> 형태였죠. tag로 I, H, T, $L 같은 게 있고요.

Next.js App Router vs. Pages Router 파헤치기 3: 네비게이션과 RSC Payload
Next.js App Router vs. Pages Router 파헤치기 3: 네비게이션과 RSC Payload두 라우터에서 실제 페이지 이동시 네트워크로 무엇이 오가는지 들여다보아요.

이 row를 실제로 파싱하는 건 react-client/src/ReactFlightClient.js에 있어요. 거대한 파일인데 핵심은 processBinaryChunkprocessFullStringRow 두 함수예요.

Chunk 상태 모델

먼저 chunk 객체가 가지는 상태부터 볼게요.

const PENDING = 'pending';
const BLOCKED = 'blocked';
const RESOLVED_MODEL = 'resolved_model';
const RESOLVED_MODULE = 'resolved_module';
const INITIALIZED = 'fulfilled';
const ERRORED = 'rejected';
const HALTED = 'halted'; // DEV-only

packages/react-client/src/ReactFlightClient.js#L158-L164

chunk 상태 상수는 Promise를 다뤄봤다면 익숙한 이름들이 등장해요. chunk 객체가 thenable로 구현돼 있고 이 상태들이 그 내부 진행을 나타내거든요. row가 도착하기 전까지는 pending, 모델 JSON은 들어왔는데 client reference가 아직 로드 안 된 상태면 blocked, 모두 풀리면 fulfilled로 바뀌어요. 덕분에 chunk가 use(Promise)나 Suspense와 자연스럽게 동작해요.

바이너리 row state machine

텍스트를 한 글자씩 읽어 row의 경계를 찾아내는 부분이에요. processBinaryChunk는 들어오는 바이트 스트림을 row 단위로 잘라요. row 파싱 state는 ROW_ID → ROW_TAG → ROW_LENGTH → ROW_CHUNK_BY_LENGTH | ROW_CHUNK_BY_NEWLINE 순으로 진행돼요.

case ROW_ID: {
  const byte = chunk[i++];           // 바이트를 하나 읽고 커서를 다음 칸으로 옮긴다
  if (byte === 58 /* ":" */) {
    // 콜론을 만나면 ID 부분이 끝났다는 뜻이므로, tag를 읽는 단계로 넘어간다
    rowState = ROW_TAG;
  } else {
    // 콜론이 아니면 아직 ID를 읽는 중이다. 16진수를 한 자리씩 누적한다.
    // (rowID << 4)로 기존 값을 왼쪽 4비트(16진수 한 자리)만큼 밀어 빈자리를 만들고,
    // 그 자리에 byte를 실제 숫자로 바꿔 채운다.
    // 소문자 a~f(97~102)는 10~15로, 숫자 0~9(48~57)는 빼서 그대로 0~9로 파싱한다.
    rowID = (rowID << 4) | (byte > 96 ? byte - 87 : byte - 48);
  }
  continue;
}

packages/react-client/src/ReactFlightClient.js#L4810-L4942

byte를 한 글자씩 읽으면서 :을 만날 때까지 16진수 ID를 누적하고 다음 단계로 넘어가요. ID 다음엔 tag 한 byte, 그 다음에 length 또는 newline까지 읽어서 row 본문을 모아요. chunk가 row 중간에서 끊어지면 buffer에 저장해두고 다음 chunk를 기다려요.

state machine으로 파싱하는 이유는 stream 환경이라 chunk 경계가 row 경계와 다를 수 있어서예요. 한 row가 여러 chunk에 걸쳐 도착해도 정확히 복원할 수 있어야 하니까요.

Tag별 분기

row 본문이 다 모이면 processFullStringRow가 첫 byte(tag)에 따라 분기해요.

switch (tag) {
  case 73 /* "I" */: {
    resolveModule(response, id, row, streamState);
    return;
  }
  case 72 /* "H" */: {
    const code = (row[0]: any);
    resolveHint(response, code, row.slice(1));
    return;
  }
  case 84 /* "T" */: {
    resolveText(response, id, row, streamState);
    return;
  }
  case 69 /* "E" */: {
    resolveErrorModel(response, id, row, streamState);
    return;
  }
  // ...
  default: {
    // JSON 모델
    resolveModel(response, id, row, streamState);
    return;
  }
}

packages/react-client/src/ReactFlightClient.js#L4688-L4808

각 tag가 하는 일을 정리하면 이래요.

  • I (Import): client component reference. 어떤 chunk URL과 export 이름인지 확인해서 모듈을 동적 로드해요.
  • H (Hint): preload hint. CSS나 폰트를 미리 fetch하라고 client에 알려줘요.
  • T (Text): 텍스트 chunk.
  • E (Error): server에서 발생한 에러를 chunk로 reject해요.
  • R/r: ReadableStream 시작 (binary/string 구분).
  • X/x: AsyncIterable 시작.
  • default: JSON 모델. React element와 props 등 대부분이 여기로 들어와요.

resolveModel이 호출되면 JSON을 파싱하면서 $L<id> 같은 토큰을 만나요. 이 토큰은 다른 chunk를 참조한다는 뜻인데, 참조 대상 chunk가 아직 pending이면 현재 chunk 상태를 blocked로 만들고 해당 chunk가 fulfilled되길 기다려요. 모든 의존이 풀리면 chunk가 fulfilled로 바뀌고 그 chunk를 보고 있던 React tree 부분이 commit돼요.

이게 1편에서 다뤘던 use(Promise) Suspense 메커니즘이 동작하는 방식과도 같아요. RSC chunk가 thenable이 되어 React Suspense 시스템에 자연스럽게 합쳐져요. 같은 도구로 server에서 보내는 데이터와 client component의 비동기 흐름을 함께 처리하는 거죠.

마무리

1, 2편에서는 React 코어에 위임하면서 코드와 판단의 영역이 줄어드는 방향이었다면 3편은 조금 달라요.

3편의 서버/클라이언트 컴포넌트의 메커니즘을 정리하면 이래요.

  • conditional exports로 진입점이 나뉘어요. 같은 react import라도 server build에선 ReactServer.js로, client build에선 index.js로 연결돼요.
  • dispatcher 슬롯이 환경별로 달라요. server는 H, A만, client는 H, A, T, S, G로 같은 hook 이름이지만 환경에 따라 다른 구현체를 가져요.
  • server에선 fiber에 의존하는 대신 request-life의 cache, cacheSignal을 활용해요.

그리고 server에서 만든 RSC payload를 client는 chunk 단위 state machine으로 파싱해서 thenable chunk로 변환하고 React Suspense 시스템에 자연스럽게 합칠 수 있도록 해요.

같은 react를 import해도 server와 client는 서로 다른 코어 위에서 동작해요. Server Component는 그 차이 덕분에 가능해진 기능이고요. CSR의 대표 주자였던 React가 server 영역까지 코어에 포함하여 최적화해가는 방향이 개인적으로 흥미로웠어요!