리액트의 Reconciliation은 UI를 업데이트 할 때, 리액트가 실제 DOM을 효율적으로 업데이트 하는 핵심 알고리즘입니다.
리액트를 공부하다보면 리액트의 파이버 아키텍쳐, Virtual DOM을 들어보셨을텐데요. Fiber Architecuture는 리액트16에 도입되어 Reconcilation 엔진을 재구성하여 탄생했고, Vitrual DOM은 Reconciliation 과정을 위해 필요한 재료입니다.
아주 간략히 Reconciliation 과정을 설명하면 아래와 같습니다.
1. 실제 DOM을 복사해둔 형태인 Virtual DOM을 유지한다.
2. 변경 사항이 생기면 Diffing 알고리즘을 통해 이전 Virtual DOM 트리와 새로운 Virtual DOM 트리를 비교한다.
3. 비교 과정 후 실제 변경된 부분만 DOM에 반영하여, 실제 DOM 조작을 최소화 한다.
또한 중요해서 미리 말해두자면, reconciliation 과정이 잘 동작하기 위해서는 key값이 정확히 1개만 존재해야합니다.
아니면 재조정 과정에서 이상한 버그가 발생할 수 있어요.
오늘은 Reconciliation 에 대해 낱낱이 파악하여 심화 이해를 가져가고, 리액트를 잘 활용하는 방법을 정리해봅시다.

Virtual DOM ? 사실은 Element Trees !
우선 잡고 가야할 개념이 하나 있습니다. 대부분의 사람이 Virtual DOM이라는 표현을 자주 쓰고, 이는 유용한 멘탈 모델이긴 하지만 Virtual DOM은 리액트의 내부적 표현을 위한 가벼운 Element Tree로 보는 것이 더욱 정확합니다.
JSX를 아래와 같이 작성하면 :
const Component = () => {
return (
<div>
<h1>Hello</h1>
<p>World</p>
</div>
);
};
리액트는 다음과 같이 자바스크립트 객체로 변환합니다 :
{
type: 'div',
props: {
children: [
{
type: 'h1',
props: {
children: 'Hello'
}
},
{
type: 'p',
props: {
children: 'World'
}
}
]
}
}
Reconciliation 과정에서 비교를 위한 기준 = Identity
비교를 하고, 다른 점만 반영하려면 당연히 비교의 기준이 존재해야하는데, 그 기준은 Identity 정체성) 입니다.
그리고 이 Identity는 총 3가지의 요소에 의해 결정됩니다.
1. Type : element의 타입 혹은 함수형 컴포넌트에 대한 참조
2. Key : key값. 리액트 컴포넌트를 구분하기 위해 넣는 특별한 prop 값
3. Position : 요소의 위치(number - 1부터 시작한다고 가정)
여기서 1(type), 2(key) 가 가장 중요한(우선순위 높은) 요소입니다. - 둘은 동등합니다.
Reconciliation 작동 방식 심화 (Case)
비교 알고리즘의 핵심 원리는 아래와 같습니다.
1. 가장 먼저 type을 살펴본다.
위의 자바스크립트 객체 형식으로 저장하는 것 보셨죠? 즉 변경된 요소의 type을 살펴보고, 변했을 경우 DOM에서 해당 요소를 부모로 한 subtree는 전부 다시 만듭니다.
// From this (first render)
<div>
<Counter />
</div>
// To this (second render)
<span>
<Counter />
</span>
위의 경우 div -> span으로 바뀌었기 때문에 div-Counter 는 파괴하고 span-Counter 조합으로 새로운 트리를 다시 만듭니다.
2. 요소의 포지션을 체크한다.
위치가 바뀌는 것, 즉 포지션이 바뀌는 것도 체크하여 반영합니다.
// Before
<>
{showDetails ? <UserProfile userId={123} /> : <LoginPrompt />}
</>
// After (when showDetails changes)
<>
{showDetails ? <UserProfile userId={123} /> : <LoginPrompt />}
</>
위의 예시를 보시면 showDetails 값에 따라 position 1번 자리에 UserProfile이 오거나, LoginPrompt가 옵니다.
이는 타입이 변한 경우(1번의 경우)라서 mount - unmonut가 일어나며 새로운 트리가 구성됩니다. 또한 postion도 변했네요.
다만 아래의 예시를 보시죠.
<>
{isPrimary ? (
<UserProfile userId={123} role="primary" />
) : (
<UserProfile userId={456} role="secondary" />
)}
</>
위의 경우에는 role이라는 prop값은 변했지만, 항상 자리가 같습니다. 또한 타입도 같네요.
이런 경우에는 리액트는 prop을 업데이트해주긴 하지만, 컴포넌트 자체를 파괴하고 재창조하지는 않습니다.
3. 포지션이 바뀌었더라도, key 값을 우선적으로 확인한다.
key 속성(attirbute)는 position-based identity를 override 합니다. 즉 우선 순위가 더 높습니다.
따라서 만약 위치가 바뀌었더라도 key값이 유지된다면 같은 요소로 인식합니다.
const Component = () => {
const [isReverse, setIsReverse] = useState(false);
return (
<>
<Input key={isReverse ? "some-key" : null} />
<Input key={!isReverse ? "some-key" : null} />
</>
);
};
위의 코드는 2개의 Input이 존재합니다. 둘은 isReverse 값에 따라 key값을 서로 바꿔가며 가져갑니다.
이때, 둘은 비록 position이 1,2로 다르지만 isReverse가 바뀔 때 리액트는 컴포넌트가 key값을 우선적으로 보기 때문에, 동일한 key값을 가진 컴포넌트가 position만 옮겼다고 생각하여 state(및 prop)만 해당 자리의 컴포넌트에게 옮겨줍니다.
cf) 개발자도구를 열어보면 input을 토글할 때마다 자주색으로 번쩍이는 모습을 볼 수 있어요.

