본문으로 건너뛰기

2주차: React Core Concepts

리액트의 내부 동작에 대해 더 깊이 이해해보겠습니다.

2.1. Virtual DOM 개념과 선언적 UI 업데이트 방식

2.1.1. Virtual DOM

Virtual DOM은 React에서 사용하는 핵심 개념으로, 실제 DOM의 가상적 표현을 메모리 상에 유지하며 UI 업데이트를 효율적으로 수행하는 방법을 말합니다

링크에서 Virtual DOM에 대한 자세한 설명을 확인할 수 있습니다.

즉, UI의 이상적인 상태를 나타내는 가상 트리를 메모리에 보관해 두고 애플리케이션의 상태 변화가 발생할 때마다 새로운 가상 트리를 만든 다음, 이전의 것과 비교하여 변경된 부분만 실제 DOM에 반영하는 방식입니다. 이 과정 전체를 가리켜 Reconciliation(조정, 재조정) 이라고 부릅니다.

조금 있다가 Reconciliation에 대해 더 자세히 다루겠습니다.

링크에서 Reconciliation에 대한 자세한 설명을 확인할 수 있습니다.

2.1.2. 선언적 UI 업데이트

이 Virtual DOM 개념 덕분에 React는 선언적(Declarative) UI 프로그래밍 방식을 구현합니다. 개발자는 그때그때 DOM을 직접 조작하며 “어떻게” 바꿀지 명령하는 대신, UI가 이러한 상태이면 된다고 선언적 으로 기술하면 됩니다. React 라이브러리는 Virtual DOM을 통해 현재 UI 상태와 새로운 UI 상태의 차이를 스스로 계산하고, 실제 DOM 업데이트를 알아서 수행합니다. 그 결과 개발자는 상태(state) 나 props를 변경함으로써 원하는 UI 상태만 기술하면 되고, DOM 처리나 이벤트 바인딩 같은 세부적인 작업은 React가 추상화해서 관리해줍니다. 이는 전통적인 DOM 조작에 비해 코드의 예측 가능성과 생산성을 높여주며, 궁극적으로 복잡한 애플리케이션에서도 UI 일관성을 유지하기 쉽게 해줍니다.

Virtual DOM 활용의 이점은 DOM 조작 최소화에서 나옵니다. 실제 DOM 업데이트는 비용이 큰 작업인데, React는 한번에 많은 작은 변경 사항들을 메모리 상의 Virtual DOM에서 처리하고, 최종 변화 집합만 추려내어 batching하여 실제 DOM을 업데이트합니다. 또한 브라우저별 DOM API 차이나 최적화까지 신경 써가며 직접 조작할 필요 없이, 일관된 방식으로 코드를 작성하면 React가 내부에서 최적의 방법으로 DOM을 다뤄주므로 크로스 브라우징이나 성능 최적화에도 유리합니다.

브라우저 랜더링 과정과 비용

브라우저 렌더링 과정과 성능 비용

현대 브라우저에서 웹 페이지가 렌더링되는 과정은 크게 다음 단계로 구분됩니다. 각 단계마다 소모되는 비용(성능 부담)이 상이하며, 일반적으로 Layout(Reflow) 작업이 가장 비용이 크고, 그다음이 Paint, 그리고 Composite 순으로 비용이 작아집니다.

1. HTML 파싱 (DOM 생성)

  1. 서버나 로컬에서 HTML 문서를 받아오고, 이를 파싱하여 DOM(Document Object Model) 트리를 구성합니다.
  2. 스크립트(<script>) 태그를 만나면 자바스크립트를 실행해야 하므로 파싱이 잠시 중단되거나, defer/async 옵션에 따라 병렬로 진행됩니다.

비용: 상대적으로 낮은 편이나, 자바스크립트 로드와 함께 진행될 경우 파싱 블로킹이 발생할 수 있습니다.

2. CSS 파싱 (CSSOM 생성)

  1. HTML 파싱과 병렬로 CSS 파일 혹은 <style> 태그 내의 CSS 코드를 파싱합니다.
  2. 파싱 결과로 CSSOM(CSS Object Model) 트리를 생성합니다.

비용: CSS 파일 크기가 크거나, 복잡한 selector가 많을 경우 파싱 시간이 늘어날 수 있습니다. 그래도 Layout(Reflow) 단계에 비하면 상대적으로 덜 부담이 됩니다.

