코드 한 줄로 경험하는 React 동시성의 마법
안녕하세요! 리멤버앤컴퍼니 파운데이션 크루에서 Web Frontend Software Engineer로 일하고 있는 성정민입니다.
최근 저는 사용자가 검색창에 키워드를 입력할 때마다 실시간으로 UI를 업데이트하는 기능을 개발했습니다. 그런데 문제가 발생했어요. 검색창에 글자를 입력할 때마다 수천 개의 데이터를 필터링하고 화면에 표시해야 하는데, 이 과정에서 입력이 멈칫거리는 현상이 나타났습니다. 예를 들어, 사용자가 “안녕”을 입력할 때 “안”까지는 괜찮다가 “녕”을 입력하는 순간 브라우저가 잠시 멈추는 식이었습니다.
처음에는 이 문제를 어떻게 해결할지 고민하다가 React 18의 동시성 모드에서 소개된 useTransition API를 적용해보았습니다. 처음 적용했을 때는 "와, 이게 해결되네!" 싶었지만, 단순히 "마법"이라고 넘기기엔 궁금한 점이 많았습니다. 그래서 React의 내부 동작을 좀 더 깊이 들여다보며 이 기능의 원리를 제대로 이해하고 싶었고, 그 내용을 여러분과 함께 나누고자 합니다.
React와 동시성 렌더링의 이해
리멤버의 웹 서비스는 대부분 React로 만들어져 있습니다. React는 2017년 Fiber 아키텍처를 도입한 이후로 Suspense, 동시성 같은 기능을 꾸준히 발전시켜왔고, 2024년엔 React Compiler라는 멋진 변화를 선보이기도 했죠. 오늘은 그중에서도 React 18의 동시성 기능과 이를 통해 UI를 최적화한 이야기를 해보려고 합니다.
동시성과 병렬성: 비슷하지만 다른 개념
동시성이란 무엇일까요? 단어 자체만 보면 여러 작업을 동시에 수행한다는 의미로 보입니다. 그렇다면 병렬성과 같은 개념일까요? 이 두 개념의 차이를 명확히 이해하는 것이 중요합니다.
Go 언어 창시자의
“동시성은 독립적으로 실행되는 프로세스들의 조합이다.”
병렬성은 멀티 코어 환경에서 실제로 여러 작업이 동시에 처리되는 개념입니다. 반면, 동시성은 싱글 코어에서도 작동할 수 있으며, 여러 작업이 동시에 실행되는 것처럼 보이지만 실제로는 번갈아 가며 실행됩니다. 마치 여러분이 동영상을 보다가 코드를 작성하는 것처럼, 작업 간에 전환하면서 여러 일을 처리하는 방식이에요.
정리하자면, 동시성은 2개 이상의 독립적인 작업을 잘게 나누어 마치 동시에 실행되는 것처럼 프로그램을 구조화하는 방법입니다.
React가 동시성을 도입한 이유: 브라우저의 렌더링 블로킹 문제
브라우저의 메인 스레드는 JavaScript 실행, DOM 조작, 스타일 계산, 레이아웃, 페인팅을 모두 담당하는 싱글 스레드로 동작합니다. 메인 스레드가 어떤 작업을 시작하면, 그 작업이 완료될 때까지 다른 작업을 수행할 수 없습니다. 일반적으로는 이것이 문제가 되지 않아요. React의 Virtual DOM과 diff 알고리즘이 매우 빠르기 때문이죠. 하지만 렌더링 과정이 길어지는 상황에서는 문제가 발생합니다. 예를 들어, 수천 개의 데이터가 있는 복잡한 목록을 필터링하거나 정렬할 때 화면이 버벅거리게 됩니다.
간단한 예를 들어볼까요? 아래 예제에서는 입력창에 글자를 입력할 때마다 입력된 글자 수의 비례해서 셀의 수가 증가하며 화면이 업데이트됩니다.
입력값이 적을 때는 문제가 없지만, 입력값이 늘어날수록 색상 목록과 사용자 입력의 렌더링 속도가 느려지는 것을 볼 수 있습니다. 이것이 바로 블로킹 렌더링입니다. JavaScript 연산이 길어지면서 프레임이 드롭되고, 사용자 입력은 뒤로 밀려나게 됩니다. 60fps로 부드럽게 동작해야 할 애니메이션이 끊기고, 버튼 클릭이나 입력이 지연되는 현상이 발생하죠.
전통적으로 이 문제는 디바운싱(debounce)이나 스로틀링(throttle)과 같은 기법으로 해결해 왔습니다. 하지만 이런 방식은 지연시간을 임의로 설정해야 하므로 사용자의 입력 속도를 예측해야 하는 한계가 있었습니다.
사용자 경험을 위한 우선순위 체계
동시성이 중요한 또 다른 중요한 이유는 사용자 경험(UX) 때문입니다. React 팀은 이를 최적화하기 위해 인간의 인지와 기대에 관한 연구를 진행했습니다.
useTransition PR에서 사용자 경험에 대한 중요한 통찰을 확인할 수 있습니다.
”사용자는 물리적인 행위에 대해서 즉각적인 반응을 기대한다. 그렇지 않다면 사용자는 뭔가 잘못되고 있다고 느낄 수 있다. 반면 A0 -> A1의 전환은 느릴 수 있다고 무의식적으로 인지하고 있으며, 모든 전환에 대한 즉각적인 반응을 기대하지 않는다.”
버튼 클릭, 키보드 입력, 화면 터치와 같은 물리적 상호작용에서는 사용자가 실제 세계의 물체를 조작할 때처럼 즉각적인 반응을 기대합니다. 이러한 행동에 지연이 발생하면 시스템이 고장 났거나 작동하지 않는다고 느끼게 됩니다. 반면, 검색 결과 로딩이나 화면 전환과 같은 상태 변화(A0 -> A1)에서는 사용자가 무의식적으로 지연을 예상하게 됩니다. 약간의 지연에도 큰 불편함을 느끼지 않아요.
React 18은 동시성을 이용해 이 문제를 해결합니다. 이를 도로에 비유해 설명해 볼게요.
1차선 도로의 한계
여기 1차선을 달리는 두 차량이 있습니다. 도로가 1차선이므로 빨간 차는 초록 차가 지나가는 속도에 맞춰 함께 달릴 수밖에 없습니다.
차량이 적으면 문제가 없지만, 차량이 증가할수록 뒤에 있는 차는 쉽사리 이동할 수 없습니다.
렌더링할 컴포넌트가 한두 개라면 싱글 스레드에서도 빠르게 처리할 수 있습니다. 하지만 입력이 증가할수록 목록을 렌더링하는 데 CPU가 많은 시간을 소비하면서 뒤에 쌓인 입력 이벤트를 처리할 수 없게 되어 블로킹 렌더링을 유발합니다.
2차선 도로의 유연함
다른 쪽에는 2차선 도로가 있습니다. 다른 차량에게 방해받지 않기 때문에 길이 막히지 않습니다. 이걸 동시성이라 표현 해볼게요. 두 개의 작업이 각각의 차선으로 나뉘어 처리됩니다. 동시성 덕분에 두 작업의 스택이 분리됐네요.
동시성 렌더링의 핵심은 메인 스레드에게 일정 시간을 양보(Yield)한다는 점입니다. 렌더링 작업을 하다가 잠시 멈추고 메인 스레드가 다른 작업을 처리할 수 있는 시간을 줍니다. 이때 브라우저는 이벤트를 처리할 수 있고, 이 덕분에 응답성이 빨라집니다.
또한 효과적인 양보를 위해, 하나의 컴포넌트 렌더링을 잘게 분해하여 처리합니다. 전체 렌더링 작업을 작은 단위로 나눠서 각 단위 사이에 메인 스레드가 다른 작업을 처리 하는거죠.
어떻게 분해된 렌더링 방식이 사용자 입력과 같은 이벤트 처리 문제를 해결할까요?
만약 렌더링 중에 새로운 사용자 입력이 발생하면 기존의 낮은 우선순위 렌더링을 잠시 멈추고 높은 우선순위 작업(입력 처리)과 페인팅을 먼저 수행합니다. 그런 다음 보류 상태였던 목록 렌더링 작업을 중단하고 새로운 작업을 우선 처리한 뒤 재개합니다.
예를 들어 사용자가 검색창에 “반응”이라고 입력했다고 가정해볼게요. React는 이 키워드에 맞는 검색 결과 목록을 렌더링하기 시작합니다. 이때 사용자가 추가로 타이핑하여 “반응형”으로 검색어를 수정했다면 어떻게 될까요?
일반적인 렌더링 방식에서는 “반응” 검색어에 대한 모든 결과가 계산되고 화면에 표시될 때까지 사용자의 추가 입력이 화면에 반영되지 않아요. 사용자는 앱이 멈춘 것처럼 느낄 수 있죠.
반면 동시성 렌더링에서는 사용자의 키보드 입력이 감지되면 진행 중이던 검색 결과 렌더링을 일시 중단하고, 사용자의 입력을 화면에 즉시 반영해요. 사용자는 입력이 지연 없이 화면에 나타나는 것을 확인할 수 있고, 그 후에 React는 새로운 검색어에 맞는 결과를 렌더링합니다.
여기서 빨간색 차가 다니는 차선은 고속 차선(높은 우선순위), 초록색 차가 다니는 차선은 저속 차선(낮은 우선순위)입니다. React에서는 이를 내부적으로 레인(Lane)이라고 부르며 우선순위를 제어하는 데 사용합니다.
레인(Lane) 모델과 동시성 렌더링
이번 섹션에서는 레인 모델이 어떻게 동작하고, 어떻게 동시성 렌더링을 가능하게 하는지 알아보겠습니다.
Lane 모델은 2020년 3월에 구현되었으며(관련 PR), Expiration Time 모델의 한계를 해결하기 위해 설계되었습니다.
“레인”은 무엇일까요? 영어로 “Lane”은 “차선”을 의미합니다. 도로에서 차선마다 다른 속도로 차량이 움직이듯이, React에서도 각 업데이트는 그 중요도에 따라 서로 다른 “차선”에 배치됩니다. 가장 중요한 업데이트는 “고속 차선”에, 덜 중요한 업데이트는 “저속 차선”에 배치되어 처리되는 방식이죠.
레인은 업데이트의 우선순위를 표시하는 것으로, 어떤 작업이 얼마나 중요한지를 나타냅니다. 이 우선순위를 사용하면 어떤 업데이트를 먼저 처리할지, 어떤 업데이트를 잠시 미룰지 결정할 수 있습니다.
레인은 어떻게 구현되는가?
레인은 32비트 정수로 구현되어 있습니다. 각 비트는 하나의 “차선”을 나타내며, 오른쪽에 있는 낮은 비트 위치일수록 우선순위가 높습니다. 마치 실제 도로에서 1번 차선이 가장 빠른 차선인 것과 유사하게 생각할 수 있습니다.
소스 코드에서는 레인을 다음과 같이 이진 형식으로 표현합니다.
react-reconciler/src/ReactFiberLane.new.js
const TotalLanes = 31
const NoLanes: Lanes = /* */ 0b0000000000000000000000000000000
const NoLane: Lane = /* */ 0b0000000000000000000000000000000
const SyncLane: Lane = /* */ 0b0000000000000000000000000000001
const InputContinuousHydrationLane: Lane = /* */ 0b0000000000000000000000000000010
const InputContinuousLane: Lane = /* */ 0b0000000000000000000000000000100
const DefaultHydrationLane: Lane = /* */ 0b0000000000000000000000000001000
const DefaultLane: Lane = /* */ 0b0000000000000000000000000010000
const TransitionHydrationLane: Lane = /* */ 0b0000000000000000000000000100000
const TransitionLanes: Lanes = /* */ 0b0000000001111111111111111000000
const TransitionLane1: Lane = /* */ 0b0000000000000000000000001000000
const RetryLanes: Lanes = /* */ 0b0000111110000000000000000000000
const RetryLane1: Lane = /* */ 0b0000000010000000000000000000000
const IdleHydrationLane: Lane = /* */ 0b0010000000000000000000000000000
const IdleLane: Lane = /* */ 0b0100000000000000000000000000000
const OffscreenLane: Lane = /* */ 0b1000000000000000000000000000000
코드를 보면 각 레인이 비트로 정의되어 있음을 알 수 있습니다. 가장 오른쪽 비트(SyncLane, 0b...001)가 가장 높은 우선순위를 가지며, 왼쪽으로 갈수록 우선순위가 낮아집니다.
레인 모델이 비트 연산을 사용하는 이유
레인 모델을 비트 단위로 설계한 선택에는 여러 기술적 이점이 있습니다.
먼저, 메모리 효율성이 두드러집니다. 단일 32비트 정수 하나로 최대 31개의 서로 다른 우선순위 레벨을 표현할 수 있어 복잡한 UI 업데이트 상황에서도 메모리 사용량을 최소화할 수 있습니다.
또한 비트 연산은 컴퓨터 아키텍처의 가장 기본적인 수준에서 직접 처리되기 때문에 CPU가 효율적으로 처리할 수 있습니다. 밀리초 단위의 성능이 중요한 사용자 상호작용에서 이는 큰 이점이 됩니다.
실제 개발 관점에서는 OR(|), AND(&), NOT(~) 같은 비트 연산자를 활용해 여러 레인을 논리적 그룹으로 쉽게 묶거나 분리할 수 있습니다. 이를 통해 관련된 업데이트들을 함께 관리하거나 특정 업데이트만 선별적으로 처리하는 복잡한 시나리오를 간결한 코드로 구현할 수 있죠.
이벤트와 레인의 우선순위
레인 모델 내에는 다양한 종류의 레인이 있으며, 각각은 특정 유형의 업데이트를 처리합니다. 이 시작점은 DOM 이벤트일 수도 있고 비동기, 전환 이벤트일 수도 있으며, 레인은 하나의 이벤트에 대응합니다.
레인은 오른쪽에 있을수록 우선순위가 높으므로 비트 위치를 기준으로 우선순위를 정렬하면 다음과 같은 계층이 형성됩니다.
우선순위 체계는 사용자 경험에 대한 이해를 바탕으로 설계되었습니다.
예를 들어, 버튼 클릭과 같은 직접적인 사용자 입력은 가장 높은 우선순위로 처리되어 즉각적인 응답을 보장하는 반면, 대량 데이터의 필터링과 같은 무거운 작업은 낮은 우선순위로 처리되어 UI의 응답성을 유지할 수 있습니다.
세 가지 우선순위 시스템
React는 Lane 우선순위, 이벤트 우선순위, 스케줄러 우선순위라는 세 가지 서로 연결된 시스템을 통해 작업의 중요도를 관리합니다.
- Lane 우선순위: 업데이트의 중요도를 나타냅니다.
- 이벤트 우선순위: 사용자 이벤트의 중요도를 나타냅니다.
- 스케줄러 우선순위: 스케줄러에서 작업 예약 시 사용되는 우선순위입니다.
이 세 가지 우선순위 시스템은 서로 연결되어 있으며, Lane 우선순위를 이벤트 우선순위로 변환하고, 다시 스케줄러 우선순위로 매핑하여 작업을 처리합니다.
먼저 이벤트 우선순위는 직접 특정 레인값에 매핑됩니다.
react-reconciler/src/ReactEventPriorities.js
export const NoEventPriority: EventPriority = NoLane
export const DiscreteEventPriority: EventPriority = SyncLane
export const ContinuousEventPriority: EventPriority = InputContinuousLane
export const DefaultEventPriority: EventPriority = DefaultLane
export const IdleEventPriority: EventPriority = IdleLane
레인에서 이벤트 우선순위로의 변환은 lanesToEventPriority 함수를 통해 이루어집니다.
react-reconciler/src/ReactEventPriorities.js
export function lanesToEventPriority(lanes: Lanes): EventPriority {
const lane = getHighestPriorityLane(lanes)
if (!isHigherEventPriority(DiscreteEventPriority, lane)) {
return DiscreteEventPriority
}
if (!isHigherEventPriority(ContinuousEventPriority, lane)) {
return ContinuousEventPriority
}
if (includesNonIdleWork(lane)) {
return DefaultEventPriority
}
return IdleEventPriority
}
lanesToEventPriority 함수는 주어진 레인 집합에서 가장 높은 우선순위를 가진 레인을 찾아 해당하는 이벤트 우선순위로 매핑합니다. 숫자가 작을수록 우선순위가 높으므로, !isHigherEventPriority(DiscreteEventPriority, lane)은 “레인이 DiscreteEventPriority보다 높거나 같은 우선순위인가?”를 확인합니다.
우선순위 결정 로직을 통해 가장 높은 우선순위부터 낮은 우선순위까지 차례로 검사하며 해당하는 이벤트 우선순위를 반환합니다. 이 과정에서 DiscreteEventPriority -> ContinuousEventPriority → DefaultEventPriority → IdleEventPriority 순으로 우선순위가 결정된다는 것을 확인할 수 있습니다.
특히 흥미로운 점은 TransitionLane이 별도의 이벤트 우선순위를 가지지 않고 DefaultEventPriority로 매핑된다는 것입니다. UI 전환 작업이 사용자 직접 입력보다는 낮지만, 백그라운드 작업보다는 높은 우선순위로 처리되어야 함을 시사함을 보여줍니다.
다음으로는 결정된 이벤트 우선순위를 다시 스케줄러 우선순위로 매핑하는 과정을 거칩니다.
react-reconciler/src/ReactFiberRootScheduler.js
// 가장 높은 우선순위 레인 찾기
const newCallbackPriority = getHighestPriorityLane(nextLanes)
// 레인을 스케줄러 우선순위로 매핑
switch (lanesToEventPriority(nextLanes)) {
case DiscreteEventPriority:
schedulerPriorityLevel = ImmediateSchedulerPriority
break
case ContinuousEventPriority:
schedulerPriorityLevel = UserBlockingSchedulerPriority
break
case DefaultEventPriority:
schedulerPriorityLevel = NormalSchedulerPriority
break
// ... 생략 ...
}
우선순위 체계는 다양한 작업의 중요도를 효과적으로 관리하여 사용자 경험을 최적화하면서도 시스템 성능을 유지할 수 있게 합니다.
예를 들어, 키보드 입력이나 클릭과 같은 사용자 상호작용은 높은 우선순위로 처리되어 즉각적인 반응성을 보장하고, 페이지 전환과 같은 작업은 중간 우선순위로, 데이터 프리페칭과 같은 작업은 낮은 우선순위로 처리됩니다.
특정 업데이트를 낮은 우선순위로 전환하는 startTransition API를 사용하는 이벤트 핸들러를 보면서 매핑이 어떻게 이루어지는지 생각해볼까요?
function handleSearch(e) {
// 이벤트 핸들러는 DefaultEventPriority로 실행됨
setSearchQuery(e.target.value) // 즉시 업데이트 (SyncLane)
startTransition(() => {
// 이 안의 상태 업데이트는 TransitionLane으로 처리됨
setSearchResults(searchData(e.target.value)) // 지연된 업데이트
})
}
startTransition 내부의 상태 업데이트에는 TransitionLane이 할당되지만, 이벤트 핸들러 자체는 DefaultEventPriority로 처리됩니다. 이렇게 함으로써 이벤트 핸들러는 적절한 시점에 실행되면서도, 실제 렌더링 작업은 낮은 우선순위로 예약됩니다. 결과적으로 전환 업데이트는 더 중요한 업데이트(e.g. 사용자 입력)가 처리된 후에만 백그라운드에서 실행됩니다.
이 두 우선순위 시스템의 분리는 이벤트 처리와 렌더링을 더 세밀하게 제어할 수 있게 해줍니다. 이것이 바로 React가 멀티스레드 환경처럼 동작하면서도 사용자 경험을 우선시할 수 있는 이유입니다.
Expiration Time 모델의 한계와 레인 모델의 장점
레인 모델이 도입되기 전에는 Expiration Time 모델을 사용했습니다. 해당 모델에서는 각 업데이트에 시간 기반의 만료시간을 할당하여, 이 값이 작업의 우선순위와 배치 처리를 모두 결정했습니다. 예를 들어, 우선순위가 A > B > C인 경우, A 작업이 완료될 때까지 B나 C 작업을 시작할 수 없었습니다.
이러한 방식은 Suspense와 같은 기능이 도입되면서 한계가 드러났습니다. 예를 들어, Suspense를 통해 데이터를 가져오는 작업(IO-Bound)은 우선순위가 높더라도 데이터가 도착할 때까지 대기해야 했습니다. 이 때문에 즉시 실행 가능한 낮은 우선순위의 UI 업데이트(CPU-Bound)를 차단하는 우선순위 역전 문제가 발생했습니다. (여기서 IO-Bound 작업은 주로 네트워크 요청과 같은 입출력(I/O) 대기로 인해 지연되는 작업을 의미하고, CPU-Bound 작업은 주로 계산 처리에 의존하는 작업을 뜻합니다.)
또한, Expiration Time 모델에서는 우선순위와 배치 처리 개념이 단일 값에 혼합되어 있어, 다양한 유형의 작업을 세밀하게 제어하기 어려웠습니다.
반면, 레인 모델은 32비트 정수와 비트 연산을 활용하여 더 세밀한 우선순위 제어를 가능하게 합니다. 각 업데이트는 고유한 레인에 할당되며, 이를 통해 서로 다른 유형의 작업(e.g. 사용자 입력, 데이터 페칭, UI 전환)을 독립적으로 스케줄링할 수 있습니다. 예를 들어, IO-Bound 작업이 데이터를 기다리는 동안 CPU-Bound 작업을 먼저 처리하여 메인 스레드를 효율적으로 활용할 수 있습니다.
비트 연산을 활용한 레인 관리
비트 연산을 사용하여 레인을 관리하면 다음과 같은 작업을 효율적으로 수행할 수 있습니다.
/**
*가장 높은 우선순위 레인 찾기
* 해당 연산은 비트 집합에서 가장 낮은 위치에 있는 1비트만을 남기는 연산입니다.
* 이는 레인 비트 체계에서 가장 높은 우선순위를 가진 레인을 식별합니다.
*/
function getHighestPriorityLane(lanes) {
return lanes & -lanes
}
/*
*레인 병합하기
* OR 연산자(|)를 사용하여 두 레인 집합을 병합합니다.
* 여러 업데이트의 레인을 하나의 집합으로 쉽게 결합할 수 있습니다.
*/
function mergeLanes(a, b) {
return a | b // 두 레인의 비트별 OR 연산
}
/*
* 레인 교차하기
* AND 연산자(&)를 사용하여 두 레인 집합의 교집합을 찾습니다.
* 두 집합에 공통으로 존재하는 레인만 추출할 수 있습니다.
*/
function intersectLanes(a, b) {
return a & b // 두 레인의 비트별 AND 연산
}
/**
* 레인 제거하기
* AND와 NOT 연산자를 조합하여 특정 레인을 집합에서 제거합니다.
*/
function removeLanes(set, subset) {
return set & ~subset // subset의 비트를 반전하고 AND 연산
}
/**
* 부분집합 확인하기
* 한 레인 집합이 다른 집합의 부분집합인지 확인합니다.
* 특정 업데이트가 다른 업데이트 그룹에 포함되는지 판단하는 데 사용됩니다.
*/
function isSubsetOfLanes(set: Lanes, subset: Lanes | Lane): boolean {
return (set & subset) === subset
}
레인 모델이 동시성을 지원하는 방식
레인 모델의 핵심 기능 중 하나는 현재 렌더링 중인 작업보다 더 높은 우선순위의 업데이트가 발생했을 때, 이전 렌더링을 중단하고 새 업데이트를 처리할 수 있는 능력입니다. 이 기능은 getNextLanes 함수에서 구현됩니다.
react-reconciler/src/ReactFiberLane.js
export function getNextLanes(
root: FiberRoot,
wipLanes: Lanes,
rootHasPendingCommit: boolean,
): Lanes {
// 보류 중인 작업이 없으면 일찍 반환
const pendingLanes = root.pendingLanes
if (pendingLanes === NoLanes) {
return NoLanes
}
// ... 중략 ...
// 이미 렌더링 중이라면, 새 레인이 더 높은 우선순위인 경우에만 전환
if (wipLanes !== NoLanes && wipLanes !== nextLanes) {
const nextLane = getHighestPriorityLane(nextLanes)
const wipLane = getHighestPriorityLane(wipLanes)
if (
// nextLane이 wipLane보다 우선순위가 같거나 낮은지 확인
nextLane >= wipLane ||
// 기본 우선순위 업데이트는 전환 업데이트를 중단해서는 안 됨
(nextLane === DefaultLane && (wipLane & TransitionLanes) !== NoLanes)
) {
// 기존 진행 중인 작업을 계속 진행. 중단하지 않음.
return wipLanes
}
}
// ... 중략 ...
return nextLanes
}
getNextLanes은 현재 진행 중인 렌더링(wipLanes)보다 우선순위가 높은 업데이트가 있는지 확인합니다. 만약 더 높은 우선순위의 업데이트가 발생하면, 현재 렌더링을 중단하고 새로운 업데이트를 처리합니다. 이것이 바로 React의 ‘중단 가능한 렌더링’의 핵심 메커니즘입니다.
만료 시간 관리
업데이트가 너무 오래 지연되는 것을 방지하기 위해 만료 시간도 관리합니다.
function computeExpirationTime(lane: Lane, currentTime: number) {
switch (lane) {
case SyncLane:
case InputContinuousLane:
// 사용자 상호작용은 더 빨리 만료되어야 함
return currentTime + 250
case DefaultLane:
case TransitionLane:
return currentTime + 5000
// ... 기타 케이스
}
}
사용자 상호작용 관련 레인은 250ms 후에 만료되는 반면, 기본 및 전환 레인은 5000ms 후에 만료됩니다. 이를 통해 사용자 입력에 더 빠르게 응답할 수 있습니다.
얽힘(Entanglement) 메커니즘
또 다른 중요한 개념은 “얽힘(Entanglement)”입니다. 얽힘은 관련된, 혹은 의존적인 업데이트들이 함께 처리되도록 보장합니다.
react-reconciler/src/ReactFiberLane.js
export function markRootEntangled(root: FiberRoot, entangledLanes: Lanes) {
// 직접적으로 얽힌 레인뿐만 아니라, 전이적으로 얽힌 레인도 고려
// 만약 C가 A와 얽혀 있고, A가 B와 얽히게 되면, C도 B와 얽힘
const rootEntangledLanes = (root.entangledLanes |= entangledLanes)
const entanglements = root.entanglements
// ... 얽힘 관계 처리 로직
}
얽힘의 핵심 특징은 전이적 속성입니다. 이는 한 레인이 다른 레인과 얽히면, 그와 관련된 모든 레인이 자동으로 서로 얽히게 되는 특성을 의미합니다. 예를 들어, 레인 C가 이미 레인 A와 얽혀 있고, A가 레인 B와 얽히게 된다면, C도 자동으로 B와 얽히게 됩니다. 이런 방식으로 복잡한 상태 업데이트 간의 관계를 효율적으로 추적하고, 관련된 모든 변경 사항이 UI에 일관되게 반영되도록 보장할 수 있습니다.
만약 useTransition이나 startTransition을 사용할 때, 해당 범위 내의 모든 상태 업데이트는 서로 얽히게 됩니다. 이를 통해 전환 과정에서 발생하는 모든 업데이트가 동일한 우선순위로 처리되며, 부분적으로 완료된 불완전한 UI 상태가 사용자에게 보이지 않도록 합니다.
사용자가 검색 필터를 변경할 때를 생각해보세요. 필터 UI와 검색 결과가 동시에 업데이트되어야 하는데, 얽힘 메커니즘이 없다면 필터 UI는 즉시 바뀌고 검색 결과는 지연될 수 있습니다. 이 경우 사용자는 잠깐 동안 최신 필터와 맞지 않는 오래된 검색 결과를 보게 되어 혼란스러울 수 있습니다. 하지만 얽힘을 통해 이런 불일치를 방지하고, UI가 항상 일관된 상태를 유지하도록 보장합니다.
지금까지 간단하게 React의 세 가지 우선순위 시스템과 레인 모델에 대해서 알아봤습니다.
레인 모델의 가장 큰 장점은 우선순위에 따라 작업을 처리할 수 있다는 점입니다. 버튼 클릭이나 키보드 입력 같은 사용자의 직접적인 상호작용은 높은 우선순위로 즉시 처리하고, 대량의 데이터 필터링과 같은 무거운 계산은 낮은 우선순위로 미룰 수 있죠.
또한 이미 진행 중인 렌더링을 필요에 따라 중단할 수 있게 해줍니다. 예를 들어 사용자가 검색 결과를 필터링하는 도중에 새 키를 입력하면, 진행 중이던 필터링을 잠시 멈추고 키 입력을 먼저 처리한 후 다시 필터링을 계속할 수 있습니다. 중단 가능한 렌더링 덕분에 UI가 버벅거리지 않고 부드럽게 동작합니다.
비트 연산을 통한 세밀한 우선순위 제어도 가능합니다. 덕분에 복잡한 업데이트 시나리오에서도 각 작업의 중요도를 효율적으로 관리할 수 있습니다.
다음 섹션에서는 동시성을 실제로 어떻게 활용하여 사용자 경험을 개선할 수 있는지 알아보겠습니다.
useDeferredValue와 useTranstion
React 18에서 추가된 대표적인 API는 useDeferredValue와 useTransition입니다. 이 두 API는 복잡하거나 무거운 UI 업데이트를 처리할 때 애플리케이션의 응답성을 유지하는 데 도움을 줍니다.
웹 애플리케이션이 점점 복잡해지면서 프론트엔드 엔지니어는 대량의 데이터를 처리하거나 복잡한 계산을 수행하면서도 UI가 반응적으로 유지되어야 하는 과제에 직면하고 있어요. 종종 사용자가 입력 필드에 타이핑하거나 UI 요소와 상호작용을 할 때, 이러한 무거운 작업이 메인 스레드를 차단하면 화면이 버벅거리고 사용자 경험이 저하되는 경험을 할 때가 있습니다.
먼저 이 API들이 왜 필요한지 이해하기 위해, 흔히 마주치는 문제 상황을 살펴보겠습니다.
CodeSandbox에서 테스트 해보세요!
이 코드에선 사용자가 입력 필드에 타이핑할 때마다 generateGridData 함수가 호출되어 무거운 계산 작업을 수행합니다. 레인 모델 관점에서 이 과정을 살펴보면,
- 사용자가 키보드로 타이핑하면
onChange이벤트가 발생합니다. setNormalInput호출은 SyncLane(최고 우선순위)에 할당됩니다.- 상태 업데이트로 때문에 컴포넌트가 다시 렌더링되고, 데이터 계산이 동일한 SyncLane에서 수행됩니다.
계산이 완료될 때까지 브라우저는 다음 사용자 입력을 처리할 수 없게 되며, 이 때문에 사용자는 타이핑 시 심각한 지연을 경험하게 됩니다. 각 키 입력 후에 계산이 완료될 때까지 기다려야 다음 키를 입력할 수 있기 때문이죠. 이는 매우 답답한 사용자 경험을 만듭니다.
useDeferredValue
useDeferredValue는 UI 응답성을 유지하면서 무거운 렌더링 작업을 처리할 수 있게 해줍니다. 이 훅은 값의 '지연된 버전'을 생성하여, 무거운 계산 작업은 브라우저가 여유 있을 때 처리하도록 합니다.
CodeSandbox에서 테스트 해보세요!
이번엔 deferredValue를 사용하여 무거운 계산 로직을 최적화했습니다. 수정된 코드는 어떻게 작동하는지 살펴볼까요?
- 사용자가 입력할 때
setDeferredInput호출은 여전히 SyncLane(최고 우선순위)에 할당됩니다. - 해당 고우선순위 작업을 즉시 처리하여 입력 필드를 업데이트합니다.
deferredValue값은 내부적으로 TransitionLane(낮은 우선순위)에 할당됩니다.- 무거운 계산 작업(
generateGridData)은 이제deferredValue를 기반으로 하므로, TransitionLane의 우선순위로 처리됩니다. - 사용자가 계속 타이핑하면 진행 중인 계산 작업을 잠시 중단하고 새로운 입력을 먼저 처리합니다.
useDeferredValue는 내부적으로 두 개의 렌더링 패스를 생성합니다. 첫 번째 패스에서는 deferredInput만 업데이트하고, 두 번째 패스에서는 deferredValue를 업데이트합니다. 이 두 번째 패스는 TransitionLane에서 처리되므로, 사용자 입력과 같은 높은 우선순위 작업에 방해되지 않습니다.
isDeferredPending = deferredInput !== deferredValue 구문은 현재 TransitionLane 작업이 진행 중인지 확인하는 효과적인 방법입니다. 두 값이 다른 경우, 지연된 렌더링이 아직 완료되지 않았다는 의미입니다.
⚠️주의사항
`useDeferredValue는 API 호출에는 적절하지 않습니다. useDeferredValue를 통해 지연되는 타이밍은 React가 내부적으로 결정하는 것으로, 주로 브라우저가 유휴 상태일 때 처리됩니다. 만약 deferredQuery를 기반으로 API 호출을 하게 되면, React의 내부 스케줄링에 따라 예상치 못한 빈도로 API가 호출될 수 있습니다. API 호출을 제어하려면 debounce나 throttle과 같은 기법을 사용하는 것이 더 적절합니다.
useTransition
useDeferredValue가 값에 초점을 맞춘다면, useTransition은 상태 업데이트 자체에 초점을 맞춥니다. 이 API를 사용하면 특정 상태 업데이트를 명시적으로 낮은 우선순위로 표시할 수 있습니다.
CodeSandbox에서 테스트 해보세요!
handleTransitionInput 함수가 호출되면, 다음과 같은 과정이 진행됩니다.
setTransitionInput(value)는 SyncLane(최고 우선순위)에 배치됩니다.startTransition내부의setTransitionGrid(generateGridData(value))는 TransitionLane(낮은 우선순위)에 배치됩니다.- SyncLane에 있는
transitionInput업데이트를 먼저 처리하고, 이후에 TransitionLane에 있는transitionGrid업데이트를 처리합니다. - isPending 상태 변수는 현재 TransitionLane 작업이 진행 중인지 알려줍니다.
여기서 startTransition의 역할은 "이 상태 업데이트는 TransitionLane에 배치해 주세요"라고 지시하는 것입니다. 명시적으로 우선순위를 낮추고 싶을 때 useTransition API를 사용합니다.
useDeferredValue vs useTransition
useDeferredValue와 useTransition은 각각 다른 방식으로 지연 처리를 구현합니다.
useDeferredValue는 특정 값의 업데이트를 지연시켜, 해당 값에 의존하는 렌더링 작업을 TransitionLane에 배치합니다. 무거운 계산이나 렌더링을 간단히 지연시키고 UI 응답성을 유지하려는 경우 적합합니다.useTransition은 상태 업데이트를 명시적으로 TransitionLane에 배치하여 지연 처리합니다. 여러 상태 업데이트를 그룹화하거나 지연 동작을 세밀히 제어할 때 유용합니다.
두 API는 동일한 레인 모델을 기반으로 작동하지만, 사용 목적에 따라 선택됩니다. 단일 값의 지연 처리가 필요하면 useDeferredValue가 간편하고, 복잡한 상태 업데이트를 관리하려면 useTransition이 적합합니다. 일반적으로 한 가지 API만으로도 충분한 경우가 많습니다.
위 예시의 전체 코드를 확인하고 실제로 동작하는 데모를 경험하고 싶다면,
를 참조하세요. 각 입력 필드에 타이핑해보면 일반 모드와 동시성 모드의 차이를 명확하게 느낄 수 있을 것입니다.
리멤버에서의 실제 적용 사례
마지막으로 리멤버의 제품에서 동시성 기능을 적용하여 사용자 경험을 개선했던 사례를 공유해 드리고 마치도록 하겠습니다. 리멤버 채용솔루션 제품 중 하나인 헤드헌팅 PRO는 전통적인 노동집약적이고 아날로그 방식의 헤드헌팅 산업을 혁신하여, 기술 기반의 디지털 솔루션으로 재정립함으로써 업무 생산성을 향상한다는 미션을 가지고 있어요.
이에대한 기술적 솔루션으로 헤드헌팅 PRO는 사용자가 수백만 개의 방대한 데이터를 직관적이고 효율적으로 필터링할 수 있는 기능을 제공하고 있어요. 복잡한 검색 조건에서도 사용자가 마치 간단한 작업을 수행하는 것처럼 느낄 수 있도록 최적화된 인터페이스와 사용자 경험을 구현하여, 헤드헌터들이 후보자 데이터를 보다 신속하고 정확하게 탐색할 수 있도록 지원하고 있습니다. (리멤버의 채용에도 이 서비스가 사용되고 있답니다! 😁)
우리 웹프론트엔드팀은 복잡한 검색 조건을 직관적으로 표현하는 인터페이스를 구성하고, 웹 기술을 통한 향상된 사용자 경험을 제공하여 인재를 더 효과적으로 찾을 수 있도록 노력하고 있습니다. 이러한 노력의 일환으로 검색 성능 최적화를 진행했으며, 이 과정에서 도입한 React의 동시성 기능이 어떻게 사용자 경험을 개선했는지 도입 전/후를 비교하며 실제 적용 사례를 살펴보겠습니다.
export const useSearch = () => {
const [params, setParams] = useSearchParams()
const searchController = useController(SearchController, {
params,
onUpdate: applyMiddleware([clearPage], (nextParams) => {
setParams(nextParams)
Storage.syncWithStorage(nextParams)
}),
})
const controller = useMemo(
() => searchController,
[JSON.stringify(searchController.getResults())],
)
return controller
}
기존 코드에서는 검색 조건이 변경될 때마다 setSearchParams를 통해 URL이 즉시 변경되고, 로컬 스토리지도 함께 업데이트 되었습니다. 이러한 작업들이 모두 메인 스레드에서 동기적으로 처리되면서 UI가 버벅거리는 현상이 발생했습니다. 특히 사용자가 여러 필터를 빠르게 전환하는 경우, 각 필터 클릭마다 URL 변경과 관련된 무거운 작업들이 즉시 실행되어 화면이 순간적으로 멈추곤 했습니다.
또한 URL 변경과 관련된 작업들이 아직 완료되지 않았는데도 UI가 먼저 변경되어 사용자는 필터가 적용된 것처럼 보였지만 , 실제 데이터와 화면은 그 후에야 변경되는 문제도 있었습니다. 이 때문에 사용자는 실제 전환 속도보다 필터링이 더 느리게 적용된다고 느꼈고, 때로는 UI 상태와 데이터 상태 사이에 불일치가 발생하기도 했죠.
이 문제를 해결하기 위해 useTransition을 도입했습니다. 코드를 함께 살펴보겠습니다.
export const useSearch = () => {
const [isPending, startTransition] = useTransition()
const [params, setParams] = useSearchParams()
const searchController = useController(SearchController, {
params,
onUpdate: applyMiddleware([clearPage], (nextParams) => {
// startTransition을 적용한 부분
startTransition(() => {
setParams(nextParams)
Storage.syncWithStorage(nextParams)
})
}),
})
const controller = useMemo(
() => searchController,
[JSON.stringify(searchController.getResults())],
)
return { ...controller, isPending }
}
가장 중요한 변화는 URL 업데이트와 로컬 스토리지 동기화 작업을 startTransition 함수로 감싸는 것입니다. 이 변경의 핵심 아이디어는 "데이터 변경을 먼저 완료하고, 그 후에 UI 업데이트를 낮은 우선순위로 처리한다" 였습니다.
사용자가 필터 버튼을 클릭하면 컨트롤러를 통해 어떤 데이터가 필요한지 즉시 계산하게 됩니다. 이 계산은 빠르게 이루어지므로 메인 스레드를 오래 점유하지 않아요. 그런 다음 계산된 결과를 바탕으로 URL 업데이트와 스토리지 동기화 같은 무거운 작업들은 startTransition 내부에서 낮은 우선순위로 처리하게 됩니다. 이렇게 하면 급한 사용자 입력이 있으면 진행 중이던 낮은 우선순위 작업이 일시 중단되고, 사용자 입력이 먼저 처리될 수 있습니다.
isPending 상태를 활용해 트랜지션이 진행 중인지 여부를 UI에 반영할 수도 있었습니다. 예를 들어, 필터 버튼의 활성 상태를 계산할 때 isPending을 함께 고려하여 트랜지션이 완료되기 전까지는 버튼이 활성화되지 않도록 했습니다. 이를통해 사용자에게 더 일관된 UI 상태를 제공하고, 데이터와 UI 간의 불일치를 방지하는 효과를 볼 수 있었습니다.
const FilterButton = ({ type, label }) => {
const { controller, update, isPending } = useSearch()
const isActive = !isPending && controller.getFilter('type') === type
return (
<Button
isActive={isActive}
onClick={() => {
if (!isActive) {
update((ctrl) => ctrl.setFilter('type', type).getParams())
}
}}
>
{label}
</Button>
)
}
변경을 적용한 후의 사용자 인터페이스가 훨씬 더 부드러워졌어요. 여러 필터를 빠르게 연속해서 클릭해도 UI가 버벅거리지 않았고, 항상 일관된 상태를 유지할 수 있었죠. 이전에는 UI가 변경된 후에도 데이터 처리가 지연되어 체감 속도가 느렸지만, 이제는 UI 업데이트와 데이터 처리가 함께 완료되므로 사용자는 변경이 즉시 적용되는 것처럼 느끼게 되었습니다.
단 몇 줄의 코드 변경으로 마법 같은 성능 개선을 이룬 것처럼 보이지만, 이 간단한 훅 사용 뒤에는 React 팀의 세심한 설계가 있었습니다. 혹시 여러분도 버벅이는 UI로 고민하고 계신다면, 이처럼 간결하면서도 효과적인 솔루션을 한번 시도해보는 건 어떨까요?
마치며
이번 글에서는 React의 렌더링 패러다임에서 일어난 근본적인 변화인 동시성 렌더링을 살펴보고, 주요 API의 활용 방법도 함께 탐구했습니다.
내부 코드를 들여다보며 느낀 점은, 기술의 발전이 단순한 성능 향상을 넘어 사용자 경험을 우선시하는 방향으로 나아가고 있다는 것입니다. 특히, 사용자 상호작용의 중요도에 따라 작업의 우선순위를 정교하게 관리하는 방식과, 모든 UI 업데이트가 동등하지 않다는 인식을 코드에 반영한 접근법에서 인사이트를 얻을 수 있었습니다.
가끔 일상의 코딩에서 멈춰 서서 **“이 기술이 본질적으로 해결하려는 것은 무엇일까?”**라는 질문을 던져보는 것도 의미 있을 것입니다.
이제 저희 팀의 채용 홍보로 글을 마무리 짓도록 하겠습니다. 리멤버 웹프론트엔드팀에서는 사용자 중심의 가치 있는 서비스를 효율적으로 구현하기 위한 기술을 개발하고 다양한 실험에 도전할 열정적인 동료를 찾고 있습니다. 저희와 함께 다양한 문제에 도전하고 싶으신 분은 언제든 채용공고에 지원 해주세요!
감사합니다😀
참고자료
리엑트 동시성 매커니즘들은 어떻게 구현되어 있을까 — 01
React 18 톺아보기 — 01. Intro | Deep Dive Magic Code
Tejas Kumar — I Wrote a Book on React — Things You Should Know