들어가며

리액트를 몇 년째 쓰고 있지만, “왜 이렇게 동작하는가”를 설명해보라고 하면 막히는 순간이 꽤 있었습니다.

이번에 모던 리액트 Deep Dive를 읽으면서 정리한 내용을 남겨봅니다. 리액트를 쓰기 위한 자바스크립트, JSX가 어디서 왔는지, 그리고 가상 DOM과 리액트 파이버까지의 이야기입니다.


리액트라는 선택

리액트는 현재 모던 웹 프레임워크 중 가장 수요가 많고, 가장 많은 개발자가 선택한 도구입니다.

다만 여기서 조심해야 할 것은, “수요가 가장 많다”와 “가장 빠르고, 퍼포먼스가 좋고, 가장 합리적인 선택이다”는 전혀 다른 이야기라는 점입니다. 리액트는 그 자리에 있을 만한 이유가 있지만, 그 이유가 곧 기술적 우위인 것은 아닙니다.

버전 현황

  • 19 버전 릴리즈: 2024년 12월
  • 최신 버전: 19.2 (2025.10.01)
  • 20 버전은 아직 공식 발표가 없으며, 당분간은 19.2가 스테이블 버전으로 유지될 예정입니다.

2013년의 리액트는 환영받지 못했습니다

지금이야 당연하게 쓰지만, 공개 당시 리액트에 대한 반응은 호평보다 비판에 가까웠습니다. 가장 큰 논쟁거리는 JSX, 즉 자바스크립트 안에 HTML 태그를 혼용한다는 것이었습니다.

당시 프론트엔드의 대전제는 “구조는 HTML, 스타일은 CSS, 동작은 JS”라는 관심사의 분리였고, 리액트는 그 전제를 정면으로 어겼습니다. 지금 우리가 컴포넌트 단위의 분리를 자연스럽게 받아들이는 것과 비교하면, 10년 사이에 “관심사”의 정의 자체가 바뀐 셈입니다. 기초적인 사실처럼 받아들여지던 원칙도 결국 시대의 산물이라는 게 흥미로운 지점입니다.

아이러니하게도, 다 갖추지 않아서 성장했습니다

개인적으로 리액트가 이렇게까지 성장할 수 있었던 이유는, 역설적으로 리액트가 모든 것을 가지고 있지 않았기 때문이라고 생각합니다.

앵귤러나 뷰는 프레임워크로서 라우팅, 폼, HTTP, 상태 관리까지 대부분을 자체적으로 제공합니다. 반면 리액트는 뷰 라이브러리일 뿐이라 나머지를 전부 서드파티로 채워야 했습니다.

그래서 커뮤니티의 도움이 절실했고, 그 빈자리가 오히려 수많은 사람들이 라이브러리를 만들어 붙이는 원동력이 되었습니다. 라우터도, 상태 관리도, 데이터 페칭도 “정답”이 정해져 있지 않았기 때문에 생태계가 폭발적으로 커질 수 있었습니다.


꼭 알아야 할 자바스크립트

동등 비교

리액트에서 리렌더링을 결정하는 것은 결국 비교입니다. propsuseEffect의 의존성 배열(dependencies)은 리렌더링과 이펙트 실행에 직접 영향을 주며, 기본적으로 얕은 비교를 사용합니다.

이건 앵귤러의 시그널도 마찬가지입니다. 실제 앵귤러 코어의 기본 비교 함수를 보면 리액트와 같은 함수를 씁니다.

// 시그널도 동일하게 해당 함수를 통해 얕은 비교를 진행합니다
export function defaultEquals<T>(a: T, b: T) {
  return Object.is(a, b)
}

리액트도 내부적으로 Object.is를 사용합니다. =====가 아닌 Object.is인 이유는 두 가지 예외 때문입니다.

-0 === +0 // true
Object.is(-0, +0) // false

NaN === NaN // false
Object.is(NaN, NaN) // true