3. Render Tree 생성 (DOM + CSSOM 결합)

  1. 렌더 트리는 각 DOM 노드가 어떤 CSS 스타일 규칙을 적용받아 화면에서 어떻게 배치될지를 나타냅니다.
  2. display: none; 처럼 실제로 보이지 않는 노드는 제외되며, 보이는 노드는 렌더 트리에 포함됩니다.

비용: 스타일 계산(recalculate style) 과정 자체는 많은 노드가 있을수록 비용이 커집니다. 하지만 아직 실제로 그리는 단계는 아니므로 후속 단계(레이아웃, 페인트)에 비해 부담이 덜합니다.

4. Layout (Reflow)

  1. 렌더 트리를 기반으로 각 요소의 정확한 위치와 크기를 계산합니다.
  2. 브라우저 뷰포트 크기, 글꼴 크기, 박스 모델, 플렉스/그리드 레이아웃 등의 규칙을 모두 적용해 픽셀 단위로 요소 배치를 산출합니다.

비용:

  • 가장 큰 비용이 발생하는 단계이며, “Reflow”라고도 불립니다.
  • 레이아웃이 한 요소만 바뀌어도, 이 요소의 형제나 부모·자식 요소의 레이아웃에 영향을 미칠 수 있어 다른 요소들도 다시 계산해야 하는 경우가 많습니다.
  • 따라서 CSS 스타일이나 DOM 구조가 복잡할수록, 그리고 변경되는 요소가 상위 레벨일수록 Reflow 비용이 기하급수적으로 증가할 수 있습니다.

5. Paint (Repaint)

  1. Layout 단계로부터 계산된 위치·크기를 바탕으로 각 요소의 픽셀 정보를 실제로 그립니다(색상, 테두리, 그림자, 텍스트 등).
  2. 화면에 나타나야 할 모든 그래픽 요소를 “레이어” 단위로 그리는 과정입니다.

비용:

  • Layout보다는 덜하지만, 그래도 브라우저가 실제로 픽셀을 찍어내는 작업인 만큼 비용이 무시할 수 없습니다.
  • 예를 들어 background-color 변화나 텍스트 색상 변화 등이 발생하면 Paint가 일어납니다(단, Layout 자체는 변동되지 않을 수도 있음).

6. Composite (합성 단계)

  1. Paint 과정에서 생성된 여러 개의 레이어(또는 오프스크린 비트맵)를 합성(Compositing) 하여 최종 화면을 만듭니다.
  2. GPU 가속이 적용되어 있는 레이어라면, GPU에서 합성 과정을 진행할 수 있어 CPU 부담을 줄일 수 있습니다.

비용:

  • Layout이나 Paint에 비해 상대적으로 비용이 낮은 편입니다.
  • 하지만 레이어가 너무 많거나, CSS 애니메이션(특히 transform, opacity)을 여러 요소에 동시에 적용하면 GPU 리소스 소비가 커질 수 있습니다.

비용 우선순위 요약

  1. Layout(Reflow)

    • 요소 위치와 크기를 다시 계산해야 하므로 가장 비싼 작업입니다.
    • DOM 구조가 깊고 복잡할수록, 그리고 빈번한 레이아웃 변경이 발생할수록 성능에 큰 영향을 미칩니다.
  2. Paint(Repaint)

    • 요소의 시각적 스타일이 변경되어 실제로 다시 그려야 할 때 일어납니다.
    • Layout보다 비용이 적지만, DOM 노드가 많을수록 Paint 비용도 무시 못 합니다.
  3. Composite

    • 이미 그려진 레이어들을 합성하는 단계.
    • Layout/Paint에 비해서는 비용이 비교적 낮습니다.

성능 최적화 포인트

  1. Layout 변화 최소화
    • 잦은 레이아웃 스로틀링 혹은 디바운싱 적용
    • 애니메이션이나 자주 변경되는 요소에 position: absolute 또는 position: fixed 등을 적용해 레이아웃 흐름에서 분리하기
  2. Repaint 영역 축소
    • transform: translateZ(0) 등으로 해당 요소를 별도 합성 레이어로 분리해 Paint 범위를 최소화하기
    • CSS 애니메이션에서 width, height, left, top 등을 변경하면 Layout이 다시 일어나므로 가능하면 transformopacity만 변경하는 게 좋음
  3. 레이어(layer) 남발 주의
    • 레이어가 너무 많아지면 합성 단계가 오히려 부담이 될 수 있으므로, 적절한 레이어 분리를 신중히 적용
  4. DOM 조작 일괄 처리
    • DOM 접근과 변경을 여러 번 분산해서 하기보다는, 한꺼번에 수행해 Reflow를 최소화함
    • 예: 여러 번 .style 변경 대신, 한 번에 className을 바꿔주거나 CSS 텍스트를 추가하는 식

