React 리렌더링(Re-rendering): Trigger → Render → Commit

2025. 10. 3. 11:05·Frontend Development
728x90

React에서 “언제, 왜, 어떻게” 리렌더링이 일어나는지 정확히 이해하면 성능 최적화와 불필요한 복잡도 감소에 큰 도움을 줍니다. 이 글은 리렌더링의 이론을 체계적으로 정리하고, 실행 가능한 예시와 실무 체크리스트로 마무리합니다.


핵심 개념: 렌더링 파이프라인 3단계

  • Trigger: 상태(state) 변경, 상위 컴포넌트 렌더, context 값 변경, key 변경, 외부 스토어 구독 변경 등으로 “업데이트 필요”가 발생합니다. React는 내부 업데이트 큐에 변경을 기록합니다.
  • Render: 변경된 상태로 함수 컴포넌트를 다시 호출하여 새로운 VDOM(Fiber 트리)을 만듭니다. 이전 트리와 비교(diff)하지만, 이 단계에서는 실제 DOM 조작이 없습니다.
  • Commit: diff 결과를 실제 DOM에 최소 변경으로 반영합니다. 레이아웃/페인팅이 발생하고 사용자가 변화를 보게 됩니다. 이때 useLayoutEffect/ref 효과가 동기적으로, useEffect는 비동기적으로 실행됩니다.

언제 리렌더링이 발생하는가

  • 자신의 setState 호출: 해당 컴포넌트가 다시 렌더됩니다. 동일 값으로 설정하면 Object.is 비교로 건너뜁니다.
  • 부모가 렌더되면 자식도 기본적으로 렌더: 단, React.memo로 자식을 메모이즈하고 props가 같으면 건너뜁니다.
  • context 값 변경: 해당 컨텍스트를 구독(useContext)하는 모든 소비자가 다시 렌더됩니다. Provider의 value 참조가 안정적이어야 불필요한 렌더를 줄일 수 있습니다.
  • 외부 스토어 변경: Redux useSelector, Zustand useStore 등은 선택자 비교 결과가 달라질 때만 리렌더됩니다.
  • key 변경: React가 다른 노드로 인식하여 마운트/언마운트가 일어납니다.

발생하지 않는 경우

  • useRef 변경: .current를 변경해도 렌더를 트리거하지 않습니다.
  • 변이만 하고 setState를 호출하지 않음: 참조 동일성이 유지되면 React는 변경을 모릅니다. 항상 불변 업데이트를 사용하세요.

React 18+ Auto Batching: 여러 상태 변경을 1번 렌더로

React 18부터는 이벤트 핸들러뿐 아니라 setTimeout, Promise, 네이티브 이벤트 등 대부분의 비동기 컨텍스트에서도 상태 변경이 자동으로 배치됩니다.

import { useState } from 'react'

export function App() {
  const [a, setA] = useState(0)
  const [b, setB] = useState(0)

  const handleClick = () => {
    // 둘 다 하나의 렌더로 합쳐짐 (자동 배칭)
    setA(v => v + 1)
    setB(v => v + 1)
  }

  setTimeout(() => {
    // 비동기 컨텍스트에서도 자동 배칭
    setA(v => v + 1)
    setB(v => v + 1)
  }, 1000)

  return (
    <button onClick={handleClick}>a: {a}, b: {b}</button>
  )
}

필요 시 렌더를 강제로 분리하려면 flushSync를 사용할 수 있습니다(과용 금지).

import { flushSync } from 'react-dom'

flushSync(() => setA(v => v + 1))
setB(v => v + 1) // 별도의 렌더

불필요한 리렌더 줄이기: 실전 패턴

  • React.memo로 자식 컴포넌트 메모이즈: 부모 렌더 시에도 props가 같으면 자식 렌더를 건너뜁니다.
  • 핸들러/객체의 참조 안정화: useCallback, useMemo로 콜백과 계산 결과의 참조를 고정하여 memo 비교를 돕습니다.
  • 컨텍스트 분할: 넓은 Provider 하나보다는, 변경 빈도에 따라 Context를 분리합니다.
  • 무거운 계산 메모이즈: 비용 큰 계산은 useMemo로, 입력이 바뀔 때만 다시 수행합니다.
  • 리스트 최적화: 안정적인 key, 가상 스크롤(virtualization), useDeferredValue로 입력 지연 처리.

실행 가능한 예시: 부모 렌더가 잦아도 자식 렌더 막기

import { memo, useCallback, useState } from 'react'

type ChildProps = {
  onAdd: () => void
  value: number
}

const Child = memo(function Child({ onAdd, value }: ChildProps) {
  console.log('Child render')
  return (
    <div>
      <p>value: {value}</p>
      <button onClick={onAdd}>Add</button>
    </div>
  )
})