이는 input 태그의 위치가 계속 바뀌는 것으로 인식하기 때문이에요. (물론, input이라서 그냥 강조 표시 되는 것도 있어요.)
자세한 내용은 아래 링크를 참고해주세요. https://ocahs.tistory.com/38
개발자도구 Element 탭 강조 표시의 기준
개발자도구에서 엘리멘트 탭을 열어보면 리렌더링 될 때마다 자주색으로 번쩍이는 부분을 볼 수 있어요. 이와 비슷하게 React Dev tools를 사용하면 Highlights 기능이 존재하긴 하는데, 이는 함수형
ocahs.tistory.com
List를 렌더링할 때 Key가 필수로 존재해야하는 이유
리스트를 렌더링 할때는, key값을 기준으로 아이템이 추가되거나 제거되거나 순서가 바뀐 것을 추적하기 때문입니다.
<ul>
{items.map((item) => (
<li key={item.id}>{item.text}</li>
))}
</ul>
만약 key값이 없다면, 리스트의 맨 앞에 새로운 아이템이 생겼을 때 다른 요소들이 아예 처음부터 새롭게 생성된 것으로 인식하고 전체 리스트를 리렌더링하게 됩니다.
List를 렌더링하는 것이 아닐 때 Key가 꼭 존재하지 않아도 되는 이유
존재하면 좋긴 합니다만, 없어도 문제가 없는 경우가 많았습니다.
// No keys needed
<>
<Input />
<Input />
</>
위의 예시와 같은 경우 static하게 positioning 하고 있습니다. 즉 정적으로 위치가 결정됩니다.
이로 인해 리액트는 해당 요소의 트리에서의 위치를 예측가능해지기 때문입니다. (key보다도 타입이나 포지션을 생각해주세요)
그러나 key값의 특성을 잘 이해했다면, key값을 이용하여 아주 효율적으로 동작하는 코드를 작성할 수 있습니다.
이미 위에서 언급한 코드를 재활용해보죠.
const Component = () => {
const [isReverse, setIsReverse] = useState(false);
return (
<>
<Input key={isReverse ? "some-key" : null} />
<Input key={!isReverse ? "some-key" : null} />
</>
);
};
해당 코드에서는 같은 타입의 요소의 key값을 바꿔줌으로써, 리액트에서 state(prop)를 서로 바꾸도록 유도합니다.
이 방식의 장점은 특히 uncontrolled input (값을 조절할 수 없는 input 요소) 에서 유용합니다. 아래의 예시를 봅시다.
const UserForm = ({ userId }) => {
// No React state here - using uncontrolled inputs
return (
<form>
<input
key={userId}
name="username"
// Uncontrolled input with defaultValue instead of value
defaultValue={userId}
/>
{/* Other form inputs */}
</form>
);
};
userId가 바뀔 때마다 input의 값이 바뀌어야한다고 한다면 기존의 input 값은 사라져야만 합니다. 이를 해결하기 위해 아주 간단하게 userId를 부모컴포넌트로부터 받아오고, 이를 key로 활용하면 새로운 요소가 마운트되고, 기존의 요소는 언마운트 시킬 수 있습니다.
만약 key값을 활용한게 아니라면, state를 하나 선언해두고 이를 useEffect를 통해 갱신하는 로직이 필요했겠죠. DOM 자체의 state를 활용한 것이 아니라 리액트 내부의 state를 활용했기에 렌더링이 최소 1회 더 일어나는 비효율이 발생했을겁니다.
List를 렌더링할 때, List가 바뀌면 그 뒤의 컴포넌트도 reconciliation이 일어나는 것은 아닌가?
결론부터 말하자면 아닙니다. 그 이유는 리스트는 하나의 뭉텅이로 여기기 때문에 postion 값이 달라지지 않아요.
<>
{items.map((item) => (
<ListItem key={item.id} />
))}
<StaticElement /> {/* Will this re-mount if items change? */}
</>
위의 코드는 아래와 같은 트리로 인식됩니다.
[
// The entire dynamic array becomes a single child
[
{ type: ListItem, key: "1" },
{ type: ListItem, key: "2" },
],
{ type: StaticElement }, // Always maintains its second position
];
따라서 StaticElement의 위치는 리스트의 아이템이 삭제되든 추가되든 상관없이 항상 2번째가 됩니다.
리스트가 동적이어도 StaticElement는 다시 마운트 되는 것을 방지한 현명한 최적화 방법이었어요.
Reconciliation에 대한 이해를 바탕으로 리액트의 패턴 이해하기
1. 인라인 컴포넌트 정의가 나쁜 이유
컴포넌트 안에 컴포넌트를 정의해두는 것은, 컴포넌트가 렌더링 될 때마다 새로운 참조를 만들어내기 때문에 좋지 않습니다.
const Parent = () => {
// Bad practice: InnerComponent recreated on every render
const InnerComponent = () => <div>Inner content</div>;
return <InnerComponent />;
};
이는 컴포넌트의 "참조", 즉 "type"을 매번 바꾸는 행위이기 때문에 리액트는 매번 완전히 새로운 컴포넌트가 생긴다고 판단하여 마운트와 언마운트를 매 리렌더링마다 진행합니다. 매우 비효율적이죠.
2. Composition Pattern이 작동하는 이유 - 더욱 효율적인 이유
컴포넌트를 조합하여 사용하는 방식은 매우 훌륭한 패턴 중 하나입니다. 아래의 코드 예시를 통해 그 이유를 알아봅시다.
const CounterButton = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
};
const Parent = () => {
return (
<div>
<CounterButton />
<ExpensiveComponent />
</div>
);
};
count 상태값이 변할 때마다, 오직 CounterButton를 root로 하는 트리만이 reconciliation이 필요해집니다.
이로 인해 ExpensiveComponent 가 root인 트리는 건들지 않게 됩니다. (애초에 분리된 branch니까요)
즉, 리렌더링이 일어나는 요소가 확연이 줄어듭니다.
React.memo를 사용해도 되겠지만 해당 방법은 차선입니다. 어차피 reconcilation의 제약 안에서 (원리를 기반으로) 동작하니까요.
효율적인 이유를 이해했다면 더 나아가 컴포넌트 합성을 위해 다음과 같은 기준들을 세울 수 있게 됩니다.
1. State는 최대한 가까이에 둘 것
불필요하게 state들을 부모요소에서 관리하게 두는 경우에는 다른 형제 요소들의 Reconcilation에도 영향을 끼칠 수 있습니다.
안 좋은 코드 에시를 먼저 봅시다.
// Poor performance - entire app re-renders when filter changes
const App = () => {
const [filterText, setFilterText] = useState("");
const filteredUsers = users.filter((user) => user.name.includes(filterText));
return (
<>
<SearchBox filterText={filterText} onChange={setFilterText} />
<UserList users={filteredUsers} />
<ExpensiveComponent />
</>
);
};
APP의 상태가 변할 때마다 불필요하게 ExpensiveComponent도 리렌더링 되네요.
개선한 좋은 코드의 예시는 아래와 같습니다.
const UserSection = () => {
const [filterText, setFilterText] = useState("");
const filteredUsers = users.filter((user) => user.name.includes(filterText));
return (
<>
<SearchBox filterText={filterText} onChange={setFilterText} />
<UserList users={filteredUsers} />
</>
);
};
const App = () => {
return (
<>
<UserSection />
<ExpensiveComponent />
</>
);
};
이렇게 해두니까, UserLocation의 재조정 과정과 이젠 ExpensiveComponent의 재조정 과정은 분리되었네요.
2. 관심사(책임) 분리를 명확히 해둘 것
특정 컴포넌트에서만 필요한 state라면 해당 컴포넌트에서 관리하는 것이 맞습니다.
(2번 원리는 곧 1번 원리와 같은 말이지만 다른 관점에서 설명하는 것이나 마찬가지입니다.)
// Problematic design - mixed concerns
const ProductPage = ({ productId }) => {
const [selectedSize, setSelectedSize] = useState("medium");
const [quantity, setQuantity] = useState(1);
const [shipping, setShipping] = useState("express");
const [reviews, setReviews] = useState([]);
// Fetches both product details and reviews
useEffect(() => {
fetchProductDetails(productId);
fetchReviews(productId).then(setReviews);
}, [productId]);
return (
<div>
<ProductInfo
selectedSize={selectedSize}
onSizeChange={setSelectedSize}
quantity={quantity}
onQuantityChange={setQuantity}
/>
<ShippingOptions shipping={shipping} onShippingChange={setShipping} />
<Reviews reviews={reviews} />
</div>
);
};
리렌더링 될 확률이 매우 높겠네요. (횟수도 많을거고요) 여러개의 state들이 부모요소에 전부 몰려있으니까요.
이로 인해 불필요하게 자식 요소들 또한 리렌더링 될겁니다. 개선하면 아래와 같아집니다.
//관심사 분리
const ProductPage = ({ productId }) => {
return (
<div>
<ProductConfig productId={productId} />
<ReviewsSection productId={productId} />
</div>
);
};
const ProductConfig = ({ productId }) => {
const [selectedSize, setSelectedSize] = useState("medium");
const [quantity, setQuantity] = useState(1);
const [shipping, setShipping] = useState("express");
// Product-specific logic
return (
<>
<ProductInfo
selectedSize={selectedSize}
onSizeChange={setSelectedSize}
quantity={quantity}
onQuantityChange={setQuantity}
/>
<ShippingOptions shipping={shipping} onShippingChange={setShipping} />
</>
);
};
const ReviewsSection = ({ productId }) => {
const [reviews, setReviews] = useState([]);
useEffect(() => {
fetchReviews(productId).then(setReviews);
}, [productId]);
return <Reviews reviews={reviews} />;
};
이제 딱 필요한 부분에서만 재조정(리렌더링)이 일어납니다.
또한 관심사를 분리한 덕분에 어떤 컴포넌트가 어떤 책임을 지고 있는지 더욱 간단하고 명확하게 파악할 수 있게 되었습니다.
3. Key를 사용하여 더욱 나은 방식으로 state를 보존하기
달라져야하는 부분은 key로 컨트롤하고, 여전히 유지해야하는 부분은 state를 활용하면 금상첨화입니다.
input을 생각해보세요! input 안에 있는 값들을 초기화 시키고 싶으면 간단하게 key를 바꿔주어 언마운트시킬 수 있었습니다.
하지만 이런 생각에 매몰되다보면 잘못된 방법으로 key를 활용할 수도 있습니다.
const TabContent = ({ activeTab }) => {
// This approach is INCORRECT and won't preserve state between different component types
return (
<div>
// using the same key won't help you here:
{activeTab === "profile" && <ProfileTab key="tab-content" />}
{activeTab === "settings" && <SettingsTab key="tab-content" />}
{activeTab === "activity" && <ActivityTab key="tab-content" />}
</div>
);
};
prop들이나 상태들을 옮기고 싶다는 이유로 위의 코드에서는 key값을 유지하는 방식을 사용했는데, 이는 사실 도움이 되지 않습니다.
어차피 함수형 컴포넌트의 타입(참조)가 달라지기 때문에 Reconcilation은 일어나고, 컴포넌트는 activeTab이 변할 때마다 마운트와 언마운트가 반복적으로 일어날 수 밖에 없습니다.
참고로, key 값을 조건부로 준다던가 key값을 중복으로 준다던가 하는 행위는 절대 하지 마세요.예상치 못한 버그를 맞닥뜨릴 확률이 높습니다. (컴포넌트가 중복되어 생성된다거나 하는...)
따라서 아래와 같이 적절히 공유해야하는 상태는 state를 통해 전달하는 것이 좋습니다.
// Correct approach: Lift state to a parent component
const TabContent = ({ activeTab }) => {
// State that needs to be preserved across tab changes
const [sharedState, setSharedState] = useState({
/* initial state */
});
return (
<div>
{activeTab === "profile" && (
<ProfileTab state={sharedState} onStateChange={setSharedState} />
)}
{activeTab === "settings" && (
<SettingsTab state={sharedState} onStateChange={setSharedState} />
)}
{activeTab === "activity" && (
<ActivityTab state={sharedState} onStateChange={setSharedState} />
)}
</div>
);
};
지금까지의 내용들을 요약하자면 다음과 같습니다.
0. 타입, key 값을 바탕으로 요소를 처음부터 다시 그리지 않고 attribute만 옮길 수 있다.
1. 리액트의 재조정 과정의 원리 - 타입, key, 포지션
2. 리스트에서의 key의 역할과 동적 리스트 뒤에 존재하는 컴포넌트가 영향받지 않게 하는 방법
3. 재조정 과정에 대한 이해를 바탕으로 리액트의 패턴 이해와 최적화 (memo보단, 바운더리 잘 정하기)
그리고 실용적인 가이드라인은 다음과 같습니다.
1. 컴포넌트 정의는 부모 컴포넌트가 없도록 하라 - 즉 컴포넌트 내부에서 컴포넌트 정의를 하지 말라
2. state는 최대한 가깝게 두어 리렌더링의 바운더리를 고립화시켜라
3. 같은 포지션에서는 컴포넌트의 타입을 최대한 유지시켜 언마운트를 피하라
4. 전략적으로 key를 사용하라 (단지 list를 위해서만이 아닌)
5. 리렌더링 이슈 때문에 디버깅을 할 일이 생긴다면 element tree 와 identity 측면에서 다시 생각해보라
6. React.memo는 단지 도구일 뿐이라는 것을 기억하라 - 결국 핵심 원리는 reconciliation 이다.
참고) https://cekrem.github.io/posts/react-reconciliation-deep-dive/
'Develop > Frontend' 카테고리의 다른 글
| 하이드레이션 에러(Hydration Error) (0) | 2025.05.07 |
|---|---|
| css는 모듈로 뽑을 수 있을까? (1) | 2025.04.29 |
| 개발자도구 Element 탭 강조 표시의 기준 (1) | 2025.04.18 |
| requestAnimationFrame 에 대한 이해 (4) | 2025.04.17 |
| Zustand 리렌더링의 조건 (트러블슈팅) (0) | 2025.04.12 |