2025-09-22 00:47

  • 리액트 렌더링은 UI의 상태 변화를 실제 화면에 반영하는 과정으로, ‘렌더 단계’와 ‘커밋 단계’로 나뉩니다.
  • 가상돔(Virtual DOM)과 재조정(Reconciliation) 알고리즘을 통해 실제 DOM 조작을 최소화하여 성능을 최적화합니다.
  • 불필요한 리렌더링을 방지하기 위해 memo, useCallback, useMemo와 같은 최적화 도구를 적절히 사용하는 것이 중요합니다.

리액트 렌더링 완벽 정복 핸드북 브라우저에 UI가 그려지기까지

리액트(React) 개발자라면 누구나 ‘렌더링’이라는 단어를 매일 같이 마주하게 됩니다. 상태가 바뀌면 화면이 업데이트되고, 사용자와의 상호작용이 UI에 반영됩니다. 이 모든 과정의 중심에는 바로 ‘렌더링’이 있습니다. 하지만 우리는 종종 리액트가 “알아서 잘” 렌더링 해줄 것이라 믿고, 그 내부 동작 원리에 대해서는 깊게 파고들지 않습니다.

애플리케이션이 작고 단순할 때는 이것이 큰 문제가 되지 않습니다. 하지만 서비스가 복잡해지고 다루는 데이터의 양이 많아질수록, 우리는 예기치 않은 성능 문제와 마주하게 됩니다. “버튼 클릭이 느려요”, “스크롤이 버벅거려요”, “페이지 로딩이 오래 걸려요”와 같은 문제들의 근원을 파헤쳐 보면, 대부분 비효율적인 렌더링과 관련이 있습니다.

이 핸드북은 리액트 렌더링의 안개를 걷어내고, 그 핵심 원리를 명확하게 이해하기 위해 작성되었습니다. 리액트가 왜 탄생했는지부터 시작하여, 가상돔(Virtual DOM)과 재조정(Reconciliation)이라는 핵심 개념, 그리고 렌더링이 실제로 언제 어떻게 발생하는지, 마지막으로 더 나은 성능을 위해 렌더링을 어떻게 최적화할 수 있는지까지 모든 것을 다룹니다. 이 글을 끝까지 읽고 나면, 여러분은 더 이상 렌더링을 막연한 마법이 아닌, 예측하고 제어할 수 있는 강력한 도구로 여기게 될 것입니다.


1부 리액트 렌더링의 탄생 배경

리액트의 렌더링 방식을 이해하려면, 먼저 리액트가 해결하고자 했던 문제가 무엇이었는지 알아야 합니다. 리액트가 등장하기 이전, 웹 개발은 어떤 모습이었을까요?

과거의 문제점 직접적인 DOM 조작의 고통

과거의 웹 애플리케이션, 특히 jQuery와 같은 라이브러리를 사용하던 시절을 떠올려 봅시다. 데이터가 변경되면 개발자는 어떤 DOM 요소를 찾아 어떻게 업데이트할지 직접 코드로 작성해야 했습니다.

예를 들어, 사용자 목록에서 특정 사용자의 이름을 변경하는 시나리오를 생각해 보겠습니다.

// 데이터
let users = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' }
];
 
// id가 2인 사용자의 이름을 'Robert'로 변경
users[1].name = 'Robert';
 
// 이제 UI를 직접 업데이트해야 한다!
// 1. id가 'user-2'인 li 요소를 찾는다.
// 2. 그 안의 span 태그를 찾는다.
// 3. span 태그의 텍스트를 'Robert'로 바꾼다.
document.querySelector('#user-2 .user-name').textContent = 'Robert';

이 코드는 단순해 보이지만, 애플리케이션의 규모가 커지면 다음과 같은 문제들이 발생합니다.

  • 복잡성 증가: UI의 모든 변화에 대해 DOM을 직접 조작하는 코드를 작성해야 하므로, 상태와 UI의 동기화를 맞추는 로직이 기하급수적으로 복잡해집니다. 이는 ‘스파게티 코드’를 유발하고 유지보수를 어렵게 만듭니다.
  • 성능 저하: DOM 조작은 브라우저에게 매우 비용이 큰 작업입니다. DOM의 일부가 변경되면, 브라우저는 해당 요소와 그 자식 요소들의 스타일을 다시 계산하고(Reflow), 화면에 다시 그리는 과정(Repaint)을 거칩니다. 잦은 DOM 조작은 불필요한 Reflow와 Repaint를 유발하여 애플리케이션의 성능을 심각하게 저하시킵니다.

