React fiber가 존재하는 이유
React 15의 재귀적 reconciliation은 렌더링을 중단할 수 없고 우선순위도 구분할 수 없었습니다. 리액트는 이를 해결하기 위해 자바스크립트 callstack 대신 자체 자료 구조인 Fiber를 도입하여, 렌더링을 작은 단위로 나누고 브라우저에게 제어권을 넘길 수 있게 했습니다.
이 글은 Understanding Why React Fiber Exists를 번역한 글입니다.
얼마 전에 트위터를 보다 아래와 같은 글을 발견했습니다.
React fiber 솔직히 말해서 사람들이 처음엔 이해한다고 생각하지만, 실제로 내부를 살펴보면 갑자기 자바스크립트 안에 숨겨진 외계 기술처럼 느껴지는 그런 기술 중 하나입니다. 모두가 fiber는 새로운 기술이다와 같은 말을 앵무새처럼 따라합니다. 원문
정말 좋은 글입니다. 리액트에 대해 많은 것을 잘 요약한 글입니다. 특히 글에서 언급한 부분이 제게 많은 생각을 하게 만들면서 리액트에 대해 더 자세히 알아보고 싶다는 흥미를 불러일으켰습니다.
"리액트는 자바스크립트 callstack으로부터 마침내 자유로워졌습니다. 재귀적인 렌더링 대신에, fiber를 하나씩 순차적으로 탐색합니다. 그래서 리액트는 그 작업을 중지할 수 있습니다. 말 그대로 렌더링 도중에 멈추고, 모든 작업을 중단하고, 브라우저가 리페인트를 하게 한 다음 입력을 처리하고 중단됐던 부분부터 다시 시작할 수 있습니다. 이제 '재귀에 빠졌다'는 상황은 더 이상 없습니다. 더 이상 내장된 스택을 사용하지 않으니 재귀에 빠질 일이 없습니다. react가 자체 스택을 동작시킵니다."
"리액트가 자바스크립트 callstack으로부터 자유로워졌다." 흠...
흥미롭네요. 자바스크립트 callstack이 어떤 문제가 있었길래 리액트는 callstack으로부터 벗어나야 했을까요?
이 글은 이미 리액트를 알고 있거나 내부 동작원리를 이해하고 싶은 개발자를 대상으로 합니다. 이 글을 이해하려면 자바스크립트의 callstack이나 이벤트 루프와 같은 개념에 익숙해야 합니다. 이 글은 react fiber가 존재하는 이유를 리액트 관점에서 설명한 글입니다. 리액트가 다른 프레임워크의 패턴 대신 이 방식을 선택한 이유에 대해서는 다루지 않습니다.
해결책을 이해하려면 문제를 먼저 이해해야 합니다. 어떤 것이 리액트 성능을 방해했는지 그리고 리액트 팀은 이 문제를 어떻게 해결했는지 살펴보겠습니다.
🌃 싱글 스레드의 문제
자바스크립트는 싱글 스레드 프로그래밍 언어입니다. 자바스크립트는 단 하나의 스레드와 callstack을 가지며 실행 컨텍스트들은 코드가 위에서 아래로 실행함에 따라 callstack에 쌓입니다.
function doSomethingHeavy() {
// thread 점유
for (let i = 0; i < 1_000_000_000; i++) {
//
}
console.log('Finished heavy task');
}
doSomethingHeavy();
do_important_task(); // 중요한 작업
위 코드에서 doSomethingHeavy 함수가 실행되면 끝날 때까지 멈추지 않습니다. 하지만 그 후에 중요한 작업을 한다고 가정해보겠습니다. 중요한 작업은 doSomethingHeavy 함수의 실행이 종료가 되어야 실행할 수 있습니다.
위 예시는 단순한 동기 프로그램입니다. 자바스크립트는 비동기 프로그래밍 또한 지원합니다. 아마 async, await를 사용해 본 적이 있을 겁니다. 비동기 작업은 event loop에 의해 처리됩니다.
doSomethingHeavy 함수와 do_important_task 함수의 순서를 바꾸면 되지 않을까 생각할 수도 있겠지만 이는 중요한 작업이 무엇인지 알 때만 가능합니다. 만약 결정할 수 없는 상황이라면요?
무거운 작업을 실행하고 있는 도중에 중요한 작업이 갑자기 나타납니다. 실행되고 있는 도중에 작업을 중단하고 callstack을 비워 새로운 작업을 실행하는 것은 불가능합니다.
예시를 살펴보겠습니다:
function App() { const [count, setCount] = useState(0); const [isBlocked, setIsBlocked] = useState(false); const blockThread = () => { setIsBlocked(true); setTimeout(() => { const start = Date.now(); let dummy = 0; // 3초간 block while (Date.now() - start < 3000) { dummy += Math.random(); } setIsBlocked(false); }, 0); }; return ( <div className="container"> <div className="count">{count}</div> <div className="buttons"> <button onClick={blockThread}> {isBlocked ? 'Processing..' : 'Start Work'} </button> <button onClick={() => setCount(count + 1)}>Click +1</button> </div> </div> ); }
start work 버튼을 클릭한 후에 click + 1 버튼을 눌러보세요. start work 버튼은 실행이 종료되는데 3초가 걸립니다. 그동안에 click + 1 버튼을 눌러도 바로 증가하지 않습니다.
3초를 기다려야 화면에 반영되는데 이는 UX에 좋지 않습니다.
섹션 요약
- 자바스크립트는 싱글 스레드이므로 무거운 작업이 실행 중이면 callstack을 비울 수 없어 다른 작업은 대기해야 한다.
- 실행 도중 더 중요한 작업이 나타나도 현재 작업을 중단할 수 없어 UI가 멈추는 문제가 발생한다.
🌃 React 15
리액트 15의 reconciler는 함수를 재귀적으로 호출해 컴포넌트 트리를 탐색합니다. 이 방식은 한 번 실행되면, 멈출 수 없습니다.
function updateComponent(component) {
const children = component.render();
children.forEach((child) => updateComponent(child));
}
updateComponent 함수가 실행될 때마다 자바스크립트의 callstack에 새로운 실행 컨텍스트가 쌓입니다. 1000개의 컴포넌트가 있는 트리라면, 1000개의 실행 컨텍스트가 쌓이게 되고 각각은 서로 중첩됩니다.
사용자가 입력하는 것들을 화면에 보여주는 인풋이 있다고 가정해보겠습니다. 사용자가 s라는 글자를 입력하면 리액트는 렌더링 프로세스를 시작하고 updateComponent를 재귀적으로 호출합니다.
재귀적으로 호출되는 동안, callstack은 함수 호출로 인한 실행 컨텍스트로 쌓이게 됩니다. 렌더링 과정 중간에 사용자가 a라는 글자를 입력해도 이 과정을 멈출 수 없습니다.
'잠깐, 사용자가 새로운 글자를 입력했어. 새로운 글자로 렌더링을 다시 시작해줘.'와 같은 말을 할 수 없습니다.
자바스크립트는 callstack을 잠깐 멈추고 상태를 저장한 다음 다시 시작할 수 있는 기능이 없습니다. 리액트는 s 글자 처리를 완전히 끝낸 뒤에야 a 글자가 입력됐다는 걸 볼 수 있습니다.
키보드 입력마다 새로운 reconciliation 과정이 시작됩니다. 입력값이 쌓일 때마다 리액트는 재귀 호출에 갇히게 됩니다.
이것이 바로 리액트가 느리게 느껴졌던 이유입니다.
섹션 요약
- React 15의 reconciler는 재귀적으로 컴포넌트 트리를 탐색하며, 한 번 시작되면 중간에 멈출 수 없다.
- 렌더링 도중 사용자 입력이 발생해도 이전 렌더링이 완전히 끝나야 처리할 수 있어 UI가 느리게 느껴졌다.
🌃 우선순위
두 번째 문제가 있습니다. 리액트는 모든 업데이트를 동일하게 처리합니다. 버튼을 클릭하는 것이 백그라운드에서 데이터를 불러오는 것과 동등한 우선순위를 가지며, 애니메이션이 로그와 동일한 우선순위를 갖습니다.
하지만 어떤 업데이트는 급하며(사용자 입력, 클릭), 어떤 건 기다려도 됩니다(통계, pre-fetching). 하지만 리액트는 이를 표현할 방법이 없습니다. 왜냐하면 일단 reconciliation이 시작되면 완료될 때까지 멈추지 않고, 모두 동일한 우선순위를 갖기 때문입니다.
서버에서 500개의 상품 목록을 가져온다고 가정해보겠습니다. 응답이 오면, 리액트는 500개의 아이템들을 화면에 렌더링합니다. 250개의 상품을 렌더링하는 도중에 검색창에 글자를 입력합니다.
이러면 리액트는 어떻게 동작해야 할까요?
상품 렌더링을 멈추고 키보드 입력을 먼저 처리해야 합니다. 인풋 박스를 즉시 업데이트해서 사용자가 입력하는 것들을 바로 반영해야 합니다.
상품을 렌더링하는 것은 기다릴 수 있습니다. 검색 결과를 보는 데 100ms의 지연은 알아차리기 어렵습니다. 하지만 키보드 입력에서 100ms의 지연은 매우 불편합니다.
섹션 요약
- React 15는 모든 업데이트를 동일한 우선순위로 처리하여, 급한 작업(사용자 입력)과 덜 급한 작업(데이터 렌더링)을 구분할 수 없었다.
- 사용자 입력처럼 즉각적인 반응이 필요한 작업이 무거운 렌더링에 밀려 지연되는 문제가 발생했다.
🌃 필요한 것
해결책을 살펴보기 전에, 해결책에서 무엇을 원하는지 명확하게 할 필요가 있습니다.
재귀적인 reconciliation에는 두 가지 주요한 문제가 있었습니다.
- 렌더링 과정을 중간에 멈출 수 없다.
- 업데이트 간 우선순위를 구분할 수 없다.
따라서 해결책은 순위가 높은 작업이 있다면 렌더링 프로세스를 중단하고 해당 작업을 먼저 처리한 다음 남은 작업을 처리해야 합니다.
왜 callstack 수준에서 멈춰야 하나요?
왜냐하면 브라우저가 그렇게 동작하기 때문입니다. 만약 callstack이 비어 있지 않으면, 브라우저는 클릭이나 키보드 입력 이벤트를 macro queue에서 callstack으로 이동시키지 않습니다.
섹션 요약
- 재귀적 reconciliation의 핵심 문제는 렌더링을 중단할 수 없다는 것과 우선순위를 구분할 수 없다는 것이다.
- 해결책은 우선순위가 높은 작업이 있을 때 렌더링을 중단하고 해당 작업을 먼저 처리하는 것이며, 이를 위해 callstack을 비워야 브라우저가 이벤트를 처리할 수 있다.
🌃 해결책
리액트 팀은 문제가 reconciliation 알고리즘에 있는 것이 아니라 알고리즘이 어떻게 실행되는지에 있다는 걸 알게 되었습니다.
자바스크립트에서 재귀 호출은 근본적으로 중단할 수 없기 때문에 아무리 최적화를 해도 이를 해결할 수 없습니다.
자바스크립트의 callstack을 원래 의도와 다르게 동작하도록 시도하는 것 대신에, 리액트는 재귀적인 모델을 포기했습니다. 자바스크립트 엔진이 reconciliation을 주도하는 것 대신 그 위에 자체적인 추상화 계층을 추가했습니다.
그 추상화가 바로 Fiber입니다.
Fiber는 callstack을 제어하지 않습니다. Callstack은 자바스크립트에 내장되어 있기 때문에 그 어떤 것도 제어할 수 없습니다. Fiber가 제어하는 것은 렌더링 작업을 구성하고 실행하는 방식입니다.
전체 렌더링 과정을 재귀적으로 한 번에 실행하는 것 대신에, 리액트는 전체 reconciliation 과정을 작은 단위로 나누었습니다. 리액트는 하나의 단위를 실행한 다음에 브라우저에게 제어권을 넘깁니다. Callstack이 비워지고 브라우저는 대기 상태에 있는 이벤트들을 처리합니다. 그런 다음 리액트는 다음 단위를 실행합니다.
이 과정은 매우 짧은 시간에 일어납니다. 리액트는 ~5ms 동안 동작하고 잠깐 멈춥니다. 그런 다음 브라우저가 동작하고 그 후에 리액트가 정지된 다음부터 다시 실행됩니다.
React 18 버전 이전의 스케줄러는 일부 경로에서 약 5ms라는 고정된 값을 기준으로 삼았습니다. 현대 리액트의 스케줄러 패키지에서는 프레임 비율에 맞게 유동적으로 조절됩니다. 다음 프레임이 시작되기 전(60fps에서는 16.7ms, 120fps에서는 8.3ms)에 끝내는 것을 목표로 하지만, 우선순위가 높은 작업이 있거나 작업을 중단해도 된다고 판단되면 브라우저에게 더 일찍 제어권을 넘깁니다.
리액트 스케줄러의 동작 방식은 별도의 블로그 주제가 될 수 있지만, 이 블로그에서는 간단하게 5ms라고 가정하겠습니다.
섹션 요약
- 리액트는 재귀적 모델을 포기하고, 자바스크립트 callstack 위에 자체 추상화 계층인 Fiber를 도입했다.
- Fiber는 렌더링 작업을 작은 단위로 나누어 약 5ms씩 실행한 뒤 브라우저에게 제어권을 넘겨, 사용자 입력 등 대기 중인 이벤트를 처리할 수 있게 한다.
🌃 자료 구조로서의 Fiber
리액트는 재귀를 포기했습니다. 하지만 callstack을 더 이상 사용하지 않는다면, 컴포넌트 트리를 순회하면서 진행 사항을 추적할 다른 수단이 필요합니다.
리액트는 자체적으로 소유할 수 있는 데이터 구조가 필요합니다. 실행 중에 중간에 멈추고 다시 재개할 수 있는 자료 구조, 그 유연한 자료 구조가 fiber입니다. Fiber는 문맥에 따라 알고리즘도 될 수 있고 자료 구조도 될 수 있습니다.
JSX 컴포넌트를 작성할 때, 리액트는 컴포넌트를 바로 렌더링하지 않고 메모리에 전체 UI 트리를 생성합니다.
const fiber = {
type: 'div',
child: h1Fiber, // Pointer to first child
sibling: buttonFiber, // Pointer to next sibling
return: AppFiber, // Pointer back to parent
};
각각의 fiber는 div나 button 같은 컴포넌트를 나타냅니다.
Fiber 객체에 child, sibling, return 이 세 개의 프로퍼티가 보이시나요? 이것들은 포인터입니다. 이 포인터들은 링크드 리스트처럼 다른 fiber와 연결해줍니다.
이것이 중요한 이유는 다음과 같습니다. 리액트는 이 데이터 구조를 소유합니다. 이것은 단순히 메모리에 있는 객체일 뿐입니다. 리액트는 원하는 대로 탐색하고, 원할 때 멈추고, 원할 때 언제든 다시 시작할 수 있습니다.
섹션 요약
- Fiber는 각 컴포넌트를 나타내는 자바스크립트 객체로,
child,sibling,return포인터를 통해 링크드 리스트처럼 트리를 구성한다. - 리액트가 이 자료 구조를 직접 소유하기 때문에 callstack에 의존하지 않고 자유롭게 탐색, 중단, 재개할 수 있다.
🌃 Fiber가 어떻게 동작하는지 알아보기
Step1: 자식으로 이동
리액트가 fiber에 대한 작업을 완료하면 가장 먼저 해당 fiber에 자식이 있는지 확인합니다. 만약 있다면 자식 fiber가 다음 작업이 됩니다.
위 예시에서는 리액트가 form fiber의 작업을 끝내면 자식 fiber로 이동합니다.
Step2: 형제로 이동
만약 fiber에 자식이 없다면, 리액트는 형제 fiber를 찾습니다.
위 예시에서 input fiber가 자식이 없기 때문에 형제로 이동합니다. 마찬가지로 Label을 거쳐 span, a로 순회가 이루어집니다.
Step3: 작업을 찾기 위해 위로 이동
Fiber가 자식도 없고 형제도 없다면 리액트는 return 포인터를 이용하여 위로 이동합니다. 이 시점에서 리액트는 아직 방문하지 않은 형제가 있는 가장 가까운 부모 fiber를 찾습니다.
위 예시에서 리액트가 자식도 없고 형제도 없는 fiber에 도달하면, form fiber로 이동합니다.
만약 부모 fiber에 형제가 없다면, 리액트는 root fiber에 도달할 때까지 위로 이동합니다.
Step4: root에서 멈추기
순회는 리액트가 root fiber에 도달할 때까지 계속됩니다. root에 도달하면 더 이상 처리할 fiber가 없으므로 render 단계는 끝납니다.
섹션 요약
- Fiber 트리는 자식 → 형제 → 부모(return) 순서로 순회한다.
- 자식이 있으면 자식으로, 없으면 형제로, 둘 다 없으면 부모로 올라가 방문하지 않은 형제를 찾는다.
- root fiber에 도달하면 render 단계가 종료된다.
🌃 시간을 분할하는 방법 알아보기
인풋에 글자를 입력했다고 가정해보겠습니다. 인풋에 글자를 입력함으로써 500개의 검색 결과가 리렌더링됩니다.
이 때 아래와 같은 일이 일어납니다.
브라우저는 리액트에게 5ms만큼의 시간을 줍니다. 많은 시간은 아니죠.
리액트는 fiber 트리를 순회하기 시작합니다. 첫 번째 fiber를 처리하는데, 어쩌면 input 컴포넌트일 수 있습니다. 해당 fiber를 업데이트한 후에 child 포인터를 통해 다음 fiber로 이동합니다.
이동한 fiber를 처리한 후에 계속해서 다음으로 이동하게 됩니다.
그렇게 몇 개의 fiber를 처리하고 나면(10~15개 정도) 리액트는 경과 시간을 확인합니다. "5ms가 지났나?"
만약 지났다면 리액트는 정지합니다. 트리의 순회를 마치지 않아도 일단 멈춥니다. 현재의 위치를 pointer에 저장한 다음 함수를 정지시키고 callstack을 비웁니다.
이제 브라우저가 다시 제어권을 가져오고, 대기 중인 이벤트나 키보드 입력, 스크롤, 클릭과 같은 것들을 처리합니다. 필요하다면 리페인트도 합니다.
브라우저가 처리할 작업이 끝나면 리액트에게 다시 제어권이 넘어갑니다. "다시 5ms의 시간을 줄게.". 리액트는 이전에 저장해둔 포인터를 참고합니다. "좋아, #15 fiber부터 시작할게." 저장된 위치부터 순회를 다시 시작하며 같은 작업을 500개의 fiber가 모두 처리될 때까지 반복합니다.
글자를 입력하는 것이 바로 반영되는 것처럼 느껴지는 이유는 브라우저가 각각의 키보드 입력을 5ms 이내로 처리하기 때문입니다. 리액트는 백그라운드에서 작은 단위로 렌더링하기 때문에 사용자를 차단하지 않습니다.
섹션 요약
- 리액트는 약 5ms 동안 fiber를 처리한 뒤 멈추고, 현재 위치를 포인터에 저장한 후 브라우저에게 제어권을 넘긴다.
- 브라우저가 키보드 입력, 클릭 등 대기 중인 이벤트를 처리한 후 다시 리액트에게 제어권을 돌려주면, 저장된 위치부터 순회를 재개한다.
- 이 과정 덕분에 사용자 입력이 즉시 반영되는 것처럼 느껴지며, 렌더링이 사용자를 차단하지 않는다.
🌃 결론
Fiber가 무엇인지를 살펴보았습니다.
Vue나 solidjs처럼 리액티브 시스템을 사용하지 않고 fiber 구조를 사용하는 이유가 무엇인지 논의하는 글을 본 적이 있습니다.
맞습니다. Vue나 solidjs와 같은 프레임워크들은 다르게 동작합니다. 그런 프레임워크들은 변경된 것들만 업데이트하기 위해 반응형 시그널이나 정밀한 추적을 사용합니다. 이런 방식을 사용할 때 이득이 되는 경우도 있지만 단점도 있습니다.
리액트의 접근 방식은 유연성, 예측 가능성 및 하위 호환성에 중점을 둡니다. 컴포넌트는 순수한 상태 함수로 유지되고, 멘탈 모델은 단순하게 유지할 수 있으며 fiber는 복잡한 스케줄링을 조용히 처리합니다.
결국에는, 리액트는 여전히 핵심 철학을 고수하고 있습니다.
UI = F(state)
함수는 상태를 UI로 표현합니다.
읽어주셔서 감사합니다. ❤️
섹션 요약
- 리액트는 반응형 시그널 대신 Fiber를 선택하여 유연성, 예측 가능성, 하위 호환성을 확보했다.
- 컴포넌트는 순수한 상태 함수로 유지되며, Fiber가 복잡한 스케줄링을 내부에서 처리한다.
- 리액트의 핵심 철학은
UI = F(state)이다.