리액트 스케줄러 완전 분석: MessageChannel을 선택한 이유

들어가며

최근 회사에서 리액트 동시성 원리에 대해 팀원들에게 발표하는 자리가 있었습니다. 발표 후 한 동료가 핵심을 찌르는 질문을 던졌습니다.

“리액트는 어떻게 브라우저에 제어권을 넘기나요?”

저는 자신 있게 답변했습니다:

javascript
function render() {
    while (5ms 지날때까지) {
       다음작업 = 렌더링()
    }

    requestIdleCallback(다음작업) // 브라우저로 제어권 넘기기
}

“requestIdleCallback API를 사용해서 브라우저의 유휴 시간에 작업을 예약한다”고 말이죠.

하지만 집에 돌아와 리액트 소스코드를 자세히 살펴보니, 제 답변은 그럴듯했지만 완전히 틀렸습니다. 리액트는 실제로 MessageChannel API를 사용해서 스케줄링을 구현하고 있었거든요.

이 글에서는 브라우저에 제어권을 넘기는 다양한 방법들을 살펴보고, 왜 리액트가 MessageChannel을 선택했는지, 그리고 이것이 어떻게 우아한 동시성 렌더링을 가능하게 하는지 탐구해보겠습니다.

브라우저 제어권 양도 방법들의 비교 분석

requestIdleCallback: 이상적이지만 현실적이지 않은 선택

requestIdleCallback은 브라우저가 유휴 상태일 때 콜백을 실행하는 API입니다. 마치 바쁜 식당의 주방장이 주문이 없을 때만 청소를 하는 것과 비슷하죠.

javascript
// requestIdleCallback 사용 예시
requestIdleCallback((deadline) => {
    // deadline.timeRemaining()으로 남은 시간 확인 가능
    while (deadline.timeRemaining() > 0 && 해야할일이있다면) {
        일부작업수행();
    }

    if (아직할일이있다면) {
        requestIdleCallback(계속작업); // 다음 유휴 시간에 재예약
    }
});

하지만 requestIdleCallback에는 치명적인 문제점들이 있었습니다. 첫 번째로 브라우저 호환성 이슈가 있습니다. Safari에서 지원하지 않는다는 것은 웹 라이브러리로서 치명적인 단점이었죠. 두 번째로는 예측 불가능한 실행 시점입니다. 브라우저가 계속 바쁘면 콜백이 실행되지 않을 수 있거든요. 마치 매우 바쁜 식당에서 청소를 전혀 못하는 상황과 같습니다. 세 번째로는 타임아웃 설정의 한계입니다. timeout 옵션을 설정해도 정확한 제어가 어려웠습니다.

이런 이유들로 리액트는 requestIdleCallback을 포기할 수밖에 없었습니다. 이론적으로는 완벽해 보였지만, 실제 프로덕션 환경에서는 신뢰할 수 없는 API였거든요.

setTimeout: 간단하지만 4ms의 벽

setTimeout을 0ms로 설정하면 즉시 실행될 것 같지만, 브라우저는 최소 4ms의 지연을 강제합니다.

javascript
console.time('setTimeout');
setTimeout(() => {
    console.timeEnd('setTimeout'); // 실제로는 4ms 이상
}, 0);

이는 HTML5 스펙에 정의된 동작으로, 중첩된 setTimeout 호출을 방지하기 위한 안전장치입니다. 하지만 부드러운 사용자 경험을 위해서는 이 4ms도 아까운 시간이었죠. 60fps를 유지하려면 16.67ms마다 프레임을 그려야 하는데, 4ms는 전체 프레임 시간의 25%에 해당하니까요.

그래서 리액트는 MessageChannel이 지원되지 않는 환경에서만 setTimeout을 fallback으로 사용합니다.

MessageChannel: 리액트가 선택한 우아한 해결책

MessageChannel API는 두 개의 포트를 가진 통신 채널을 생성하는 API입니다. 원래는 Web Worker와의 통신을 위해 설계되었지만, 리액트는 이를 창의적으로 활용했습니다.

javascript
const channel = new MessageChannel();
const port1 = channel.port1;
const port2 = channel.port2;

port1.onmessage = () => {
    console.log('메시지 받음!');
};

port2.postMessage('hello'); // port1으로 메시지 전송

MessageChannel을 이해하려면 이벤트 루프의 구조를 먼저 살펴봐야 합니다. 이벤트 루프는 하나의 사이클에서 다음과 같은 순서로 작업을 처리합니다: Call Stack을 실행하고, Microtask Queue의 모든 작업을 실행한 다음, Macrotask Queue에서 하나의 작업만 실행하고, 필요한 경우 렌더링을 수행한 후, 다시 처음부터 반복합니다.

MessageChannel의 메시지는 Macrotask Queue에 등록됩니다. 이는 매우 중요한 의미를 가집니다. 사용자 입력이 우선 처리됨을 보장할 수 있거든요. 사용자가 클릭하거나 타이핑하면 해당 이벤트가 먼저 처리되고, 그 후에 MessageChannel 콜백이 실행됩니다. 또한 즉시 제어권 회수가 가능합니다. 사용자 입력 처리 후 바로 다음 Macrotask로 리액트가 실행되니까요. 그리고 4ms 지연이 없습니다. setTimeout과 달리 인위적인 지연이 전혀 없어요.

하지만 실제로는 setImmediate가 가장 높은 우선순위를 가집니다. 리액트의 실제 구현을 살펴보면:

javascript
// React 스케줄러의 실제 코드 (정확한 버전)
let schedulePerformWorkUntilDeadline;

if (typeof setImmediate === 'function') {
    // Node.js와 IE에서 setImmediate가 가장 우선순위가 높음
    // MessageChannel보다 더 나은 의미론(semantics)을 제공
    schedulePerformWorkUntilDeadline = () => {
        setImmediate(performWorkUntilDeadline);
    };
} else if (typeof MessageChannel !== 'undefined') {
    // 대부분의 브라우저에서 MessageChannel 사용
    const channel = new MessageChannel();
    const port = channel.port2;

    channel.port1.onmessage = performWorkUntilDeadline;

    schedulePerformWorkUntilDeadline = () => {
        port.postMessage(null);
    };
} else {
    // 최후의 fallback으로 setTimeout 사용
    schedulePerformWorkUntilDeadline = () => {
        setTimeout(performWorkUntilDeadline, 0);
    };
}

이 구조 덕분에 리액트는 매우 우아한 흐름을 만들 수 있었습니다. 리액트가 5ms 동안 렌더링 작업을 수행하고, 시간 초과 시 MessageChannel로 다음 작업을 예약하면, 브라우저가 사용자 입력이나 다른 중요한 작업을 처리한 후, MessageChannel 콜백이 실행되어 리액트 작업이 재개되는 것이죠.

리액트 Fiber의 시분할 렌더링 구조

workLoop: 시간을 나누어 먹는 렌더링

리액트의 핵심은 시분할(Time Slicing) 렌더링입니다. 마치 CPU가 여러 프로세스에 시간을 나누어 주는 것처럼, 리액트도 렌더링 작업을 작은 단위로 나누어 처리합니다.

javascript
// React Scheduler의 workLoop (단순화)
function workLoop() {
    while (workInProgress !== null && !shouldYieldToHost()) {
        // 하나의 Fiber 노드 처리
        workInProgress = performUnitOfWork(workInProgress);
    }

    if (workInProgress !== null) {
        // 아직 할 일이 남았다면 다음 사이클에 예약
        schedulePerformWorkUntilDeadline();
    }
}

function shouldYieldToHost() {
    // 현재 시간이 데드라인을 넘었는지 확인
    return getCurrentTime() >= deadline;
}

이 과정을 자세히 살펴보면, 작업이 시작되면 workLoop에서 Fiber 트리를 순회하며 작업을 수행합니다. 각 노드 처리 후에는 shouldYieldToHost로 시간 초과를 확인하고, 시간이 초과되면 현재 진행 상황을 저장하고 중단합니다. 그 후 schedulePerformWorkUntilDeadline으로 다음 사이클에 작업을 재예약하고, MessageChannel 콜백이 실행되어 중단된 지점부터 재개합니다.

