React 컴포넌트의 Render와 Commit은 정확히 무엇이 다를까

Frontend

팀에서 React 성능 이야기를 하다 보면 매번 같은 지점에서 대화가 어긋납니다.

  • "리렌더링이 5번 일어났는데 화면은 그대로예요. 이게 정상인가요?"
  • "console.log는 두 번 찍히는데 DOM은 한 번만 바뀌었어요. 둘 중에 뭐가 맞는 거죠?"
  • "React DevTools Profiler에서 render는 5ms라는데 체감상 훨씬 느립니다. 이 숫자를 믿어도 되나요?"
  • "useEffect에서 ref.currentnull이 아닌데, 렌더링 중에 읽으면 null이에요. 왜 다르죠?"

이 질문들은 서로 달라 보이지만 뿌리가 하나입니다. "렌더링됐다"는 말을 "화면이 바뀌었다"와 같은 뜻으로 쓰고 있기 때문입니다.

React에서 한 번의 업데이트는 크게 두 단계로 나뉩니다. 다음 UI가 어떤 모습이어야 하는지 계산하는 단계(Render phase) 와, 계산 결과를 실제 DOM에 반영하는 단계(Commit phase) 입니다. 두 단계는 성격이 다릅니다. 하나는 중단하고 버릴 수 있는 순수 계산이고, 다른 하나는 중단할 수 없는 부수효과입니다.

이 경계를 흐릿하게 두면 성능 논의도 버그 진단도 계속 어긋납니다. "리렌더링이 많다"는 관찰에서 곧바로 React.memo를 붙이게 되고, 정작 느린 원인이 commit이나 브라우저 paint 쪽에 있으면 아무 효과도 못 봅니다. 반대로 render phase에 부수효과를 넣어두면 평소에는 잘 돌다가 concurrent 환경이나 StrictMode에서 이상하게 터집니다.

이 글에서는 Trigger에서 Render, Commit을 거쳐 브라우저 paint까지 이어지는 흐름을 단계별로 나눠보고, 각 단계에서 무엇이 허용되고 무엇이 금지되는지, 그리고 이 구분을 실제 성능 진단에서 어떻게 써먹는지 정리합니다.

한눈에 보면

먼저 짧게 정리하면 이렇습니다.

  • React의 업데이트는 Trigger → Render → Commit 순서로 진행되고, 그 뒤에야 브라우저 렌더링 파이프라인이 돕니다.
  • Render phase는 컴포넌트 함수를 호출해 다음 UI를 계산하는 단계이고, 중단·재시작·폐기가 가능합니다.
  • render phase는 순수해야 하고, 개발 모드의 StrictMode는 함수를 두 번 호출해 순수하지 않은 코드를 드러냅니다.
  • Commit phase는 계산 결과를 DOM에 반영하는 단계이고, 동기적이며 중단할 수 없습니다.
  • 계산 결과가 이전과 같으면 commit할 변경 자체가 없으므로, 컴포넌트 함수가 실행됐다고 DOM이 바뀌는 것은 아닙니다.
  • useLayoutEffect는 commit 직후 paint 전에, useEffect는 paint 이후에 비동기로 실행됩니다.
  • "느리다"의 원인이 render인지 commit인지 paint인지에 따라 처방이 완전히 달라집니다.

즉, React 성능을 제대로 이야기하려면 "몇 번 렌더링됐는가"가 아니라 어느 단계에서 시간이 새고 있는가를 먼저 물어야 합니다.

React의 업데이트는 어떤 단계로 나뉠까?

전체 흐름을 먼저 그려두면 나머지 설명이 쉬워집니다.

flowchart LR
  T[Trigger<br/>최초 마운트 또는 상태 변경] --> R[Render phase<br/>컴포넌트 함수 호출과 diffing]
  R --> C[Commit phase<br/>DOM 변경과 ref 부착]
  C --> P[Browser<br/>Layout Paint Composite]
 
  R -.->|중단 재시작 폐기 가능| R
  C -.->|중단 불가| C

