리액트(React)를 사용하면서 우리가 누리는 가장 큰 혜택 중 하나는 "상태 변화에 따른 UI 업데이트를 리액트가 알아서 해준다"는 점입니다.
하지만 이 '알아서' 해주는 과정 뒤에는 컴퓨터 과학의 난제를 실용적으로 풀어낸 Diffing 알고리즘이 숨어 있습니다.
오늘은 리액트 성능의 핵심인 재조정(Reconciliation) 과정과, 시간 복잡도를 혁신적으로 줄인 최적화 전략을 정리해 보겠습니다.
1. 가상 DOM과 재조정(Reconciliation)
실제 브라우저의 DOM은 변경될 때마다 레이아웃을 다시 계산(Reflow)하고 화면을 그리는(Repaint) 비싼 비용을 지불합니다.
리액트는 이를 최적화하기 위해 Virtual DOM을 활용합니다.
- Render: 상태가 변경되면 새로운 가상 DOM 트리를 생성합니다.
- Diffing: 이전 가상 DOM 트리와 새 트리를 비교하여 차이점을 찾습니다.
- Commit: 변경된 부분만 실제 DOM에 적용합니다.
이 중 2번의 비교 과정을 바로 **재조정(Reconciliation)**이라고 부릅니다.
2. 왜 O(n³)이 아닌 O(n)인가?
일반적으로 두 개의 트리를 비교하여 최소한의 변경 사항을 찾아내는 알고리즘은 O(n³) 시간 복잡도를 가집니다.
만약 노드가 1,000개라면 10억 번의 연산이 필요하죠. 60fps(초당 60프레임)를 유지해야 하는 웹 환경에서는 치명적인 속도입니다.
리액트는 이 문제를 해결하기 위해 두 가지 강력한 휴리스틱(Heuristic, 경험적 추론) 가정을 세워 알고리즘을 **O(n)**으로 최적화했습니다.
- 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 형성한다.
- 개발자가
keyprop을 통해 여러 렌더링 사이에서 어떤 자식 엘리먼트가 안정적인지 암시할 수 있다.
3. 핵심 규칙 1: 엘리먼트 타입에 따른 비교
리액트는 트리를 비교할 때 **너비 우선 탐색(BFS)**과 유사하게 같은 레벨의 노드끼리만 비교합니다.
⚠️ 타입이 바뀌면? (예: <div> → <span>)
부모 노드의 타입이 바뀌면 리액트는 이전 트리를 완전히 버리고 새 트리를 구축합니다.
- 이전 노드들은 모두 Unmount (상태 파괴)
- 새로운 노드들은 Mount (상태 초기화)
// ❌ 안티 패턴: 조건에 따라 태그 자체를 바꾸는 행위
{isError ? (
<div className="wrapper"><Alert /></div>
) : (
<section className="wrapper"><Alert /></div> // Alert 컴포넌트는 완전히 새로 마운트됨
)}
💡 최적화 팁: 컴포넌트 구조를 동일하게 유지하고 className이나 스타일 속성만 변경하는 것이 성능상 훨씬 유리합니다.
4. 핵심 규칙 2: 리스트와 Key의 마법
자식 노드들이 순서만 바뀌거나 중간에 삽입될 때, 리액트는 어떤 노드가 유지되었는지 알 방법이 없습니다. 이때 필요한 것이 바로 **key**입니다.
🚫 Index를 Key로 쓰면 안 되는 이유
배열의 인덱스를 key로 사용하면, 리스트 중간에 아이템이 추가되거나 삭제될 때 인덱스가 밀리게 됩니다.
// 초기 상태
[
{ key: 0, text: 'Apple' },
{ key: 1, text: 'Banana' }
]
// 맨 앞에 'Cherry' 추가 후
[
{ key: 0, text: 'Cherry' }, // 기존 0번(Apple)과 비교 시 텍스트만 바뀐 것으로 오해
{ key: 1, text: 'Apple' }, // 기존 1번(Banana)과 비교 시 텍스트만 바뀐 것으로 오해
{ key: 2, text: 'Banana' } // 새로 생성
]
이 경우 리액트는 모든 요소를 리렌더링하고 DOM을 조작하게 됩니다.
성능 저하는 물론, 체크박스 같은 상태가 엉뚱한 곳에 남는 버그의 원인이 됩니다.
✅ 올바른 해결책: 데이터베이스의 고유 ID(id)를 key로 사용하세요.
5. 실무자를 위한 성능 최적화 전략
Diffing 알고리즘의 원리를 이해했다면, 다음 전략을 통해 성능을 극대화할 수 있습니다.
① 불필요한 래퍼 제거 (Fragment)
불필요한 <div> 중첩은 가상 DOM 트리의 깊이를 깊게 만들어 비교 연산의 양을 늘립니다. <> </> (Fragment)를 활용하세요.
② Props 타입 안정성 유지
동일한 컴포넌트에 넘겨주는 props의 구조가 자주 바뀌면 리액트는 내부적으로 객체를 비교하는 데 더 많은 비용을 씁니다.
③ React.memo의 적절한 활용
부모가 리렌더링될 때 자식의 props가 변하지 않았다면, Diffing 과정 자체를 건너뛰도록 React.memo를 사용할 수 있습니다.
const UserProfile = React.memo(({ user }) => {
return <div>{user.name}</div>;
});
🎯 요약
- 재조정은 가상 DOM과 실제 DOM을 동기화하는 과정이다.
- 리액트는 O(n³) 알고리즘을 O(n)으로 최적화하기 위해 타입 비교와 Key라는 휴리스틱을 사용한다.
- 타입이 다르면 버리고, 같으면 속성만 업데이트한다.
- 고유한 Key는 리스트 렌더링 성능의 핵심이다.
이러한 리액트의 내부 동작 원리를 이해하면, 단순히 기능을 구현하는 것을 넘어
"왜 내 코드가 느린지" 혹은 "어떻게 더 효율적으로 짤 수 있는지"에 대한 명확한 기준을 가질 수 있습니다.
함께 읽어보면 좋은 글: