React 19 Bailout 완전 정복: 리렌더링을 건너뛰는 React의 비밀

React는 왜 어떤 컴포넌트는 다시 그리고, 어떤 컴포넌트는 건너뛸까? beginWork와 lanes 시스템으로 파헤치는 Bailout의 모든 것.

Kaen
KaenSeptember 3, 2026 · 2 views

React 19 Bailout 완전 정복: 리렌더링을 건너뛰는 React의 비밀

React는 왜 어떤 컴포넌트는 다시 그리고, 어떤 컴포넌트는 건너뛸까? beginWorklanes 시스템으로 파헤치는 Bailout의 모든 것.


부모 컴포넌트의 상태가 바뀌었는데 화면 일부만 업데이트되는 경험, 한 번쯤 해보셨을 겁니다. "분명히 부모가 바뀌었는데 왜 이 자식은 안 바뀌지?" 혹은 반대로 "왜 이건 바뀌는 거지?"라는 의문이 드는 순간이죠. 이 현상을 이해하려면 React 내부의 Bailout 메커니즘을 알아야 합니다. 단순한 최적화 팁을 넘어, React가 내부적으로 어떤 판단 기준으로 렌더링을 결정하는지 소스 코드 수준에서 살펴보겠습니다.


수수께끼부터 시작하자: 왜 일부만 리렌더링될까?

다음과 같은 컴포넌트 트리가 있다고 가정합니다.

<A>
  <B>
    <C>
      <button onClick={handleClick} />
      <D />
    </C>
  </B>
  <E>
    <F />
  </E>
</A>

C 내부의 버튼을 클릭해 C의 상태를 변경하면 어떻게 될까요? 직관적으로는 트리 전체가 다시 그려질 것 같지만, 실제로는 CD만 리렌더링됩니다. A, B, E, F는 아무 일도 없었다는 듯이 그대로입니다.

React는 어떻게 이 사실을 알고 최소한의 작업만 수행할 수 있었을까요? 이 질문이 Bailout 이해의 출발점입니다.


React가 상태 변경을 추적하는 방식: lanes와 Fiber

Fiber 트리란 무엇인가

React는 내부적으로 컴포넌트 트리를 Fiber 노드 트리로 관리합니다. 각 Fiber 노드는 하나의 컴포넌트에 대응하며, 컴포넌트의 상태·props·작업 우선순위 등을 담고 있는 작업 단위입니다.

setState가 호출되는 순간, React는 단순히 "다시 그려야 함" 표시를 전체 트리에 붙이지 않습니다. 훨씬 정밀하게, 변경이 발생한 지점과 그 경로에만 흔적을 남깁니다.

lanes와 childLanes: 두 가지 깃발

Fiber 노드에는 핵심적인 두 가지 속성이 있습니다.

  • lanes: 해당 Fiber 자신에게 처리해야 할 업데이트가 있음을 나타냅니다. "나 자신이 업데이트되어야 해!"
  • childLanes: 해당 Fiber의 자식 트리 어딘가에 처리해야 할 업데이트가 있음을 나타냅니다. "내 자식 중에 업데이트가 필요한 녀석이 있어!"

C의 버튼이 클릭되어 setState가 호출되면 다음 순서로 흔적이 남습니다.

  1. C Fiber의 lanes에 업데이트 플래그 설정
  2. C의 부모인 BA 순서로 거슬러 올라가며 각 노드의 childLanes 업데이트

이 두 개의 깃발 시스템 덕분에 React는 트리를 순회할 때 "이쪽에는 업데이트가 없으니 완전히 건너뛰어도 된다"는 판단을 빠르게 내릴 수 있습니다.


Bailout의 핵심 관문: beginWork 소스 코드 분석

beginWork가 하는 일

React가 Fiber 트리를 순회하며 각 노드를 처리할 때 호출하는 함수가 바로 beginWork입니다. 이 함수가 각 컴포넌트의 렌더링 여부를 결정하는 핵심 관문입니다.