세 단계의 성격을 한 문장씩 요약해보겠습니다.

  • Trigger: React에게 "다시 계산해야 한다"고 알리는 계기입니다. 최초 root.render, setState, context 변경 등이 여기 속합니다.
  • Render phase: 컴포넌트 함수를 호출해 React element 트리를 만들고, 이전 트리와 비교해 무엇이 달라졌는지 판단합니다. 이 단계는 화면에 아무 영향도 주지 않습니다.
  • Commit phase: 앞에서 계산한 변경 목록을 실제 DOM에 적용하고, ref를 붙이고, layout effect를 실행합니다.

핵심은 두 번째와 세 번째 사이의 성격 차이입니다. render phase는 결과를 버려도 되는 계산이고, commit phase는 한 번 하면 사용자가 즉시 보게 되는 반영입니다. React가 이 둘을 굳이 분리해둔 이유가 여기 있습니다.

리렌더링이 왜 일어나는지, 어떤 계기가 트리거가 되는지는 React의 리렌더링은 왜 일어날까에서 이미 정리했으니 이 글에서는 Trigger 이후에 집중하겠습니다.

Render phase는 정확히 무엇을 할까?

컴포넌트 함수 호출은 그냥 계산입니다

Render phase에서 React가 하는 일은 생각보다 소박합니다. 컴포넌트 함수를 호출해서 반환값을 받아옵니다.

function PriceTag({ amount }: { amount: number }) {
  console.log('PriceTag 호출됨');
 
  return <span className="price">{amount.toLocaleString()}</span>;
}

이 함수가 호출되면 console.log가 찍히고, <span> 형태를 서술한 객체가 반환됩니다. DOM 노드가 아닙니다. JSX는 컴파일되면 이런 모양이 됩니다.

// <span className="price">1,200원</span> 이 만들어내는 것
const element = {
  type: 'span',
  props: { className: 'price', children: '1,200원' },
  key: null,
  // ...
};

render phase가 끝난 시점에 React가 손에 쥐고 있는 것은 "화면이 이렇게 생겨야 한다"는 설계도뿐입니다. 실제 document.createElement는 아직 한 번도 호출되지 않았습니다. console.log가 찍혔다는 사실은 DOM이 바뀌었다는 증거가 되지 못합니다.

Fiber 트리와 더블 버퍼링

React는 이 설계도를 Fiber라는 자료구조로 관리합니다. 필요한 만큼만 짚고 넘어가겠습니다.

React는 트리를 두 벌 유지합니다.

  • current 트리: 지금 화면에 반영되어 있는 트리
  • workInProgress 트리: 이번 업데이트를 위해 새로 만들고 있는 트리

Render phase는 workInProgress 트리를 만드는 작업입니다. 화면에 보이는 current 트리는 그동안 손대지 않습니다. 그리고 commit 시점에 두 트리의 역할을 맞바꿉니다. 게임 그래픽에서 쓰는 더블 버퍼링과 발상이 같습니다.

flowchart TD
  A[Render phase 시작] --> B[current 트리를 기준으로<br/>workInProgress 트리 생성]
  B --> C{작업 완료?}
  C -->|더 급한 업데이트가 들어옴| D[workInProgress 폐기<br/>current 는 그대로]
  C -->|완료| E[Commit phase]
  E --> F[current 와 workInProgress 교체]
  D --> B

이 구조가 주는 이점은 분명합니다. 작업 중인 트리를 통째로 버려도 화면은 아무 영향을 받지 않습니다. React가 render phase를 중단하고 재시작하고 폐기할 수 있는 근거가 바로 이 분리입니다.

그래서 render phase는 "시도"입니다

render phase에서 컴포넌트 함수가 호출됐다는 사실은 다음 중 어느 것도 보장하지 않습니다.

  • 이 결과가 화면에 반영된다는 보장
  • 이 함수가 이번 업데이트에서 딱 한 번만 호출된다는 보장
  • 계산이 끝까지 진행된다는 보장

React 18의 concurrent 렌더링에서는 이 성질이 훨씬 자주 드러납니다. startTransition으로 낮춘 우선순위의 렌더링은 사용자 입력이 들어오면 중간에 버려질 수 있습니다. 이 부분은 React 18의 렌더링 방식은 무엇이 달라졌을까에서 다뤘으니 여기서는 "render는 시도, commit은 확정"이라는 한 줄만 들고 가겠습니다.