그리고 리액트는 여기서 한 걸음 더 나갑니다. Object.is만으로는 객체 비교가 항상 false가 되어버리기 때문에, props 객체에 대해 1 depth까지 한 번 더 얕은 비교를 수행합니다.

// 개념적으로는 이런 형태입니다
function shallowEqual(objA, objB) {
  if (Object.is(objA, objB)) return true

  const keysA = Object.keys(objA)
  const keysB = Object.keys(objB)
  if (keysA.length !== keysB.length) return false

  // 여기까지만, 즉 1 depth까지만 비교합니다
  return keysA.every(key => Object.is(objA[key], objB[key]))
}

딱 1 depth까지만이라는 게 핵심입니다. props로 넘긴 객체 안의 객체가 바뀌어도 리액트는 알아채지 못하고, 반대로 매 렌더링마다 새로 만든 객체나 함수를 넘기면 값이 같아도 항상 다른 것으로 판단합니다. memo를 붙였는데도 리렌더링이 멈추지 않는 대부분의 이유가 여기에 있습니다.

falsy 값과 연산자

falsy 값 때문에 연산자를 잘못 고르면 의도와 다르게 동작합니다. 솔직히 저도 종종 ??를 써야 할 자리에 ||를 써놓고 뒤늦게 물음표를 하나 더 찍을 때가 있습니다.

const count = 0

count || 10 // 10  ← 0이 falsy라서 넘어갑니다
count ?? 10 // 0   ← null/undefined일 때만 넘어갑니다

0, '', NaN처럼 유효한 값이지만 falsy한 것들이 섞이는 순간 ||는 버그가 됩니다.

함수

  • 자바스크립트의 함수는 일급 객체입니다. 변수에 담고, 인자로 넘기고, 반환할 수 있습니다.
  • 화살표 함수 역시 일급 함수이며, 자신만의 this를 갖지 않고 상위 스코프의 this를 그대로 사용합니다.
  • 즉시 실행 함수(IIFE)는 캡슐화와 렉시컬 환경을 보장하기 때문에 여전히 유용합니다.
  • 함수가 일급 객체이므로 고차 함수를 자유롭게 쓸 수 있고, 리액트의 HOC나 커스텀 훅도 결국 이 성질 위에 서 있습니다.
  • 부수 효과가 없는 함수를 지향합시다. 쏙쏙 들어오는 함수형 코딩에서 말하는 “계산(calculation)“과 “액션(action)“의 구분을 떠올리면 좋습니다.
  • 함수는 가능한 한 작게 만듭니다. 하나의 함수는 하나의 일만 (SRP), 그리고 이름이 곧 추상화의 수준이 되도록 합니다.

클래스

클래스 내부의 인스턴스 메서드는 실제로는 프로토타입 메서드입니다. 인스턴스가 아니라 prototype 객체에 붙기 때문에, 생성된 인스턴스에서 프로토타입 체인을 따라 올라가며 호출되고 확인할 수 있습니다.

class Car {
  drive() {}
}

const car = new Car()
Object.getPrototypeOf(car) === Car.prototype // true
car.hasOwnProperty('drive') // false ← 인스턴스가 아니라 프로토타입에 있습니다

리액트가 클래스 컴포넌트에서 함수 컴포넌트로 넘어왔지만, 라이프사이클과 this 바인딩 이슈를 이해하려면 여전히 알아야 하는 부분입니다.

클로저

스코프와 클로저를 잘 이해하면, 클래스 없이도 캡슐화가 잘 된 팩토리를 함수형으로 만들 수 있습니다.

function createCounter(initial = 0) {
  let count = initial // 외부에서 접근 불가

  return {
    increase: () => ++count,
    decrease: () => --count,
    get value() {
      return count
    },
  }
}

useState가 컴포넌트 밖에서도 상태를 기억하는 것, 이벤트 핸들러가 예전 렌더의 값을 붙잡고 있는 stale closure 문제 모두 같은 원리 위에 있습니다.

비동기 통신과 이벤트 루프