이런 방식 덕분에 리액트는 아무리 복잡한 컴포넌트 트리라도 사용자 입력을 방해하지 않으면서 렌더링할 수 있게 되었습니다.

Render Phase와 Commit Phase: 두 단계의 렌더링

리액트의 렌더링은 크게 두 단계로 나뉩니다. Render Phase는 비동기적이고 중단 가능한 단계이고, Commit Phase는 동기적이고 중단 불가능한 단계입니다.

Render Phase에서는 메모리에서만 작업이 이루어지며, 실제 DOM에는 영향을 주지 않습니다. 따라서 언제든 중단하고 다시 시작할 수 있어요. Work in Progress 트리를 생성하고, 트리를 순회하며 변경사항을 탐지하고, Effect 플래그를 마킹하는 작업들이 이 단계에서 이루어집니다.

javascript
// Render Phase의 주요 작업들
function renderPhase() {
    // Work in Progress 트리 생성
    const wipTree = createWorkInProgressTree(currentTree);

    // 트리 순회하며 변경사항 탐지
    while (currentFiber !== null) {
        // props, state 비교
        const hasChanges = comparePrevAndNext(currentFiber);

        if (hasChanges) {
            // Effect 플래그 마킹
            currentFiber.flags |= Update;
        }

        // 시간 체크 - 중단 가능 지점
        if (shouldYieldToHost()) {
            scheduleNextWork();
            return; // 여기서 중단!
        }

        currentFiber = getNextFiber(currentFiber);
    }
}

반면 Commit Phase에서는 실제 DOM을 변경하므로 중단되면 안 됩니다. 사용자에게 일관성 없는 화면을 보여줄 수 있거든요. DOM 변경사항을 적용하고 트리를 교체하는 작업들이 이 단계에서 동기적으로 실행됩니다.

javascript
// Commit Phase는 동기적으로 실행
function commitPhase(finishedWork) {
    // DOM 변경사항 적용 - 중단 불가!
    commitAllWork(finishedWork);

    // 트리 교체
    currentTree = finishedWork;
}

이런 구조 덕분에 리액트는 복잡한 컴포넌트 트리를 렌더링하면서도 사용자 입력에 즉시 반응할 수 있게 되었습니다.

Lane 시스템을 통한 우선순위 스케줄링

31개 Lane의 비트 플래그 시스템

리액트는 작업의 우선순위를 세밀하게 관리하기 위해 Lane 시스템을 사용합니다. 이는 31개의 비트로 구성된 매우 효율적인 시스템입니다.

javascript
// React의 Lane 정의 (실제 코드)
export const NoLanes = 0b0000000000000000000000000000000;
export const SyncLane = 0b0000000000000000000000000000010;
export const InputContinuousLane = 0b0000000000000000000000000000100;
export const DefaultLane = 0b0000000000000000000000000010000;
export const TransitionLane1 = 0b0000000000000000000000001000000;
// ... 총 31개의 Lane

각 Lane은 비트 하나에 대응되며, 비트 연산을 통해 매우 빠르게 우선순위를 계산할 수 있습니다. AND 연산으로 포함 여부를 확인하고, OR 연산으로 Lane을 합치고, NOT과 AND 연산을 조합해서 Lane을 제거하는 작업들을 O(1) 시간에 처리할 수 있거든요.

javascript
// Lane 조작 예시
function hasLanes(lanes, subset) {
    return (lanes & subset) !== 0; // AND 연산으로 포함 여부 확인
}

function mergeLanes(a, b) {
    return a | b; // OR 연산으로 Lane 합치기
}

function removeLanes(set, subset) {
    return set & ~subset; // NOT + AND 연산으로 Lane 제거
}

5가지 우선순위 그룹: 응급실의 트리아지 시스템

리액트 스케줄러는 31개의 Lane을 5개의 우선순위 그룹으로 분류합니다. 이는 병원 응급실의 트리아지 시스템과 매우 유사해요.

ImmediatePriority는 응급환자와 같습니다. 버튼 클릭이나 입력 필드 포커스 같은 사용자의 직접적인 입력은 즉시 처리되어야 하거든요. 생명에 지장이 있는 응급환자처럼 말이죠.