참고 자료:

2.2. Reconciliation (조정, 재조정)

React의 Reconciliation 알고리즘은 이전 Virtual DOM 트리와 새로운 Virtual DOM 트리를 비교하여, 최소한의 조작으로 실제 DOM을 업데이트하는 과정을 담당합니다.

이는 일종의 diff 알고리즘으로 볼 수 있는데, 일반적인 트리 diff 알고리즘은 O(n^3)까지도 복잡도가 나와 성능상 비실용적이므로, React는 몇 가지 휴리스틱(경험적 가정) 을 적용하여 O(n) 수준의 성능으로 동작하는 자체 알고리즘을 사용합니다. React 팀이 이 알고리즘을 설계하면서 둔 두 가지 주요 가정은 다음과 같습니다:

  1. 서로 다른 타입의 두 엘리먼트는 완전히 다른 트리를 만든다.
  2. 개발자가 key prop을 통해 각 엘리먼트의 고유성을 부여할 수 있다. (리스트 아이템 등 순서가 바뀔 수 있는 경우를 대비)

위 원칙을 바탕으로, React는 기존 트리와 신규 트리를 동일한 구조로 순회하면서 비교합니다. 구체적인 동작을 정리하면:

  • 노드 타입이 다른 경우: 이전 노드와 새 노드의 엘리먼트 타입 (태그 혹은 컴포넌트 클래스/함수)이 다르면, React는 기존 트리를 버리고 새 노드로부터 하위 트리를 모두 생성합니다. 예를 들어 이전에 <a>였는데 새로운 트리에 <img>로 바뀌었다면 두 요소 이하의 모든 하위 노드를 새로 만들고 기존 것은 언마운트합니다. 클래스형 컴포넌트의 경우에도 타입(컴포넌트 클래스)이 다르면 기존 컴포넌트 인스턴스를 버리고 새로운 인스턴스를 만들며, 이전 트리의 상태는 유지되지 않고 모두 파기됩니다. (다른 타입인 경우 최적화 여지 없이 전부 다시 만드는 편이 낫다는 가정입니다.)

  • DOM 엘리먼트 노드의 타입이 같은 경우: 예를 들어 이전 트리에 <div> 노드가 있었고 새로운 트리에도 동일한 <div> 노드가 있는 경우, React는 해당 DOM 노드를 재사용합니다. 그리고 두 노드의 속성(props)을 비교하여 변경된 속성만 갱신합니다. 예컨대 <div className="before" title="hi">에서 <div className="after" title="hi">로 바뀌었다면, title은 동일하므로 그대로 두고 className만 업데이트합니다. 또한 스타일 객체의 경우에도 변경된 속성만 적용합니다. 이렇게 실제 DOM 조작을 최소화한 후, 해당 노드의 자식들에 대해서 재귀적으로 reconciliation을 진행합니다.

  • 컴포넌트 노드의 타입이 같은 경우: 이전과 새로운 트리에서 모두 <MyComponent> 같은 동일한 사용자 정의 컴포넌트가 위치해 있다면, React는 기존 컴포넌트 인스턴스를 재사용합니다. 따라서 그 내부의 state도 유지됩니다. React는 새로운 props를 기존 인스턴스에 넘겨주고 render()를 다시 호출하여 하위 가상 DOM을 얻습니다. 그리고 나서 그 하위 트리에 대해서 다시 diff를 진행합니다. (클래스 컴포넌트의 경우 이 과정에서 componentWillReceiveProps -> shouldComponentUpdate -> render -> componentDidUpdate 등의 생명주기 메서드 호출이 일어납니다. 함수 컴포넌트의 경우 props 변경으로 다시 실행하게 됩니다.) 동일한 컴포넌트 타입이면 기존 state를 살린 채로 업데이트하는 것이 핵심입니다.

  • 리스트 구조의 비교: 자식 엘리먼트들의 리스트를 비교할 때, 기본적으로 React는 동일한 인덱스끼리 비교하면서 진행합니다. 예를 들어 이전에 [A, B, C] 순서의 자식이 있었고 새로운 배열이 [A, B, C, D]라면, 앞에서부터 A는 A끼리, B는 B끼리, C는 C끼리 비교하고, 끝에 D가 새로 추가된 것을 감지하여 D에 해당하는 노드를 생성합니다.