왜 render phase는 순수해야 할까?

중단·폐기·재실행이 가능하다는 것은 뒤집으면 제약입니다. 여러 번 실행되거나 아예 버려져도 문제가 없어야 합니다. 그래서 컴포넌트 함수는 순수해야 합니다.

아래 코드는 평소에는 멀쩡해 보이지만 위험합니다.

let viewCount = 0;
 
function ProductCard({ product }: { product: Product }) {
  // 1. 외부 변수를 렌더링 중에 변경
  viewCount += 1;
 
  // 2. 렌더링 중에 네트워크 요청
  logger.send('card_view', { id: product.id });
 
  // 3. 렌더링 중에 DOM 직접 접근
  const width = document.getElementById('grid')?.clientWidth ?? 0;
 
  return <div style={{ width }}>{product.name}</div>;
}

세 줄 모두 render phase에서는 금지입니다. 이유를 하나씩 보겠습니다.

  • viewCount는 폐기된 렌더링에서도 증가합니다. 화면에 보이지도 않은 카드가 조회수에 잡힙니다.
  • logger.send는 commit되지 않은 렌더링에서도 전송됩니다. 지표가 부풀려집니다.
  • document.getElementById로 읽은 값은 아직 commit되지 않은 이전 화면의 값입니다. 게다가 이 순간 강제 레이아웃 계산이 일어날 수 있습니다.

같은 로직을 올바른 자리로 옮기면 이렇게 됩니다.

function ProductCard({ product }: { product: Product }) {
  const [width, setWidth] = useState(0);
  const boxRef = useRef<HTMLDivElement>(null);
 
  // 로깅은 commit 이후에 → 폐기된 렌더링에서는 실행되지 않는다
  useEffect(() => {
    logger.send('card_view', { id: product.id });
  }, [product.id]);
 
  // DOM 측정은 commit 이후에 → 반영된 뒤의 값을 읽는다
  useLayoutEffect(() => {
    setWidth(boxRef.current?.parentElement?.clientWidth ?? 0);
  }, []);
 
  return (
    <div ref={boxRef} style={{ width }}>
      {product.name}
    </div>
  );
}

StrictMode는 이걸 어떻게 잡아낼까

React가 개발 모드에서 컴포넌트 함수를 두 번 호출하는 이유가 여기에 있습니다. 순수한 함수라면 두 번 호출해도 결과가 같아야 합니다. 결과가 달라진다면 그 함수는 순수하지 않다는 뜻입니다.

function Ticket() {
  // 두 번 호출하면 값이 달라진다 = 순수하지 않다
  const serial = Math.random().toString(36).slice(2);
 
  return <p>{serial}</p>;
}

StrictMode에서 이 컴포넌트를 렌더링하면 개발 도구에서 두 결과가 어긋나는 것을 확인할 수 있습니다. 랜덤값이나 Date.now()처럼 호출할 때마다 달라지는 값은 render phase가 아니라 이벤트 핸들러나 effect에서 만들어 state로 보관해야 합니다.

StrictMode가 개발 모드에서 두 번 실행하는 대상은 컴포넌트 함수만이 아닙니다.

대상 개발 모드 StrictMode 동작
컴포넌트 함수 본문 두 번 호출
useState 초기화 함수 두 번 호출
useMemo 계산 함수 두 번 호출
reducer 함수 두 번 호출
useEffect 마운트 → 클린업 → 다시 마운트

공통점이 보입니다. 전부 render phase에 속하거나, 재실행에 안전해야 하는 코드입니다. StrictMode의 이중 호출은 버그가 아니라 "이 코드가 재실행에 견디는가"를 묻는 테스트입니다.

Commit phase는 무엇을 할까?

Render phase가 끝나면 React는 "무엇을 어떻게 바꿔야 하는가"를 정리한 목록을 들고 있습니다. 이제 실제로 손을 대는 단계입니다.

flowchart TD
  A[workInProgress 트리 완성] --> B[1단계 before mutation<br/>DOM 변경 직전 스냅샷 수집]
  B --> C[2단계 mutation<br/>DOM 삽입 갱신 삭제]
  C --> D[current 트리 교체]
  D --> E[3단계 layout<br/>ref 부착과 useLayoutEffect 실행]
  E --> F[브라우저 paint]
  F --> G[passive effects<br/>useEffect 실행]