실제 React 소스 코드(react-reconciler/src/ReactFiberBeginWork.js)를 단순화하면 다음과 같습니다.

function beginWork(current, workInProgress, renderLanes) {
  // current가 존재하면 업데이트 상황 (최초 렌더링이 아님)
  if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;

    // 조건 1: props 또는 context가 변경되었는가? (참조 비교)
    if (oldProps !== newProps || hasContextChanged()) {
      didReceiveUpdate = true; // 리렌더링 필요
    } else {
      // 조건 2: props는 동일하다. 그렇다면 예약된 업데이트(lanes)가 있는가?
      const hasScheduledUpdate = checkScheduledUpdateOrContext(
        current,
        renderLanes
      );

      if (!hasScheduledUpdate) {
        // 조건 3: 예약된 업데이트도 없다. Bailout 시도!
        // 내부적으로 childLanes를 확인해 자식만 계속 탐색할지 결정
        return attemptEarlyBailoutIfNoScheduledUpdate(
          current,
          workInProgress,
          renderLanes
        );
      }
    }
  }

  // Bailout되지 않았다면, 컴포넌트 함수를 실행해 리렌더링 진행
  return updateFunctionComponent(current, workInProgress, ...);
}

판단 흐름을 단계별로 정리하면

beginWork는 각 Fiber를 처리할 때 다음 세 가지 질문을 순서대로 던집니다.

  1. "props나 context가 바뀌었나?" → 바뀌었으면 즉시 리렌더링
  2. "이 Fiber 자신에게 예약된 업데이트(lanes)가 있나?" → 있으면 리렌더링
  3. "자식들 중에 업데이트가 있나?(childLanes)" → 있으면 자신은 건너뛰되 자식으로 계속 탐색, 없으면 완전히 Bailout

이 세 조건을 모두 통과해야만 해당 컴포넌트는 리렌더링을 면할 수 있습니다.

앞선 예시에 대입해 보면 이렇습니다.

컴포넌트laneschildLanes결과
A없음있음 (C 경로)자신 건너뜀, 자식 탐색 계속
B없음있음 (C 경로)자신 건너뜀, 자식 탐색 계속
C있음리렌더링
E없음없음완전 Bailout
F없음없음완전 Bailout

EFchildLanes조차 없으므로 탐색 자체가 중단됩니다. 반면 AB는 자식 경로에 업데이트가 있으므로 자기 자신의 렌더링은 건너뛰되 탐색은 계속합니다.


숨겨진 함정: 왜 <D />는 리렌더링될까?

이제 흥미로운 질문이 남습니다. Dlanes도 없고, childLanes도 없는데 왜 리렌더링될까요?

원인은 C의 리렌더링 과정에 있습니다. C가 리렌더링되면 C의 함수가 다시 실행되고, 그 결과로 <D />라는 새로운 React 엘리먼트 객체가 만들어집니다. 이 새 엘리먼트 객체는 이전 것과 내용은 같아도 메모리 주소가 다릅니다.

DbeginWork가 실행될 때 첫 번째 질문에서 바로 걸립니다.

// oldProps와 newProps는 내용이 같아도 서로 다른 객체
if (oldProps !== newProps) {
  didReceiveUpdate = true; // 리렌더링 결정
}

JavaScript의 객체 비교는 내용이 아닌 참조(메모리 주소) 기준입니다. D가 받는 props가 없더라도, {}{}는 서로 다른 객체이기 때문에 React는 "props가 바뀌었다"고 판단합니다. 이것이 D가 억울하게 리렌더링되는 진짜 이유입니다.


해결책: 참조를 동일하게 유지하라

Bailout이 제대로 작동하게 하려면 불필요한 참조 생성을 막아야 합니다. 대표적인 방법들은 다음과 같습니다.

React.memo로 컴포넌트 감싸기

