React 18 톺아보기 - 03. Transition Lane_2
a year ago
Lane 모델이 해결하고자 하는 문제점을 다음과 같이 짚어보았습니다.
- 업데이트의 개념 분리
- 불필요한 렌더링 건너뛰기
- 우선순위가 더 높은 업데이트를 먼저 처리하기
위 세 개 모두 Transition Lane과 연관있는 기능이며 Transition Lane을 설명하기에 적절한 주제입니다. 하나씩 짚어보며 설명을 이어가겠습니다.
1. 업데이트의 개념 분리
Lane 모델에서 업데이트 개념 분리를 설명할 때 CPU와 IO Bound라는 개념을 언급했습니다. 일반적인 모든 렌더링은 CPU에 의존적이기 때문에 CPU-Bound에 속합니다. IO-Bound는 네트워크와 전환이 결합된 경우에만 해당합니다.
리액트에서 네트워크와 관련된 컴포넌트는 <Suspense/>
가 있습니다. <Suspense/>
는 하위 요소가 준비되지 않았다면 Fallback을 노출하는 컴포넌트입니다. case 3를 통해서 일반 업데이트와 <Suspense/>
의 동작 방식을 확인해보세요.
전환 업데이트 렌더링(Concurrent Render)의 특징은 사용자를 차단하지 않고 백그라운드에서 렌더링 작업을 진행하여 완료된 경우에만 사용자에게 UI를 노출하는 것입니다. UI 전환에 네트워크가 필요하여 <Suspense/>
를 사용했다면 렌더링 경로의 요소들이 모두 준비되지 않는 한 렌더링을 완료하지 않습니다. 즉 해당 업데이트의 렌더링을 보류합니다. 이와 같은 이유로 <Suspense/>
의 Fallback 또한 렌더링 되지 않습니다. case 4에서 확인해보세요.
Render Phase에서 <Suspense/>
의 Fallback인 [Text]: loading..
가 찍혔지만, Commit Phase를 진행하지 않았기 때문에 사용자에게는 노출되지 않습니다.
이게 도대체 IO-Bound와 무슨 상관일까요? <Suspense/>
에 대해서 인지해야 할 부분이 하나 더 있습니다. 개발을 할 때 복수의 네트워크 요청이 필요한 경우, 요청 간의 의존성이 있지 않은 한 순차적이 아닌 병렬적으로 요청을 보냅니다. <Suspense/>
도 마찬가지로 렌더링 과정에서 하나의 컴포넌트가 준비되지 않았다고 바로 렌더링을 그 위치에서 중단하는 게 아닌 모든 하위 요소를 탐색하여 네트워크 요청을 발생시킵니다. case 5에서 확인해보세요.
다시 정리하면, <Suspense/>
는 모든 하위 요소를 탐색하고 전환은 렌더링 경로의 모든 요소의 렌더링이 완료되지 않는 한 렌더링을 마무리하지 않습니다.
case 5에서 AsyncB
가 완료되어 렌더링을 다시 시작했지만 AsyncA
가 완료되지 않아 해당 렌더링은 다시 한번 보류 처리됩니다. 하지만 렌더링은 여기서 끝나지 않고 모든 하위 요소를 탐색하며 렌더링합니다. 이때의 렌더링을 바로 IO-Bound라고 합니다. 렌더링이 네트워크에 의존적인 상태이며 해당 네트워크 요청이 완료되지 않는 한 렌더링은 완료되지 못하지만, 아직 모든 렌더링 경로를 탐색한 것이 아니기 때문에 렌더링 작업이 계속 진행 중인 이때의 렌더링이 IO-Bound입니다.
참고로 일반 업데이트와 <Suspense/>
의 조합의 렌더링은 항상 CPU-Bound입니다. 왜냐하면, 이때의 렌더링은 절대 보류되지 않습니다. 이때의 <Suspense/>
는 하위 모든 요소를 탐색한 후 하나의 요소라도 준비되지 않았다면 Fallback으로 대체하여 렌더링을 마무리하기 때문에 네트워크에 의존적인 상태에서 렌더링을 이어가는 상황은 발생할 수 없으므로 항상 CPU-Bound입니다.
이제 다시 업데이트의 개념 분리로 돌아와서, 전환 업데이트의 렌더링 작업이 IO - CPU 순으로 발생한 경우에 IO-Bound 렌더링으로 인한 CPU-Bound 렌더링이 차단되는 상황을 짚어보겠습니다. 리액트가 현재의 렌더링이 IO-Bound임을 인지하는 시점은 <Suspense/>
의 렌더링을 완료한 후 하위 요소가 아직 준비되지 않았음을 확인한 직후입니다. 리액트는 계속해서 IO-Bound 렌더링을 이어가지만, 중간에 전환 업데이트가 추가로 발생하면(interleaved update) IO-Bound 렌더링의 Lanes를 root의 suspendedLanes
로 기록하고 신규 전환 업데이트로 렌더링을 새로 시작합니다.
// scheduleUpdateOnFiber() <- 신규 업데이트가 발생하면 호출된다.
// concurrent render 과정에서 메인 스레드를 비워준 시점에 발생한 업데이트인지 확인
if ((executionContext & RenderContext) === NoContext) {
workInProgressRootInterleavedUpdatedLanes = mergeLanes(
workInProgressRootInterleavedUpdatedLanes,
lane // 신규 업데이트의 lane
)
}
// 현재 렌더링 경로 중에 보류된 요소가 있었는지. 즉, 현재 렌더링이 IO-Bound인지 확인.
if (workInProgressRootExitStatus === RootSuspendedWithDelay) {
markRootSuspended(root, workInProgressRootRenderLanes)
}
// markRootSuspended()
suspendedLanes = removeLanes(
suspendedLanes,
workInProgressRootInterleavedUpdatedLanes
)
// 현재 렌더링의 Lane을 suspendedLanes로 관리하여 렌더링 대상에서 제외한다.
root.suspendedLanes |= suspendedLanes
root.pingedLanes &= ~suspendedLanes
이제 다음 렌더링 시작 시점의 getNextLanes()
는 suspendedLanes
를 제외한 신규 업데이트의 전환 Lanes를 반환하면서 자연스레 이전 렌더링이 CPU-Bound의 전환 Lanes로 Interrupt 됩니다.
// getNextLanes()
/*...*/
const suspendedLanes = root.suspendedLanes
/*...*/
const nonIdleUnblockedLanes = nonIdlePendingLanes & ~suspendedLanes
nextLanes = getHighestPriorityLanes(nonIdleUnblockedLanes)
/*...*/
IO - CPU 상황에서 진행 중이던 IO를 중단하고 CPU를 먼저 렌더링하는 과정을 case 6에서 확인해보세요.
같은 전환 업데이트라도 CPU와 IO를 나눌 수 있는 이유는 Transition Lane이 하나가 아닌 복수기 때문에 가능합니다. Expiration Time과는 다르게 같은 우선순위를 가졌어도 업데이트를 다르게 배치처리할 수 있는 것입니다.
const TransitionLane1: Lane = /* */ 0b0000000000000000000000001000000
// ~
const TransitionLane16: Lane = /* */ 0b0000000001000000000000000000000
function claimNextTransitionLane(): Lane {
const lane = nextTransitionLane
nextTransitionLane <<= 1
if ((nextTransitionLane & TransitionLanes) === NoLanes) {
nextTransitionLane = TransitionLane1
}
return lane
}
2. 불필요한 업데이트 건너뛰기
자동완성 앱에서 자동완성 UI의 트리가 매우 커서 렌더링이 오래 걸린다면 다음과 같은 상황이 발생할 수 있습니다.
사용자 입장에서 “리액” 입력이 완료된 시점에 “리”의 자동완성 UI에는 관심 없을 것입니다. 하지만 위 상황에서는 데이터 도착 순서대로 렌더링을 진행하게 되면서 이미 “리액”의 데이터가 도착했을지라도 “리” 데이터의 렌더링이 진행 중이기 때문에 렌더링이 차단됩니다. 그리고 사용자는 더 이상 관심 없는 “리”의 자동완성 UI를 확인하게 됩니다.
리액트는 대규모 UI 전환 중이더라도 오래된 상태의 렌더링을 중단하고 최신 상태의 렌더링만 수행할 수 있습니다. 이를 확인하기 위해서는 렌더링 과정에서 업데이트를 어떻게 처리하는지 알아야 하므로 몇 가지 내부 구현사항을 간단히 정의하고 넘어가겠습니다.
먼저 자동완성 앱에서 “리”의 렌더링이 끝난 상태에서 “리액”의 사용자 입력이 발생했다고 해봅시다. 사용자의 입력 처리 코드는 다음과 같습니다.
const AutoComplete = () => {
/*...*/
onChange={(...) => {
setInput("리액");
startTransition(() => setTransitionInput("리액"));
}}
/*...*/
}
위 코드 실행 이후 Fiber를 표현하면 다음과 같습니다.
- Fiber의 memoizedState에는 컴포넌트에서 사용된 Hook을 연결 리스트 형태로 참조하고 있습니다(참고).
- Hook의 memoizedState에는 반영된 상태를 참조하고 있습니다(참고).
- Hook에는 업데이트를 담을 큐를 가지고 있습니다(참고).
- 업데이트는 해당 Hook의 큐에 연결 리스트 형태로 추가됩니다(참고).
- 업데이트에는 Lane이 할당됩니다(참고).
- 업데이트 생성 문자 표현을 A
A Hook SSync Update 1생성 시점 의 형태로 정의하겠습니다.
위 상황에서 사용자가 “리액트”를 입력했다고 해봅시다. Fiber 내부에서는 다음과 같은 과정을 거칩니다.
사용자 입력 UI 업데이트
3. 우선순위가 더 높은 업데이트를 먼저 처리하기
지금까지 여러 번 우선순위에 따라 업데이트의 처리 순서가 다를 수 있음을 언급했습니다. 이는 전환 업데이트의 렌더링이 비동기 점진적 진행 방식이기에 중간마다 사용자 인풋을 처리할 수 있어 가능한 부분입니다. case 8에서 확인해보세요.
느린 화면으로 확인해보면 숫자의 변경이 더 잘 확인됩니다.
업데이트 처리 방식은 Git의 그것과 유사하기 때문에 Git을 예로 들면서 개념을 먼저 이해해보겠습니다.
A라는 Hook에 업데이트가 다음과 같이 발생했다고 가정해 봅시다.
AT1 - AS2 - AT3
여기서 A Hook은 Main 브랜치이고 업데이트가 쌓이는 큐는 Feature 브랜치가 됩니다. 그리고 각 업데이트는 Feature 브랜치의 커밋이며, 현재 렌더링의 Lanes는 배포 버전 입니다.
(main) AT0
(feature) AT1 - AS2 - AT3
업데이트 중 가장 먼저 배포해야 할 버전은 Sync Lane입니다. AS2를 Cherry pick 해서 Main 브랜치에 병합합니다.
(main) AT0 - AS2
(develop) AT1 - AS2 - AT3
다음 배포 대상은 Transition Lane입니다. 이때는 커밋의 순서를 맞추기 위해 Rebase를 진행합니다.
(main) AT0 - AT1 - AS2 - AT3
(feature) AT1 - AS2 - AT3
모든 커밋이 병합되었으니 Feature 브랜치를 삭제합니다.
(main) AT0 - AT1 - AS2 - AT3
(feature)
우선순위에 따라 동일한 훅에서 업데이트 처리 순서가 달라질 경우 이런 Rebase 과정이 추가로 필요합니다.
만약 모든 업데이트가 같은 우선순위를 가졌다면 Cherry Pick -> Rebase 과정 없이 간단히 Fast Forward Merge만 진행합니다.
(main) AT0
(feature) AT1 - AT2 - AT3
->
(main) AT0 - AT1 - AT2 - AT3
(feature)
이 과정에서 사용되는 리액트 구성 요소는 다음과 같으며 현재 필요하지 않은 속성은 생략했습니다.
// 역시 Git에 빗대어 설명합니다.
// Main 브랜치
const hook = {
memoizedState: null, // 최종 코드
baseQueue: null, // 병합 대상 커밋 Head
baseState: null, // 병합 대상(baseQueue) 이전의 최종 코드(Fast Foward Merg만 진행했다면 memoizedState와 같다)
queue: null, // Feature 브랜치
next: null, // 다음 Hook
}
// Feature 브랜치
const queue = {
pending: null, // Main 브랜치 병합 대상에 아직 포함되지 않은 갓 커밋된 커밋 Head
}
// Commit
const update = {
action, // 작업 내역
lane, // 배포될 버전
next: (null: any), // 다음 업데이트
}
case_8의 업데이트 처리 과정은 다음과 같습니다.
이처럼 우선순위에 따라서 중간 상태는 다를 수 있지만 결국 마지막에는 Rebase를 통해 업데이트 순서를 보장하므로 마지막 상태 값은 항상 사용자 상호 작용과 일치하게 됩니다.
업데이트를 처리하는 코드는 확인해볼 가치는 있지만, 길기도 하고 필수적으로 알아야 하는 내용은 아니기에 관심이 있으신 분은 확인해보세요.
코드 펼치기
function updateReducer(reducer, initialArg, init) {
// 훅 객체를 참조한다.
const hook = updateWorkInProgressHook()
const queue = hook.queue
const current = currentHook
let baseQueue = current.baseQueue // 병합 대상
// 병합 대상에 아직 포함되지 않은 커밋
const pendingQueue = queue.pending
if (pendingQueue !== null) {
// 병합 대기중인 대상이 있는지 확인한다.
if (baseQueue !== null) {
// 병합 대상에 포함되지 않은 커밋을 가장 뒤쪽에 연결한다.
const baseFirst = baseQueue.next // baseQueue는 원형 연결 리스트이며, 마지막 노드로 관리한다. 고로 baseQueue의 next는 원형 연결 리스트의 첫 번째 노드이다.
const pendingFirst = pendingQueue.next // pendingQueue도 baseQueue와 같다. next는 첫 번째 노드를 가리킨다.
baseQueue.next = pendingFirst
pendingQueue.next = baseFirst
}
current.baseQueue = baseQueue = pendingQueue
queue.pending = null // 모든 커밋을 병합 대상으로 옮겼으므로 초기화한다.
}
// 병합할 대상이 있는지 확인한다.
if (baseQueue !== null) {
const first = baseQueue.next // 병합할 대상의 첫 번째 노드를 참조한다.
let newState = current.baseState // 병합할 대상의 첫 번째 노드 이전의 main 브랜치의 최종 코드를 참조한다.
let newBaseState = null // 병합된 최종 코드가 저장된다.
let newBaseQueueFirst = null // Rebase 대상의 첫 번째 커밋이 저장된다.
let newBaseQueueLast = null // Rebase 대상의 마지막 커밋이 저장된다.
let update = first // 병합을 진행할 커밋
do {
const updateLane = update.lane // 커밋의 배포 버전을 참조한다.
// 커밋이 현재 배포 버전에 포함되지 않는지 확인한다. (renderLanes & updateLane) === updateLane;
if (!isSubsetOfLanes(renderLanes, updateLane)) {
const clone = {
lane: updateLane,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
next,
}
// 현재 커밋을 Rebase 대상으로 추가한다.
if (newBaseQueueLast === null) {
// 병합되지 못한 첫 번째 커밋이다. 해당 커밋을 포함하여 이후 모든 커밋이 Rebase 대상이 된다.
newBaseQueueFirst = newBaseQueueLast = clone
// Rebase 대상 이전까지 병합된 코드를 기록한다.
newBaseState = newState
} else {
newBaseQueueLast = newBaseQueueLast.next = clone
}
// 추후에 다시 배포해야 하기 때문에 배포되지 않은 버전을 기록한다.
currentlyRenderingFiber.lanes = mergeLanes(
currentlyRenderingFiber.lanes,
updateLane
)
} else {
// 병합될 커밋이다.
// 이번 배포에서 Rebase 대상이 존재하는지 확인한다. 존재한다면 커밋 순서를 맞추기 위해 모든 커밋을 Rebase 대상으로 추가한다.
if (newBaseQueueLast !== null) {
const clone = {
// 이미 배포된 커밋은 추후 Rebase 과정에서 항상 포함될 수 있도록 NoLane(0)을 할당한다.
lane: NoLane,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
next: null,
}
newBaseQueueLast = newBaseQueueLast.next = clone
}
// 병합을 진행하고 코드를 뽑아낸다.
const action = update.action
newState = reducer(newState, action) // typeof action === 'function' ? action(newState) : action;
}
// 다음 병합 대상을 참조한다.
update = update.next
// 원형 연결 리스트이기 때문에 무한 루프에 빠지지 않도록 첫 번째 노드를 확인한다.
} while (update !== null && update !== first)
// Rebase 대상이 있는지 확인한다.
if (newBaseQueueLast === null) {
// Rebase 대상이 없다는 것은 Fast Forward Merge로 진행되었다는 의미이다.
newBaseState = newState
} else {
// 병합되지 못한 커밋이 있다. 원형 연결 리스트로 관리한다.
newBaseQueueLast.next = newBaseQueueFirst
}
hook.memoizedState = newState // Main 브랜치의 최종 코드, Rebase 대상이 존재한다면 중간 상태의 코드가 된다.
hook.baseState = newBaseState // 병합되지 못한 첫 번째 커밋 직전의 병합된 코드. 만약 모두 병합되었다면 memoizedState와 같다.
hook.baseQueue = newBaseQueueLast // Rebase 대상을 병합 대상으로 관리한다.
}
}
위 코드에서 한가지 짚고 넘어가고 싶은 부분은 얕은 복사로 clone
객체를 만드는 부분입니다.
Concurrent Rendering이 가능해지면서 컴포넌트가 여러 번 호출될 수 있고 때로는 호출된 결과를 폐기할 때도 있습니다. 얕은 복사를 하지 않고 작업해버리면 의존성이 없는 렌더링 간의 영향을 줄 수 있기 때문에 반드시 작업 이전에 객체를 얕은 복사 해야 합니다.
<Suspense/> vs <Suspense/> + Transition
<Suspense/>
는 Fallback을 노출하여 렌더링을 완료하는 반면에 <Suspense/>
+ Transition은 네트워크 요청과 전환이 모두 완료되기 전까지 렌더링을 보류했습니다. 이 둘의 쓰임새는 다르며 이를 이해하는 것이 중요하기 때문에 짚고 넘어가겠습니다.
다음 동영상은 구글맵을 사용하는 영상입니다. 당연히 리액트로 개발된 것은 아니겠지만, 영상에 나오는 UI의 동작은 <Suspense/>
와 <Suspense/>
+ Transition의 그것과 같습니다. 한번 구별해보시고 사용자 관점에서 느껴보시길 바랍니다.
자동 완성 UI는 <Suspense/>
+ Transition이고, 경로 UI는 <Suspense/>
입니다. 두 UI에는 어떠한 차이점이 있기 때문에 이런 구현상의 차이가 있는 것일까요? <Suspense/>
만 사용해도 대부분의 전환에 대해 잘 작동하지만, 특정 상황에서는 그렇지 않습니다.
전환이 매우 짧은 시간 안에 끝난다면 사용자는 이런 찰나의 전환에서 요청과 관련된 피드백을 인지하기도 전에 전환이 완료되어 UI가 크게 요동치는 느낌을 받을 수 있습니다. 리액트에서 이런 찰나의 순간의 임계값을 JND(Just Signable Difference)라고 합니다.
만약 전환이 JND 아래에 머물 수 있다면 사용자 경험을 위해 <Suspense/>
의 Fallback을 건너뛸 가치가 있습니다. 전환이 항상 사용자가 알아채지 못할 정도로 지연 시간이 작다는 것을 보장할 수 있다면 말입니다. 이런 경우 트리의 일관성을 포기하고 이전 상태의 화면을 유지하면서 Fallback을 생략하는 방법을 선택할 수 있습니다.
<Suspense/>
만 사용한 경우에도 JND 아래에 위치한다면 Fallback을 생략할 수 있도록 하려는 움직임이 있습니다. 아직 실험 단계이며<Suspense/>
의 expectedLoadTime 속성으로 개발 중에 있습니다.
그렇다고 전환이 찰나의 순간이 아니라면 무조건 <Suspense/>
를 써야 한다고 이야기 하는게 아닙니다. JND보다 큰 지연에도 오래된 상태의 UI를 유지하는 게 더 나은 경우도 많습니다. 이때는 Fallback과 마찬가지로 지연 상태에 대한 피드백을 사용자에게 전달해야 하며, startTransition
API의 isPending
속성을 활용할 수 있습니다(참고).
차트 영역의 색상을 수정하여 사용자에게 지연 상태를 피드백하고 있다.
useTransition vs startTransition
전환 업데이트를 생성하는 방법은 두 가지가 있으며, 둘의 차이점은 쓰임새에 있습니다. 그리고 이 쓰임새로 인한 성능 차이도 존재하기 때문에 알고 쓰는 게 중요합니다. case 9에서 확인해보세요.
최종 렌더링까지 걸린 시간을 비교하면 useTransition()
가 대략 1초 정도 더 느리며, 원인은 isPending
에 있습니다. isPending
은 전환의 시작과 끝을 알려주는 변수입니다. 그렇기 때문에 전환 업데이트보다 먼저 렌더링을 진행하여 isPending
의 상태를 반영해야 합니다. 그리고 난 후에 isPending
을 활용하여 피드백 UI를 렌더링하는 것입니다. 즉, useTransition()
는 startTransition()
보다 한 번의 Render Phase - Commit Phase가 더 필요합니다.
case 7에서는
<Bubble/>
의 색상으로 이를 표현했습니다. 다만,<Bubble/>
이 아닌 규모가 작은 UI에 이를 표현했다면 성능의 차이점은 거의 없었을 것이지만, 이와 같이 사용되는 경우도 있기에 눈에 띄는 방식으로 작성했습니다.
useTransition()
의 세부 구현사항은 다음과 같습니다.
startTransition()
의 구현사항은 이미 Lane 모델에서 확인했습니다.
reconciler > ReactFiberHooks.js
function mountTransition() { // 컴포넌트 Mount 시점에 사용되는 useTransition() 구현체
const [isPending, setPending] = mountState(false);const start = startTransition.bind(null, setPending);const hook = mountWorkInProgressHook();
hook.memoizedState = start;
return [isPending, start];
}
reconciler > ReactFiberHooks.js
function startTransition(setPending, callback, options) {
setPending(true);
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = {};
try {
setPending(false); callback(); } finally {
ReactCurrentBatchConfig.transition = prevTransition;
}
}
개발자가 mountTransition()
이 반환하는 start()
를 통해 전환 업데이트를 생성하면 먼저 isPending
를 true로 설정start()
를 호출한 이벤트를 기반으로 결정됩니다. case 7에서는 setTimeout()
에서 사용했기 때문에 Default Lane
이 할당됩니다.
그 다음으로 Transition 플래그를 세우고isPending
을 falsecallback()
을 실행callback()
으로 생성한 업데이트가 있으며, 렌더링 대기 중인 상태입니다.
여기서 우선순위가 가장 높은 건 전환 플래그를 세우기 전에 생성한 isPending
을 true로 변경하는 업데이트입니다. 해당 렌더링이 완료되면 지연 피드백 UI가 사용자에게 노출됩니다. 이어서 전환 업데이트의 렌더링이 진행될 때는 이미 isPending
를 false로 변경하는 업데이트도 같이 배치처리 되기 때문에 자연스레 지연 피드백 UI는 미적용된 상태로 전환 UI를 렌더링하게 됩니다.
리액트 18로 넘어오면서 리액트 어플리케이션에서 발생하는 업데이트를 이해하는 것이 중요해졌습니다. 앞으로 나올 신규 기능도 이런 업데이트의 개념이 밑바탕에 깔려있기 때문에 반드시 이해하고 넘어가면 좋겠습니다.
다음은 Concurrent Render과 Sync Render에 대해서 알아볼 것입니다.