commit phase의 성질은 render phase와 정반대입니다.

  • 동기적입니다. 중간에 브라우저에게 제어권을 넘기지 않습니다.
  • 중단할 수 없습니다. DOM을 절반만 바꾼 상태를 사용자에게 보여줄 수는 없기 때문입니다.
  • 부수효과가 본업입니다. DOM 조작, ref 부착, 클린업 실행이 전부 여기서 일어납니다.

ref가 언제 채워지는지도 이 그림에서 답이 나옵니다. ref는 commit의 layout 단계에서 부착됩니다. render phase에서 ref.current를 읽으면 null이거나 이전 값이고, useLayoutEffectuseEffect 안에서 읽으면 최신 DOM 노드가 들어 있습니다.

function Editor() {
  const inputRef = useRef<HTMLInputElement>(null);
 
  // render phase: 아직 DOM 이 없거나 이전 것
  console.log('render 중 ref:', inputRef.current); // 최초 렌더에서 null
 
  useLayoutEffect(() => {
    // commit 이후: 실제 DOM 노드
    console.log('layout effect 중 ref:', inputRef.current); // <input>
    inputRef.current?.focus();
  }, []);
 
  return <input ref={inputRef} />;
}

렌더링 결과가 같으면 commit은 어떻게 될까?

여기가 이 글에서 가장 실용적인 부분입니다.

React는 render phase에서 이전 트리와 새 트리를 비교해 변경 목록을 만듭니다. 비교 결과 바꿀 것이 없으면 DOM에 적용할 것도 없습니다. 컴포넌트 함수는 분명히 실행됐지만 화면은 한 픽셀도 안 바뀝니다.

function Dashboard() {
  const [tick, setTick] = useState(0);
 
  useEffect(() => {
    const id = setInterval(() => setTick((prev) => prev + 1), 1000);
    return () => clearInterval(id);
  }, []);
 
  // tick 이 바뀌어도 이 컴포넌트의 출력은 항상 같다
  return (
    <section>
      <StaticHeader />
      <p>대시보드</p>
    </section>
  );
}
 
function StaticHeader() {
  console.log('StaticHeader render'); // 1초마다 찍힌다
  return <h1>매출 현황</h1>; // 그런데 DOM 은 그대로다
}

1초마다 StaticHeader render가 찍힙니다. 그런데 브라우저 개발자 도구의 Elements 패널에서 DOM 변경 하이라이트를 켜두면 아무 반응도 없습니다. 계산은 다시 했지만 결과가 같아서 반영할 것이 없기 때문입니다.

그래서 "리렌더링이 일어났다"와 "비싸다"는 자동으로 연결되지 않습니다.

상황 render phase 비용 commit phase 비용 실제 문제인가
가벼운 컴포넌트가 자주 재실행 매우 작음 없음 대개 문제 아님
리스트 1000개가 매 입력마다 재실행 작을 수도 있음 render 최적화 필요
결과가 매번 달라 DOM 수백 개 교체 보통 commit 최적화 필요
한 노드만 바뀌는데 레이아웃이 흔들림 작음 작음 브라우저 단계 문제

물론 render phase가 공짜라는 뜻은 아닙니다. 함수 호출, 훅 처리, element 객체 생성, diffing에는 분명히 비용이 듭니다. 다만 그 비용은 트리 규모와 계산량에 비례하지, 단순히 "몇 번 실행됐는가"에 비례하지는 않습니다. 무엇을 언제 메모이제이션할지는 React.memo, useMemo, useCallback은 언제 어떻게 써야 할까에서 더 자세히 다뤘습니다.

useLayoutEffect와 useEffect는 왜 타이밍이 다를까?

두 훅의 차이는 API 이름이 아니라 파이프라인에서의 위치입니다.