이 경우 효율적으로 동작하지만, 리스트의 중간에 삽입/삭제가 일어나는 경우에는 문제가 생길 수 있습니다. 예를 들어 이전 자식이 ['서울대', '고려대']이고 새로운 자식이 ['연세대', '서울대', '고려대']라고 해봅시다. React는 기본적으로 인덱스 0의 ‘서울대’를 ‘연세대’과 비교하여 내용이 변경되었다고 판단해 업데이트하고, 인덱스 1의 ‘고려대’를 ‘서울대’로 변경했다고 판단하여 업데이트하며, 마지막에 ‘고려대’를 새로 추가합니다.

결국 실제 DOM 상에서는 기존 ‘서울대’ 노드의 텍스트를 ‘연세대’으로 바꾸고, 기존 ‘고려대’를 ‘서울대’로 바꾸고, 새로운 ‘고려대’ 노드를 추가하게 됩니다. 이 방식은 리스트 앞에 삽입이 일어난 상황에서 모든 항목을 업데이트하는 비효율을 낳고, 더 심각하게는 각 항목에 연동된 컴포넌트 state나 DOM 상태가 엉뚱하게 연결되는 문제를 일으킬 수 있습니다 (예: 리스트 아이템에 입력 필드가 있었는데, 내용만 교체되면 기존 입력값이나 포커스 등이 남아서 혼란). 이를 해결하기 위해 도입된 것이 key 속성입니다. key는 리스트 내 각 엘리먼트를 구별하는 고유 ID로서, React가 리스트 diff 과정에서 항목을 식별하는 데 사용됩니다. 위 예시에서 각 팀 이름 앞에 연도를 key로 부여했다고 하면, 이전 트리의 키 배열이 [2015, 2016], 새로운 트리가 [2014, 2015, 2016]가 됩니다. React는 이를 보고 “2014 키는 신규 항목, 2015와 2016 키는 기존에 있던 항목”으로 인식하여, ‘연세대’에 해당하는 새로운 노드만 추가하고 나머지 ‘서울대’, ‘고려대’ 항목은 기존 노드를 재배치합니다. 즉, ‘서울대’와 ‘고려대’에 연결된 컴포넌트 인스턴스나 DOM 노드도 그대로 유지되어 효율적이고, 항목별 state도 보존됩니다. 결과적으로 key를 사용하면 리스트의 앞에 새 항목이 추가되거나 중간에 순서가 바뀌어도 React가 각 항목을 제대로 추적할 수 있어 불필요한 업데이트를 방지하고 정합성(consistency) 을 유지할 수 있습니다.

key는 보통 데이터의 고유 ID를 사용합니다 (<li key={item.id}>). 별도의 ID가 없을 때는 배열 index를 key로 쓰기도 하지만, index를 key로 쓰는 것은 권장되지 않습니다. 순서가 바뀌면 key도 바뀌어버리기 때문에 기존 항목들을 재사용하지 못하고 매번 새로 생성하게 되어 성능 저하와 state 손실이 발생할 수 있습니다.

따라서 키는 리스트 내에서 유일하고 변하지 않는 값을 사용해야 합니다. (예: 데이터베이스의 기본 키, 또는 내용 자체를 문자열로 만들어서 사용 등.)

정리하면 React의 reconciliation 알고리즘은 동일 타입 노드는 속성만 비교/업데이트, 다른 타입 노드는 트리 전체 교체라는 전략을 기반으로 동작하며, 리스트에서는 key를 활용하여 최소 변경을 적용합니다. 이러한 구현 덕분에 React는 복잡한 UI도 상당히 빠르게 업데이트할 수 있습니다. (과거 알고리즘 대비 큰 향상으로, 1,000개의 노드 비교에 일반 diff는 10^9번 비교가 필요할 것을 React 방식은 1,000번 수준으로 줄였습니다.)

참고: React의 diff 알고리즘은 완벽하게 최소 DOM 조작을 보장하지는 않습니다. 주어진 가정을 어기는 edge case에서는 최적이 아닐 수 있습니다. 그러나 대부분의 실무 상황에서는 잘 들어맞도록 설계되었고, React 팀도 계속 휴리스틱을 개선하고 있습니다. 결국 개발자는 React의 reconciliation이 잘 동작하도록 컴포넌트 구조를 비교적 안정적으로 유지하고, 적절한 key 부여 등 규칙을 따르면 됩니다.

링크에서 Reconciliation에 대한 자세한 설명을 확인할 수 있습니다.

