재귀에는 멈춤 버튼이 없다
React 15까지의 재조정 방식은 허무할 만큼 단순했다. 컴포넌트 트리를 루트부터 재귀로 타고 내려가면서 이전 트리와 비교하고, 바뀐 곳을 DOM에 반영한다. 이걸 스택 재조정기(stack reconciler)라고 부른다. 이름 그대로, 자바스크립트 엔진의 콜 스택에 함수 호출을 쌓아가며 동작한다.
문제는 이 재귀에 멈춤 버튼이 없다는 거였다. 한번 트리를 타고 내려가기 시작하면 끝까지 가야 한다. 콜 스택의 주인은 자바스크립트 엔진이지 React가 아니니까, React가 "잠깐 멈추고 나중에 이어서 하자"고 할 방법이 없다.
트리가 작을 땐 상관없다. 하지만 컴포넌트가 수천 개인 화면을 통째로 다시 그려야 하면, 그 계산이 끝날 때까지 메인 스레드가 통째로 붙잡힌다. 그 몇십 밀리초 동안 사용자가 입력한 키, 누른 버튼, 굴린 스크롤은 전부 큐에 밀려서 버벅임이 된다. 재귀는 빠른 대신, 중간에 멈춰서 양보하는 게 안 된다.
스택을 자료구조로 옮기다
React 16이 2017년에 도입한 Fiber는, 한 문장으로 요약하면 콜 스택을 자바스크립트 엔진에 맡기지 않고 React가 직접 관리하는 자료구조로 옮긴 것이다.
Fiber 하나는 작업의 최소 단위다. 컴포넌트 하나, DOM 노드 하나에 대응하는 평범한 자바스크립트 객체이고, 세 개의 포인터로 이웃과 연결된다. 자식을 가리키는 child, 형제를 가리키는 sibling, 그리고 부모로 되돌아갈 return. 재귀 호출로 암묵적으로 쌓이던 스택 프레임이, 이제 힙에 놓인 명시적인 연결 리스트가 된 거다.
이렇게 바꿔서 얻은 건 딱 하나다. 이제 스택의 주인이 React다. React는 작업 루프를 돌면서 Fiber를 하나 처리할 때마다 "브라우저에게 급한 일이 있는가?"를 묻고, 있으면 지금까지의 진행 상태를 그 자료구조에 저장한 채 제어권을 넘긴다. 그리고 한가해지면 저장해둔 지점부터 다시 잇는다. 재귀였다면 불가능했을, 멈췄다가 저장하고 이어서 하는 일이 가능해진 것이다.

여담이지만 React 팀은 처음에 브라우저의 requestIdleCallback으로 이 양보를 구현하려다, 타이밍이 성에 차지 않아 결국 자체 스케줄러를 만들었다. 아키텍처를 하나 바꾸려다 보면 늘 이렇게 옆 동네까지 파고들게 된다.
렌더와 커밋, 두 개의 단계
Fiber 덕분에 가능해진 진짜 핵심은 렌더링을 두 단계로 쪼갠 거다.
첫 번째는 렌더 단계(render phase)다. 여기서 React는 work-in-progress 트리라는 새 Fiber 트리를 뒤에서 조립한다. 무엇이 바뀌었는지 계산하고 표시만 할 뿐, 실제 DOM은 건드리지 않는다. 그래서 이 단계는 중단해도 안전하다. 중간까지 만들다 멈춰도 되고, 더 급한 업데이트가 오면 만들던 걸 통째로 버리고 처음부터 다시 해도 된다. 화면에는 아무 흔적도 안 남는다.
두 번째는 커밋 단계(commit phase)다. 완성된 트리를 실제 DOM에 한 번에 반영한다. 이 단계는 짧게, 쪼개지 않고 한 번에 끝낸다. 공식 문서도 전체 트리를 다 계산한 뒤에 DOM을 바꾼다고 설명한다. 그래서 반쯤 갱신된 화면을 사용자가 볼 일이 없다.
이 구조는 더블 버퍼링 그 자체다. 화면에 보이는 current 트리와 뒤에서 짓는 work-in-progress 트리를 따로 두고, 커밋 순간에 둘을 맞바꾼다.
16에서 18까지, 뒤늦게 드러난 효과
여기서 좀 의외인 부분이 있다. Fiber가 이 모든 걸 가능하게 만든 게 2017년 React 16인데, 정작 그때 사용자가 체감한 변화는 거의 없었다. 16의 렌더링은 여전히 기본적으로 동기였다. 중단 가능한 아키텍처를 깔아놓고도, 그 위에서 실제로 중단하는 기능은 켜지 않은 채로 몇 년을 보냈다.
React 17은 한술 더 떠서 "새 기능 없음"을 대표 슬로건으로 내건 릴리스였다. 한 페이지에 두 버전의 React를 공존시킬 수 있게 해서 점진적 업그레이드의 길을 트고, 이벤트를 document가 아니라 루트 컨테이너에 위임하도록 바꿨다. 눈에 띄는 신기능 대신, 다음 버전을 위한 밑작업을 해둔 셈이다.