구분 useLayoutEffect useEffect
실행 시점 commit 직후, paint 전 paint 이후
실행 방식 동기 비동기 스케줄
브라우저를 막는가 막습니다 막지 않습니다
DOM 측정 가능 가능 가능하지만 이미 그려진 뒤
잘 맞는 일 레이아웃 측정 후 즉시 보정 데이터 요청, 구독, 로깅
SSR 서버에서 실행 안 됨, 경고 발생 문제 없음

깜빡임이 생기는 경우와 생기지 않는 경우

툴팁을 트리거 요소 위에 띄우는 흔한 요구사항을 예로 들어보겠습니다. 툴팁의 높이는 렌더링해봐야 알 수 있으니, 일단 그린 뒤 측정해서 위치를 잡아야 합니다.

function Tooltip({ anchor, children }: TooltipProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const [top, setTop] = useState(0);
 
  useEffect(() => {
    const box = boxRef.current;
    const target = anchor.current;
    if (!box || !target) return;
 
    const rect = target.getBoundingClientRect();
    setTop(rect.top - box.offsetHeight - 8);
  }, [anchor]);
 
  return (
    <div ref={boxRef} style={{ position: 'fixed', top }}>
      {children}
    </div>
  );
}

이 코드의 문제는 순서에 있습니다. useEffect는 paint 이후에 실행되므로 흐름이 이렇게 됩니다.

  1. top: 0으로 commit
  2. 브라우저가 화면 왼쪽 위에 툴팁을 그림
  3. effect 실행, 위치 계산 후 setTop
  4. 다시 render → commit → paint

2번과 4번 사이에 툴팁이 엉뚱한 자리에 한 프레임 보입니다. 이게 흔히 말하는 깜빡임입니다. 고치는 방법은 훅 이름 하나를 바꾸는 것뿐입니다.

// useEffect → useLayoutEffect 로 바꾸면 위 2번 단계가 사라진다
useLayoutEffect(() => {
  const box = boxRef.current;
  const target = anchor.current;
  if (!box || !target) return;
 
  const rect = target.getBoundingClientRect();
  setTop(rect.top - box.offsetHeight - 8);
}, [anchor]);

useLayoutEffect 안에서 호출한 setTop은 paint 전에 동기적으로 처리됩니다. React는 이 업데이트의 render와 commit을 그 자리에서 마저 끝내고, 브라우저는 최종 위치가 반영된 화면만 그립니다. 사용자는 중간 상태를 보지 못합니다.

스크롤 위치 복원

같은 원리가 필요한 또 다른 사례가 스크롤 복원입니다. 목록에서 상세로 갔다가 뒤로 왔을 때 원래 스크롤 위치로 돌려놓는 코드입니다.

function useScrollRestore(listRef: RefObject<HTMLDivElement>) {
  useLayoutEffect(() => {
    const saved = sessionStorage.getItem('list-scroll');
    if (saved && listRef.current) {
      listRef.current.scrollTop = Number(saved);
    }
 
    return () => {
      const el = listRef.current;
      if (el) sessionStorage.setItem('list-scroll', String(el.scrollTop));
    };
  }, [listRef]);
}

여기서 useEffect를 쓰면 목록 맨 위가 한 프레임 보였다가 원래 위치로 튀어 오릅니다. 사용자 눈에는 화면이 덜컹거리는 것으로 보입니다. "paint 전에 반드시 끝나야 하는 보정"이라는 조건이 붙으면 useLayoutEffect 라고 기억하면 됩니다.

그래도 기본값은 useEffect입니다

useLayoutEffect가 더 안전해 보이니 전부 그걸로 쓰면 되지 않을까 싶지만, 이 훅은 브라우저의 paint를 막습니다. 여기서 무거운 작업을 하면 그 시간만큼 화면이 멈춥니다.

// 이러지 말 것: paint 가 그만큼 지연된다
useLayoutEffect(() => {
  const result = heavyAnalysis(items); // 40ms 걸린다고 가정
  setSummary(result);
}, [items]);

판단 기준은 단순합니다.

  • 이 작업의 결과가 화면 배치에 즉시 반영되어야 하는가useLayoutEffect
  • 그 외 전부 → useEffect

데이터 요청, 이벤트 구독, 로깅, 타이머 등록은 전부 후자입니다. 서버 컴포넌트나 SSR을 쓰는 프로젝트라면 useLayoutEffect가 서버에서 실행되지 않아 경고가 뜬다는 점도 고려해야 합니다.

