
원문: React Reconciliation: The Hidden Engine Behind Your Components
조정 엔진(Reconciliation Engine)
이전 글들(1, 2)에서 React.memo
의 작동 방식과 컴포지션을 활용한 성능 최적화 방법을 살펴보았습니다. 하지만 리액트 성능을 제대로 정복하기 위해서는 리액트의 핵심 엔진, 즉 조정(Reconciliation) 알고리즘을 깊이 이해해야 합니다.
조정은 리액트가 DOM을 컴포넌트 트리와 일치하도록 업데이트하는 과정입니다. 이를 통해 리액트의 선언형 프로그래밍 모델이 가능해집니다. 개발자가 원하는 결과를 선언적으로 기술하면, 리액트가 이를 효율적으로 구현하는 방법을 찾아 적용하는 것입니다.
컴포넌트 정체성(Identity)과 상태 유지(State Persistence)
우선, 리액트가 컴포넌트 정체성을 어떻게 다루는지 보여주는 흥미로운 동작을 살펴보겠습니다.
다음은 텍스트 인풋을 토글하는 간단한 예제입니다.
1 | const UserInfoForm = () => { |
인풋에 텍스트를 입력한 후 “Cancel” 버튼을 클릭하고 다시 “Edit” 버튼을 클릭하면, 입력한 텍스트가 그대로 남아 있습니다. 두 input
요소가 서로 다른 props(클래스 명 및 비활성화 여부)를 가지고 있음에도 불구하고 말이죠.
리액트는 두 요소가 동일한 타입(input
)이고, 요소 트리 내에서 같은 위치에 있을 때, 해당 DOM 요소와 그 상태를 그대로 유지합니다. 이때 리액트는 단순히 기존 요소의 props만 업데이트할 뿐, 새로 생성하지 않습니다.
코드를 다음과 같이 변경하고 다시 테스트해봅시다.
1 | { |
이번에는 편집 모드를 토글하면 입력한 텍스트가 사라집니다. 완전히 다른 요소가 언마운트(input
) 및 마운트(div
)되기 때문입니다.
이는 리액트의 조정에서 요소 타입이 컴포넌트의 정체성을 결정하는 중요한 요소임을 보여줍니다. 이 개념을 이해하는 것이 리액트 성능을 마스터하는 핵심 열쇠입니다.
가상 DOM이 아닌 “요소(Element)” 트리
흔히 리액트가 업데이트를 최적화하기 위해 “가상 DOM”을 사용한다고 알려져 있지만, 그보다는 화면에 표시되어야 할 내용을 간단히 기술한 “요소 트리”로 생각하는 것이 더 정확합니다.
다음과 같이 JSX를 작성해 봅시다.
1 | const Component = () => { |
리액트는 이를 다음과 같은 단순한 자바스크립트 객체 트리로 변환합니다.
1 | { |
div
나 input
같은 DOM 요소의 “type”은 문자열입니다. 반면, 커스텀 리액트 컴포넌트의 “type”은 실제 함수에 대한 참조입니다.
1 | { |
조정이 작동하는 방식
UI를 업데이트하고자 할 때(상태 변경 또는 재렌더링 시), 리액트는 다음의 과정을 거칩니다.
- 컴포넌트를 호출하여 새로운 요소 트리를 생성합니다.
- 이전 트리와 비교합니다.
- 실제 DOM을 새로운 트리와 일치시키기 위해 작업이 필요한 DOM을 파악합니다.
- 파악한 작업을 효율적으로 수행합니다.
비교 알고리즘은 다음의 주요 원칙을 따릅니다.
1. 요소 타입이 정체성을 결정합니다.
리액트는 먼저 요소의 “type”을 확인하고, 타입이 변경되면 전체 하위 트리를 다시 빌드합니다.
1 | // 첫 번째 렌더링 |
div
가 span
으로 변경되었으므로 리액트는 이전 트리 전체를 제거하고 새 트리를 처음부터 다시 빌드합니다.
2. 트리에서의 위치가 중요합니다.
리액트의 조정 알고리즘은 트리 구조 내에서 컴포넌트의 위치에 크게 의존합니다. 위치는 비교 과정에서 주요 정체성 지표로 작용합니다.
1 | // showDetails = true: <UserProfile userId={123} /> |
1 | // showDetails = false: <LoginPrompt /> |
이 조건부 렌더링 예제에서, 리액트는 프래그먼트의 첫 번째 자식 위치(위치 1)를 단일 “슬롯”으로 처리합니다. showDetails
가 true
에서 false
로 변경되면, 리액트는 해당 위치에서 렌더링 간의 내용을 비교하여 컴포넌트 타입이 서로 다름(UserProfile
과 LoginPrompt
)을 파악합니다. 위치 1의 컴포넌트 타입이 변경되었으므로, 리액트는 이전 컴포넌트를 완전히 언마운트(상태 포함)하고 새 컴포넌트를 마운트합니다.
더 간단한 다음 예시에서는 위치 기반 정체성 덕분에 컴포넌트가 상태를 유지합니다.
1 | // isPrimary = true: <UserProfile userId={123} role="primary" /> |
1 | // isPrimary = false: <UserProfile userId={456} role="secondary" /> |
isPrimary
값에 상관없이, 리액트는 위치 1에 렌더링할 컴포넌트 타입이 동일함(UserProfile
)을 파악합니다. 따라서 컴포넌트를 다시 마운트하지 않고 props만 업데이트하여 컴포넌트 인스턴스를 유지합니다.
이 위치 기반 접근 방식은 대부분의 시나리오에서 잘 작동하지만, 다음과 같은 경우에는 문제가 될 수 있습니다.
- 컴포넌트 위치가 동적으로 변경될 때(예: 리스트를 동적으로 정렬(sort)하는 경우)
- 컴포넌트가 다른 위치로 이동하더라도 상태를 보존하고자 할 때
- 컴포넌트의 재마운트 시점을 제어하고자 할 때
이러한 상황에서는 리액트의 key 시스템이 유용합니다.
3. key는 위치 기반 비교보다 우선합니다.
key
속성은 리액트의 기본적인 위치 기반 식별 방식보다 우선 적용되어, 개발자가 명시적으로 컴포넌트 정체성을 제어할 수 있도록 합니다.
1 | const TabContent = ({ activeTab, tabs }) => { |
UserProfile
컴포넌트가 조건부 렌더링에서 서로 다른 위치에 나타나더라도, 리액트는 동일한 key를 가진 컴포넌트를 동일한 컴포넌트로 간주합니다. 어떤 탭이 활성화되든, 활성화된 탭의 “active-profile” key는 일정하게 유지되므로, 리액트는 컴포넌트의 상태를 보존함으로써 탭 간 전환을 더 부드럽게 처리할 수 있습니다.
역자주: 두 개의 탭이 존재한다고 했을 때, activeTab의 값이 "1"일 때와 "2"일 때의 렌더링 결과는 다음과 같습니다.7번 줄과 26번 줄의 컴포넌트 타입 및 key가 동일하므로, 리액트는 이를 동일한 컴포넌트로 파악하여 재렌더링하지 않고 props만 변경합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 /* const tabs = [{ id: "1", userId: "a", role: "aa" }, { id: "2", userId: "b", role: "bb" }] */
// activeTab === "1"일 때
(
<div className="tab-container">
<div key="1" className="tab-content">
<UserProfile key="active-profile" userId="a" role="aa" />
</div>
<div key="2" className="tab-content">
<div key="placeholder" className="placeholder">
Select this tab to view {tab.userId}'s profile
</div>
</div>
</div>
)
// activeTab === "2"일 때
(
<div className="tab-container">
<div key="1" className="tab-content">
<div key="placeholder" className="placeholder">
Select this tab to view {tab.userId}'s profile
</div>
</div>
<div key="2" className="tab-content">
<UserProfile key="active-profile" userId="b" role="bb" />
</div>
</div>
)
이처럼 key는 렌더 트리의 구조적 위치와 상관없이 컴포넌트 정체성을 유지할 수 있는 방법을 제공하는, 리액트의 컴포넌트 계층구조조정 방식을 제어할 수 있는 강력한 도구입니다.
key의 마법
key는 주로 리스트에서 쓰이는 것으로 알려져 있지만, 실은 리액트의 조정 과정에서 더 깊은 의미를 가집니다.
리스트에 key가 필요한 이유
리스트를 렌더링할 때, 리액트는 key를 통해 어떤 항목이 추가, 제거, 또는 재정렬 되었는지를 파악합니다.
1 | <ul> |
key가 없으면, 리액트는 리스트 내에서의 요소의 위치만을 기준으로 판단하게 됩니다. 이때 만약 리스트의 맨 앞에 새로운 항목을 삽입하면, 리액트는 모든 요소가 위치를 변경한 것으로 이해하여 전체 리스트를 다시 렌더링할 것입니다.
key를 사용하면, 리액트는 위치의 변경과 무관하게, 변경 전후의 렌더링 과정에서 변경된 요소 및 변경되지 않은 요소를 정확히 파악할 수 있습니다.
1. 배열이 아닌 경우의 key?
리액트는 정적인 요소에 대해 key를 강제하지 않습니다.
1 | // key가 필요하지 않습니다. |
위 예제에서 두 인풋은 정적 요소로써 리액트가 트리에서의 위치를 파악할 수 있으므로 key가 필요하지 않습니다.
그러나 다음과 같이, key는 리스트가 아닌 경우에도 강력한 기능을 제공합니다.
1 | const Component = () => { |
isReverse
가 토글될 때, key 'some-key'
가 한 인풋 요소에서 다른 인풋 요소으로 이동하여 리액트가 컴포넌트의 상태를 두 위치 간에 “이동”하도록 만듭니다!
역자주:
isReverse = true
일 때 6번 줄의Input
과,isReverse = false
일 때 7번 줄의Input
은 모두 동일한 key('some-key'
)를 가지므로, 리액트는 이를 동일한 컴포넌트가 6번 줄에서 7번 줄로 이동한 것으로 파악합니다.
2. 동적 요소와 정적 요소 혼합
흔히 동적 리스트의 뒷부분에 정적 요소를 추가하면 정적 요소의 정체성이 변경될지를 우려합니다.
1 | <> |
리액트는 이를 지능적으로 처리합니다. 리액트는 전체 동적 리스트를 하나의 단위로 취급하므로, StaticElement
는 리스트 변경과 상관없이 항상 동일한 위치와 정체성을 유지합니다.
리액트가 이를 내부적으로 어떻게 표현하는지 살펴보면 다음과 같습니다.
1 | [ |
리스트에 항목을 추가하거나 제거하더라도, StaticElement
는 부모 배열에서 두 번째 위치를 유지합니다. 따라서 리스트 변경으로 인해 정적 요소가 다시 마운트 되지 않습니다. 이는 리액트가 불필요한 재마운트를 방지하고자 영리하게 최적화한 결과입니다.
3. 전략적인 DOM 제어를 위한 key
리액트에서 key는 리스트만을 위한 것이 아닙니다. key는 리액트에서 컴포넌트와 DOM 요소의 정체성을 제어하는 강력한 도구입니다. 서로 다른 뷰 간에 리액트 컴포넌트 상태를 유지할지를 판단하는 기준으로 key와 컴포넌트 타입이 함께 쓰인다는 점을 기억하세요. 동일한 key를 가진 컴포넌트라도 타입이 다르면 여전히 언마운트 및 리마운트가 발생합니다. 이러한 경우에는 상태를 상위 컴포넌트로 끌어올리는 것이 일반적으로 더 나은 접근 방식입니다.
1 | // 상태를 다른 뷰 간에 보존하기 위한 접근법 - 상태 끌어올리기 (여기서는 key가 효과적이지 않습니다.) |
이 경우, 탭 간에 타입(및 참조)이 다르기 때문에 key를 유지하는 것만으로는 충분하지 않습니다.
하지만 key와 비제어 컴포넌트를 사용하는 다음 예제를 살펴보세요.
1 | const UserForm = ({ userId }) => { |
userId를 기반으로 비제어 인풋에 key를 부여하면, userId가 변경될 때마다 리액트가 완전히 새로운 DOM 요소를 생성하도록 보장할 수 있습니다. 비제어 인풋의 상태는 리액트 상태가 아닌 DOM 자체에 존재하므로, 다른 사용자로 전환하면 인풋이 초기화됩니다. 그래서 이런 경우에는 key만으로도 충분합니다.
꽤 흥미롭지 않나요? 😊
상태의 지역화(State Colocation): 강력한 성능 패턴
상태의 지역화는 상태를 사용하는 곳에 최대한 가깝게 유지하는 패턴입니다. 이 접근법은 상태 변경으로 인해 영향을 받는 컴포넌트만 업데이트 되도록 보장하여 불필요한 재렌더링을 최소화합니다.
다음 예제를 살펴봅시다.
1 | // 성능 낮음 - 필터가 변경되면 맵 전체를 다시 렌더링함 |
filterText
가 변경되면, ExpensiveComponent
처럼 필터와 관련 없는 컴포넌트를 포함하여 전체 App
컴포넌트가 재렌더링됩니다.
반면, 필터 상태를 실제로 사용하는 컴포넌트 내부로 옮기면 다음과 같이 됩니다.
1 | const UserSection = () => { |
필터가 변경될 때 UserSection
만 재렌더링됩니다. 이 패턴은 성능을 향상시킬 뿐 아니라, 각 컴포넌트가 실제로 사용해야 할 상태만 관리하도록 설계하여 더 나은 컴포넌트 구조를 만듭니다.
컴포넌트 설계: 변경에 대한 최적화
보통 성능 최적화는 컴포넌트 설계 문제에서부터 시작합니다. 컴포넌트가 너무 많은 일을 하면 불필요하게 재렌더링될 가능성이 높아집니다.
React.memo
를 사용하기 전에 다음 질문을 던져보세요.
- 이 컴포넌트가 여러 가지 책임을 지고 있나요?
여러 관심사를 처리하는 컴포넌트는 더 자주 재렌더링될 가능성이 있습니다. - 상태가 너무 높은 위치로 끌어올려져 있나요?
상태가 필요 이상으로 트리 상단에 위치하면 더 많은 컴포넌트가 재렌더링됩니다.
다음 예제를 봅시다.
1 | // 문제 있는 설계 - 여러 가지 책임을 지는 컴포넌트 |
사이즈, 수량, 배송 옵션이 변경될 때마다 페이지 전체가 재렌더링되며, 리뷰 섹션과 같은 관련 없는 부분도 영향을 받습니다.
더 나은 설계는 이러한 책임을 분리하는 것입니다.
1 | const ProductPage = ({ productId }) => { |
이 구조는 제품 사이즈를 변경해도 리뷰가 재렌더링되지 않도록 보장합니다. 메모이제이션 없이도 컴포넌트 경계를 잘 설정하면 성능을 최적화할 수 있습니다.
조정(Reconciliation)과 클린 아키텍처
리액트의 조정 알고리즘은 클린 아키텍처 원칙과 완벽하게 일치합니다.
- 단일 책임 원칙(Single Responsibility Principle)
각 컴포넌트는 변경 이유가 하나여야 합니다. 컴포넌트가 단일 책임에 집중하면 불필요한 재렌더링이 줄어듭니다. - 의존성 역전 원칙(Dependency Inversion)
컴포넌트는 구체적인 구현이 아닌 추상화에 의존해야 합니다. 이를 통해 컴포지션을 통해 성능을 최적화하기가 더 쉬워집니다. - 인터페이스 분리 원칙(Interface Segregation)
컴포넌트는 최소한의 집중된 인터페이스를 가져야 합니다. 이는 props 변경으로 인해 불필요한 재렌더링이 발생할 가능성을 줄여줍니다.
실용적인 가이드라인
조정에 대한 심층 분석을 바탕으로 다음과 같은 실용적인 가이드라인을 제안합니다.
- 컴포넌트 정의를 부모 컴포넌트 외부로 이동하여 재마운트를 방지하세요.
- 상태를 하위로 이동하여 재렌더링 경계를 분리하세요.
- 동일한 위치에서 일관된 컴포넌트 타입을 유지하여 언마운트를 방지하세요.
- key를 전략적으로 사용하세요. 리스트뿐만 아니라 컴포넌트 정체성을 제어하고 싶을 때도 유용합니다.
- 재렌더링 문제를 디버깅할 때, 요소 트리와 컴포넌트 정체성을 기준으로 생각하세요.
- React.memo는 단지 도구일 뿐입니다. 조정 알고리즘의 제약 내에서 작동하며, 근본적인 알고리즘을 변경하지는 않습니다.
결론
리액트의 조정 알고리즘을 이해하면 많은 리액트 성능 패턴의 “이유”를 파악할 수 있습니다. 컴포지션이 효과적인 이유, 리스트에 key가 필요한 이유, 나아가 컴포넌트를 내부에 정의하는 것의 문제점을 드러냅니다.
이 지식은 리액트 애플리케이션의 성능을 자연스럽게 끌어올릴 수 있는 더 나은 아키텍처를 결정하는 데 도움을 줍니다. 과도한 메모이제이션으로 리액트의 조정 알고리즘과 싸우는 대신, 리액트가 컴포넌트를 식별하고 업데이트하는 방식에 맞춰 컴포넌트 구조를 설계함으로써 이를 활용할 수 있습니다.
다음에 리액트 애플리케이션을 최적화할 때, 컴포넌트 구조가 조정 과정에 어떤 영향을 미치는지 생각해 보세요. 때때로 가장 좋은 최적화는 리액트가 컴포넌트를 식별하고 업데이트하는 방식을 고려하여 더 간단하고 집중된 컴포넌트 트리를 만드는 것입니다.
리액트의 조정 프로세스를 다룰 때 여러분이 가장 효과적이라고 생각한 패턴은 무엇인가요? 여러분의 경험을 듣고 싶습니다. 댓글 남겨주세요 🤓