javascript
// 예: 버튼 클릭, 입력 필드 포커스
onClick={() => {
    // 이 업데이트는 SyncLane에 할당됨
    setCount(count + 1);
}}

UserBlockingPriority는 긴급환자에 해당합니다. 드래그나 스크롤 이벤트처럼 사용자의 연속적인 입력을 방해하지 않도록 높은 우선순위를 가집니다.

javascript
// 예: 드래그, 스크롤 이벤트
onMouseMove={(e) => {
    // InputContinuousLane에 할당
    setMousePosition({ x: e.clientX, y: e.clientY });
}}

NormalPriority는 일반환자로, 네트워크 응답 후 상태 업데이트나 일반적인 화면 전환이 여기에 해당합니다.

javascript
// 예: 네트워크 응답 후 상태 업데이트
fetch('/api/data').then(data => {
    // DefaultLane에 할당
    setData(data);
});

LowPriority는 비응급환자이고, IdlePriority는 예약환자와 같습니다. Suspense의 fallback 해제나 분석 데이터 수집, 로그 저장 같은 작업들이 유휴 시간에 처리됩니다.

스케줄러의 동작 방식: 시간과 우선순위의 조화

리액트 스케줄러는 우선순위에 따라 서로 다른 타임아웃을 설정합니다. ImmediatePriority는 즉시 실행되고, UserBlockingPriority는 250ms 내에, NormalPriority는 5초 내에, LowPriority는 10초 내에 실행되며, IdlePriority는 무기한 연기가 가능합니다.

javascript
function scheduleCallback(priorityLevel, callback) {
    const currentTime = getCurrentTime();

    // 우선순위에 따른 타임아웃 설정
    let timeout;
    switch (priorityLevel) {
        case ImmediatePriority:
            timeout = -1; // 즉시 실행
            break;
        case UserBlockingPriority:
            timeout = 250; // 250ms 내 실행
            break;
        case NormalPriority:
            timeout = 5000; // 5초 내 실행
            break;
        case LowPriority:
            timeout = 10000; // 10초 내 실행
            break;
        case IdlePriority:
            timeout = maxSigned31BitInt; // 무기한 연기 가능
            break;
    }

    const expirationTime = currentTime + timeout;

    // 우선순위 큐에 삽입
    const newTask = {
        callback,
        priorityLevel,
        expirationTime,
        sortIndex: expirationTime
    };

    push(taskQueue, newTask);

    // 스케줄링 시작
    requestHostCallback(flushWork);
}

스케줄러는 브라우저의 프레임 단위를 고려하여 작업 시간을 할당합니다. 기본적으로 5ms 단위로 작업을 수행하는 이유는 React 팀의 설명에 따르면 “5ms는 마법의 숫자가 아니다. 중요한 것은 120fps 디바이스에서도 단일 프레임보다 작다는 것이다. 따라서 애니메이션을 차단하지 않는다”는 것입니다.

javascript
// 5ms는 기본값이지만 동적으로 조정 가능
let yieldInterval = 5; // 기본값

// forceFrameRate API로 디바이스에 맞게 조정 가능
function forceFrameRate(fps) {
    if (fps < 0 || fps > 125) {
        console.error('forceFrameRate는 0-125 사이의 값만 허용');
        return;
    }
    if (fps > 0) {
        yieldInterval = Math.floor(1000 / fps); // fps에 따라 동적 계산
    } else {
        yieldInterval = 5; // 기본값으로 리셋
    }
}

function performWorkUntilDeadline() {
    const currentTime = getCurrentTime();
    deadline = currentTime + yieldInterval; // 동적으로 계산된 시간 사용

    let hasMoreWork = true;
    try {
        hasMoreWork = scheduledHostCallback(); // 실제 작업 수행
    } finally {
        if (hasMoreWork) {
            // 아직 할 일이 있다면 다음 사이클에 예약
            schedulePerformWorkUntilDeadline();
        }
    }
}