React의 commit이 끝나면 브라우저는 무엇을 할까?

경계를 확실히 그어둘 필요가 있습니다. React의 commit은 브라우저 렌더링의 시작 신호일 뿐, 렌더링 그 자체가 아닙니다.

React가 commit phase에서 하는 일은 결국 appendChild, setAttribute, textContent = ... 같은 DOM API 호출입니다. 이 호출들은 DOM 트리를 바꾸고 "다시 계산해야 할 부분"을 표시할 뿐, 픽셀을 만들지 않습니다. 픽셀은 그 뒤에 브라우저가 만듭니다.

flowchart LR
  subgraph REACT[React 영역]
    R[Render phase] --> C[Commit phase<br/>DOM API 호출]
  end
  subgraph BROWSER[브라우저 영역]
    L[Style] --> LA[Layout] --> P[Paint] --> CO[Composite]
  end
  C --> L

이 경계를 알면 다음 상황이 설명됩니다.

  • React 컴포넌트 개수가 적은데도 화면이 버벅인다 → commit된 DOM 변경이 레이아웃 전체를 흔드는 종류일 수 있습니다.
  • Profiler상 render와 commit이 둘 다 짧은데 프레임이 떨어진다 → 원인이 React 바깥, 즉 Layout과 Paint에 있습니다.

React를 쓴다고 브라우저 렌더링 비용이 사라지지 않습니다. 오히려 React는 DOM 변경을 최소화해줄 뿐이고, 그 최소화된 변경이 어떤 속성을 건드리느냐는 여전히 우리 몫입니다. widthtop을 애니메이션하면 React가 아무리 효율적으로 commit해도 매 프레임 Layout이 돕니다.

이 뒷단 이야기는 브라우저 Reflow와 Repaint (1) 기본 원리브라우저 Reflow와 Repaint (2) 비용 줄이기에서 자세히 다뤘습니다. React 성능 문제를 파다가 "React 쪽은 다 깨끗한데 여전히 느리다"는 결론에 도달했다면 그 두 글이 다음 단계입니다.

각 단계에서 하면 안 되는 일은 무엇일까?

지금까지 나온 제약을 한 표로 모으면 이렇습니다.

단계 하면 안 되는 일 대신
Render setState 직접 호출 무한 루프 위험 이벤트 핸들러 또는 effect
Render DOM 읽기·쓰기 아직 반영 전 값이고 강제 레이아웃 유발 useLayoutEffect
Render 네트워크 요청·로깅 폐기된 렌더링에서도 실행됨 useEffect
Render 외부 변수·ref.current 변경 재실행 시 결과가 달라짐 effect 또는 이벤트 핸들러
Render 이벤트 구독 등록 정리 시점이 없음 useEffect 클린업
Render Math.random, Date.now 호출마다 값이 달라짐 state로 보관하거나 useMemo 밖에서
Commit 무거운 동기 계산 paint가 그만큼 밀림 useEffect로 미루기
Commit layout effect 안에서 연쇄 setState 동기 render·commit이 반복됨 계산을 render 단계로
Commit 정리 없는 구독 생성 언마운트 시 누수 반드시 클린업 반환

render phase의 규칙은 결국 하나로 요약됩니다. "이 코드가 두 번 실행되거나 아예 실행되지 않아도 괜찮은가?" 대답이 "아니오"라면 그 코드는 render phase에 있으면 안 됩니다.

성능 진단에서 이 구분을 어떻게 쓸까?

이제 실전입니다. "화면이 느리다"는 신고를 받았을 때, 이 세 단계로 나누면 진단이 빨라집니다.

React DevTools Profiler는 commit 하나를 막대 하나로 보여주고, 각 컴포넌트의 render 시간을 나눠 보여줍니다. Profiler 컴포넌트를 코드에 직접 심어 수치를 뽑을 수도 있습니다.

import { Profiler, type ProfilerOnRenderCallback } from 'react';
 