그리고 2022년 React 18에 와서야 Fiber 위에서만 가능한 기능들이 실제로 나왔다. createRoot로 Concurrent 렌더링이 켜졌고, startTransition으로 급하지 않은 업데이트를 표시해 입력 같은 급한 일에 양보시킬 수 있게 됐다. setState가 어디서 호출되든 자동으로 묶이는 Automatic Batching도 이때 기본이 됐다. 공식 문서의 정의대로, concurrent 렌더링은 "업데이트를 시작하고, 멈추고, 나중에 잇거나, 아예 버릴 수 있는" 렌더링이다. 이건 2017년에 깔아둔 Fiber가 없었으면 한 줄도 못 쓸 기능들이다.
좋은 아키텍처는 나온 날이 아니라 몇 년이 지나서야 진가가 드러난다. Fiber가 딱 그랬다. 좋은 추상화는 원래 늦게 온다는 이야기와도 통하는 데가 있다.
아키텍처가 내 코드에 요구한 것
여기까지는 React 내부 이야기다. 그런데 이 전환 때문에 우리가 짜는 컴포넌트에도 규칙이 하나 생겼다.
렌더가 중단되고, 버려지고, 다시 실행될 수 있다면, render 함수는 몇 번을 실행해도 같은 결과를 내는 멱등 함수여야 한다. 이건 코딩 스타일 취향 문제가 아니라, 아키텍처상 지킬 수밖에 없는 규칙이다. 렌더 도중에 밖의 무언가를 바꾸거나, 실행 횟수에 의존하거나, 순서에 기대는 컴포넌트는 concurrent 환경에서 깨진다. React가 그 렌더를 한 번 굴리다 버리고 다시 굴렸을 뿐인데, 그 부작용은 두 번 일어나거나 어중간하게 반쯤 일어나기 때문이다.
React가 StrictMode에서 개발 중에 컴포넌트와 초기화 함수, 업데이트 함수를 일부러 두 번씩 호출하는 이유가 여기 있다. 순수하지 않은 코드를 개발 중에 미리 티 나게 만들어서 잡으려는 거다. 공식 문서의 문장을 빌리면, React는 컴포넌트가 순수 함수형 언어 수준으로 완벽하게 순수할 것까지는 요구하지 않지만 멱등할 것은 요구한다. 부작용은 render가 아니라 커밋 이후의 이펙트나 이벤트 핸들러에서 일어나야 한다는 규칙도, 따지고 보면 렌더 단계가 중단 가능해야 한다는 이 구조에서 그대로 따라 나온 것이다.
외부 스토어를 렌더 도중에 직접 읽던 상태 관리 라이브러리들이 tearing 문제를 만난 것도 같은 뿌리다. 한 렌더 사이클 안에서 중단됐다 재개되는 사이에 외부 값이 바뀌면, 같은 화면의 두 컴포넌트가 서로 다른 값을 읽어 화면이 찢어진다. React 18이 useSyncExternalStore를 내놓은 것도, concurrent 렌더링의 새 규칙을 외부 상태까지 지키게 하려는 거였다.
그래서 뭘 배울 수 있나
두 가지 정도인 것 같다.
첫째, 프레임워크가 거는 제약에는 대개 더 깊은 이유가 있다. "왜 render 안에서 부작용을 쓰면 안 되지?", "왜 StrictMode는 두 번 렌더하지?" 같은 규칙을 그냥 번거로운 제약으로만 보면 계속 걸리게 된다. 하지만 그 규칙이 중단 가능한 렌더링이라는 구조에서 나올 수밖에 없다는 걸 이해하면, 규칙을 지키는 게 곧 미래의 기능을 공짜로 받는 길이 된다. 실제로 16 시절부터 컴포넌트를 순수하게 유지한 앱들은, 18로 올라가면서 concurrent 기능을 거의 그냥 얻었다. 일찍부터 규칙을 지킨 게 나중에 이득으로 돌아온 것이다.
둘째, 큰 아키텍처는 효과가 늦게 나타난다는 걸 감안하고 봐야 한다. Fiber가 나왔을 때 "그래서 뭐가 빨라졌는데?"라고 물었다면 답이 궁색했을 거다. 진짜 답은 5년 뒤에 있었다. 기술의 가치를 나온 날의 체감으로만 재면, 토대를 까는 일이 얼마나 중요한지를 매번 놓치게 된다. 봐야 할 건 나온 순간이 아니라, 그 구조가 앞으로 뭘 할 수 있게 열어주느냐다.
React가 재귀를 포기한 건 결국 제어권을 되찾기 위해서였다. 그리고 그 제어권을 실제로 쓰려고, React는 개발자에게도 render를 순수하게 유지하라고 요구했다. 따지고 보면 당연하다. 제어권을 가져온 만큼 지켜야 할 규칙도 늘어난 것뿐이다.
참고 자료
- Andrew Clark, react-fiber-architecture, Fiber 설계를 정리한 비공식 문서
- React 팀, React v18.0 릴리스 노트 (2022)
- React 공식 문서, Keeping Components Pure