내가 직접 구현한 리액트 목업 버전에 대하여
리액트를 개인적으로 1년, 실무에서 2년 반 동안 사용해 왔고, 이제 총 3년 반 정도의 경험이 되었다. 그동안 리액트를 깊이 있게 활용해왔지만, 내부의 코어 원리를 제대로 이해하지 못하고 있다는 생각이 들었고, 이에 직접 리액트를 구현해보는 프로젝트를 진행했다.
내가 만든 리액트 목업은 v16 버전을 기준으로 하였으며, 리액트가 제공하는 모든 기능을 구현한 것은 아니다. 대신 다음과 같이 리액트의 핵심 개념만을 간결하게 구현하였다:
구현한 기능
가상 DOM: DFS 방식의 가상 DOM 트리 구조 구현
상태 관리:
useState
훅을 통한 컴포넌트 상태 관리이펙트 처리:
useEffect
훅을 통한 사이드 이 펙트 관리이벤트 처리: DOM 이벤트 리스너 등록 및 관리
재조정 (Reconciliation): 이전과 새로운 가상 DOM 비교를 통한 효율적인 DOM 업데이트
키 기반 최적화:
key
속성을 이용한 리스트 렌더링 최적화
미구현된 기능 (v17+ 기반의 Fiber 관련 기능)
아쉽게도 다음과 같은 최신 기능들은 구현하지 못했다:
동시성 처리: Fiber 아키텍처 미구현으로 인해 단일 스레드 렌더링
배칭 처리: 상태 업데이트의 자동 배칭 처리 미지원
이벤트 우선순위: 이벤트 우선 순위 조절 미구현
렌더링 중단 및 재개: 긴 작업 중 렌더링을 중단하고 다시 재개하는 기능 미지원 (현재 구현에서는 DFS 방식으로 blocking한 렌더링만 지원하고 있으며, Fiber의 렌더링 중단/재개, 우선순위 큐 기반 처리는 X)
참고 자료는 다양하게 활용했으며, 특히 이재상님의 개인 세미나 강연과 가이드를 많이 참고하였다.
클래스 기반 구현 이유
리액트 자체는 함수형 컴포넌트를 중심으로 진화했지만, 나는 이 목업을 클래스 문법으로 구현했다. 그 이유는 다음과 같다:
캡슐화: 외부에 노출할 필요가 없는 내부 메서드나 변수들을 은닉할 수 있다.
컨텍스트 일관성: 상태나 훅이 공유하는 전역 컨텍스트를 싱글톤 인스턴스 형태로 유지하여 흐름을 안정적으로 제어할 수 있다.
모듈화와 유지보수성: export된 함수들 간의 의 존성을 줄이고, 하나의 객체 안에서 흐름을 관리하는 것이 코드 유지보수에 유리하다 판단했다.
구현하며 알게 된 주요 개념 정리
1. JSX → createElement → 가상 DOM 트리 변환
기존에 babel
이 JSX 문법을 createElement
로 변환한다고 알고 있었으나, 실제로는 typescript
만으로도 가능했다. 대부분의 환경에서는 Babel이 JSX를 처리하지만, TypeScript에서도 jsx: "react"
설정으로 트랜스파일이 가능하다는 사실을 알 수 있었다. 이 변환 과정에서 가상 DOM 노드 트리가 생성되며, 이를 기반으로 실 DOM을 구성한다.
렌더링 과정에서 다음과 같은 흐름이 진행된다:
루트 컴포넌트는
render(rootVNode, container)
형태로 시작내부적으로
reconcile()
이 재귀적으로 호출되며, 가상 DOM 트리를 DFS로 순회각 노드의 타입이 함수이면 컴포넌트로 간주해 호출, 일반 태그면
createElement
로 실 DOM 요소 생성이렇게 생성된 실 DOM은 부모 노드에 append
2. 훅의 조건문 분기 호출이 금지된 이유
useState
나 useEffect
같은 훅들은 각 컴포넌트의 전용 컨텍스트 에 저장되는 것이 아니라, 렌더링 순서에 따라 배열에 순차적으로 저장된다. 따라서 훅의 호출 순서가 바뀌면, 이전 렌더링에서의 훅 상태와 일치하지 않게 되어 상태 참조가 꼬이거나 예기치 않은 동작이 발생할 수 있다.
아래 코드는 함수형 컴포넌트가 렌더링되는 시점에 useState
, useEffect
등이 내부적으로 실행하는 로직의 예시이다.
🔸 useState 구현 예시
const hooks = (this.currentRenderingComponent.hooks ||= []);
if (hooks.length <= this.hookIndexInEachComponent) {
hooks.push(defaultValue); // 마운트 시 초기값 저장
}
const state = hooks[this.hookIndexInEachComponent];
this.hookIndexInEachComponent++;
🔸 useEffect 구현 예시
const effect: Effect = { callback, deps, cleanup: undefined };
hooks.push(effect); // effect 배열에 저장
this.scheduleEffect(effect); // 실행 큐에 등록
this.hookIndexInEachComponent++;
여기서 핵심은 다음과 같다:
hookIndexInEachComponent
는 컴포넌트가 렌더링될 때마다 0으로 초기화된다.훅이 호출될 때마다 이 인덱스는 1씩 증가하여, 해당 위치에 상태나 이펙트를 저장한다.
훅의 순서가 렌더링마다 정확히 동일해야, 이전에 저장된 훅 값과 정확히 매칭된다.
조건문 안에서 훅을 호출할 경우, 호출 여부나 순서가 매 렌더링마다 달라질 수 있으므로 상태가 엉키게 된다.
따라서 리액트는 훅을 컴포넌트 최상단에서 무조건 동일 한 순서로 호출해야 한다는 규칙을 강제한다. 이 규칙이 깨지면 훅 배열의 인덱싱이 어긋나고, 결국 상태나 효과의 참조가 잘못되어 런타임 오류나 예측 불가능한 결과가 발생할 수 있다.
즉, 훅은 조건적으로 “호출할 수도 있고, 안 할 수도 있는” 형태가 되어서는 안 되며, “호출한다면 반드시 항상 호출되어야” 한다. 이는 훅의 안정적 실행을 위한 가장 핵심적인 원칙이다.
3. 컴포넌트 외부에서 훅을 호출하면 안 되는 이유
훅은 반드시 렌더링 중인 함수형 컴포넌트 내부에서 호출되어야 한다. 그 이유는, 훅이 동작하기 위해서는 현재 렌더링 중인 컴포넌트에 대한 정보가 전역 컨텍스트에 존재해야 하기 때문이다.
리액트 내부에서는 currentRenderingComponent
같은 전역 변수를 통해 현재 어떤 컴포넌트가 렌더링되고 있는지를 추적한다. 이 값이 설정되어 있어야 훅은 자신이 어떤 컴포넌트 컨텍스트 안에서 호출되고 있는지를 파악할 수 있다.
if (!this.currentRenderingComponent) {
throw new Error('useState can only be called inside a function component.');
}
위처럼 훅 내부에서는 currentRenderingComponent
가 존재하지 않을 경우 에러를 던진다. 이는 훅이 호출되는 시점에 리액트가 어떤 컴포넌트를 렌더링 중인지 모른다는 뜻이기 때문이다.
렌더링 중 컴포넌트 정보는 어떻게 설정되는가?
훅이 정상적으로 동작하기 위해서는 렌더링 흐름 중에서 currentRenderingComponent
가 설정되어 있어야 한다. 이 값은 리액트가 함수형 컴포넌트를 실행하기 전에 아래처럼 주입된다:
private renderSubtree(virtualNode: VirtualNode) {
if (typeof virtualNode.type !== 'function') {
return;
}
this.currentRenderingComponent = virtualNode;
// reconcile 로직
this.currentRenderingComponent = null;
}
렌더링 대상이 함수 컴포넌트일 경우, 해당 가상 노드가 currentRenderingComponent
에 설정되며, 이 시점부터 훅 호출이 가능해진다. 함수 실행이 끝나면 이 값은 다시 null
로 초기화된다.
결론
결국 훅이 유효하게 호출되려면 다음 조건이 충족되어야 한다:
리액트 내부에서의 렌더링 흐름 중에 호출될 것
currentRenderingComponent
가 설정된 시점에서 실행될 것
이런 흐름을 벗어난, 예컨대 컴포넌트 외부에서 인위적으로 훅을 호출하는 경우, currentRenderingComponent
는 null
이며, 훅은 자신이 어떤 컨텍스트에서 실행되는지 판단할 수 없기 때문에 에러를 던지게 된다.
이런 제한은 리액트 훅의 핵심 원칙인 **“컴포넌트 최상단에서 정해진 순서로 호출”**이라는 규칙을 강제하기 위한 장치이기도 하다.
4. 상태 변경 시 서브트리만 재렌더링이 가능한 이유
useState
내부에서 상태를 업데이트할 때, 해당 훅이 속한 컴포넌트의 참조를 클로저로 저장한다. setState
가 실행되면 이 참조를 기반으로 해당 컴포넌트만 재렌더링할 수 있다. 이것이 상태가 변경되었을 때, 가상돔 트리의 루트부터 재렌더링하는 것이 아닌 부분 리렌더링을 통한 돔트리 교체가 가능한 이유이다.
const closureRenderingComponent = this.currentRenderingComponent;
const setState = (updatedState) => {
hooks[closureIndex] = typeof updatedState === 'function'
? updatedState(hooks[closureIndex])
: updatedState;
this.renderSubtree(closureRenderingComponent!);
};
→ 전체 트리가 아닌 해당 컴포넌트부터 하위만 재조정(Reconciliation) 한다.
5. Key를 이용한 재조정 알고리즘
이 부분은 이재상님의 자료를 참고하여 구현했다. 핵심 목표는 리스트 렌더링에서 요소의 추가, 삭제, 순서 변경이 발생하더라도 불필요한 DOM 조작 없이 필요한 부분만 효율적으로 업데이트하는 것이다. 이를 위해 key 값을 기반으로 이전과 새로운 자식 노드를 비교 하고, 실 DOM에 최소한의 변경만 반영한다.
전체 흐름은 총 네 단계로 이루어져 있으며, 이를 reconcileOldAndNewChildrenByCompare()
함수로 구현하였다:
① 이전 자식 노드를 key 기반 Map에 저장
for (let i = 0; i < oldVirtualChildren.length; i++) {
const oldChild = oldVirtualChildren[i];
oldChildrenMap.set(oldChild.props.key ?? i, oldChild);
}
이전 렌더링의 자식 노드들을 Map<key, VirtualNode>
구조로 저장해둔다. key
가 없다면 index를 대신 사용한다. 이로써 빠르게 비교할 수 있는 lookup 테이블이 형성된다.
② 새로운 자식 노드를 순회하며 비교 및 재조정
for (let i = 0; i < newVirtualChildren.length; i++) {
const newChild = newVirtualChildren[i];
const newChildKey = newChild.props.key ?? i;
const oldChild = oldChildrenMap.get(newChildKey) ?? null;
this.reconcile(parentRealNode, parentVirtualNode, oldChild, newChild);
oldChildrenMap.delete(newChildKey);
}
새로운 가상 자식 노드를 순회하면서 같은 key를 가진 이전 노드가 있다면 reconcile()
로 비교 및 갱신하고, 없으면 새로 추가된다. 처리한 노드는 Map에서 제거한다.
③ 자식 노드 순서 보정 (insertBefore 활용)
이 단계에서 reorderChildren()
함수가 호출된다. 이 함수는 재조정 이후 리스트 순서가 바뀌었는지 확인하고, 순서가 어긋난 경우에만 insertBefore
를 호출해 위치를 보정한다.
for (let i = newVirtualChildren.length - 2; i >= 0; i--) {
const newChild = newVirtualChildren[i].realNode;
const currentNextSibling = newChild?.nextSibling;
const newNextSibling = newVirtualChildren[i + 1].realNode;
if (newChild && newNextSibling && currentNextSibling !== newNextSibling) {
parentRealNode.insertBefore(newChild, newNextSibling);
}
}
재정렬은 뒤에서부터 처리되며, 현재 노드의 nextSibling과 재조정 이후 예상되는 nextSibling이 다를 때만 위치를 옮긴다. 맨 마지막 요소는 이동 대상이 아니기 때문에 length - 2
부터 순회한다는 점도 주석에 명확히 적어두었다.
④ 처리되지 않은 이전 노드 제거
for (const key of oldChildrenMap.keys()) {
this.reconcile(parentRealNode, parentVirtualNode, oldChildrenMap.get(key)!, null);
}
새로운 렌더링에 등장하지 않은 이전 노드들은 Map에 남아 있으며, 이들을 null과 비교해 reconcile()
을 호출하면 removeChild
로직이 실행되어 DOM에서 제거된다.
✅ 이 알고리즘이 처리하는 주요 케이스 요약
old는 있고, new는 없음 →
removeChild
old는 없고, new는 있음 →
appendChild
둘 다 있고, type이 다름 →
replaceChild
둘 다 있고, type도 같음 → 자식 재귀 비교
순서가 바뀐 경우 →
insertBefore
로 정렬
이 구현은 실제 리액트의 Fiber 구조보다는 단순화된 버전이지만, key 기반 재조정의 핵심 원리를 잘 반영하고 있다. 직접 이 과정을 구현하면서 리스트 렌더링의 정확성과 성능이 어떤 식으로 유지되는지를 실감할 수 있었다.
마무리하며
참고 자료가 있었지만, 그 내용을 온전히 이해하고 직접 구현해내는 건 쉽지 않았다. 내가 만든 목업은 실제 리액트보다는 훨씬 단순하고 가벼운 버전이지만, 내부 흐름을 직접 설계하고 코드를 짜보는 과정은 리액트를 훨씬 입체적으로 이해하는 계기가 됐다.
물론 이걸 구현하는 데 시간도 많이 들었고, 지금 당장 Fiber 같은 복잡한 구조까지 손대기엔 부담이 큰 것도 사실이다. useMemo
, useCallback
, 그리고 Fiber 아키텍처는 일단 보류 중이고, 나중에 기회가 되면 천천히 구현해볼 생각이다.
리액트를 처음 배우는 사람이나, 나처럼 어느 순간 리액트의 동작 원리가 궁금해진 사람이라면, 직접 만들어보는 것도 꽤 괜찮은 방식이라고 생각한다. 꼭 처음부터 끝까지 다 짤 필요는 없고, 핵심만 짚어가며 구현해보는 것만으로도 얻는 게 많다. 이 글이 그런 시도를 해보고 싶은 사람한테 약간의 힌트 정도가 될 수 있다면 충분하다.
ps: 아직 테스트코드나 타인의 검증이 없어 구현상 코드에 버그나 블로그 글에 틀린 점이 있을 수 있습니다. 이에 대한 피드백은 환영입니다. :)