무심코 쓰는 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가 마운트할 컨테이너 하나를 확보해 둔 상태입니다.
아래 컴포넌트에서 흐름을 직관적으로 확인해 보세요.
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 연결 및 이벤트 위임
주요 노드들이 생성된 후 markContainerAsRoot와 listenToAllSupportedEvents가 호출됩니다.
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를 붙이는 방식이 아닙니다. 대신 루트 컨테이너에 여러 이벤트를 한 번에 수집할 수 있도록 준비합니다. 이게 바로 이벤트 위임입니다.
- 사용자가 버튼을 클릭합니다.
- 이벤트는 DOM 트리를 따라 위로 버블링합니다.
- 루트 컨테이너에 설치된 리스너가 이를 가로챕니다.
- React는
markContainerAsRoot로 연결된 정보를 타고 올라가 가상 DOM(Fiber) 트리를 찾아냅니다. - 이 이벤트가 어떤 Fiber 경로와 연결되는지 추적합니다.
- 최종적으로 우리가 작성한
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을 실제로 바꾸는지 더 깊게 살펴보겠습니다.