const D = React.memo(function D({ value }) {
  return <div>{value}</div>;
});

React.memobeginWork 내부에서 props를 **얕은 비교(shallow equal)**로 검사합니다. 참조가 달라도 내용이 같으면 Bailout을 허용합니다. 단, 이 방법은 props 자체의 내용 비교는 해주지만, props로 전달되는 객체나 함수가 매번 새로 만들어지면 의미가 없습니다.

useMemo와 useCallback으로 props 안정화

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

  // 렌더링마다 새 객체가 만들어지지 않도록 고정
  const config = useMemo(() => ({ theme: 'dark' }), []);
  const handleAction = useCallback(() => { /* ... */ }, []);

  return (
    <>
      <button onClick={() => setCount(c => c + 1)} />
      <D config={config} onAction={handleAction} />
    </>
  );
}

useMemouseCallback은 의존성 배열이 바뀌지 않는 한 이전 렌더링의 참조를 그대로 반환합니다. DoldProps !== newProps 비교에서 false가 되어 Bailout에 성공합니다.

children prop 패턴 활용

function A() {
  return (
    <C>
      <D /> {/* D는 A의 렌더링 결과물 → C가 리렌더링되어도 D의 참조는 유지됨 */}
    </C>
  );
}

DC의 내부에서 직접 생성하지 않고 children으로 외부에서 주입하면, C가 리렌더링되더라도 D 엘리먼트는 A의 렌더링 결과로 고정됩니다. C의 재렌더가 D의 참조에 영향을 주지 않는 구조가 됩니다.


React Compiler가 바꾸는 미래

React 19와 함께 점진적으로 도입되고 있는 React Compiler는 위에서 설명한 모든 메모이제이션 작업을 자동화하는 것을 목표로 합니다.

컴파일러는 빌드 타임에 컴포넌트 코드를 분석해, 불필요하게 재생성되는 값과 컴포넌트를 탐지하고 자동으로 메모이제이션 코드를 삽입합니다. 개발자가 useMemouseCallback을 언제 써야 하는지 고민하는 시간을 크게 줄여줄 것으로 기대됩니다.

단, 컴파일러가 어떤 판단을 내렸는지 이해하려면 결국 Bailout 메커니즘 자체를 아는 것이 도움이 됩니다. 도구를 맹목적으로 믿기보다, 원리를 이해하고 사용하는 것이 예상치 못한 버그를 빠르게 잡는 가장 좋은 방법이니까요.


결론: Bailout을 이해하면 React가 다르게 보인다

React의 Bailout은 단순한 성능 트릭이 아닙니다. 핵심을 정리하면 다음과 같습니다.

  • laneschildLanes: 상태 변경의 흔적을 Fiber 트리에 정밀하게 기록해 불필요한 탐색을 차단합니다.
  • beginWork의 세 가지 관문: props 변경 여부 → 자신의 lanes → 자식의 childLanes 순서로 판단하며, 모두 해당 없으면 완전 Bailout합니다.
  • 참조 동일성이 핵심: oldProps !== newProps는 내용이 아닌 참조 비교입니다. 같은 내용이라도 새 객체이면 리렌더링이 트리거됩니다.
  • React.memo, useMemo, useCallback, children 패턴: 모두 참조 안정성을 확보해 Bailout이 작동하도록 돕는 수단입니다.

React 코드를 작성할 때 "왜 이 컴포넌트가 다시 그려지지?"라는 질문이 생길 때마다 이 흐름을 떠올려 보세요. 원리를 알면 원인을 찾는 시간이 절반으로 줄어들고, 더 예측 가능한 코드를 작성할 수 있게 됩니다.

React 내부를 깊이 파고들고 싶은 분께는 react-reconciler 패키지의 ReactFiberBeginWork.js 파일을 직접 열어보는 것을 권장합니다. 생각보다 읽을 수 있습니다.

댓글 0개

댓글

댓글을 불러오는 중...

지원 문의