2.2.1. React Fiber (컴포넌트 업데이트 흐름)

이제 React에서 상태 업데이트가 실제 UI 변경으로 반영되기까지의 내부 흐름을 살펴보겠습니다. React 애플리케이션은 여러 개의 컴포넌트로 이루어져 있고, 각 컴포넌트는 state나 부모로부터 받은 props에 따라 화면 출력(UI)을 결정합니다.

컴포넌트의 state 또는 props가 변경되면 React는 해당 컴포넌트를 다시 렌더링합니다 (함수 컴포넌트라면 함수 재호출, 클래스 컴포넌트라면 render() 재호출).

이때 해당 컴포넌트의 하위 자식들도 재귀적으로 렌더링하여 전체 하위 트리에 대한 새로운 Virtual DOM을 생성합니다. 그런 다음 앞서 설명한 reconciliation 과정을 통해 이전 트리와 새 트리를 비교하여 필요한 업데이트만 실제 DOM에 적용합니다.

React 16부터 도입된 Fiber 아키텍처는 이 렌더링 및 업데이트 과정을 더욱 효율적이고 유연하게 만들었습니다. Fiber는 간단히 말해 React의 새로운 재조정 엔진(reconciliation engine) 으로, 기존의 재귀적이고 동기적인 렌더링을 탈피하여 작업을 여러 단계로 분할하고 중간에 끊었다 이어갈 수 있는(pause and resume) 기능을 제공합니다. Fiber 이전의 React (15 이하 버전)에서는 하나의 업데이트가 시작되면 전체 Virtual DOM 계산과 DOM 업데이트가 완료될 때까지 중단 없이 진행되었습니다. 트리가 매우 크다면 이 작업이 메인 쓰레드를 오래 점유하여 브라우저의 다른 작업(애니메이션, 입력 처리 등)이 막히는 현상이 발생할 수 있었습니다. Fiber는 이러한 문제를 해결하고자, 렌더링 작업을 잘게 쪼개어 실행하다가 필요한 경우 브라우저에 제어권을 잠시 넘겼다가 다시 이어서 작업할 수 있게 한 것입니다. 이를 통해 프레임(drop) 발생을 줄이고 부드러운 사용자 경험을 제공할 수 있습니다.

Fiber 아키텍처에서 React의 업데이트 처리 단계는 크게 두 단계로 나뉩니다:

  • Render Phase (렌더 단계) – Reconciliation 단계: 이 단계에서는 변경된 state나 props를 바탕으로 새로운 Virtual DOM 트리를 계산(diff 연산 수행) 합니다. 클래스 컴포넌트의 render()나 함수 컴포넌트 함수가 호출되고, 새로운 React Element들이 만들어지며, React는 기존 Fiber 트리와 새로 생성된 결과를 비교합니다. Fiber의 특징은 이 렌더 단계 작업을 여러 번의 프레임에 나누어서 수행할 수 있다는 점입니다 . 즉, 렌더링 작업 도중에 시간이 오래 걸릴 것 같으면 React는 중간에 작업을 중단하고 (브라우저에 제어권을 돌려줘서) UI가 끊임없이 반응하도록 한 뒤, 다음 틱(tick)에 이어서 남은 작업을 처리합니다. 이때 우선순위가 높은 업데이트(예: 사용자 인터랙션에 의한 업데이트)는 먼저 처리하고, 우선순위가 낮은 작업(예: 데이터 불러오기 결과에 따른 화면 갱신 등)은 나중으로 미루는 스케줄링도 가능합니다  . 이러한 비동기적이고 분할 가능한 렌더링 덕분에, React 18부터 지원되는 **Concurrent Mode(동시성 모드)**에서는 여러 상태 업데이트가 병합되거나 우선순위에 따라 처리되어 더욱 부드러운 UX를 보장합니다.

  • Commit Phase (커밋 단계) – DOM 업데이트 단계: 렌더 단계에서 변경 사항에 대한 계산이 모두 완료되면, React는 실제로 DOM을 업데이트하는 커밋 단계에 들어갑니다. 이 단계에서는 변경이 확정된 Fiber 노드들을 실제 DOM 노드에 적용하는 작업이 진행됩니다. 구체적으로 DOM 노드를 생성/제거하거나 속성을 변경하고, React 컴포넌트의 경우 lifecycle 메서드 중 componentDidUpdate, componentDidMount 등이 호출되며, useEffect 훅의 콜백도 이 시점에 실행됩니다. 커밋 단계는 단일 태스크로 간주되어 중단되지 않고 즉시 실행되는데, 이는 DOM 조작은 브라우저 화면에 바로 반영되어야 하기 때문입니다. 따라서 커밋 단계는 가능하면 짧게 유지되도록, 대부분의 복잡한 연산은 렌더 단계에서 마치는 것이 원칙입니다. (React는 렌더 단계에서 만들어 놓은 변경 목록을 커밋 단계에서 빠르게 실행하는 형태로 최적화합니다.)

  • (추가 단계: Cleanup Phase): 커밋이 완료된 후에는 필요 없어진 메모리 정리나, 언마운트된 컴포넌트의 componentWillUnmount 호출 등이 일어납니다. React는 지워진 DOM 노드를 정리하고, 다음 렌더링에 사용될 새 Fiber 트리를 현재 트리로 설정하는 등 후속 처리를 합니다. 이로써 하나의 업데이트 사이클이 마무리되고, 애플리케이션은 안정 상태로 돌아옵니다.

