React fiber 아키텍처
fiber 이전의 리액트 내부 메커니즘
리액트는 기존에 Stack Reconciler
라는 방식의 메커니즘을 사용하였습니다.
이를 간단하게 말해보면 재귀를 통해 변경사항을 파악하고 이를 한번에 업데이트하는 방식입니다.
이러한 방식은 매우 직관적이지만 몇가지 문제가 있었습니다.
대규모 업데이트 시 발생하는 frame drop 현상
React v15까지의 방식을 조금 더 자세히 서술하면 크게 2파트를 통해 진행됩니다.
- 렌더 phase: 무엇을 바꿀지 계산만 하는 순수 함수적 구간 - ex:
render
- 커밋 phase: 계산된 결과를 실제 DOM/Ref/Effect 등 부수효과를 일으키며 적용 - ex:
componentDidMount
즉 1단계에서 DOM에 반영할 것을 계산하고 -> 2단계에서 실제로 반영 및 부수효과를 실행시킵니다.
위 방식에서 가장 큰 문제는 이 일련의 과정이 동기적으로 실행된다는 점입니다. 리액트는 결국 자바스크립트 라이브러리이고, 이말은 즉 싱글쓰레드라는 뜻입니다. 이를 인지하고 위 방식을 다시 생각해보면 모든 변경사항을 한번에 업데이트하는 방식에는 분명 문제가 발생할것입니다. 왜냐하면 업데이트, 즉 UI 변경을 반영한다는 것은 콜 스택을 점유한다는 의미입니다. 근데 점유된 작업이 매우 큰 분량의 작업이라면 다른 작업이 그동안 차단된다는 뜻이기도 합니다.
이를 수치적으로 말하면, 브라우저는 기본적으로 하나의 업데이트를 60fps(약 16.7ms)에 처리해야 사용자 입장에서 끊김없는 부드러운 업데이트가 이루어진다고 알려져있습니다.
참고: dev.to
하지만 만약에 작업이 매우 커서 이 범위를 넘어간다면 사용자에게는 일종의 끊김 현상 ( janky )이 발생합니다.
또한 이후 설명할 동시성모드의 핵심인 우선순위 업데이트가 이 메커니즘에서는 존재하지 않기 때문에 상대적으로 중요한 업데이트가 우선 업데이트 되지 못하였습니다. 예를들어 유저의 입력같이 즉각적인 피드백이 나와야하는 중요한 업데이트가 차단되는 현상이 존재하였습니다.
사용자 입력 시작
|
v
대규모 DOM 업데이트 시작
|
v
대규모 DOM 업데이트 완료 전까지 사용자 입력이 차단됨
Fiber 도입
리액트 팀 또한 이러한 문제의 심각성을 인지하고 있었고, 이 문제를 해결하고 더 나은 리액트를 구현하기 위해서 v16부터 새로운 reconciler을 선보입니다. 이때 리액트 팀에서 잡은 핵심 목표는 아래와 같습니다
참고: React 맴버 깃허브
- 작업을 적절한 우선순위에 따라 처리 (애니메이션 등 긴급한 작업을 우선 수행)
- 이때 우선순위 비교를 단순 숫자 비교가 아닌 Lanes 모델을 사용합니다.
- 우선순위를 1 bit로 표현한 비트마스크
- 비트마스크 덕분에 한 정수에 동시 다중 우선순위 보존이 가능해집니다.
- 또한 대기 업데이트가 매우 많아져도 지금 바로 처리할 최고 우선순위 작업을 상수 시간에 결정할 수 있습니다.
- 이때 우선순위 비교를 단순 숫자 비교가 아닌 Lanes 모델을 사용합니다.
- 작업을 일시 중지했다가 나중에 재개 (브라우저 프레임 예산을 초과하지 않도록)
- 더 이상 필요 없는 작업은 중단/취소
- 이전 완료된 작업 결과를 재사용 (불필요한 연산 줄이기)
Fiber의 핵심은 렌더 작업을 unit of work 로 잘게 나누고, 각 unit 을 Lanes 기반 스케줄러에 위임해 메인 스레드 블로킹을 피하는 데 있습니다. 이렇게 여러개로 찢은 후 각 작업 사이에 브라우저 제어를 넘겨주는 방식으로 위의 문제를 해결하려 접근하였습니다.
조금 더 자세히 서술해보면 fiber는 전체 트리를 한 번에 끝까지 내려가는 대신 작은 단위로 내려갔다가 다시 올라오는 과정을 반복하며 작업을 진행합니다. 저는 이 문장을 처음 읽었을때 두가지 의문이 들었습니다.
- 올라감, 내려감을 반복하면 오히려 한번에 재귀를 도는것보다 더 느린거 아닌지?
- 그렇다면 16ms를 넘어가는게 아닌지?
이 부분에 대해서 더 자세히 이해하려면 올라감
, 내려감
이 뭔지 정확히 알면 이해가 됩니다.
- 내려감(begin phase)
- 이부분은 말 그대로 컴포넌트 트리의 최상단에서 시작하여 하나의 컴포넌트씩 아래로 내려가면서 작업을 수행하는 행위입니다.
- 올라감(complete phase)
- 하나의 작업 단위를 끝내면 다음 작업을 바로 진행하는 것이 아니라, 잠깐 멈추고 현재 상태를 평가하여 "남은 시간이 충분한지" 혹은 "더 긴급한 작업이 없는지" 확인하는 과정을 말합니다.
이를 실생활에 비유해보자면 마치 집안 청소를 할 때 전체 집을 한 번에 청소하는 대신, 방 하나씩 청소하면서 잠시 멈추고 집에 손님이 온 건 아닌지 확인하거나, 긴급한 전화가 오면 그 일을 먼저 처리하는 것과 비슷합니다.
정리하면 하나의 단위를 단순히 내려갔다가 올라가면서 읽는게 아니라, 내려가면서 읽고 그 작업이 끝나면 다음 작업을 실행해도 되는지 고려합니다 (남은 시간이 충분한지, 다른 우선순위 높은 작업이 있는지등,,) 이렇게 되면 하나의 작업을 읽은 다음 바로 다음 작업을 실행하면서 메인 쓰레드를 계속 차지하는 방식이 아니라, 중간에 브라우저에게 중간 제어권을 넘겨주기 때문에, 브라우저가 인터랙션을 처리하거나 우선순위가 높은 작업을 먼저 처리할 수 있게 해주므로 실제 사용자가 느끼는 체감 성능이 좋아집니다.
그래서 1번의 의문에 대해서는 아래처럼 답변이 가능할거같습니다.
- 전체적인 시간은 늘어날수있지만 (아주 약간..?) 사용자 체감 시간(반응성)은 더 개선됩니다.
- 16ms안에 작업을 완료 못할 수 있습니다. 다만 앞서 언급했듯이 하나의 작업 단위를 매우 잘게 쪼개놓아서 16ms내에 전체 작업이 완료되지 않더라도 끊김 현상이 아니라 부드러운 전환이 가능해집니다.
Fiber 자세히 살펴보기
위 내용을 정리하면 fiber는 두가지 핵심 개념에 의해 동작합니다
- 하나의 작업 단위를 잘게 짜름
- 우선순위가 존재하여, 긴급한(중요한) 단위부터 처리됨
그렇다면 이 파트에서는 fiber가 어떻게 구성되어있고 어떻게 동작하는지 자세히 살펴보겠습니다.
fiber 노드(하나의 작업 단위)는 js 객체로 구현되며, 특정 컴포넌트(또는 DOM 요소)에 대한 정보를 담고 있습니다. fiber 노드들은 서로 연결 리스트 형태의 트리를 구성하는데, child, sibling, return과 같은 포인터를 통해 부모-자식 및 형제 관계를 표현합니다. 이러한 구조 덕분에 React는 재귀 호출 대신 반복이나 순회 알고리즘으로 트리를 탐색할 수 있고, 필요한 시점에 작업을 중단하거나 재개할 수 있습니다.
fiber 노드의 구성은 아래와 같습니다.
필드 | 타입 | 역할 & 비고 |
---|---|---|
type | Function | string | 컴포넌트 함수/클래스 또는 "div" 같은 호스트 태그 |
key | string | null | 리스트 diff 시 형제 노드 고유 식별자 |
child / sibling / return | Fiber | null | DFS 순회 포인터 |
lanes | Lanes (bitmask) | 이 노드에 걸린 우선순위 비트 |
childLanes | Lanes | 하위 서브트리의 lanes OR 집합 → 스킵 여부 O(1) 판정 |
flags / subtreeFlags | SideEffectFlags | 커밋‑phase에서 실행할 부수효과 표시 |
memoizedProps / memoizedState | any | 직전 렌더 결과 캐시 |
pendingProps | any | 이번 렌더에서 사용할 새 props |
alternate | Fiber | null | 이중 버퍼 구조: |
참고
React 18부터 기존pendingWorkPriority
숫자 필드는 사라지고,
lanes
·childLanes
비트마스크가 모든 우선순위 정보를 담습니다.
아래는 fiber의 메커니즘을 매우 간단히 시각화한 것입니다.
Fiber 시각화
정리하면 React fiber 아키텍처는 리액트가 사용자 경험을 향상시키고 성능을 최적화하기 위해 내놓은 아주 중요한 개선사항입니다. 특히 fiber는 작업을 잘게 나누고 우선순위를 부여하여, 긴급한 작업부터 처리할 수 있게 합니다. 또한 긴 작업을 처리할 때도 프레임 드롭 현상을 최소화하여 더욱 부드러운 UI를 제공합니다.