리액트의 해결책 가상돔(Virtual DOM)

리액트는 이 문제를 해결하기 위해 **‘가상돔(Virtual DOM)‘**이라는 개념을 도입했습니다. 가상돔은 실제 DOM을 추상화한, 자바스크립트 객체로 이루어진 가벼운 복사본입니다. 마치 건물의 실제 설계도(실제 DOM)를 변경하기 전에, 변경 사항을 먼저 스케치해보는 청사진(가상돔)과 같습니다.

리액트의 렌더링 과정은 이 가상돔을 통해 다음과 같이 이루어집니다.

  1. 상태 변경 발생: 컴포넌트의 상태(state)나 속성(props)이 변경됩니다.
  2. 새로운 가상돔 생성: 리액트는 변경된 상태를 기반으로 새로운 가상돔 트리를 메모리 상에 생성합니다.
  3. 비교 (Diffing): 리액트는 이전 가상돔 트리와 새로 생성된 가상돔 트리를 비교하여 어떤 부분이 변경되었는지 찾아냅니다. 이 비교 과정을 **‘재조정(Reconciliation)‘**이라고 합니다.
  4. 최소한의 변경사항 계산: 리액트는 두 트리의 차이점을 바탕으로 실제 DOM에 적용해야 할 최소한의 변경사항을 계산합니다.
  5. 실제 DOM 업데이트 (Batch Update): 계산된 변경사항들을 한 번에 모아서 실제 DOM에 적용합니다. 이를 통해 불필요한 DOM 접근과 조작을 최소화합니다.

이 방식은 개발자를 직접적인 DOM 조작의 고통에서 해방시켜 주었습니다. 개발자는 그저 상태가 어떻게 변할지만 선언적으로 정의하면, 리액트가 알아서 가장 효율적인 방법으로 UI를 업데이트해주기 때문입니다. 이것이 바로 리액트가 ‘UI를 상태의 함수’로 보는 철학의 핵심입니다.


2부 리액트 렌더링의 핵심 구조

리액트가 내부적으로 렌더링을 처리하는 과정은 크게 두 단계로 나뉩니다. 바로 **렌더 단계(Render Phase)**와 **커밋 단계(Commit Phase)**입니다. 이 두 단계를 이해하는 것은 리액트의 동작을 깊이 있게 파악하는 데 매우 중요합니다.

렌더 단계와 커밋 단계

단계주요 작업특징
렌더 단계 (Render Phase)컴포넌트 실행, 변경 사항 계산 (Diffing)- 비동기적으로 실행 가능
- 중단, 재시작, 폐기 가능
- 실제 DOM에 영향을 주지 않음 (Side-effect X)
커밋 단계 (Commit Phase)계산된 변경 사항을 실제 DOM에 적용- 동기적으로 실행
- 중단 불가능
- 실제 DOM에 영향을 줌 (Side-effect O)

1. 렌더 단계 (Render Phase)

렌더 단계는 “무엇을 바꿀지 결정하는” 단계입니다. 리액트는 상태가 변경된 컴포넌트를 시작으로, 그 하위의 모든 컴포넌트를 호출합니다.

  • render() 메서드 (클래스 컴포넌트) 또는 함수 자체 (함수 컴포넌트)를 실행합니다.
  • 이전 가상돔과 비교하여 변경 사항(예: “h1 태그의 텍스트를 바꿔라”, “div 태그를 새로 추가해라”)을 파악합니다.

리액트 16(Fiber)부터 렌더 단계는 비동기적으로 동작할 수 있게 되었습니다. 즉, 렌더링 작업이 길어질 경우, 브라우저의 다른 중요한 작업(애니메이션, 사용자 입력 등)을 막지 않기 위해 렌더링을 잠시 멈추고 나중에 다시 시작할 수 있습니다. 이러한 특성 때문에 렌더 단계에서는 useEffect와 같은 사이드 이펙트를 실행해서는 안 됩니다. 렌더링이 중간에 폐기될 수도 있기 때문입니다.

2. 커밋 단계 (Commit Phase)