이렇게 작은 단위로 작업을 나누는 이유는 60fps를 유지하기 위해서입니다. 16.67ms 프레임 내에서 렌더링 시간을 확보하고, 나머지 시간을 브라우저에 양보하여 입력 처리 시간을 확보하며, 과도한 CPU 사용을 방지하여 배터리 효율성도 고려한 것이죠. 그리고 Sebastian Markbåge가 설명한 대로 “4ms 지연은 받아들일 수 없다. 대략 5ms마다 양보하는 tight loop를 실행하기 때문에, 4ms 간격이 있으면 모든 작업이 대략 두 배 느려진다”는 것이 핵심입니다.

보너스: 더 깊이 들여다보기

SyncLane의 실행 원리

앞서 SyncLane이 가장 높은 우선순위를 가진다고 했는데, 실제로는 어떻게 “즉시” 실행될까요?

SyncLane의 핵심은 Microtask Queue를 활용한다는 점입니다. React는 PR #20872 “Schedule sync updates in microtask”에서 동기 업데이트를 스케줄러 태스크 대신 microtask에 스케줄링하도록 변경했습니다.

javascript
// 사용자가 버튼을 클릭했을 때
function onClick() {
    // discrete event는 자동으로 sync priority로 처리됨
    setCount(count + 1); // 이 업데이트는 microtask에 예약됨
}

// 또는 명시적으로 flushSync 사용
flushSync(() => {
    setCount(count + 1); // 이 업데이트도 microtask에 예약됨
});

Microtask Queue vs Macrotask Queue의 실행 순서를 이해하는 것이 핵심입니다:

javascript
// 이벤트 루프 우선순위
// 1. 현재 실행 중인 코드 완료
// 2. Microtask Queue의 모든 작업 실행 (SyncLane이 여기!)
// 3. Macrotask Queue에서 하나의 작업 실행 (MessageChannel이 여기)
// 4. 브라우저 렌더링 (필요한 경우)
// 5. 다시 1번부터 반복

console.log('1. 동기 코드');

// MessageChannel (Macrotask)
schedulePerformWorkUntilDeadline();

// Microtask (SyncLane)
queueMicrotask(() => {
    console.log('3. Microtask - SyncLane 처리');
});

Promise.resolve().then(() => {
    console.log('4. Promise microtask');
});

console.log('2. 동기 코드 끝');

// 실행 순서: 1 → 2 → 3 → 4 → MessageChannel 콜백

SyncLane이 우선순위를 갖는 메커니즘은 이것입니다:

javascript
// React의 실제 구현 (단순화)
function scheduleSyncCallback(callback) {
    // React 18부터: microtask에 스케줄링
    queueMicrotask(() => {
        flushSyncCallbacks(); // 모든 sync 업데이트를 즉시 처리
    });
}

function scheduleCallback(priority, callback) {
    // 일반 업데이트들: MessageChannel에 스케줄링 (Macrotask)
    schedulePerformWorkUntilDeadline();
}

이벤트 핸들러의 특별한 처리도 중요합니다. 클릭, 키보드 입력 같은 discrete event는 자동으로 sync priority를 받아서 microtask에서 처리됩니다:

javascript
// 클릭 이벤트 핸들러
function handleClick() {
    setCount(count + 1); // 자동으로 microtask에 예약됨
    console.log('클릭 핸들러 끝');

    // 이 콘솔 로그 다음에 즉시 microtask가 실행되어
    // DOM이 업데이트됨 (Macrotask 작업들보다 먼저!)
}

flushSync와의 연관성도 이제 명확해집니다:

javascript
function handlePrint() {
    flushSync(() => {
        setIsPrinting(true); // microtask에 예약
    });
    // 여기서 DOM은 이미 업데이트 완료 상태
    window.print(); // 정확한 상태로 프린트됨
}

이런 방식으로 SyncLane은 스케줄러를 완전히 우회하는 것이 아니라, microtask queue의 높은 우선순위를 활용해서 다른 모든 React 작업(MessageChannel을 통한 Macrotask들)보다 먼저 실행되는 것입니다. 이는 마치 응급실에서 심정지 환자가 오면 별도의 응급 처치실에서 즉시 처치하는 것과 같아요.

useEffect의 실행 타이밍 이슈

뜬금없을 수 있지만 위 맥락과 비슷하다고 생각해서 useEffect에 대해서도 다뤄보겠습니다.