브라우저의 렌더링은 마이크로태스크 큐가 모두 비워진 뒤에 일어나고, 그다음에 태스크 큐로 넘어갑니다.

태스크 하나 실행
  → 마이크로태스크 큐 전부 소진 (Promise.then, queueMicrotask ...)
  → 필요하다면 렌더링 (스타일 → 레이아웃 → 페인트)
  → 다음 태스크 (setTimeout, 이벤트 핸들러 ...)

그래서 Promise 체인을 아무리 길게 걸어도 그 사이에는 화면이 갱신되지 않고, 마이크로태스크 안에서 무한 루프를 돌면 브라우저가 통째로 멈춥니다. 무거운 작업을 쪼개 넣어야 할 때 setTimeout이 여전히 쓰이는 이유이기도 합니다.


JSX

JSX는 ECMAScript 표준이 아닙니다. 페이스북이 자체적으로 만든 문법이고, 트랜스파일러를 거쳐야만 실행됩니다. 지금은 리액트 밖에서도 널리 쓰여서 LIT 같은 라이브러리에서도 만날 수 있습니다.

마크업의 계보

태초에 SGML이 있었고, 데이터의 처리와 기술을 단순화하기 위해 XML이 만들어졌습니다. HTML 역시 2.0부터는 SGML의 문법을 따르게 되었습니다. JSX가 XML스러운 문법을 갖게 된 것도 이 계보 안에 있습니다.

JSX를 이루는 요소

JSX는 JSXElement, JSXAttributes, JSXChildren, JSXStrings 네 가지를 기반으로 구성됩니다.

  • JSXElementJSXOpeningElement로 시작해 JSXClosingElement로 끝나야 올바른 문법으로 간주됩니다. 당연히 여는 태그와 닫는 태그의 이름은 같아야 합니다.
  • 그 외에 자기 자신을 닫는 JSXSelfClosingElement(<div />)와 JSXFragment(<></>)가 있습니다.
  • JSXChildren은 JSXChild를 0개 이상 가질 수 있습니다. 즉, 자식이 없어도 됩니다.

스스로 닫는 태그의 함정

HTML에서 스스로 닫는 태그(void element)가 허용된 것은 다음이 전부입니다.

area, base, br, col, embed, hr, img,
input, link, meta, source, track, wbr

이 목록 밖의 태그는 HTML 파서 입장에서 <div />닫히지 않은 여는 태그로 취급합니다. JSX에서는 아무 문제가 없지만, LIT처럼 브라우저의 HTML 파서를 그대로 쓰는 라이브러리에서 리액트 습관대로 <my-element />를 쓰면 뒤따르는 마크업이 전부 그 안으로 빨려 들어갑니다. 리액트만 쓰다가 다른 스택으로 넘어갔을 때 꼭 한 번씩 밟는 지뢰입니다.

JSXMemberExpression

<foo.bar> 형식으로 선언하는 문법입니다. 이걸 Context API와 함께 쓰면 컴파운드 컴포넌트 패턴이 됩니다.

<Select>
  <Select.Trigger />
  <Select.Options>
    <Select.Option value="a" />
  </Select.Options>
</Select>

내부 상태를 Context로 감춰두고 하위 컴포넌트끼리만 공유하기 때문에 캡슐화 수단으로 쓰입니다. 반대로 Context 없이 그냥 객체에 컴포넌트를 담아둔 것이라면, 그건 패턴이라기보다 단순한 네임스페이스의 확장에 가깝습니다.


가상 DOM과 리액트 파이버

브라우저는 어떻게 화면을 그리는가

HTML이 DOM 트리가 되기까지는 크게 네 단계를 거칩니다.