커밋 단계는 “결정한 변경 사항을 실제로 실행하는” 단계입니다. 렌더 단계에서 계산된 모든 변경사항을 실제 DOM에 한 번에 적용합니다.

  • 이 과정은 동기적으로 일어나며, 중간에 멈출 수 없습니다. 만약 이 과정이 중단된다면 사용자는 일부분만 변경된 불완전한 UI를 보게 될 수 있기 때문입니다.
  • DOM 노드를 추가, 수정, 삭제하는 작업이 이 단계에서 이루어집니다.
  • useEffect와 같은 사이드 이펙트 훅은 이 커밋 단계가 완료된 후에 비동기적으로 실행됩니다.

재조정 (Reconciliation)과 Diffing 알고리즘

렌더 단계의 핵심은 ‘재조정’, 즉 이전 가상돔과 새로운 가상돔을 비교하는 과정입니다. 리액트는 어떻게 효율적으로 두 트리의 차이점을 찾아낼까요? 여기에 바로 Diffing 알고리즘이 사용됩니다.

리액트는 두 가지 가정에 기반하여 복잡한 트리 비교 알고리즘을 의 시간 복잡도로 단순화했습니다.

  1. 서로 다른 타입의 두 엘리먼트는 다른 트리를 만들어낼 것이다.
    • <div><span>으로 바뀌었다면, 리액트는 두 요소를 비교하지 않고 이전 <div>를 포함한 하위 트리를 모두 파괴하고 새로운 <span> 트리를 만듭니다.
  2. 개발자가 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 그대로 유지되는지 표시해 줄 것이다.

이 가정을 바탕으로 한 Diffing 알고리즘의 동작 방식은 다음과 같습니다.

  • 엘리먼트 타입이 다른 경우:

    • 이전 트리를 완전히 제거하고 새로운 트리를 구축합니다. 관련된 모든 컴포넌트의 상태도 사라집니다.
    // 변경 전
    <div><Counter /></div>
     
    // 변경 후
    <span><Counter /></span>
     
    // 결과: div와 이전 Counter 인스턴스는 제거되고,
    //      span과 새로운 Counter 인스턴스가 생성됩니다.
  • 엘리먼트 타입이 같은 경우:

    • 리액트는 최대한 같은 인스턴스를 유지하면서 속성(attributes)만 업데이트합니다. 스타일이나 클래스 이름 등이 변경되면 해당 내용만 실제 DOM에 반영합니다.
    // 변경 전
    <div className="before" title="stuff" />
     
    // 변경 후
    <div className="after" title="stuff" />
     
    // 결과: className만 "before"에서 "after"로 업데이트됩니다.
  • 리스트 렌더링과 key prop:

    • 자식 엘리먼트들을 재귀적으로 처리할 때, key prop은 매우 중요합니다. key는 형제 노드 사이에서만 고유하면 되며, 리액트가 어떤 항목이 변경, 추가, 또는 삭제되었는지 식별하는 데 사용됩니다.
    // 비효율적인 경우 (key가 없을 때)
    // <li>Duke</li>
    // <li>Villanova</li>
    // => 리스트 맨 앞에 <li>Connecticut</li> 추가
     
    // 리액트의 생각:
    // 1. Duke가 Connecticut으로 바뀌었네? -> <li> 수정
    // 2. Villanova가 Duke로 바뀌었네? -> <li> 수정
    // 3. 마지막에 Villanova를 새로 추가해야겠군! -> <li> 추가
    // (불필요한 수정이 2번 발생)
     
    // 효율적인 경우 (key가 있을 때)
    // <li key="duke">Duke</li>
    // <li key="villanova">Villanova</li>
    // => 리스트 맨 앞에 <li key="connecticut">Connecticut</li> 추가
     
    // 리액트의 생각:
    // 1. 'duke'와 'villanova' 키는 그대로 있네.
    // 2. 'connecticut' 키를 가진 엘리먼트만 새로 추가하면 되겠군! -> <li> 추가
    // (단 한 번의 추가로 끝남)

    인덱스를 key로 사용하는 것은 리스트의 순서가 바뀌거나 항목이 추가/삭제될 때 비효율을 유발하고 예측 불가능한 버그를 만들 수 있으므로, 항상 데이터에 있는 고유한 ID를 key로 사용해야 합니다.


3부 리액트 렌더링 사용법 언제, 어떻게 발생하는가?

