Frontend

무심코 쓰는 React 파헤치기 1탄

React를 쓰는 것은 쉽습니다. 하지만 React가 실제로 무엇을 하고 있는지 아는 것은 또 다른 이야기입니다.

웹 진화

프론트엔드 개발에 사실상 표준이 된 React는 너무 자연스럽게 쓰기 때문에, 그 원리를 놓치기 쉽습니다. 원리를 모른채 사용만 할 줄 아는 기술은 오래가기 어렵고 자기 발전에 관심이 있다면, 매번 원리에 대한 이해를 강요받는 순간이 찾아올 것입니다. 이 글은 그 강요의 굴레를 벗어나 다음 스탭으로의 가기 위한 관문의 역할을 할 것입니다.

1. React가 가동되기 이전

React가 가동되기 위한 사전 작업을 잠깐 살펴보겠습니다.

사용자가 https://jwblog.dev 같은 주소를 입력하고 Enter를 치는 순간, 브라우저는 단순히 페이지를 여는 것이 아니라 하나의 네트워크 작업을 시작합니다.

  • 먼저 DNS lookup이 일어납니다.

브라우저는 도메인 이름(jwblog)만으로는 서버에 갈 수 없기 때문에, 이 이름이 어떤 IP 주소에 대응하는지 질의합니다.

  • IP를 얻고 나면 TCP 연결을 준비합니다.

HTTPS라면 그 위에 TLS handshake가 이어져서, 브라우저와 서버가 안전한 암호화 통로를 합의합니다.

  • 그 다음에야 HTTP 요청을 보낼 수 있습니다.

브라우저는 서버에 "이 페이지를 보여줘"라고 요청하고, 서버는 그에 대한 응답으로 HTML 문서를 돌려줍니다.

Next.js의 SSG/SSR처럼 서버가 HTML과 초기 데이터를 함께 채워 주는 방식도 있지만, 이번 글은 React의 시작점을 보기 위해 가장 단순한 CSR 흐름을 기준으로 설명하겠습니다.

CSR에서는 보통 HTML 자체가 매우 얇습니다.

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>React App</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="/main.js"></script>
  </body>
</html>

이 HTML은 완성된 페이지라기보다, React가 올라탈 수 있게 준비된 빈 무대에 가깝습니다. 브라우저는 이 시점에 아직 실제 UI를 거의 가진 것이 없습니다. 다만 나중에 React가 마운트할 컨테이너 하나를 확보해 둔 상태입니다.

아래 컴포넌트에서 흐름을 직관적으로 확인해 보세요.

https://jwblog.dev
Query:jwblog.dev
IP:104.21.XX.XX

jwblog.dev 도메인이 어떤 IP 주소를 가지고 있는지 네임 서버에 질의합니다.

이제 브라우저는 서버로부터 받은 HTML을 한 줄씩 읽으면서 (HTML 파싱 작업) DOM을 만듭니다. 이 과정에서 <script src="/main.js"></script>를 만나면, 스크립트 로딩 규칙에 따라 JavaScript 파일을 다운로드하고 실행할 준비를 합니다.

대부분의 번들러 환경에서는 이 스크립트가 HTML 파싱과 병렬로 다운로드됩니다. 그리고 HTML 파싱이 충분히 진행되어 div#root가 DOM에 올라온 뒤, 해당 스크립트가 실행될 수 있습니다.

이 타이밍이 중요한 이유는 단순합니다. React는 먼저 DOM이 있어야 그 DOM을 잡을 수 있기 때문입니다.

// main.js에 포함된 코드
const container = document.getElementById("root");

이 한 줄은 아주 작아 보이지만 사실상 "브라우저가 HTML을 다 읽었는가"라는 전제를 포함합니다. HTML 파싱이 아직 끝나지 않았다면 root는 없을 수 있습니다. 반대로 일반적인 앱 진입점에서는 스크립트가 뒤에서 실행(이미 HTML 파싱이 끝난 상태)되므로, div#root는 이미 준비되어 있는 경우가 대부분입니다.

2. React의 진입점: createRoot

이제 React 코드가 실행됩니다.

const container = document.getElementById("root");
const root = ReactDOM.createRoot(container);