const handleRender: ProfilerOnRenderCallback = (
  id,
  phase, // 'mount' | 'update' | 'nested-update'
  actualDuration, // 이번 업데이트에서 실제로 렌더링에 든 시간
  baseDuration, // 메모이제이션 없이 전부 렌더링했을 때 예상 시간
  startTime,
  commitTime
) => {
  console.table({ id, phase, actualDuration, baseDuration });
};
 
export function ProfiledList({ items }: { items: Product[] }) {
  return (
    <Profiler id="ProductList" onRender={handleRender}>
      <ProductList items={items} />
    </Profiler>
  );
}

actualDurationrender phase 시간에 가깝습니다. 이 숫자가 작다고 사용자가 빠르다고 느끼는 것은 아닙니다. Performance 패널의 프레임 타임라인과 같이 봐야 전체 그림이 나옵니다.

증상 어디를 보는가 원인일 가능성 처방
Profiler에서 특정 컴포넌트 render 시간이 김 React DevTools Profiler render phase 무거운 계산 useMemo, 컴포넌트 분리, 가상 스크롤
render는 짧은데 commit 막대가 김 Profiler의 commit 막대 commit phase DOM 변경량 줄이기, key 안정화, 리스트 재생성 방지
commit도 짧은데 프레임이 떨어짐 Performance 패널의 Layout·Paint 브라우저 파이프라인 transform·opacity 전환, contain, 레이아웃 흔드는 속성 제거
입력이 한 박자 늦게 반영됨 입력부터 paint까지 전체 render 우선순위 startTransition, useDeferredValue
화면이 한 프레임 잘못 보였다가 고쳐짐 effect 타이밍 effect 위치 useEffectuseLayoutEffect
개발 모드에서만 로그가 두 배 StrictMode 순수하지 않은 render 부수효과를 effect로 이동

이 표에서 가장 중요한 줄은 두 번째와 세 번째입니다. render 시간이 짧다는 것은 "React가 계산을 빨리 끝냈다"는 뜻이지 "사용자에게 빨리 보였다"는 뜻이 아닙니다. 이 둘을 섞으면 최적화 방향이 통째로 어긋납니다.

실제로 겪었던 사례 하나를 붙이면, 필터를 바꿀 때마다 목록이 버벅이길래 Profiler를 켰더니 render는 6ms였습니다. 처음에는 React.memo를 여기저기 붙였는데 아무 변화가 없었습니다. Performance 패널을 열어보니 commit 이후 Layout에서 40ms 넘게 쓰고 있었습니다. 원인은 목록 카드의 높이가 이미지 로딩 후에 결정되는 구조여서, 카드 하나가 늘어날 때마다 아래 전체가 다시 배치되는 것이었습니다. aspect-ratio로 자리를 미리 잡아주자 해결됐습니다. React 코드는 한 줄도 안 고쳤습니다.

Concurrent 렌더링에서는 무엇이 더 달라질까?

React 18 이후 createRoot를 쓰는 환경에서는 render phase가 중간에 양보될 수 있습니다. 실무 관점에서 달라지는 점은 세 가지입니다.

  • 컴포넌트 함수가 호출된 횟수와 commit 횟수가 1:1이 아닙니다. 낮은 우선순위 렌더링은 버려질 수 있습니다.
  • 그만큼 render phase의 부수효과는 더 위험해집니다. "한 번 더 실행됐다" 정도가 아니라 "실행됐는데 화면엔 안 나왔다"가 됩니다.
  • 반대로 commit phase는 변함없이 동기적이고 원자적입니다. 사용자가 보는 화면은 언제나 일관된 하나의 트리입니다.
const deferredKeyword = useDeferredValue(keyword);
// deferredKeyword 로 도는 렌더링은 commit 전에 폐기될 수 있다
return <HeavyResult query={deferredKeyword} />;

HeavyResult의 render 로그가 입력 글자 수보다 적게 찍히거나 중간 값이 건너뛴 것처럼 보여도 정상입니다. 우선순위와 스케줄링을 더 알고 싶다면 React 18의 렌더링 방식은 무엇이 달라졌을까를 참고하면 됩니다.

실무 체크리스트