바이트 → [변환] → 문자 → [토큰화] → 토큰 → [렉싱] → 노드 → [DOM 생성] → DOM 트리
  • 변환: 브라우저가 HTML의 원시 바이트를 읽어와, 지정된 인코딩(예: UTF-8)에 따라 개별 문자로 변환합니다.
  • 토큰화: 문자열을 W3C 표준에 지정된 고유 토큰으로 변환합니다. <(0x3C)를 만나면 태그의 시작으로 인식하고, >(0x3E)를 만날 때까지의 문자를 태그 이름으로 인식합니다. 이 과정을 HTML 끝까지 반복합니다.
  • 렉싱: 토큰을 의미를 가진 객체(노드)로 변환합니다. 토크나이저 단계에 포함해서 보기도 합니다.
  • DOM 생성: 태그 간의 관계를 해석해 트리 구조로 연결합니다.

CSS도 동일한 과정을 거쳐 CSSOM이 됩니다. 예전에는 이 파싱을 별도로 처리했지만, 이제는 브라우저마다 CSS 엔진이 내장되어 있습니다.

DOM과 CSSOM이 만들어지면 어태치먼트 단계를 거쳐 렌더 트리가 만들어집니다. 이때 레이아웃에서 각 요소의 위치와 크기가 정해지고, 페인트에서 실제 픽셀이 칠해집니다.

그리고 화면이 갱신될 때는 리플로우(reflow)와 리페인트(repaint) 가 발생합니다. 크기나 위치가 바뀌면 레이아웃부터 다시 계산해야 하니 비싸고, 색상처럼 페인트만 다시 하면 되는 변경은 상대적으로 쌉니다.

이 비용을 최소화하기 위해 등장한 것이 가상 DOM이며, 이를 더 잘 다루기 위한 아키텍처가 리액트 파이버입니다.

리액트 리콘실리에이션

리콘실리에이션(reconciliation, 재조정)은 쉽게 말해 변경된 부분만 찾아 업데이트해서 성능을 최적화하는 방식입니다.

가상 DOM을 기준으로 이전 트리와 새 트리를 비교하고, 최소한의 변경만 실제 DOM에 반영합니다. 두 트리를 완벽하게 비교하는 알고리즘은 O(n³)이라 현실적이지 않기 때문에, 리액트는 휴리스틱을 사용해 O(n) 으로 처리합니다.

그 휴리스틱은 크게 두 가지 가정 위에 서 있습니다.

  1. 타입이 다른 두 엘리먼트는 서로 다른 트리를 만든다 → 통째로 버리고 새로 만든다
  2. 개발자가 key로 어떤 자식이 유지되는지 알려준다

리스트에서 key를 인덱스로 주면 안 된다는 이야기가 나오는 지점이 바로 여기입니다. 리액트는 key를 “같은 것”의 근거로 믿기 때문입니다.

리액트 파이버

  • 파이버는 16 버전부터 적용되었습니다.
  • 파이버는 특별한 무언가가 아니라 단순한 자바스크립트 객체입니다.
  • 작업을 작은 단위로 나누고, 각 단위에 우선순위를 부여합니다.
  • 파이버는 싱글톤이 아닙니다. 컴포넌트(엘리먼트) 하나당 하나씩 존재하며, 렌더링마다 새로 만들지 않고 최대한 재사용됩니다.
  • 이전에는 스택 기반이었습니다. 이 차이가 전부입니다.

파이버 객체를 아주 단순화하면 이런 모습입니다.

{
  tag,        // 컴포넌트의 종류
  stateNode,  // 실제 DOM 노드나 인스턴스
  child,      // 첫 번째 자식
  sibling,    // 다음 형제
  return,     // 부모 (돌아갈 곳)
  pendingProps,
  memoizedProps,
  memoizedState,
  alternate,  // 반대편 트리의 짝
}

리액트는 이 파이버를 두 벌 유지합니다. 화면에 반영되어 있는 current 트리와 지금 계산 중인 workInProgress 트리이고, 둘은 alternate로 서로를 가리킵니다. 작업이 끝나면 포인터만 바꿔서 통째로 교체합니다. 일종의 더블 버퍼링입니다.

Fiber는 리액트 렌더러의 코루틴스러운 “실행 컨텍스트”