여기서 중요한 점은 createRoot가 단순한 객체 생성 함수가 아니라는 것입니다. 이 호출은 React가 앞으로 이 DOM 영역을 "자기 영역"으로 관리하겠다고 선언하는 순간이며, 리액트만의 세상을 구축할 준비를 시작하는 것입니다.

실제로는 createRoot가 먼저 루트 인프라를 만들고, 그 뒤에 render가 그 기반 위에 UI를 얹습니다.

🔎 createRoot가 루트 인프라를 만드는 과정

react-dom 패키지 내부를 뜯어보면, 이 함수는 브라우저의 DOM과 React의 Fiber 세계를 연결하는 여러 단계를 거칩니다.

1. FiberRootNode 생성

createRoot()를 호출하면 내부적으로 createContainer라는 함수가 실행되면서 두 가지 핵심 객체가 생성됩니다.

export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions,
): RootType {
  if (!isValidContainer(container)) {
    throw new Error('Target container is not a DOM element.');
  }
 
  const root = createContainer(
    container,
    ...
  );
 
  return new ReactDOMRoot(root);
}

이 과정에서 생성되는 두 객체는 다음과 같습니다.

  • FiberRoot: 전체 리액트 트리에 대한 정보를 담고 있는 거대한 관리자 객체입니다. (실제 DOM과 연결되는 최상위 지점) 스케줄링, 상태 저장, 업데이트 큐 관리 등 모든 핵심 로직이 여기에 집중됩니다.
  • RootFiber (Host Root Fiber): 우리가 흔히 말하는 'Fiber' 트리의 실제 시작점입니다. 사용자가 작성한 컴포넌트(예: <App />)가 이 루트 아래에 연결됩니다.

생성되는 대략적인 Fiber 트리 구조:

FiberRoot (관리자 객체)
 └─ RootFiber (Fiber 트리 루트)
      └─ Fiber(App) (사용자가 정의한 최상위 컴포넌트)
           ├─ Fiber(Header)
           ├─ Fiber(Main)
           └─ Fiber(Footer)

이 구조가 중요한 이유는, React가 나중에 UI를 갱신할 때 "어디서부터 비교하고 어디까지 내려갈지"를 판단할 기준이 되기 때문입니다.

2. 실제 DOM 연결 및 이벤트 위임

주요 노드들이 생성된 후 markContainerAsRootlistenToAllSupportedEvents가 호출됩니다.

export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions,
): RootType {
  if (!isValidContainer(container)) {
    throw new Error('Target container is not a DOM element.');
  }
 
  // 주요 객체 생성 완료
 
  markContainerAsRoot(root.current, container);
 
  const rootContainerElement: Document | Element | DocumentFragment =
    !disableCommentsAsDOMContainers && container.nodeType === COMMENT_NODE
      ? (container.parentNode: any)
      : container;
  listenToAllSupportedEvents(rootContainerElement);
 
  return new ReactDOMRoot(root);
}

이 두 호출이 React의 이벤트 시스템과 DOM 연결의 핵심입니다.

markContainerAsRoot의 핵심 역할: "영토 표시"

이 함수는 전달받은 실제 DOM 요소(예: <div id="root">)에 리액트 내부 객체에 접근할 수 있는 비밀 열쇠를 심어두는 역할을 합니다.

실제로 이런 로직이 들어있습니다.

function markContainerAsRoot(hostRoot, container) {
  container["__reactContainer$" + randomKey] = hostRoot;
}

이 코드의 핵심은 "영토 표시"입니다. React는 DOM을 직접 소유하는 것이 아니라, 브라우저 DOM 위에 논리적인 경계를 하나 만들어 둡니다.

