웹에서 텍스트 에디터를 개발하다보면 당연시 여기던 것들도 개발해야 하는 때를 마주하게 됩니다.
저도 약 1년 반 동안 텍스트 에디터를 개발하면서 당연히 웹에서 지원해주겠지 하는 것들을 개발을 하게 되었고, 그 과정에서 흔하지 않은 문제들을 접한 경험이 있습니다.
에디터를 개발하다 보면 브라우저 기본 커서만으로는 구현하지 못하는 (다중 커서, 텍스트 위치의 정밀한 제어, 자연스러운 UX) 사항이 있어 커서를 직접 구현해야 하는 경우가 있습니다.
그런데 React 18로 마이그레이션한 이후, 입력 중 커서가 사라지는 현상을 겪게 되었고, 이를 디버깅하는 과정에서 React 18의 이벤트 처리 방식이 제가 알고 있던 것과 다르다는 점을 알게 되었습니다.
이번 글에서는 React 18의 내부 코드를 직접 분석하면서 알게 된 이벤트 처리 흐름의 변화와, 그 변화가 실제 렌더링 타이밍에 어떤 영향을 주는지를 공유해보려 합니다.
커서를 그리기 위해선 텍스트 엘리먼트의 실제 높이가 필요합니다. 이를 위해 텍스트 컴포넌트가 커밋된 이후 커서 컴포넌트가 렌더링되어야 합니다.
텍스트 컴포넌트의 커밋 이 후 커서를 렌더링하기 위해, 커서 컴포넌트는 의도적으로 마이크로태스크(microtask)로 지연시켜 구현했습니다.
queueMicrotask(() => {
// 텍스트 엘리먼트 크기 계산 후 커서 렌더링
cursorComponent();
});
React17에서 React18로 마이그레이션한 이후, 키보드로 텍스트 입력 시 구현된 커서가 사라지는 현상이 발생했습니다.
키 입력 이벤트와 마우스 클릭 이벤트를 비교한 결과, 다음과 같은 차이를 발견했습니다:
커서 렌더링 시점에 텍스트 DOM이 아직 준비되지 않아, 높이 계산이 실패하고 렌더링에 실패한 것이었죠.
디버깅 결과:
즉, React 18에서는 텍스트 렌더링 시점 자체가 이벤트 직후가 아닌, 마이크로태스크로 지연되었고, 이로 인해 기존의 커서 렌더링 방식이 더 이상 동작하지 않게 된 것입니다.
문제의 핵심 요약
1. React 18에서는 텍스트 컴포넌트의 렌더링이 마이크로태스크로 지연됨
2. 커서 컴포넌트는 여전히 마이크로태스크에서 렌더링됨
3. 텍스트 DOM 생성 전 커서 렌더링 시도 → 렌더링 실패 및 UX 오류 발생
공식 문서에 나와 있는 Automatic Batching, Concurrent Rendering 등의 개념만으로는 원인을 파악할 수 없었고, 결국 React 내부 코드를 직접 확인하며 문제를 이해할 수 있었습니다.
React 18의 가장 큰 변화 중 하나는 Concurrent Rendering의 도입입니다. Concurrent Rendering은 여러 작업을 동시적으로 스케줄링하고 우선순위에 따라 적절히 분류해서 처리해, 사용자 인터페이스의 응답성을 높이고 브라우저를 블로킹하지 않으면서 UI를 점진적으로 렌더링할 수 있도록 해줍니다.
React의 Concurrent Rendering이 해결하고자 하는 핵심 문제는 다음과 같습니다:
React 18에서는 작업의 우선순위를 관리하기 위해 Lane 시스템을 사용합니다. Lane은 각각의 업데이트에 우선순위를 할당하여, 중요한 업데이트가 먼저 처리되도록 보장합니다.
Lane은 비트 마스크를 사용하여 구현되며, 우측에 위치할수록 우선순위가 높습니다.
// React 18 Lane 정의
const NoLanes = 0b0000000000000000000000000000000;
const NoLane = 0b0000000000000000000000000000000;
const SyncLane = 0b0000000000000000000000000000010;
const InputContinuousLane = 0b0000000000000000000000000001000;
const DefaultLane = 0b0000000000000000000000000100000;
const TransitionLanes = 0b0000000011111111111111110000000;
const TransitionLane1 = 0b0000000000000000000000010000000;
// ... TransitionLane16까지
const RetryLanes = 0b0000111100000000000000000000000;
const RetryLane1 = 0b0000000100000000000000000000000;
// ... RetryLane4까지
Discrete Event는 사용자 액션으로부터 발생하는 개별적으로 분리되어 실행되는 이벤트입니다. 사용자 액션으로 발생했기 때문에 즉각적으로 반응해야 하며, 스케줄링 시 우선순위가 가장 높습니다.
DOM 이벤트 분류
react-dom/src/events/ReactDOMEventListener.js
// DOM 이벤트별 우선순위 결정
function getEventPriority(domEventName) {
switch (domEventName) {
case 'click':
case 'input':
case 'keydown':
case 'keyup':
case 'submit':
return DiscreteEventPriority; // Sync Lane
case 'drag':
case 'scroll':
case 'mousemove':
case 'wheel':
return ContinuousEventPriority; // InputContinuous Lane
default:
return DefaultEventPriority; // Default Lane
}
}
이벤트 유형별 Lane 매핑:
react-reconciler/src/ReactFiberLane.js
이벤트 종류 | 예시 | 대응 Lane | 우선순위 |
---|---|---|---|
Discrete | click, keydown, input | SyncLane | ⬆️ 매우 높음 |
Continuous | scroll, mousemove, wheel | InputContinuousLane | ⬆️ 중간 |
Default | fetch, setTimeout 등 일반 작업 | DefaultLane | ➖ 보통 |
Transition | 페이지 전환, 대규모 상태 변경 | TransitionLanes | ⬇️ 낮음 |
즉각적인 반응을 위해 Discrete Event로 인해 발생한 업데이트는 Sync Lane으로 처리되며, React는 이를 위해 일반적인 스케줄러 대신 내부 syncQueue를 사용합니다. 이는 다음과 같은 과정을 거칩니다.
Discrete Event 처리 과정:
SyncLane
으로 분류됩니다.performSyncWorkOnRoot
가 syncQueue
에 등록됩니다.// Discrete Event 처리 시 syncQueue 등록
function ensureRootIsScheduled(root, currentTime) {
const nextLanes = getNextLanes(root, NoLanes);
const newCallbackPriority = getHighestPriorityLane(nextLanes);
if (newCallbackPriority === SyncLane) {
// Sync Lane인 경우 syncQueue에 등록
scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
}
}
flushSyncCallbacks()
를 통해 syncQueue
에 등록된 콜백이 실행되며, 다음 작업이 수행됩니다:이러한 우선순위 시스템을 통해 React는 사용자의 즉각적인 입력에 빠르게 반응하면서도, 우선순위가 낮은 업데이트를 적절히 지연시켜 전체적인 성능을 최적화합니다.
그런데 핵심은 React 18에서 이 syncQueue
의 처리 시점이 변경되었다는 점입니다.
React 17과 18에서 syncQueue
에 추가된 callback을 처리하는 시점이 변경되었습니다. 관련 commit
performSyncWorkOnRoot
를 syncQueue에 등록 react-reconciler/src/SchedulerWithReactIntegration.new.js
export function scheduleSyncCallback(callback: SchedulerCallback) {
if (syncQueue === null) {
syncQueue = [callback]; // performSyncWorkOnRoot 등록
} else {
syncQueue.push(callback);
}
}
flushSyncCallbackQueue
를 바로 호출 react-reconciler/src/ReactFiberWorkLoop.old.js
export function discreteUpdates(fn, a, b, c, d) {
// ... discrete event 처리
try {
return runWithPriority(UserBlockingSchedulerPriority, fn.bind(null, a, b, c, d));
} finally {
if (executionContext === NoContext) {
flushSyncCallbackQueue(); // 즉시 실행!
}
}
}
flushSyncCallbackQueue
에서는 syncQueue에 있는 callback 함수 performSyncWorkOnRoot
를 실행 react-reconciler/src/SchedulerWithReactIntegration.new.js
function flushSyncCallbackQueueImpl() {
if (syncQueue !== null) {
const queue = syncQueue;
syncQueue = null; // syncQueue에 있는 callback들 실행
for (let i = 0; i < queue.length; i++) {
let callback = queue[i]; // performSyncWorkOnRoot
do {
callback = callback(true);
} while (callback !== null);
}
}
}
performSyncWorkOnRoot
를 syncQueue에 등록 (React 17과 동일)react-reconciler/src/ReactFiberSyncTaskQueue.new.js
export function scheduleSyncCallback(callback: SchedulerCallback) {
if (syncQueue === null) {
syncQueue = [callback]; // performSyncWorkOnRoot 등록
} else {
syncQueue.push(callback);
}
}
flushSyncCallbackQueue
를 마이크로태스크로 등록 변경 commit react-reconciler/src/ReactFiberWorkLoop.new.js
function ensureRootIsScheduled(root, currentTime) {
if (newCallbackPriority === SyncLane) {
// syncQueue에 callback 등록
scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
if (supportsMicrotasks) {
// 마이크로태스크로 예약!
scheduleMicrotask(() => {
if (executionContext === NoContext) {
flushSyncCallbacks();
}
});
}
}
}
flushSyncCallbackQueue
를 호출하지 않음 변경 commitreact-reconciler/src/ReactFiberWorkLoop.new.js
export function discreteUpdates(fn, a, b, c, d) {
try {
setCurrentUpdatePriority(DiscreteEventPriority);
return fn(a, b, c, d); // discrete event 처리
} finally {
setCurrentUpdatePriority(previousPriority);
// React 18: flushSyncCallbackQueue 호출 없음!
}
}
flushSyncCallback
함수 (함수명 변경)를 실행하며, syncQueue에 있는 callback 함수 (performSyncWorkOnRoot
)를 실행 (React 17과 동일)react-reconciler/src/ReactFiberSyncTaskQueue.new.js
export function flushSyncCallbacks() {
if (syncQueue !== null) {
const queue = syncQueue;
syncQueue = null;
// syncQueue에 있는 callback들 실행
for (let i = 0; i < queue.length; i++) {
let callback = queue[i]; // performSyncWorkOnRoot
do {
callback = callback(true);
} while (callback !== null);
}
}
}
핵심 차이점:
위의 변경으로 인해 React 18에서는 Discrete Event(키보드, 클릭 등)로 인한 동기 렌더링도 마이크로태스크에서 실행되게 되었습니다. 이로 인해 React 18에서는 텍스트 컴포넌트의 렌더링이 마이크로태스크로 지연되며, 앞서 언급한 커서 렌더링 문제의 직접적인 원인이 되었습니다.
React 18에서는 Discrete Event
외에도 Continuous
, Default
, Idle
이벤트가 존재하며, 이들은 SyncLane
이 아닌 다른 Lane 우선순위로 분류되어 Scheduler를 통해 비동기적으로 처리됩니다.
performConcurrentWorkOnRoot
를 매크로태스크로 예약SyncLane
이 아닌 경우, 다음과 같이 이벤트 우선순위에 따라 Scheduler의 scheduleCallback()
을 호출하여 callback을 매크로태스크로 등록합니다.react-reconciler/src/ReactFiberWorkLoop.new.js
function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
if (newCallbackPriority === SyncLane) {
// SyncLane 처리
} else {
// Continuous/Default/Idle 처리
let schedulerPriorityLevel;
switch (lanesToEventPriority(nextLanes)) {
case DiscreteEventPriority:
schedulerPriorityLevel = ImmediateSchedulerPriority;
break;
case ContinuousEventPriority:
schedulerPriorityLevel = UserBlockingSchedulerPriority;
break;
case DefaultEventPriority:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
case IdleEventPriority:
schedulerPriorityLevel = IdleSchedulerPriority;
break;
default:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
}
// Scheduler를 통한 비동기 스케줄링
newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root), // 매크로태스크로 스케쥴링
);
}
root.callbackPriority = newCallbackPriority;
root.callbackNode = newCallbackNode;
}
performConcurrentWorkOnRoot
실행react-reconciler/src/ReactFiberWorkLoop.new.js
function performConcurrentWorkOnRoot(root, didTimeout) {
// 렌더링 실행
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes) // 양보 가능한 렌더링
: renderRootSync(root, lanes); // 동기 렌더링
if (exitStatus !== RootInProgress) {
if (exitStatus !== RootDidNotComplete) {
// 렌더링 완료 - 커밋 준비
const finishedWork: Fiber = root.current.alternate;
root.finishedWork = finishedWork;
root.finishedLanes = lanes;
finishConcurrentRender(root, exitStatus, lanes);
}
}
// 다음 작업 스케줄링
ensureRootIsScheduled(root, now());
// 연속 실행 여부 결정
if (root.callbackNode === originalCallbackNode) {
return performConcurrentWorkOnRoot.bind(null, root);
}
return null;
}
구분 | Discrete Events | Continuous/Default Events |
---|---|---|
Lane | SyncLane | ContinuousLane / DefaultLane |
스케줄링 | scheduleSyncCallback → 마이크로태스크 | scheduleCallback → 매크로태스크 |
실행 함수 | performSyncWorkOnRoot | performConcurrentWorkOnRoot |
렌더링 방식 | 동기 렌더링 (renderRootSync ) | 타임 슬라이싱 (renderRootConcurrent ) |
양보 가능 | 불가 | 가능 |
배칭 처리 | 마이크로태스크 내부에서 배칭 | Scheduler 기반 자동 배칭 |
Batching은 React가 여러 개의 상태 업데이트를 하나의 렌더링으로 묶어 처리하는 성능 최적화 기법입니다. React 17 이전까지는 React 이벤트 핸들러 내부에서만 배칭이 동작했지만, React 18에서는 모든 업데이트가 자동으로 배칭됩니다.
React 17에서는 React 이벤트 핸들러 내부에서만 배칭이 가능했습니다:
function handleClick() {
setCount(c => c + 1); // 리렌더링 발생하지 않음
setFlag(f => !f); // 리렌더링 발생하지 않음
// React가 하나의 리렌더링으로 배칭 처리
}
하지만 비동기 작업(Promise, setTimeout 등) 내부에서는 배칭이 되지 않았습니다:
function handleClick() {
fetchSomething().then(() => {
setCount(c => c + 1); // 첫 번째 리렌더링
setFlag(f => !f); // 두 번째 리렌더링
});
}
setTimeout(() => {
setCount(c => c + 1); // 첫 번째 리렌더링
setFlag(f => !f); // 두 번째 리렌더링
}, 1000);
React 18에서 createRoot
를 사용하는 경우, setTimeout, Promise, fetch 등 모든 컨텍스트에서 자동으로 배칭이 적용됩니다.
function handleClick() {
fetchSomething().then(() => {
setCount(c => c + 1); // 리렌더링 발생하지 않음
setFlag(f => !f); // 리렌더링 발생하지 않음
// React가 하나의 리렌더링으로 배칭 처리
});
}
setTimeout(() => {
setCount(c => c + 1); // 리렌더링 발생하지 않음
setFlag(f => !f); // 리렌더링 발생하지 않음
// React가 같은 이벤트 루프에서 배칭 처리
}, 1000);
Promise.resolve().then(() => {
setCount(c => c + 1); // 리렌더링 발생하지 않음
setFlag(f => !f); // 리렌더링 발생하지 않음
// React가 같은 이벤트 루프에서 배칭 처리
});
Auto Batching이 가능해진 핵심은 업데이트 처리 방식의 변경입니다:
1. Discrete Event (키 입력, 클릭 등)
// React 17: 즉시 동기 실행
function handleKeyDown() {
setState(...);
// → 즉시 flush → 바로 렌더링
}
// React 18: 마이크로태스크로 지연 실행
function handleKeyDown() {
setState(...);
// → flushSyncCallbacks()를 마이크로태스크로 예약
// → 이벤트 핸들러가 끝난 후 렌더링 수행
}
2. 비동기 업데이트 (setTimeout, Promise 등)
// React 17: 각각 별도 렌더링
setTimeout(() => {
setA(1); // 렌더링 1
setB(2); // 렌더링 2
});
// React 18: 하나로 배칭
setTimeout(() => {
setA(1);
setB(2);
// 하나의 렌더링으로 처리
});
앞서 살펴본 React 18의 변화는 커서 렌더링 로직에 직접적인 영향을 주었습니다. 이번엔 실제로 어떤 방식으로 concurent rendering과 Auto baching이 커서 렌더링 문제가 발생하게 되었는지 구체적으로 분석해 보겠습니다.
React 17에서는 텍스트 컴포넌트가 즉시 동기적으로 렌더링된 후, 커서 컴포넌트는 queueMicrotask
로 지연되어 실행되면서 자연스럽게 텍스트 → 커서 순서가 보장되었습니다.
하지만 React 18에선 텍스트 컴포넌트 자체가 마이크로태스크로 defer되면서, 커서와 텍스트가 같은 마이크로태스크에서 실행 되었고, 그 결과 커서가 텍스트 컴포넌트가 커밋되기 전에 먼저 실행돼 문제가 발생했습니다.
function handleKeyDown(event) {
setText(newText); // 1. 상태 업데이트
// 2. flushSyncCallbacks() 즉시 실행
// 3. 텍스트 렌더링 & 커밋 완료
// 4. 텍스트 DOM 계산
queueMicrotask(() => {
cursorComponent(textHeight); // 4. 커서 렌더링
});
}
function handleKeyDown(event) {
setText(newText); // 1. 상태 업데이트 (syncQueue에 추가)
// 2. flushSyncCallbacks() → 마이크로태스크로 예약
queueMicrotask(() => {
cursorComponent(textHeight); // 3. textHeight 없음 ❌
// 4. 같은 마이크로태스크에서 flushSyncCallbacks() 실행되어 텍스트 렌더링
});
}
마이크로태스크
React 18의 두 핵심 변경사항이 결합되어 문제를 일으켰습니다:
변화 요소 | 역할 및 영향 |
---|---|
Concurrent Rendering | Discrete 이벤트 처리 시, sync 작업을 마이크로태스크로 지연 |
Auto Batching | 마이크로태스크로 업데이트들을 한 번에 묶어 처리 (렌더링 타이밍 지연) |
→ 이로 인해 텍스트와 커서가 같은 타이밍에 렌더링되며 순서가 꼬임
단계 | React 17 | React 18 |
---|---|---|
1 | 키 입력 이벤트 발생 | 키 입력 이벤트 발생 |
2 | setState 호출 → 즉시 동기 렌더링 | setState 호출 → 마이크로태스크 예약 |
3 | 텍스트 DOM 업데이트 완료 & DOM 계산 | 마이크로태스크 큐 대기 |
4 | queueMicrotask : 커서 렌더링 ✅ | 마이크로태스크: 커서 렌더링 시도 ❌ |
5 | - | 마이크로태스크: 텍스트 DOM 계산 |
React 18에서는 다음과 같은 순서로 마이크로태스크가 실행됩니다:
[
() => cusrsorComponent(), // 커서 로직: 먼저 큐에 등록됨
() => flushSyncCallbacks(), // 텍스트 렌더링: React가 나중에 추가함
]
왜 이런 변경이 이루어졌나?
React 18의 변경은 성능 최적화가 주목적이었습니다:
React 18에서 Discrete Event의 syncQueue 처리가 마이크로태스크로 변경되면서,
기존에 "텍스트 렌더링 → 텍스트 DOM 계산 → 커서 렌더링" 순서로 동작하던 로직이 "텍스트 렌더링 → 커서 렌더링 시도 → 텍스트 DOM 계산"으로 바뀌었습니다.
이로 인해 커서가 정확한 위치를 계산하지 못하고 렌더링되지 않는 이슈가 발생한 것입니다.
이것이 바로 React 18의 성능 최적화가 의도치 않게 커서 구현에 미친 영향입니다.
React 18에서 발생한 커서 렌더링 이슈를 해결하기 위해 여러 방법을 시도해보았습니다.
가장 먼저 시도한 방법은 커서 렌더링을 setTimeout
을 이용한 매크로태스크로 지연시키는 방식이었습니다.
function handleKeyDown(event) {
setText(newText); // 텍스트 업데이트
// DOM 계산 완료
setTimeout(() => { // 매크로태스크에서 실행
cursorComponent(textHeight); // 커서 렌더링
}, 0);
}
커서가 정확한 위치에 렌더링되기는 했지만, 사용자 경험에 문제가 있었습니다.
여러 글자를 빠르게 입력할 경우, 커서가 순간이동하는 듯한 현상이 발생했습니다.
매크로태스크를 사용한 방식의 한계는 이벤트 루프에서 매크로태스크의 실행 시점이 지나치게 느리다는 데 있었습니다.
매크로태스크와 마이크로 태스크의 차이점을 이해하기 위해 JavaScript 이벤트 루프를 분석했습니다.
이벤트 루프 기본 흐름
console.log('1. 동기');
queueMicrotask(() => console.log('2. 마이크로태스크'));
setTimeout(() => console.log('4. 매크로태스크'), 0);
queueMicrotask(() => console.log('3. 마이크로태스크'));
// 출력: 1 → 2 → 3 → 4
필요한 실행 시점은:
즉, 마이크로태스크와 매크로태스크 사이에 실행되는 절묘한 시점이 필요했습니다.
queueMicrotask
안에서 또 한 번 queueMicrotask
를 호출해 React의 flushSyncCallbacks 이후에 커서 렌더링을 시도하는 방법을 생각했습니다.
function handleKeyDown(event) {
setText(newText); // 텍스트 업데이트
queueMicrotask(() => { // 첫 번째 마이크로태스크
queueMicrotask(() => { // 두 번째 마이크로태스크
cursorComponent(); // 커서 렌더링
});
});
}
Auto Batching과 이중 마이크로태스크의 관계
// 검증 1: 같은 레벨의 마이크로태스크는 배칭됨
queueMicrotask(() => {
setText("첫 번째 텍스트");
setText("두 번째 텍스트"); // Auto Batching으로 하나로 묶임
});
// 검증 2: 중첩된 마이크로태스크끼리는 별도 배칭
queueMicrotask(() => {
setText("외부 텍스트");
queueMicrotask(() => {
setText("내부 텍스트"); // 별도 배치로 처리됨
});
});
React 18의 실행 순서를 고려한 정교한 타이밍 조정:
// React 18에서의 실행 순서
handleKeyDown();
// 1. setText() 호출 → scheduleMicrotask(flushSyncCallbacks)
// 2. queueMicrotask(첫 번째) 등록
// 3. queueMicrotask(두 번째) 등록 (첫 번째 안에서)
// 마이크로태스크 큐 실행:
// → flushSyncCallbacks() (텍스트 렌더링)
// → 첫 번째 마이크로태스크
// auto batching으로 같이 렌더링
// → 두 번째 마이크로태스크 (커서 렌더링) ✅
중첩된 마이크로태스크는 결론적으로:
// 공동편집에서의 이벤트 분류
const updateFromOtherUser = (newText) => {
setText(newText); // 다른 사용자의 변경사항
// 이는 Discrete Event가 아닌 Default Event (낮은 우선순위)
};
공동편집 중에는 다른 사용자로부터 들어오는 업데이트가 React 내부에서 Default event로 처리됩니다. 이 경우, 렌더링 타이밍이 매크로태스크로 지연되므로 별도의 전략이 필요합니다.
이벤트 타입 | Lane 우선순위 | 렌더링 시점 | 예시 |
---|---|---|---|
Discrete | SyncLane | 마이크로태스크 | 키 입력, 클릭 |
Default | DefaultLane | 매크로태스크 | 네트워크 응답, 타이머 |
공동편집에서는 이벤트 타입에 따라 다른 전략을 사용해야 했습니다:
function handleTextUpdate(newText, source = 'user') {
setText(newText);
if (source === 'user') {
// 키 입력 기반: 빠르고 정확한 렌더링
queueMicrotask(() => {
queueMicrotask(() => {
cursorComponent();
});
});
} else {
// 공동편집 기반: 안정적인 매크로태스크 활용
setTimeout(() => {
cursorComponent();
}, 0);
}
}
React 18의 렌더링 타이밍 변화로 인해 커서 렌더링 로직에 적절한 타이밍 제어가 필수적이었습니다.
React 18로 업데이트되면서 생긴 Auto Batching과 Concurrent Rendering은 그저 "React 내부에서 알아서 잘 최적화해주는 기능" 정도로 이해하고 있었습니다. 이벤트를 자동으로 분류하고, 성능을 위해 처리 순서를 조정한다는 내용 정도만 흔히 찾아볼 수 있었습니다.
하지만 실제로 에디터 커서 렌더링 문제를 겪으면서 React가 어떤 기준으로 이벤트를 처리하는지, 그리고 렌더링이 어떤 시점에 발생하는지를 직접 내부 코드를 확인하게 되었습니다.
특히 새로 알게 된 부분은 다음과 같습니다:
scheduleSyncCallback
, scheduleCallback
)와 executionContext
판단에 따라 결정된다는 점이건 업데이트만 보고선 알 수 없는 내용이었습니다. 실제 React 내부 코드를 분석하면서 명확하게 이해할 수 있었습니다. 단순히 개념으로만 알고 있던 "Concurrent Rendering"이 어떤 방식으로 이벤트를 처리하고 어떤 타이밍으로 렌더링을 지연시키는지, Auto Batching이 어떤 이벤트 루프 단위로 처리되는지를 몸소 느꼈던 경험이었습니다.
무엇보다도 React의 "최적화"가 복잡한 프로젝트에서 개발자가 의도한 결과를 보장하진 않는다는 점, 그래서 내부 동작 원리를 이해하려는 노력이 정말 중요하다는 걸 다시 느꼈습니다.
참고 자료
https://goidle.github.io/react/in-depth-react18-lane/
https://goidle.github.io/react/in-depth-react18-concurrent_render/
기본적으로 동기적: React 18로 업그레이드만 하고 새로운 동시성 기능을 사용하지 않는다면, 업데이트는 이전 버전과 동일하게 단일하고 중단되지 않는 동기적 트랜잭션으로 렌더링됩니다. 즉, 업데이트가 시작되면 사용자에게 결과가 표시될 때까지 아무것도 중단할 수 없습니다. https://www.mybkexperience.it.com