Fiber 이전에도 React는 setState 호출을 배치 처리(batch) 하거나, 비동기 setState (React 16+) 등의 최적화를 해왔지만, Fiber 도입으로 React는 내부적으로 스케줄러 개념을 가지고 업데이트의 우선순위와 수명 을 관리하게 되었습니다. 예를 들어 setState가 매우 빈번하게 일어나도, 화면 업데이트는 브라우저 프레임율에 맞춰 적절히 조절되며, 불필요한 중간 단계 렌더링은 건너뛸 수도 있습니다. Fiber 구조 안에서 각 컴포넌트는 Fiber 노드라는 객체로 표현되고, 여기에는 컴포넌트의 타입, 상태, DOM 참조, 그리고 형제/자식 Fiber에 대한 포인터 등이 담겨 있습니다. 이 Fiber 노드들은 일종의 Linked List Tree 형태로 연결되어 있어, React가 트리를 순회하고 업데이트 작업을 분할하는 데 용이하게 설계되었습니다.

정리하면, React의 컴포넌트 업데이트 흐름은 다음과 같습니다:

  1. 상태/props 변경 발생 – (setState, useState state setter, 부모로부터 새 props 등)

  2. Render Phase 시작 – 변경된 해당 컴포넌트와 하위 트리를 가상 DOM으로 다시 렌더링 (Fiber 재조정, diff 수행). 이 단계는 시간 분할이 가능하고, 여러 업데이트가 병합될 수도 있습니다.

  3. 변경 내용 확정 – 어떤 실제 변화가 필요한지 계산 완료.

  4. Commit Phase – 실제 DOM 업데이트 적용, DOM 변동에 따른 부수효과 처리.

  5. 후처리 – 메모리 정리, 다음 사이클 준비 등.

Fiber 아키텍처는 내부 구현체이므로 개발자가 Fiber 객체를 직접 다룰 일은 없습니다. 그러나 Fiber 개념을 이해하면, React가 렌더링을 어떻게 최적화하고 동시성을 처리하는지 감을 잡을 수 있습니다. 또한 React Profiler나 개발자 도구를 통해 렌더 단계에서 어떤 컴포넌트들이 다시 렌더링되었는지, Commit단계에서 어떤 DOM 업데이트가 일어났는지를 확인할 수 있는데, Fiber 기반으로 이러한 정보들이 수집되고 표시됩니다.

React mount vs rerender

왜 다시 마운트(mount)되면 state를 소실하고, 재렌더(re-render)되면 state를 유지할까?