export function Parent() {
  const [count, setCount] = useState(0)
  const [value, setValue] = useState(0)

  // 참조가 안정적이어야 Child가 매 렌더마다 다시 그려지지 않음
  const handleAdd = useCallback(() => setValue(v => v + 1), [])

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Re-render parent: {count}</button>
      <Child onAdd={handleAdd} value={value} />
    </div>
  )
}

설명

  • Parent가 count로 자주 렌더되어도, Child의 props 참조가 동일하면 React.memo가 비교를 통과해 렌더를 생략합니다.
  • handleAdd를 useCallback으로 고정하지 않으면 매번 새로운 함수 참조가 전달되어 Child가 다시 렌더됩니다.

실무 주의사항과 흔한 실수

  • 불변 업데이트 확보: 배열/객체는 반드시 새 참조로 갱신하세요. 예: setItems(prev => prev.map(...)), setObj(prev => ({ ...prev, a: 1 })).
  • 과도한 메모이제이션 금지: React.memo, useMemo, useCallback의 비교/저장 비용도 존재합니다. 핫스팟에만 적용하세요.
  • Context value의 참조 안정화: value={{ a, b }}는 부모 렌더마다 새 객체가 됩니다. useMemo(() => ({ a, b }), [a, b])로 고정하세요.
  • 동일 값 setState는 렌더 생략: setCount(c => c)는 의미 없습니다. bail-out을 신뢰하되 의도치 않은 동일성 유지 버그에 주의하세요.
  • Profiler로 실측: 추측 대신 React DevTools Profiler로 실제 렌더 비용을 측정하고 개선을 검증하세요.

체크리스트

  • 상태는 최소화했는가? 파생 가능한 값은 계산으로 대체했는가?
  • 부모 렌더가 자식 렌더를 유발하지 않도록 메모이제이션이 필요한가?
  • 컨텍스트 범위를 적절히 분할했는가? value 참조는 안정적인가?
  • 비싼 연산은 useMemo로 감싸졌는가? 이벤트 핸들러 참조는 useCallback인가?
  • 리스트 렌더에 안정적인 key와 가상화가 적용되어 있는가?

결론: 개념 정리 + 적용 가이드

  • 리렌더는 Trigger → Render → Commit의 파이프라인으로 진행됩니다. 실제 DOM 변경은 Commit에서만 일어납니다.
  • React 18의 자동 배칭으로 대부분의 상태 변경은 1번 렌더로 합쳐집니다. 필요할 때만 flushSync로 분리하세요.
  • 성능 최적화의 핵심은 “불필요한 리렌더 원인 제거”입니다. React.memo, 참조 안정화, 컨텍스트 분할, 비용 큰 계산의 메모이즈를 상황에 맞게 적용하고 Profiler로 검증하세요.
728x90
저작자표시 비영리 변경금지 (새창열림)

'Frontend Development' 카테고리의 다른 글

실무에서 꼭 알아야 할 JWT 저장소 보안 패턴과 공격 탐지 방법  (0) 2025.09.13
Next.js SSR 페이지 풀 페이지 캐싱  (0) 2025.09.08
useEffect에서 setInterval이 상태를 못 따라오는 이유 (stale closure)  (0) 2025.09.07
React Error Boundary: 왜 아직도 클래스일까?  (0) 2025.09.07
setTimeout vs Promise.then vs queueMicrotask  (0) 2025.09.05
'Frontend Development' 카테고리의 다른 글
  • 실무에서 꼭 알아야 할 JWT 저장소 보안 패턴과 공격 탐지 방법
  • Next.js SSR 페이지 풀 페이지 캐싱
  • useEffect에서 setInterval이 상태를 못 따라오는 이유 (stale closure)
  • React Error Boundary: 왜 아직도 클래스일까?
Kun Woo Kim
Kun Woo Kim
안녕하세요, 김건우입니다! 웹과 앱 개발에 열정적인 전문가로, React, TypeScript, Next.js, Node.js, Express, Flutter 등을 활용한 프로젝트를 다룹니다. 제 블로그에서는 개발 여정, 기술 분석, 실용적 코딩 팁을 공유합니다. 창의적인 솔루션을 실제로 적용하는 과정의 통찰도 나눌 예정이니, 궁금한 점이나 상담은 언제든 환영합니다.
  • Kun Woo Kim
    WhiteMouseDev
    김건우
  • 깃허브
    포트폴리오
    velog
  • 전체
    오늘
    어제
  • 공지사항

    • [인사말] 이제 티스토리에서도 만나요! WhiteMouse⋯
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
    • 분류 전체보기 (133)
      • Frontend Development (62)
      • Backend Development (25)
      • Algorithm (33)
        • 백준 (11)
        • 프로그래머스 (17)
        • 알고리즘 (5)
      • Infra (1)
      • 자료구조 (3)
      • Language (6)
        • JavaScript (6)
  • 링크

    • Github
    • Portfolio
    • Velog
  • 인기 글

  • 태그

    frontend development
    tailwindcss
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Kun Woo Kim
React 리렌더링(Re-rendering): Trigger → Render → Commit
상단으로

티스토리툴바