useEffect의 실행 타이밍은 paint 단계 이전과 이후 모두에서 발생할 수 있습니다. 이 문제의 원인에 대해 살펴보겠습니다.

이 문제는 React의 한 가지 중요한 규칙 때문에 생깁니다.

React의 핵심 규칙: “새 업데이트 전에 이전 Effect를 반드시 먼저 실행”

React에는 이런 규칙이 있습니다: “새로운 렌더링을 시작하기 전에, 이전 렌더링의 모든 useEffect를 먼저 실행해야 한다

javascript
// React의 기본 동작
function 새로운업데이트가시작될때() {
    // 1. 먼저 이전 useEffect들을 모두 실행
    이전useEffect들실행(); // paint 여부와 상관없이!

    // 2. 그 다음에 새 렌더링 시작
    새렌더링시작();
}

이 규칙은 Effect가 누락되는 것을 방지하기 위한 안전장치입니다. 하지만 이 때문에 useEffect가 예상과 다른 타이밍에 실행됩니다.

언제 문제가 생기나?

  • 문제는 “새로운 업데이트가 paint 전에 시작되는 경우”에 생깁니다.

케이스 1: useLayoutEffect에서 상태 변경

javascript
function MyComponent() {
    const [count, setCount] = useState(0);

    useLayoutEffect(() => {
        // 여기서 상태를 바꾸면?
        setCount(prev => prev + 1); // 새 업데이트가 paint 전에 시작됨!
    });

    useEffect(() => {
        console.log('나는 paint 후에 실행될 줄 알았는데...');
        // 실제로는 paint 전에 실행됨!
    });
}

왜 이런 일이?

  1. useLayoutEffect에서 setCount 호출
  2. 새로운 업데이트가 paint 전에 시작됨
  3. React 규칙: “새 업데이트 전에 이전 useEffect 먼저 실행”
  4. 결과: useEffect가 paint 전에 실행됨

케이스 2: 클릭 이벤트

javascript
function handleClick() {
    setCount(count + 1); // 클릭으로 인한 업데이트
}

useEffect(() => {
    console.log('클릭 때문에 나도 paint 전에 실행될 수 있음');
});

React 18부터는 클릭, 키보드 입력 같은 사용자 상호작용으로 인한 useEffect를 의도적으로 paint 전에 실행합니다. 이는 다음 이벤트가 Effect의 결과를 “볼 수 있게” 하기 위함입니다.

결론

useEffect가 paint 전에 실행되는 이유:

  • useLayoutEffect에서 상태를 바꿀 때
  • 클릭 등 사용자 상호작용이 있을 때
  • 기타 “급한” 업데이트가 paint 전에 시작될 때

React는 Effect 누락 방지일관된 타이밍보다 중요하게 생각해서 이런 설계를 선택했습니다. “가끔 예상과 다르게 동작하더라도, Effect가 누락되는 것보다는 낫다”는 철학이죠.

useEffect가 DOM 변경 후 실행되는 것은 맞지만, paint 후 실행이 항상 보장되는 것은 아닙니다. 이는 리액트 커뮤니티에서도 흔히 오해되는 부분입니다.

브라우저 렌더링 파이프라인을 먼저 이해해야 합니다. 브라우저는 JavaScript를 실행하고, Style을 계산하고, Layout(Reflow)을 수행하고, Paint를 그리고, Composite를 하는 순서로 렌더링을 진행합니다.

리액트의 Effect 스케줄링 전략을 살펴보면, Commit Phase에서 DOM 변경사항을 즉시 적용하고, Layout Effects(useLayoutEffect)를 동기적으로 실행한 다음, Passive Effects(useEffect)를 비동기로 예약합니다.

javascript
// Commit Phase에서 Effect 스케줄링
function commitRoot(root, finishedWork) {
    // DOM 변경사항 즉시 적용
    commitMutationEffects(finishedWork);

    // Layout Effects 동기 실행 (useLayoutEffect)
    commitLayoutEffects(finishedWork);

    // Passive Effects 비동기 예약 (useEffect)
    if (rootDoesHavePassiveEffects) {
        scheduleCallback(NormalPriority, () => {
            flushPassiveEffects();
            return null;
        });
    }
}