리액트 렌더링의 내부 구조를 이해했다면, 이제 렌더링이 ‘언제’ 일어나는지 아는 것이 중요합니다. 렌더링은 크게 두 가지로 나눌 수 있습니다: 초기 렌더링리렌더링.

초기 렌더링 (Initial Render)

초기 렌더링은 애플리케이션이 처음 로드될 때 발생하는 단 한 번의 렌더링입니다.

import ReactDOM from 'react-dom/client';
import App from './App';
 
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />); // 바로 이 시점!

root.render()가 호출되면, 리액트는 <App /> 컴포넌트와 그 모든 자식 컴포넌트를 처음부터 끝까지 렌더링하여 DOM 트리를 구축하고 화면에 표시합니다.

리렌더링 (Re-rendering)의 조건

초기 렌더링 이후에 발생하는 모든 렌더링을 리렌더링이라고 합니다. 리액트 컴포넌트는 다음과 같은 조건 중 하나라도 만족하면 리렌더링을 촉발시킵니다.

  1. 컴포넌트의 상태(State)가 변경될 때

    • useState 훅의 세터(setter) 함수가 호출되면 리액트는 해당 컴포넌트의 리렌더링을 예약합니다. 이것이 가장 일반적인 리렌더링의 원인입니다.
    const [count, setCount] = useState(0);
    // setCount(1)이 호출되면, 이 컴포넌트는 리렌더링됩니다.
  2. 컴포넌트가 새로운 프롭스(Props)를 받을 때

    • 부모 컴포넌트로부터 전달받는 props가 변경되면 자식 컴포넌트는 리렌더링됩니다. (정확히는, props의 변경 여부와 상관없이 부모가 리렌더링되면 자식도 기본적으로 리렌더링됩니다. 이에 대한 최적화는 4부에서 다룹니다.)
  3. 부모 컴포넌트가 리렌더링될 때

    • 매우 중요한 규칙입니다. 부모 컴포넌트가 리렌더링되면, 그 자식 컴포넌트들은 props가 변경되지 않았더라도 기본적으로 모두 리렌더링됩니다. 리액트는 자식 컴포넌트의 props가 이전과 동일한지 확인하는 비용보다 일단 렌더링하고 보는 것이 더 빠르다고 가정하기 때문입니다.
  4. 구독하고 있는 Context의 값이 변경될 때

    • useContext 훅을 통해 특정 Context를 구독하고 있는 컴포넌트는, 해당 Context의 Provider에서 제공하는 값이 변경될 때마다 리렌더링됩니다.

4부 심화 내용 리액트 렌더링 최적화

“부모가 렌더링되면 자식도 무조건 렌더링된다”는 규칙은 때로 심각한 성능 저하를 유발할 수 있습니다. 특히 자식 컴포넌트가 복잡한 연산을 수행하거나 거대한 리스트를 렌더링하는 경우, 불필요한 리렌더링은 사용자 경험을 해치는 주범이 됩니다.

리액트는 이러한 불필요한 렌더링을 방지하고 성능을 최적화할 수 있는 여러 가지 도구를 제공합니다.

불필요한 리렌더링을 막는 기술

1. React.memo

React.memo는 고차 컴포넌트(Higher-Order Component)로, 함수형 컴포넌트를 감싸서 렌더링 결과를 메모이제이션(memoization)합니다. React.memo로 감싸진 컴포넌트는 props가 이전과 동일하다면, 부모가 리렌더링되더라도 자신은 리렌더링되지 않고 이전에 렌더링된 결과를 재사용합니다.

// 자식 컴포넌트
const MyComponent = ({ name }) => {
  console.log('MyComponent 렌더링됨!');
  return <div>안녕하세요, {name}</div>;
};
 
// React.memo로 감싸기
export default React.memo(MyComponent);
 
// 부모 컴포넌트
const Parent = () => {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('React');
 
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>카운트 증가</button>
      {/* count가 변경되어 Parent는 리렌더링되지만,
          MyComponent의 prop인 name은 그대로이므로 MyComponent는 리렌더링되지 않음 */}
      <MyComponent name={name} />
    </div>
  );
};

2. useCallback

React.memo를 사용해도 문제가 해결되지 않는 경우가 있습니다. 바로 props로 함수를 전달할 때입니다. 자바스크립트에서 함수는 객체이므로, 컴포넌트가 리렌더링될 때마다 함수는 새로 생성됩니다. 즉, 이전 렌더링의 함수와 현재 렌더링의 함수는 내용이 같더라도 다른 참조값을 가지므로, React.memoprops가 변경되었다고 판단합니다.