여기가 이번 정리에서 제일 하고 싶었던 이야기입니다.

파이버 이전의 리액트는 자바스크립트의 콜스택을 그대로 빌려 썼습니다. 컴포넌트를 재귀적으로 렌더링했기 때문에, 한 번 시작하면 트리 전체를 다 훑을 때까지 멈출 수 없었습니다. 콜스택은 우리가 중간에 끼어들어 “잠깐 멈춰두고 나중에 이어서 해줘”라고 말할 수 있는 자료구조가 아니기 때문입니다. 그래서 트리가 크면 그동안 메인 스레드가 잠기고, 입력은 씹혔습니다.

파이버가 한 일은 그 스택 프레임을 힙 위의 객체로 다시 구현한 것입니다.

  • return은 리턴 주소(돌아갈 부모)
  • child, sibling은 다음에 처리할 작업
  • pendingProps / memoizedProps는 인자와 지역 상태

콜스택이 하던 일을 전부 객체 필드로 옮겨 놓은 셈입니다. 스택이 아니라 힙에 있는 연결 리스트이므로, 리액트는 작업 도중 아무 지점에서나 멈추고, 그 파이버를 손에 쥔 채 브라우저에 제어권을 돌려주고, 나중에 정확히 그 자리부터 다시 시작할 수 있습니다.

이것이 코루틴의 성질과 정확히 겹칩니다. 코루틴은 중간에 yield로 실행을 양보하고 자신의 실행 컨텍스트를 보존했다가 재개하는 함수인데, 파이버가 하는 일이 바로 그것입니다. 실제로 리액트에는 ReactCoroutine이라는 실험적 구현이 있었고, 파이버는 그 계보 위에 있습니다.

[스택 재조정자]   렌더 시작 ────────────────────────▶ 렌더 끝
                  (중간에 끼어들 수 없음. 메인 스레드 점유)

[파이버 재조정자] 작업 ─▶ 양보 ─▶ 작업 ─▶ 양보 ─▶ 작업 ─▶ 커밋
                        (사용자 입력)   (애니메이션)

그래서 렌더링이 두 단계로 나뉩니다.

  • 렌더 단계: 파이버 트리를 만들며 무엇이 바뀌었는지 계산합니다. 중단·재개·폐기가 가능하고, 그래서 부수 효과가 없어야 합니다. StrictMode에서 함수 컴포넌트를 두 번 호출해보는 것도, 이 단계가 언제든 버려질 수 있기 때문입니다.
  • 커밋 단계: 계산된 결과를 실제 DOM에 반영합니다. 화면이 찢어지면 안 되므로 중단할 수 없고 한 번에 끝냅니다.

이 구조 덕분에 우선순위라는 개념이 성립합니다. 사용자의 타이핑은 급하고, 화면 뒤편의 목록 렌더링은 조금 늦어도 됩니다. 리액트 18의 Lane 모델과 startTransition, Suspense가 가능한 것도 결국 “작업을 중간에 멈춰서 미뤄둘 수 있다” 는 이 하나의 전제 위에 서 있습니다.

정리하면, 파이버는 성능 최적화 기법이라기보다 리액트가 자바스크립트 런타임의 콜스택에서 벗어나 자기만의 실행 컨텍스트를 갖게 된 사건에 가깝습니다. 리액트가 자기 스케줄러를 갖게 된 순간이기도 합니다.


마치며

정리하면서 가장 크게 남은 건, 리액트의 주요 결정들이 대부분 자바스크립트의 한계를 우회하기 위한 선택이었다는 점입니다.

Object.is와 얕은 비교는 값 비교의 한계에서, JSX는 마크업과 로직을 한 곳에 두려는 선택에서, 파이버는 콜스택을 제어할 수 없다는 제약에서 나왔습니다. 결국 리액트를 이해한다는 건 자바스크립트를 이해하는 일과 크게 다르지 않았습니다.

다음 글에서는 리액트 훅과 렌더링 최적화를 이어서 정리해보겠습니다.