중요한 점은 Passive Effects가 NormalPriority로 스케줄링된다는 것입니다. 이는 앞서 설명한 5가지 우선순위 중 3번째에 해당하며, MessageChannel을 통해 다음 Macrotask에 예약됩니다.

하지만 useEffect가 paint 전에 실행되는 경우들이 있습니다:

React 18부터 discrete user input (클릭, 키보드 입력)으로 인한 useEffect는 paint 전에 동기적으로 실행될 수 있습니다. 이는 이벤트 시스템이 Effect의 결과를 관찰할 수 있도록 하기 위함입니다.

javascript
// 클릭 이벤트로 인한 useEffect는 paint 전에 실행될 수 있음
function handleClick() {
    setCount(count + 1);
}

useEffect(() => {
    // 클릭으로 인한 렌더링이라면 paint 전에 실행될 수 있음
    console.log('Effect 실행');
});

useLayoutEffect에서 상태 업데이트가 발생하면 useEffect도 paint 전에 실행됩니다. useLayoutEffect에서 상태가 변경되면 새로운 렌더링이 paint 전에 시작되고, 이전 렌더링의 모든 Effect들이 새 렌더링 전에 플러시되기 때문입니다.

javascript
function MyComponent() {
    const [count, setCount] = useState(0);

    useLayoutEffect(() => {
        // 여기서 상태 업데이트가 일어나면
        if (someCondition) {
            setCount(prev => prev + 1); // 새로운 렌더링이 paint 전에 시작됨
        }
    });

    useEffect(() => {
        // 이 Effect도 paint 전에 실행될 수 있음!
        console.log('Effect 실행');
    });

    return <div>{count}</div>;
}

flushSync 내에서도 useEffect가 paint 전에 실행됩니다.

useEffect는 대부분의 경우 paint 후에 실행되지만, 사용자 상호작용이나 useLayoutEffect에서의 상태 업데이트, flushSync 사용 등의 상황에서는 paint 전에 실행될 수 있습니다. 또한 Effect에서 발생하는 추가 업데이트들은 다시 스케줄러를 거치므로, 다른 높은 우선순위 작업에 의해 중단될 수 있어 전체적인 반응성을 유지할 수 있습니다.

마무리

MessageChannel 선택의 핵심 이유들을 정리해보면, 즉시성 측면에서 setTimeout의 4ms 지연 없이 최대한 빠른 스케줄링이 가능하고, 예측 가능성 측면에서 requestIdleCallback과 달리 확실한 실행을 보장하며, 호환성 측면에서 모든 주요 브라우저에서 안정적인 지원을 받고, 제어권 양보 측면에서 사용자 입력 처리 후 즉시 리액트로 제어권을 회수할 수 있습니다.

하지만 실제 구현에서는 setImmediate가 최우선순위라는 점이 중요합니다. setImmediate > MessageChannel > setTimeout 순서로 fallback이 구성되어 있으며, 이는 각 환경에서 가장 적합한 스케줄링 메커니즘을 선택하기 위함입니다. Node.js 환경에서는 setImmediate가 MessageChannel보다 더 나은 의미론을 제공하기 때문입니다.

리액트 동시성의 아름다움은 마치 훌륭한 오케스트라 지휘자와 같습니다. Fiber는 각 악기(컴포넌트)의 연주를 세밀하게 조율하고, Scheduler는 전체적인 템포와 우선순위를 관리하며, MessageChannel은 정확한 타이밍에 지휘봉을 올려줍니다.

결과적으로 사용자는 복잡한 애플리케이션에서도 끊김 없는 부드러운 경험을 얻게 됩니다. 이것이 바로 리액트가 단순한 UI 라이브러리를 넘어서 사용자 경험을 혁신하는 플랫폼으로 평가받는 이유입니다.

다음번에 리액트 앱에서 버튼을 클릭할 때, 그 뒤에서 돌아가는 이 정교한 메커니즘을 떠올려보세요. 여러분의 클릭이 SyncLane에 할당되어 즉시 처리되고, MessageChannel을 통해 브라우저와 우아하게 협력하여 완벽한 사용자 경험을 만들어내고 있을 겁니다.