React fiber 아키텍처
fiber 이전의 리액트 내부 메커니즘
리액트는 기존에 Stack Reconciler
라는 방식의 메커니즘을 사용하였습니다.
이를 간단하게 말해보면 재귀를 통해 변경사항을 파악하고 이를 한번에 업데이트하는 방식입니다.
이러한 방식은 매우 직관적이지만 몇가지 문제가 있었습니다.
대규모 업데이트 시 발생하는 frame drop 현상
리액트는 어쨋든 자바스크립트 라이브러리이고, 이말은 즉 싱글쓰레드라는 뜻입니다. 이를 인지하고 위 방식을 다시 생각해보면 모든 변경사항을 한번에 업데이트하는 방식에는 분명 문제가 발생할것입니다. 왜냐하면 업데이트, 즉 UI 변경을 반영한다는 것은 콜 스택을 점유한다는 의미입니다. 근데 점유된 작업이 매우 큰 분량의 작업이라면 다른 작업이 그동안 차단된다는 뜻이기도 합니다.
이를 수치적으로 말하면, 브라우저는 기본적으로 하나의 업데이트를 60fps(16ms)에 처리해야 사용자 입장에서 끊김없는 부드러운 업데이트가 이루어진다고 알려져있습니다.
참고: dev.to
하지만 만약에 작업이 매우 커서 이 범위를 넘어간다면 사용자에게는 일종의 끊김 현상 ( janky )이 발생합니다.
또한 이후 설명할 동시성모드의 핵심인 우선순위 업데이트가 이 메커니즘에서는 존재하지 않기 때문에 상대적으로 중요한 업데이트가 우선 업데이트 되지 못하였습니다. 예를들어 유저의 입력같이 즉각적인 피드백이 나와야하는 중요한 업데이트가 차단되는 현상이 존재하였습니다.
사용자 입력 시작
|
v
대규모 DOM 업데이트 시작
|
v
대구모 DOM 업데이트 완료 전까지 사용자 입력이 차단됨
fiber 도입
리액트 팀 또한 이러한 문제의 심각성을 인지하고 있었고, 이 문제를 해결하고 더 나은 리액트를 구현하기 위해서 v16부터 새로운 reconciler을 선보입니다. 이때 리액트 팀에서 잡은 핵심 목표는 아래와 같습니다
참고: React 맴버 깃허브
- 작업을 적절한 우선순위에 따라 처리 (애니메이션 등 긴급한 작업을 우선 수행)
- 작업을 일시 중지했다가 나중에 재개 (브라우저 프레임 예산을 초과하지 않도록)
- 더 이상 필요 없는 작업은 중단/취소
- 이전 완료된 작업 결과를 재사용 (불필요한 연산 줄이기)
fiber의 핵심은 작업 단위를 여러개로 찢는 방식입니다. 이렇게 여러개로 찢은 후 각 작업 사이에 브라우저 제어를 넘겨주는 방식으로 위의 문제를 해결하려 접근하였습니다.
조금 더 자세히 서술해보면 fiber는 전체 트리를 한 번에 끝까지 내려가는 대신 작은 단위로 내려갔다가 다시 올라오는 과정을 반복하며 작업을 진행합니다. 저는 이 문장을 처음 읽었을때 두가지 의문이 들었습니다.
- 올라감, 내려감을 반복하면 오히려 한번에 재귀를 도는것보다 더 느린거 아닌지?
- 그렇다면 16ms를 넘어가는게 아닌지?
이 부분에 대해서 더 자세히 이해하려면 올라감
, 내려감
이 뭔지 정확히 알면 이해가 됩니다.
- 내려감
- 이부분은 말 그대로 컴포넌트 트리의 최상단에서 시작하여 하나의 컴포넌트씩 아래로 내려가면서 작업을 수행하는 행위입니다.
- 올라감
- 하나의 작업 단위를 끝내면 다음 작업을 바로 진행하는 것이 아니라, 잠깐 멈추고 현재 상태를 평가하여 "남은 시간이 충분한지" 혹은 "더 긴급한 작업이 없는지" 확인하는 과정을 말합니다.
이를 실생활에 비유해보자면 마치 집안 청소를 할 때 전체 집을 한 번에 청소하는 대신, 방 하나씩 청소하면서 잠시 멈추고 집에 손님이 온 건 아닌지 확인하거나, 긴급한 전화가 오면 그 일을 먼저 처리하는 것과 비슷합니다.
정리하면 하나의 단위를 단순히 내려갔다가 올라가면서 읽는게 아니라, 내려가면서 읽고 그 작업이 끝나면 다음 작업을 실행해도 되는지 고려합니다 (남은 시간이 충분한지, 다른 우선순위 높은 작업이 있는지등,,) 이렇게 되면 하나의 작업을 읽은 다음 바로 다음 작업을 실행하면서 메인 쓰레드를 계속 차지하는 방식이 아니라, 중간에 브라우저에게 중간 제어권을 넘겨주기 때문에, 브라우저가 인터랙션을 처리하거나 우선순위가 높은 작업을 먼저 처리할 수 있게 해주므로 실제 사용자가 느끼는 체감 성능이 좋아집니다.
그래서 1번의 의문에 대해서는 아래처럼 답변이 가능할거같습니다.
- 전체적인 시간은 늘어날수있지만 (아주 약간..?) 사용자 체감 시간(반응성)은 더 개선됩니다.
- 16ms안에 작업을 완료 못할 수 있습니다. 다만 앞서 언급했듯이 하나의 작업 단위를 매우 잘게 쪼개놓아서 16ms내에 전체 작업이 완료되지 않더라도 끊김 현상이 아니라 부드러운 전환이 가능해집니다.
fiber 자세히 살펴보기
위 내용을 정리하면 fiber는 두가지 핵심 개념에 의해 동작합니다
- 하나의 작업 단위를 잘게 짜름
- 우선순위가 존재하여, 긴급한(중요한) 단위부터 처리됨
그렇다면 이 파트에서는 fiber가 어떻게 구성되어있고 어떻게 동작하는지 자세히 살펴보겠습니다.
fiber 노드(하나의 작업 단위)는 js 객체로 구현되며, 특정 컴포넌트(또는 DOM 요소)에 대한 정보를 담고 있습니다. fiber 노드들은 서로 연결 리스트 형태의 트리를 구성하는데, child, sibling, return과 같은 포인터를 통해 부모-자식 및 형제 관계를 표현합니다. 이러한 구조 덕분에 React는 재귀 호출 대신 반복이나 순회 알고리즘으로 트리를 탐색할 수 있고, 필요한 시점에 작업을 중단하거나 재개할 수 있습니다.
fiber 노드의 구성은 아래와 같습니다.
- type: 해당 fiber가 가리키는 컴포넌트 타입입니다. 함수/클래스 컴포넌트면 그 함수나 클래스 자체, DOM 같은 호스트 컴포넌트면 문자열 태그 이름이 됩니다. React 엘리먼트의 타입을 복사해오며, diff시 기존 노드와 타입이 다르면 새로운 트리를 생성하는 데 사용됩니다.
- key: 리스트 조화시 사용하는 키 값입니다. React 엘리먼트의 key를 가져오며, 형제들 사이에서 노드를 고유하게 식별하여 재사용 여부를 판단하는 데 활용됩니다.
- child: 이 fiber에 내장된 컴포넌트의 첫 번째 자식 fiber를 가리킵니다. 예를 들어
Parent
컴포넌트가<Child/>
를 반환한다면 Parent fiber의 child는 Child fiber가 됩니다. 여러 자식을 반환하는 경우 첫 번째 자식만 child로 연결되고, 나머지는 sibling으로 연결됩니다. - sibling: 동일 부모를 가지는 다음 형제 fiber를 가리킵니다. fiber들은 각 노드의 sibling 포인터를 통해 단일 연결 리스트 형태로 형제들을 연결합니다. 예를 들어
<Parent>
가[<Child1/>, <Child2/>]
두 개의 자식을 반환하면, Child1 fiber의 sibling이 Child2 fiber가 됩니다. - return: 현재 fiber의 부모 fiber를 가리킵니다. 재귀 호출의 복귀 지점에 해당하며, child/sibling을 통해 내려갔다가 다시 상위로 올라올 때 사용됩니다. 여러 자식이 있는 경우 모든 자식 fiber의 return은 부모 fiber를 참조합니다.
- pendingProps / memoizedProps: 해당 fiber에 전달된 최신 prop 값과 직전에 렌더링에 사용된 prop 값을 보관합니다. 업데이트 과정에서 새 props(pendingProps)를 적용한 후 작업이 끝나면 memoizedProps로 저장합니다. 이 둘을 비교하여 변경이 없다면 이전 결과를 재사용함으로써 불필요한 작업을 피할 수 있습니다.
- pendingWorkPriority: 이 fiber에 대기 중인 작업의 우선순위(priority)를 나타내는 값입니다. 숫자로 표현되며, React 내부에 미리 정의된 우선순위 레벨에 따라 할당됩니다 (0은 NoWork로 작업 없음, 숫자가 작을수록 높은 우선순위). fiber 트리 내의 작업 스케줄러는 이 값을 보고 다음에 처리해야 할 작업 단위를 결정합니다.
- alternate: 동일한 컴포넌트의 대체 fiber를 가리킵니다. React는 fiber 트리를 이중 버퍼링 형태로 관리하는데, 현재 화면에 렌더링된 현재 fiber와 새 업데이트를 적용하기 위한 작업 중 fiber를 번갈아 사용합니다. alternate는 현재 fiber가 가리키는 상대편 fiber로, 필요 시 기존 객체를 재활용하여 메모리 할당을 줄입니다. (즉, 매 업데이트마다 fiber를 새로 만드는 대신, 기존 alternate를 서로 교체하면서 사용)
아래는 실제 리액트가 구현한 fiber 코드중 일부입니다.
function FiberNode(
this: $FlowFixMe,
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
) {
// Instance
this.tag = tag;
this.key = key;
this.elementType = null;
this.type = null;
this.stateNode = null;
// Fiber
this.return = null;
this.child = null;
this.sibling = null;
this.index = 0;
this.ref = null;
this.refCleanup = null;
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
// Effects
this.flags = NoFlags;
this.subtreeFlags = NoFlags;
this.deletions = null;
this.lanes = NoLanes;
this.childLanes = NoLanes;
this.alternate = null;
}
아래는 fiber의 메커니즘을 매우 간단히 시각화한 것입니다.
Fiber 시각화
정리하면 React fiber 아키텍처는 리액트가 사용자 경험을 향상시키고 성능을 최적화하기 위해 내놓은 아주 중요한 개선사항입니다. 특히 fiber는 작업을 잘게 나누고 우선순위를 부여하여, 긴급한 작업부터 처리할 수 있게 합니다. 또한 긴 작업을 처리할 때도 프레임 드롭 현상을 최소화하여 더욱 부드러운 UI를 제공합니다.