왜 이 "영토 표시"가 중요한가요?

  • 이벤트의 소속 확인 (가장 중요): 브라우저에서 클릭 이벤트가 발생하면, 이벤트는 DOM 트리를 타고 위로 올라갑니다. React의 이벤트 시스템은 최상위(Root)에서 이 이벤트를 가로채는데, 이때 "이 이벤트가 내가 관리하는 리액트 앱 내부에서 발생한 게 맞나?"를 확인해야 합니다. markContainerAsRoot로 심어둔 표식을 보고 "아, 이건 내 구역(FiberRoot) 소속이구나!"라고 판단하여 리액트의 합성 이벤트(SyntheticEvent)를 실행합니다.
  • 중복 렌더링 방지: 이미 리액트 루트로 지정된 DOM 요소에 실수로 createRoot를 또 호출하는 것을 막습니다. 만약 이 표식이 이미 있다면, 리액트는 "여기 이미 내가 관리 중이야!"라고 알리거나 기존 루트를 정리하는 대응을 할 수 있습니다.
  • 포탈(Portal) 지원: DOM 구조상으로는 루트 바깥에 있는 요소라도, 이 표식을 추적하면 논리적으로 어떤 리액트 루트에 속해 있는지 찾아낼 수 있습니다.

listenToAllSupportedEvents와의 시너지

markContainerAsRoot가 "여기는 내 땅이다"라고 깃발을 꽂는 행위라면, 바로 다음에 오는 listenToAllSupportedEvents는 "이 땅에서 일어나는 모든 소리를 듣겠다"며 도청기(이벤트 리스너)를 설치하는 행위입니다.

React의 이벤트 시스템은 버튼마다 addEventListener를 붙이는 방식이 아닙니다. 대신 루트 컨테이너에 여러 이벤트를 한 번에 수집할 수 있도록 준비합니다. 이게 바로 이벤트 위임입니다.

  1. 사용자가 버튼을 클릭합니다.
  2. 이벤트는 DOM 트리를 따라 위로 버블링합니다.
  3. 루트 컨테이너에 설치된 리스너가 이를 가로챕니다.
  4. React는 markContainerAsRoot로 연결된 정보를 타고 올라가 가상 DOM(Fiber) 트리를 찾아냅니다.
  5. 이 이벤트가 어떤 Fiber 경로와 연결되는지 추적합니다.
  6. 최종적으로 우리가 작성한 onClick 같은 핸들러를 실행합니다.

이 구조가 좋은 이유:

  • 이벤트 리스너 개수를 대폭 줄일 수 있습니다.
  • 이벤트 우선순위를 중앙에서 다루기 쉽습니다.
  • DOM 구조와 논리적 React 트리를 분리해 생각할 수 있습니다.

연결 고리: listenToAllSupportedEvents가 루트 컨테이너에 모든 이벤트를 걸어두면, 나중에 이벤트가 발생했을 때 React는 markContainerAsRoot로 연결된 정보를 타고 올라가 가상 DOM(Fiber) 트리를 찾아냅니다. 이 둘이 없으면 이벤트는 발생하지만 리액트가 어느 컴포넌트의 핸들러를 실행해야 할지 알 수 없게 됩니다.

💡 한번에 이해가 안될 수 있으니 여러 번 반복해서 읽어봐요..!

3. createRoot 직전의 상태를 한 번 정리하면

createRoot가 반환되기 직전 React는 이미 꽤 많은 일을 끝낸 상태입니다.

  • div#root가 React 관리 영역으로 등록됩니다.
  • FiberRoot와 RootFiber가 연결됩니다.
  • 루트 표식이 DOM에 남습니다 (__reactContainer$...).
  • 이벤트 수집 체계가 붙습니다.
  • 이제 root.render(<App />)만 들어오면 실제 렌더링이 시작됩니다.

즉, React는 아직 UI를 그리기 전이지만, 이미 "그릴 수 있는 구조"와 "그려질 때의 규칙"을 모두 만들어 둔 상태입니다.

React를 단순히 "화면을 그리는 라이브러리"로 보면 createRoot는 그저 시작 함수처럼 보입니다. 하지만 실제로는 React가 브라우저 DOM 위에 자신의 루트, 소속, 이벤트 경계, 그리고 스케줄링 기반을 만드는 핵심 관문입니다.

이 글에서는 root.render가 호출되기 직전까지를 따라왔습니다. 다음 글에서는 드디어 root.render(<App />)가 호출된 뒤, React가 어떤 순서로 비교하고, 어떤 시점에 DOM을 실제로 바꾸는지 더 깊게 살펴보겠습니다.

Contact

궁금한 점이나 이야기하고 싶은 게 있다면 편하게 메시지를 남겨주세요.