코드 리뷰나 디버깅에서 바로 꺼내 쓸 수 있는 항목으로 정리하면 이렇습니다.

  • 컴포넌트 함수 본문에 함수 호출이 있다면, 그게 순수 계산인지 부수효과인지 한 번 더 확인합니다.
  • console.log 횟수로 성능을 판단하지 않습니다. Profiler의 commit 막대와 같이 봅니다.
  • 레이아웃 측정 후 보정이 필요하면 useLayoutEffect, 그 외에는 useEffect를 기본값으로 씁니다.
  • StrictMode에서 이상 동작이 보이면 끄는 대신 원인을 찾습니다. 대부분 진짜 버그입니다.
  • "느리다"는 신고를 받으면 render, commit, browser 중 어디인지부터 나눕니다.
  • ref는 commit 이후에만 신뢰합니다. render 중의 ref.current는 이전 화면의 정보입니다.

자주 하는 오해

1. 리렌더링 = DOM 업데이트다

아닙니다. 리렌더링은 render phase에서 결과를 다시 계산했다는 뜻입니다. 계산 결과가 이전과 같으면 commit할 변경이 없고, DOM은 그대로입니다. console.log가 찍혔다고 화면이 바뀐 것이 아닙니다.

2. render 로그가 많으면 무조건 문제다

아닙니다. 가벼운 컴포넌트가 몇 번 더 실행되는 것은 대체로 무해합니다. 문제는 횟수가 아니라 한 번의 render가 얼마나 비싼가, 그리고 그 결과가 얼마나 큰 commit을 만드는가입니다. 횟수만 보고 React.memo를 뿌리면 비교 비용만 추가되는 경우도 많습니다.

3. useEffect는 렌더링 직후에 실행된다

정확히는 commit 이후, 브라우저 paint 이후에 비동기로 실행됩니다. "렌더링 직후"라고 이해하면 깜빡임 문제를 진단할 수 없습니다. paint 전에 끼어들어야 하는 작업은 useLayoutEffect입니다.

4. StrictMode의 이중 호출은 버그다

아닙니다. 개발 모드에서만 동작하는 의도적인 검증입니다. 순수하지 않은 render, 클린업이 빠진 effect, 재마운트에 취약한 구독을 미리 드러냅니다. 이중 호출로 문제가 생긴다면 문제는 StrictMode가 아니라 그 코드에 있습니다.

5. render phase에서 DOM을 읽는 건 그냥 좀 지저분한 정도다

그 이상입니다. render 중에 읽는 DOM 값은 이번 업데이트가 반영되기 전의 값이라 논리적으로 틀렸고, offsetHeight 같은 속성을 읽으면 브라우저가 강제로 레이아웃을 계산합니다. 틀린 값을 비싸게 얻는 셈입니다.

6. commit phase는 React가 알아서 하니 신경 쓸 게 없다

commit 비용은 우리가 만드는 DOM 변경량에 정비례합니다. key가 불안정해서 리스트 항목이 매번 새로 만들어지면 render는 비슷해도 commit은 훨씬 커집니다. 리스트를 다루는 코드에서는 특히 이 부분을 확인할 필요가 있습니다.

정리하면

  • React의 업데이트는 Trigger → Render → Commit → 브라우저 paint 순서로 진행되고, 각 단계는 성격이 다릅니다.
  • Render phase는 순수 계산이고 폐기될 수 있습니다. 그래서 부수효과를 넣으면 안 되고, StrictMode의 이중 호출이 이를 검증합니다.
  • Commit phase는 동기적이고 중단 불가능한 반영입니다. DOM 변경, ref 부착, layout effect가 여기서 일어납니다.
  • 계산 결과가 이전과 같으면 반영할 변경이 없으므로, 컴포넌트 함수 실행이 곧 DOM 변경은 아닙니다.
  • useLayoutEffect는 paint 전, useEffect는 paint 후입니다. 깜빡임 문제는 대부분 이 위치를 잘못 잡아서 생깁니다.

내일 바로 쓸 수 있는 기준 하나만 남긴다면 이겁니다. "느리다"는 말을 들으면 먼저 render인지 commit인지 브라우저인지 나누고, 나뉘기 전에는 어떤 최적화도 시작하지 않는 것. 단계가 정해지면 처방은 대체로 자동으로 따라옵니다.

같이 보면 좋은 글