React 컴포넌트가 “언마운트 → 다시 마운트” 되는 것과, “이미 마운트된 상태에서 렌더만 다시 수행”되는 것은 내부적으로 완전히 다른 프로세스입니다.

  1. 마운트(Mount)

    • 처음으로 DOM에 컴포넌트를 삽입하는 과정입니다.
    • 클래스 컴포넌트라면 constructorrendercomponentDidMount 호출이 일어나고, 함수 컴포넌트라면 해당 함수가 호출되고 → useEffect(마운트 시점) 등이 동작합니다.
    • “새로운 컴포넌트 인스턴스(또는 Fiber 노드)”가 만들어지므로, 기존에 있던 state와는 전혀 연결되지 않는 **“새로운 state”**가 생겨납니다.
  2. 언마운트(Unmount)

    • 이미 DOM에 있던 컴포넌트를 제거하는 과정입니다.
    • 클래스 컴포넌트라면 componentWillUnmount가 호출된 뒤, React 내부에서 이 컴포넌트에 대한 상태와 레퍼런스를 완전히 정리합니다.
    • 함수 컴포넌트라면 useEffect의 cleanup 함수가 실행되고, React Fiber 노드 역시 완전히 파기됩니다.
    • 따라서 원래 가지고 있던 모든 state도 소멸합니다.
  3. 재렌더(Re-render)

    • 이미 마운트된 컴포넌트가 propsstate 변경을 통해 다시 화면을 그리는 과정입니다.
    • 즉, 같은 컴포넌트 인스턴스(또는 Fiber 노드)를 재사용하므로, 그 안에 저장된 state가 유지됩니다.
    • 함수 컴포넌트는 내부 함수를 다시 호출하지만, React가 “훅 호출 순서”를 추적하여 기존 state를 매칭해주기 때문에 값이 소실되지 않습니다.
    • 클래스 컴포넌트라면 this.state가 같은 인스턴스 안에 여전히 존재하기 때문에 그대로 유지됩니다.

핵심 이유

“언마운트되면 컴포넌트 인스턴스를 완전히 파기하므로 기존 state가 사라지고, 재렌더는 동일 인스턴스를 기반으로 다시 렌더링하므로 state가 유지된다.”

React 내부(Fiber 구조 기준)에서 컴포넌트는 각자의 “상태 저장 공간”을 갖는데, 언마운트하면 이 공간 자체가 소멸됩니다. 다음에 같은 컴포넌트를 다시 마운트하면 전혀 새로 생성된 공간을 쓰므로 기존 state가 없음이 당연합니다. 반면 리렌더는 인스턴스를 그대로 두고 UI만 갱신하므로, 기존 state가 보존됩니다.

2.2.2. React Concurrent Mode (동시성 모드)

React 18부터는 Concurrent Mode(동시성 모드)라는 새로운 기능이 도입되었습니다. Concurrent Mode는 Fiber 아키텍처를 활용하여, 렌더링 작업을 더욱 효율적으로 처리하고, 사용자 경험을 더욱 부드럽게 만들어주는 기능입니다.

Concurrent Mode의 핵심은 우선순위 스케줄링 입니다. 이전 React에서는 렌더링 작업이 시작되면 끝까지 진행되어야 했지만, Concurrent Mode에서는 렌더링 작업을 여러 단계로 나누어 우선순위에 따라 처리할 수 있습니다. 예를 들어 사용자 인터랙션에 의한 업데이트는 높은 우선순위로 즉시 처리되고, 데이터 불러오기 등의 백그라운드 작업은 낮은 우선수이라면 브라우저 프레임율에 맞춰 조절되어 더욱 부드러운 UX를 제공할 수 있습니다.

Concurrent Mode는 React 18부터 지원됨

주요 개념과 작동 원리

  1. 협력적 스케줄링(Cooperative Scheduling)
    • React는 렌더링 작업을 여러 작은 조각으로 나누고, 우선순위가 높은 작업부터 처리해.
    • 예를 들어, UI를 새로 그리는 것보다 사용자 이벤트 처리가 중요하다면, 우선적으로 이벤트 처리를 끝마친 뒤 남은 UI 업데이트를 진행하는 식이 가능해.
  2. Interruptible Rendering(렌더링 중단 가능)
    • 동기적 렌더링에서는 한 번 시작된 작업을 취소하지 못했지만, 동시성 모드에서는 React가 작업을 잠시 멈추고 다른 중요한 작업을 먼저 처리한 뒤 돌아올 수 있어.
  3. Suspense와 Transition
    • Suspense는 비동기적인 데이터 fetching 등의 상황에서 컴포넌트 렌더링을 지연시키고, 대체 UI(Loading UI 등)를 보여주어 사용자에게 스트레스를 덜 주는 방식이야.
    • Transition은 사용자 인터랙션과 그 외 렌더링을 구분하여, 예를 들어 검색창 입력 반응(고우선순위)과 서서히 변화하는 UI 업데이트(저우선순위)를 분리함으로써 UX를 부드럽게 만드는 전략이야.

아래 예시는 React 18 이후 등장한 Concurrent Features 중 하나인 useTransition 훅을 활용한 간단한 예시입니다. 입력값이 바뀌는 대로 필터링된 리스트를 화면에 렌더링해주되, 입력(고우선순위)과 리스트 렌더링(저우선순위)을 분리해 사용자 입력이 즉각적으로 처리되고, 리스트가 느리게 렌더링되어도 UI가 버벅이지 않게끔 합니다.

