Part 2. 리액트 동작 원리와 상태 관리

리액트 렌더링의 2단계: Render Phase와 Commit Phase의 차이점

리액트 렌더링의 2단계: Render Phase와 Commit Phase의 차이점

크리스가 리액트 코드를 디버깅하다가 고개를 갸웃거린다.

"분명 console.log가 찍혔는데, 왜 화면은 그대로지? 그리고 왜 콘솔은 두 번씩 찍히는 거야?"

많은 개발자가 "렌더링(Rendering)"이라는 단어를 "화면을 그리는 것(Painting)"과 혼동하곤 한다. 하지만 리액트의 세계에서 렌더링은 화면 업데이트만을 의미하지 않는다. 심지어 렌더링은 일어났지만, 화면은 1픽셀도 변하지 않을 수도 있다.

리액트가 컴포넌트를 호출하고 화면에 반영하기까지의 과정을 정확히 이해해야 불필요한 재렌더링을 막고, useEffect가 언제 실행되는지 예측할 수 있다. 그 비밀인 Render PhaseCommit Phase를 파헤쳐 본다.

리액트의 주문 시스템

리액트의 렌더링 과정을 식당 주문에 비유해보자.

  • Render Phase (주문 접수 및 확인): 손님(컴포넌트)이 메뉴를 고르고, 주방장(React)이 주문서를 작성한다. "이전 주문과 달라진 게 있나?" 확인하는 단계다.
  • Commit Phase (요리 및 서빙): 주문서대로 요리를 만들어서 식탁(DOM)에 실제로 내놓는다.
  • 중요한 건, 주문서를 쓴다고 해서(Render Phase) 무조건 요리가 나오는 것(Commit Phase)은 아니다라는 점이다. 주문 내용이 이전과 똑같다면 주방장은 요리를 시작하지 않는다.

    1. Render Phase (렌더 단계)

    이 단계는 "계산"의 영역이다. 리액트는 컴포넌트 함수를 호출하여 리턴된 JSX(Virtual DOM)를 이전 렌더링 결과와 비교한다. 이것을 재조정(Reconciliation)이라고 한다.

  • 하는 일: 컴포넌트 호출, Virtual DOM 생성, Diffing(비교).
  • 특징: 부수 효과(Side Effect)가 없어야 한다. 즉, 이 단계에서 API를 호출하거나 DOM을 직접 수정하면 안 된다. 언제든 멈추거나 재시작될 수 있기 때문이다.
  • // Render Phase 예시
    function UserProfile({ name }: { name: string }) {
      // 1. 컴포넌트 본문 실행 (Render Phase)
      console.log('Render Phase: 계산 중...'); 
      
      // 2. JSX 반환 (이 객체를 이전 것과 비교함)
      return <div>{name}</div>; 
    }

    왜 콘솔이 두 번 찍힐까? (Strict Mode)

    개발 모드에서 console.log가 두 번 찍히는 이유는 리액트의 Strict Mode 때문이다. 리액트는 "Render Phase가 순수(Pure)한가?"를 검증하기 위해 컴포넌트를 의도적으로 두 번 호출해본다. 두 번 실행했을 때 결과가 다르다면 사이드 이펙트가 있다는 뜻이니 고치라는 신호다.

    2. Commit Phase (커밋 단계)

    이 단계는 "적용"의 영역이다. Render Phase에서 계산된 "변경 사항"을 실제 DOM에 반영한다.

  • 하는 일: DOM 노드 생성/삭제/업데이트, ref 업데이트.
  • 특징: 이 과정은 동기적(Synchronous)으로 일어나며 중간에 멈출 수 없다. DOM 변경이 완료된 직후에 useLayoutEffect가 실행되고, 브라우저가 화면을 그린(Paint) 뒤에 useEffect가 실행된다.
  • // Commit Phase 이후 실행
    useEffect(() => {
      console.log('Commit Phase 완료 후: 이제 사이드 이펙트 실행');
    }, []);

    전체 흐름도

    크리스가 버튼을 눌러 상태를 변경했을 때의 흐름을 따라가 보자.

  • Trigger: setCount(1) 호출.
  • Render Phase:
  • Commit Phase:
  • 만약 상태를 setCount(0)으로 똑같은 값으로 업데이트했다면?

    Render Phase에서 비교해 보니 변경 사항이 없다. 리액트는 Commit Phase를 생략한다. 즉, DOM 조작 비용을 아끼는 것이다.

    실전 코드: 단계별 실행 순서 확인

    아래 코드를 실행하면 콘솔에는 어떤 순서로 출력될까?

    import React, { useState, useEffect, useLayoutEffect } from 'react';
    
    function CycleTest() {
      console.log('1. Render Phase');
    
      const [count, setCount] = useState(0);
    
      useLayoutEffect(() => {
        console.log('2. Commit Phase (DOM 업데이트 직후, Paint 전)');
      }, [count]);
    
      useEffect(() => {
        console.log('3. Commit Phase (Paint 후)');
      }, [count]);
    
      return (
        <button onClick={() => setCount(c => c + 1)}>
          {count}
        </button>
      );
    }

    결과는 1 -> 2 -> 3 순서다.

    특히 useLayoutEffect는 브라우저가 화면을 그리기 전에 실행되므로, 깜빡임 없이 DOM 레이아웃을 측정하거나 변경해야 할 때 사용한다. (대부분은 useEffect로 충분하다.)

    핵심 정리

  • 렌더링 ≠ 화면 갱신: 렌더링은 리액트가 변경 사항을 계산하는 과정이다. 실제 화면 갱신은 커밋 단계에서 일어난다.
  • Render Phase: 컴포넌트를 호출하고 변경 사항을 비교한다(Diffing). 순수해야 한다.
  • Commit Phase: 변경 사항을 DOM에 반영한다. 이후 useEffect가 실행된다.
  • 최적화: 리액트는 Render Phase 결과가 이전과 같다면 Commit Phase를 건너뛰어 성능을 최적화한다.
  • 이제 렌더링의 겉과 속을 알았으니, 리액트가 화면을 전환하는 방법인 라우팅에 대해 알아볼 차례다.

    "React Router와 History API: SPA는 어떻게 페이지를 전환할까?" 편에서 계속된다.


    🔗 참고 링크

  • Render and Commit
  • React.Component - The Lifecycle (클래스형이지만 개념은 동일함)