TIL:08: Build your own React

개발자로 일하면서 React를 거의 매일 사용합니다. 그런데 "setState를 부르면 정확히 무슨 일이 일어나나요?"라는 질문에는 "재조정이 일어나고... 가상 DOM을 비교해서..." 수준의 대답밖에 못 했습니다. 도구를 10년 가까이 쓰면서 내부는 추상적으로만 아는 상태. 이 글은 그 상태를 주말 이틀로 청산해본 기록입니다.
1. Build your own X
어느날 개발자 커뮤니티를 떠돌다가 build-your-own-x라는 저장소를 우연히 알게 되었습니다. Git·Docker·데이터베이스·운영체제 같은 것들을 직접 만들어보는 튜토리얼 링크 모음이었습니다. 카테고리만 30개 — 흥미가 돋긴 했는데 뭐부터 볼지 좀 막막했습니다.
그래서 이 저장소 링크를 Claude에게 그냥 제공했습니다. 평소 업무 대화가 쌓여 있던터라 Claude는 제가 어떤 일을 하는지 알고 있기 때문에 Claude에게 추천을 좀 받고자 했던 것입니다. 돌아온 추천은 세 개였습니다. 업무에서 문서 검색 기능을 만들고 있으니 RAG를 바닥부터, 프론트엔드 모노레포를 매일 만지니 Build your own React, 그리고 도구 자동화를 하고 있으니 Git 내부 구조.
셋 다 탐났지만 저는 React를 골랐습니다. 이유는 단순합니다. 매일 쓰는 도구인데 내부를 가장 모르니까요. 교재는 Rodrigo Pombo의 Build your own React. 300줄 남짓한 코드로 React의 축소판(Didact)을 만들면서 createElement부터 useState까지 여덟 스텝을 밟는 글입니다.
계획도 Claude와 같이 세웠습니다. 금요일 밤에는 준비 운동을 하고, 토요일에 Step 1부터 5까지, 일요일에 Step 6부터 8까지. 그리고 규칙 하나 — 복사-붙여넣기 금지, 전부 손으로 타이핑해보기로 했습니다. 코드를 복사해서 붙여넣는 순간 아무것도 안 남는다는 걸 경험으로 알고 있었습니다.
2. Day 0 — 오랜만의 JavaScript 손풀기
백엔드와 프론트엔드를 오가며 일하다 보니 순수 JavaScript를 손으로 쳐본 게 꽤 오래전이었습니다. 특히나 요즘은 거의 모든 코드 작성을 Claude와 Codex에게 일임하기 때문에 더욱이 코드를 읽는데 어려움이 있을 것 같았습니다.
원문이 2019년 글이라 옵셔널 체이닝 없이 &&로 방어하는 코드 스타일인 것도 걸렸습니다. 그래서 본편에 들어가기 전 금요일 밤, 준비 운동을 하자는 마음가짐으로 기초 문법부터 다시 시작했습니다.
let/const와 화살표 함수 — Didact는wipRoot,currentRoot같은 전역let변수를 계속 재할당하는 스타일이라 이 감각이 먼저 필요했습니다- rest 파라미터와 스프레드 — Step I의
createElement가 사실상 이 문법 하나로 이루어져 있습니다 - 단축 평가(
&&,||)와 truthy/falsy — Step VI 코드를 읽는 열쇠 - 클로저 — "바깥 함수가 끝나도 변수는 살아남는다"를 get/set 쌍 만들기 실습까지. 이게 Step VIII의 복선이 될 줄은 이때 어렴풋이만 알았습니다
Claude에게 원문을 효과적으로 학습하기 위해 사전에 필수적으로 알아야 하는 기본 문법들을 고르게 했고 각 개념 별로 학습 자료를 HTML로 만들게 했습니다. 브라우저에서 HTML 파일을 열어 개발자 콘솔 창을 열고 왼쪽에 내용을 띄우고 오른쪽의 콘솔창에 직접 입력 해가면서 학습을 진행했습니다.
학습 방식은 전부 같았습니다. 학습 자료를 읽으면서 직접 모든 코드를 손으로 쳐보고 자료 마지막에 퀴즈를 모두 풀 수 있어야 다음 단계로 넘어갔습니다.
3. 본편 — Step 0부터 8까지 손으로
토요일. Vite로 빈 프로젝트를 만들고 시작했습니다. 첫 관문은 코드가 아니라 환경이었습니다. 요즘 빌드 도구는 JSX를 자동으로 react/jsx-runtime으로 변환하는데, 이 글은 Didact.createElement를 직접 호출하는 고전 방식이 필요합니다. 설정에서 classic 변환으로 바꾸는 것부터가 첫 번째 학습이었습니다.
여덟 스텝은 대략 이런 여정이었습니다.
| Step | 내용 | 체감 난이도 |
|---|---|---|
| 0~2 | React 3줄을 바닐라 JS로 치환, createElement와 render | 준비 운동 |
| 3 | 재귀 렌더링의 문제 — 메인 스레드 블로킹 → workLoop | 문제의식 심기 |
| 4 | fiber — 트리를 연결 리스트로 풀어서 중단 가능하게 | 첫 번째 산 |
| 5 | render phase와 commit phase의 분리 | 고비 넘김 |
| 6~8 | Reconciliation, 함수 컴포넌트, Hooks | 두 번째 산 |
원문 글은 구성이 탄탄하고 설명을 쉽게 해주기 때문에 앞부분을 이해하는 데 큰 어려움은 없었습니다.
난이도는 Step 4부터 조금 어려워지기 시작했습니다. "왜 트리를 child/sibling/parent 포인터로 풀어놓는가"를 코드부터 읽으면 반드시 헤맵니다. 재귀로 트리를 돌면 '어디까지 했는지'가 콜 스택에 갇혀 중단할 수 없습니다. fiber는 그 콜 스택을 객체 링크로 풀어놓은 것입니다. 그림을 먼저 그리고 나서야 코드가 읽혔습니다.
그렇게 토요일에 Step 5까지, 화면에 정적인 UI를 그리는 데 성공했습니다. Step 4와 5는 한번에 딱 이해가 가지는 않았으나 코드를 직접 손으로 쳐보고 실행 흐름을 머릿속으로 그려가보면 충분히 이해할 수 있는 수준이었습니다.
4. 두 번째 산 — 학습에 Claude 사용하기
Step 6(Reconciliation)부터 난이도가 달라졌습니다. 코드 양은 얼마 안 되는데 wipRoot, currentRoot, alternate 세 포인터가 머릿속에서 자꾸 엉켰습니다. 원문을 두 번 읽어도 "80%는 알겠는데 100%는 아닌" 상태가 계속됐습니다.
여기서 접근을 바꿨습니다. Claude에게 개념을 설명해달라고 매번 물어보는 대신, 시각화 중심의 학습 자료를 만들어달라고 했습니다. 조건은 세 가지였습니다. 다이어그램을 충실히 넣을 것, 알고리즘을 한 단계씩 직접 돌려볼 수 있는 인터랙티브 시뮬레이터를 넣을 것, 그리고 각 단계마다 답을 보기 전에 예측하게 만들 것.
이게 생각보다 훨씬 잘 작동했습니다. 글을 읽는 것과 시뮬레이터의 "다음" 버튼을 누르기 전에 결과를 예측하는 것은 완전히 다른 인지 활동이었습니다. 예측이 틀리는 곳이 정확히 이해가 빈 곳이었습니다. 거기만 원문으로 돌아가 다시 읽으면 됐습니다.
아래에 그 학습 자료의 핵심을 이 블로그에 맞게 React 컴포넌트로 포팅해서 넣었습니다. 저처럼 마지막 세 스텝에서 막힌 분들은 직접 눌러보시길 바랍니다.
4-1. Reconciliation — 기억, 비교, 반영
Step 5까지의 Didact는 '추가'밖에 못 합니다. render를 두 번 부르면 UI가 두 벌 쌓입니다. 그렇다고 렌더할 때마다 화면을 통째로 지우고 다시 그리는 식으로 고치면, 매번 DOM을 전부 새로 만드는 비용에 더해 사용자가 입력해 둔 데이터처럼 DOM이 들고 있던 상태까지 함께 사라집니다.
이것을 고치려면 이번에 그리려는 것과 지난번에 그린 것의 차이를 알아야 합니다. 그래서 세 가지가 추가됩니다. 기억(마지막으로 커밋한 트리 currentRoot, 그리고 fiber마다 이전 세대의 같은 자리를 가리키는 alternate), 비교(reconcileChildren), 반영(effectTag를 읽는 commitWork).
비교의 핵심은 단 하나의 질문입니다:
const sameType =
oldFiber &&
element &&
element.type == oldFiber.type이 질문의 답에 따라 fiber의 운명이 세 갈래로 갈립니다.
표나 그림으로 보면 알 것 같지만, 진짜 이해는 루프를 직접 돌려봐야 옵니다. 아래 시뮬레이터는 reconcileChildren의 while 루프를 한 반복씩 실행합니다. 시나리오 B에서 elements가 소진된 뒤에도 루프가 계속 도는 이유(|| 조건), 시나리오 C에서 리스트 맨 앞에 하나를 끼웠을 뿐인데 전 좌석이 밀리는 것(key가 없는 위치 기반 매칭의 대가)까지 확인해보세요.
인터랙티브 — reconcileChildren 시뮬레이터
다음 반복의 판정(UPDATE / PLACEMENT / DELETION)을 먼저 예측한 뒤 버튼을 누르세요.
old [h1, p, span] → new [h1, div, span, a]. 네 반복 안에 UPDATE, PLACEMENT+DELETION 동시 발생, oldFiber 소진 후 반복이 모두 등장합니다.
elements — 새 렌더의 children 배열 (index로 전진 →)
old fibers — alternate.child부터의 연결 리스트 (.sibling으로 전진 →)
시작 전. index = 0, oldFiber = wipFiber.alternate.child. 첫 반복의 판정을 예측한 뒤 "다음 반복 실행"을 누르세요.
새 fiber 체인: —
deletions: [ ]
제일 오래 헤맨 지점을 하나만 꼽으면 deletions 배열입니다. UPDATE와 PLACEMENT는 새 fiber를 만들어 새 트리에 편입되니 커밋이 트리를 순회하며 자연히 만나는데, DELETION은 옛 fiber에 표시만 남기고 새 fiber를 만들지 않습니다. 옛 fiber는 새 트리 어디에도 연결돼 있지 않으니 새 트리를 아무리 돌아도 삭제 대상은 만날 수 없습니다. 그래서 삭제 대상만 담는 별도 명단이 필요합니다.
4-2. 함수 컴포넌트 — 유령 fiber와 두 개의 걷기
Step VII에서 <App name="foo" />처럼 type이 문자열이 아니라 함수인 element가 등장합니다. 차이는 두 가지로 압축됩니다. 자식이 props.children에 있는 게 아니라 함수를 실행해야 나온다는 것(fiber.type(fiber.props) — 이 한 줄이 "컴포넌트를 렌더링한다"의 실체입니다), 그리고 <App />에 대응하는 DOM 노드가 존재하지 않는다는 것.
재미있는 문제는 두 번째 차이에서 나옵니다. fiber 트리와 DOM 트리의 1:1 대응이 깨지면서 fiber 트리에만 존재하는 '유령 층'이 생깁니다.
이 유령 때문에 커밋 코드가 두 방향의 '걷기'를 배워야 합니다. PLACEMENT는 위로. h1을 붙일 곳을 찾으려면 dom 없는 조상들을 while 루프로 건너뛰어 올라가야 합니다(컴포넌트는 중첩되니까 if로는 부족합니다). DELETION은 아래로. <App />을 지우려면 dom을 가진 첫 후손을 찾아 내려가야 합니다. 방향은 반대지만 원리는 같습니다. 유령은 DOM 세계에 실체가 없으니, DOM 조작의 대상과 위치는 항상 가장 가까운 실체에서 찾는다.
인터랙티브 — <App /> 마운트 스테퍼
render 5단계 + commit 5단계. 각 fiber가 함수/host 중 어느 길로 가는지, dom이 언제 생기는지 예측하며 넘기세요.
시작 전.
Didact.render(<App name="foo" />, container)직후 — workLoop이 fiber들을 처리하다 App fiber(함수 컴포넌트)에 도달하기 직전입니다.- "다음 단계"를 누르며 각 fiber가 함수/host 중 어느 길로 가는지 예측해보세요.
이 스텝은 사실상 단계 6~7이 전부입니다. App fiber는 커밋에서 아무 일도 하지 않고 투명하게 통과합니다(가드 fiber.dom != null에 걸립니다). h1의 커밋에서 while 루프가 유령을 건너뛰어 container를 찾아내는 순간, Step VII이 추가한 코드가 실제로 일을 시작합니다.
4-3. Hooks — 상태는 fiber에 산다
마지막 스텝은 useState입니다. Counter 컴포넌트가 성립하려면 세 가지 질문에 답해야 합니다. 컴포넌트 함수는 매 렌더 처음부터 다시 실행되는데 상태는 어디에 살길래 살아남는가? 이름도 키도 안 넘기는데 useState는 어떻게 자기 상태를 찾는가? 그냥 함수 호출일 뿐인 setState가 어떻게 화면을 다시 그리는가?
답은 순서대로 이렇습니다. 상태는 함수가 아니라 fiber의 hooks 배열에 삽니다. 신원은 호출 순서, 그러니까 hookIndex라는 그냥 배열 인덱스입니다. setState는 render()와 똑같은 수법으로 새 wipRoot를 세팅해 재렌더를 겁니다. "훅을 조건문 안에서 부르지 말라"는 규칙의 이유를 그렇게 오래 외우고 다녔는데, hooks[hookIndex]라는 코드 한 줄로 환원되는 걸 봤을 때의 허탈함이란.
setState가 상태를 바꾸지 않는다는 것도 이번에 처음 제대로 이해했습니다. 하는 일은 예약(자기 훅의 queue에 action을 push)과 방아쇠(새 wipRoot 세팅)뿐입니다. 정산은 다음 렌더의 useState 안에서 일어납니다.
이 구조가 곧 배칭입니다. 렌더가 시작되기 전에 클릭이 여러 번 오면 큐에 action이 쌓이고 렌더는 한 번만 돌면서 순서대로 정산합니다. 아래 스테퍼의 단계 8~9에서 클릭 2회가 렌더 1회로 처리되는 걸 직접 확인할 수 있습니다.
인터랙티브 — useState 스테퍼: 클릭에서 화면까지
마운트 → 클릭 → 재렌더 → 연속 클릭 배칭까지 10단계. 각 단계의 state와 queue를 먼저 예측하세요.
이전 세대 (alternate)
—
세대1 Counter fiber — 작업 중 (wipRoot 쪽)
hooks: [] — 방금 비움. useState 호출 대기
wipRoot: 세대1 (render()가 방금 세팅) · currentRoot: null
시작 전.
Didact.render(<Counter />, container)직후 — workLoop이 Counter fiber(함수 컴포넌트)에 도달하기 직전입니다.- 각 단계에서 state와 queue를 먼저 예측해보세요.
단계 4가 개인적인 하이라이트입니다. 클릭 직후 — 큐에는 action이 들어갔지만 state도 화면도 그대로인 순간. "setState 직후에 state를 읽으면 왜 옛값인가"라는 오래된 질문의 답이 이 화면 하나에 들어 있습니다.
setState는 단순히 state를 변경해놓는 게 아니라 action을 큐에 담아 놓고 재렌더링을 걸어놓습니다. 그리고 그 일은 브라우저가 한가한 틈에 workLoop가 집어가 render phase, commit phase 순서로 처리합니다. 그래서 setState 직후에는 state가 변경되지 않고 다음 렌더에서 변경되는 것입니다.
5. 회고
지금까지 단순히 라이브러리 사용법으로서만 알고 있었던 React의 몇몇 규칙들이 왜 그런지 알 수 있었습니다. 훅은 왜 최상위에서만 불러야 하는지(hookIndex는 배열 인덱스니까), 리스트에는 왜 key를 줘야 하는지(위치 기반 매칭은 맨 앞 삽입을 전체 밀림으로 인식하니까), 컴포넌트를 컴포넌트 안에서 정의하면 왜 안 되는지(매 렌더 새 함수 참조라 sameType이 항상 false니까). 지금까지 외우던 React의 규칙들이 전부 "그럴 수밖에 없는 구현상의 이유"로 바뀌었습니다.
AI와 학습하는 방식에서도 하나 배웠습니다. 이번 주말에 AI에게 답을 물어본 적은 거의 없습니다. 대신 학습 경로를 추천받고, 계획을 같이 세우고, 막힌 구간에서는 예측을 강제하는 학습 자료를 만들어달라고 했습니다. AI를 답안지가 아니라 교재 편집자로 쓴 셈인데, 손으로 타이핑하고 예측하는 수고는 그대로 제 몫으로 남겨두는 이 분업이 저한테는 잘 맞았습니다.
물론 이 300줄짜리 Didact는 실물 React가 아닙니다. 변경 없는 서브트리도 전부 재조정하고, key가 없고, 삭제 커밋에는 구멍도 있습니다. 원문 에필로그가 실물 React와 어디가 다른지 정리해주는데, 그 목록을 읽는 것 자체가 마무리 복습이 됐습니다.
주말 이틀 총 8시간 정도를 사용하여 아주 간단한 버전의 React를 새로 만들어봤습니다. 매일 쓰는 도구의 내부가 궁금했던 분들께 이 코스를 권합니다.
- 원문 글: Build your own React
- 학습 Repository: didact-study