이때 useCallback을 사용합니다. useCallback함수를 메모이제이션하여, 의존성 배열(deps)의 값이 변경되지 않는 한 이전에 생성된 함수를 재사용하게 해줍니다.

const Parent = () => {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('React');
 
  // name이 변경될 때만 새로운 함수를 생성
  const handleClick = useCallback(() => {
    console.log(`Hello, ${name}`);
  }, [name]);
 
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>카운트 증가</button>
      <MyComponent onClick={handleClick} />
    </div>
  );
};

3. useMemo

useMemouseCallback과 비슷하지만, 함수가 아닌 ‘값(value)‘을 메모이제이션합니다. 복잡하고 무거운 연산의 결과를 저장하고, 의존성 배열의 값이 변경될 때만 해당 연산을 다시 수행합니다.

const calculateHeavyValue = (num) => {
  // 매우 복잡하고 오래 걸리는 연산...
  console.log('복잡한 계산 중...');
  return num * 1000;
};
 
const MyComponent = ({ number }) => {
  // number가 변경될 때만 calculateHeavyValue 함수를 실행
  const heavyValue = useMemo(() => calculateHeavyValue(number), [number]);
 
  return <div>계산 결과: {heavyValue}</div>;
};

렌더링 성능 측정하기

최적화를 진행하기 전에는 반드시 측정을 통해 병목 지점을 찾아야 합니다. “추측에 의한 최적화는 모든 악의 근원이다”라는 말을 기억해야 합니다.

  • React Developer Tools Profiler: 브라우저 확장 프로그램인 React Developer Tools에는 Profiler 탭이 있습니다. Profiler를 사용하여 애플리케이션의 렌더링 과정을 녹화하면, 어떤 컴포넌트가 몇 번 렌더링되었고, 각 렌더링에 시간이 얼마나 걸렸는지 시각적으로 분석할 수 있습니다. 이는 불필요한 리렌더링을 찾는 가장 강력한 도구입니다.

추가적인 최적화 기법

  • 가상화 (Virtualization): 수천 개의 항목이 있는 리스트를 렌더링해야 할 때, 모든 항목을 DOM에 올리는 것은 매우 비효율적입니다. react-windowreact-virtualized와 같은 라이브러리는 현재 화면에 보이는 영역의 항목들만 렌더링하여 성능을 극대화합니다.
  • 코드 분할 (Code Splitting) & 지연 로딩 (Lazy Loading): React.lazySuspense를 사용하면, 당장 필요하지 않은 컴포넌트를 별도의 파일로 분리하고, 해당 컴포넌트가 실제로 렌더링되어야 할 시점에 로드할 수 있습니다. 이는 초기 번들 크기를 줄여 페이지 로딩 속도를 향상시킵니다.

결론 똑똑한 렌더링으로 최고의 사용자 경험 만들기

리액트 렌더링은 단순히 UI를 그리는 행위를 넘어, 애플리케이션의 성능과 사용자 경험을 좌우하는 핵심적인 메커니즘입니다. 우리는 이 핸드북을 통해 렌더링이 가상돔과 재조정 알고리즘을 기반으로 어떻게 효율적으로 동작하는지, 그리고 어떤 조건에서 렌더링이 발생하는지를 살펴보았습니다.

더 중요한 것은, memo, useCallback, useMemo와 같은 도구들을 언제 그리고 왜 사용해야 하는지 이해하게 되었다는 점입니다. 모든 곳에 이들을 남용하는 것은 오히려 코드의 복잡성을 높이고 미미한 성능 향상만 가져올 수 있습니다. 항상 Profiler를 통해 병목 지점을 측정하고, 그 데이터를 기반으로 전략적인 최적화를 수행하는 습관을 들이는 것이 중요합니다.

리액트 렌더링의 원리를 깊이 이해하는 것은 여러분을 한 단계 더 성장한 개발자로 만들어 줄 것입니다. 이제 여러분은 눈에 보이는 UI 너머의 보이지 않는 흐름을 읽고, 더 빠르고, 더 부드럽고, 더 나은 사용자 경험을 제공하는 애플리케이션을 만들 수 있는 힘을 갖게 되었습니다.