import React, { useState, useTransition } from "react";

function SearchableList({ data }) {
const [query, setQuery] = useState("");
// isPending 은 현재 낮은 우선순위 작업(transition) 진행 중인지 여부를 알려줌
// startTransition 함수로 감싼 업데이트는 낮은 우선순위로 처리됨
const [isPending, startTransition] = useTransition();

const handleChange = (e) => {
const newValue = e.target.value;

// 입력 자체는 즉시 반영하고, 리스트 필터링에 대한 상태 업데이트는
// startTransition으로 처리함으로써 사용자 입력 처리가 지연되지 않도록 함
startTransition(() => {
setQuery(newValue);
});
};

// query가 변경됨에 따라 filteredList가 재계산되지만, transition이 적용되었기 때문에
// CPU 부담이 큰 작업을 하더라도 입력 지연 없이 부드럽게 처리 가능
const filteredList = data.filter((item) => item.includes(query));

return (
<div>
<input
type="text"
value={query}
onChange={handleChange}
placeholder="검색어를 입력해보세요"
/>
{/* isPending이 true인 동안 로딩 문구나 스피너 등을 표시해 사용자가 대기 상태를 인지하도록 */}
{isPending && <p>검색 중입니다...</p>}

<ul>
{filteredList.map((item, idx) => (
<li key={idx}>{item}</li>
))}
</ul>
</div>
);
}

export default function App() {
// 가령 1만 개 이상의 방대한 데이터가 있다고 가정
const bigDataArray = Array(10000)
.fill(null)
.map((_, i) => `Item ${i}`);

return (
<div>
<h1>Concurrent Features Demo</h1>
<SearchableList data={bigDataArray} />
</div>
);
}

위 코드에서 useTransition 훅이 중요한 역할을 합니다:

  1. const [isPending, startTransition] = useTransition();
  • isPending: 현재 transition(낮은 우선순위 작업)이 진행 중이면 true가 됩니다.
  • startTransition(callback): 콜백 내부의 상태 업데이트를 낮은 우선순위로 처리해줍니다.
  1. 낮은 우선순위로 처리되는 부분
  • 예시에서는 setQuery(newValue)가 포함된 부분을 startTransition으로 감싸서, 필터링 작업이 대량의 데이터를 처리하더라도 사용자 입력 자체는 지연되지 않습니다.
  1. UX 개선
  • 화면이 복잡하거나 상태 업데이트가 잦은 경우에도, 최우선적으로 사용자의 입력 반응을 처리하고, 무거운 렌더링이나 데이터 필터링은 여유가 있을 때 실행해 자연스러운 경험을 유도할 수 있습니다.
  • isPending 상태를 활용해 대기 UI(“검색 중입니다…”) 등을 표시하면 사용자에게 진행 상황을 알릴 수 있습니다.

Suspense를 활용한 동적 로딩 예시

아래 예시는 React.lazy와 Suspense를 활용하여 코드 분할(Code Splitting) 을 구현한 간단한 예시입니다. 큰 컴포넌트를 지연 로딩하여 초기 번들 크기를 줄이고, 로드가 완료될 때까지 로딩 UI를 표시합니다.

import React, { Suspense, lazy } from "react";

// 라우팅하거나 특정 이벤트가 발생했을 때만 필요한 컴포넌트는 lazy 로딩
const HeavyComponent = lazy(() => import("./HeavyComponent"));

function App() {
return (
<div>
<h1>Suspense Example</h1>
{/* Suspense의 fallback 프로퍼티로 로딩 중 표시할 UI를 제공 */}
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
</div>
);
}

export default App;
  1. const HeavyComponent = lazy(() => import('./HeavyComponent'));

    • 기존의 import 문이 아닌 동적 import() 함수를 사용해 코드 분할을 수행합니다. 이 컴포넌트는 실제로 필요할 때 로드됩니다.
  2. <Suspense fallback={<div>Loading...</div>}>

    • Suspense 영역을 설정하여, 아직 로드되지 않은 컴포넌트를 렌더링하려 할 때 fallback에 지정된 로딩 UI가 표시됩니다. 로드가 완료되면 자동으로 실제 컴포넌트가 렌더링됩니다.
  3. 비동기 로딩 + Concurrent Mode

    • Suspense는 Concurrent Mode와 결합해, 여러 비동기 상황(데이터 페칭, 코드 스플리팅 등)을 보다 부드럽게 처리하도록 지원합니다.