내가 직접 구현한 리액트 목업 버전에 대하여
리액트를 개인적으로 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) 한다.
