웹 브라우저 렌더링 최적화: 가상 DOM(Virtual DOM)의 동작 원리와 메모이제이션(useMemo/useCallback) 올바른 사용법
모던 프론트엔드 웹 애플리케이션(React, Vue 등)의 사용자 인터페이스가 고도화되면서, 화면의 작은 데이터 변경 하나에도 전체 뷰가 버벅거리거나 프레임 드랍(Jank)이 발생하는 성능 문제가 빈번히 발생합니다. 브라우저의 실제 문서 객체 모델인 실제 DOM(Real DOM)은 변경이 일어날 때마다 요소의 크기와 위치를 다시 계산하는 리플로우(Reflow)와 픽셀을 다시 칠하는 리페인트(Repaint)를 유발하여 연산 비용이 매우 무겁습니다. 리액트(React)는 이를 극복하기 위해 메모리상에 가벼운 자바스크립트 객체 형태로 UI 트리를 유지하는 가상 DOM(Virtual DOM)과 재조정(Reconciliation) 알고리즘을 도입했습니다. 그러나 가상 DOM이 모든 성능 문제를 마법처럼 해결해 주지는 않으며, 불필요한 하위 컴포넌트 재렌더링을 차단하기 위한 메모이제이션(React.memo, useMemo, useCallback)의 정석적인 설계가 수반되어야 합니다. 본 포스팅에서는 가상 DOM의 내부 동작 원리부터 실무 메모이제이션 코드 패턴과 남용 시 발생하는 안티패턴까지 상세히 정리합니다.
1. 실제 DOM vs 가상 DOM: 동작 원리와 재조정(Reconciliation)
전통적인 웹 방식에서는 10개의 데이터가 바뀌면 브라우저가 실제 DOM API(appendChild, innerHTML)를 10번 호출하며 매번 무거운 레이아웃 트리를 갱신했습니다. 가상 DOM은 이 비효율을 자바스크립트 객체 메모리 수준에서 추상화합니다.
| 구분 | 실제 DOM (Real DOM) | 가상 DOM (Virtual DOM) |
|---|---|---|
| 실체 및 저장 위치 | 브라우저 렌더링 엔진 내부 C++ 객체 트리 | 메모리상의 순수 자바스크립트(JS) 객체 트리 |
| 조작 연산 비용 | 매우 높음 (DOM 조작 시 리플로우/리페인트 유발) | 극히 저렴함 (단순 JS 객체 속성 비교 연산) |
| 업데이트 메커니즘 | 호출 즉시 화면에 동기적으로 반영 | 변경 사항을 Diffing 후 일괄(Batching) 적용 |
1.1 가상 DOM의 3단계 렌더링 파이프라인
- 렌더 단계(Render Phase): 상태(State)나 속성(Props)이 변경되면 컴포넌트 함수가 재호출되면서 새로운 가상 DOM 트리를 생성합니다.
- 비교 단계(Diffing Phase): 이전 가상 DOM 트리와 새 가상 DOM 트리를 $O(N)$ 복잡도의 휴리스틱 알고리즘으로 비교하여 변경된 엘리먼트만을 정밀하게 추출합니다. (이때 고유한
key프로퍼티가 리스트 노드의 불필요한 전체 재생성을 막아줍니다). - 커밋 단계(Commit Phase): 실제로 달라진 최소한의 노드만을 실제 브라우저 DOM에 단 1회의 일괄 패치(Batch Mutation)로 주입하여 렌더링 부하를 극소화합니다.
2. 불필요한 재렌더링의 원인: 얕은 비교(Shallow Compare)와 참조 동일성
리액트는 기본적으로 부모 컴포넌트가 렌더링되면 모든 자식 컴포넌트도 무조건 함께 재렌더링됩니다. 자식 컴포넌트 입장에서 자신이 받는 데이터가 실제로 변하지 않았더라도, 자바스크립트의 객체(Object)나 함수(Function)는 렌더링될 때마다 새로운 메모리 주소(참조값)를 갖기 때문에 얕은 비교(===)에서 항상 false로 판정되어 불필요한 재호출이 발생합니다.
3. 메모이제이션 3대 도구의 올바른 실전 사용법
3.1 React.memo: 컴포넌트 단위 캐싱
컴포넌트를 React.memo()로 감싸면, 전달받는 Props가 이전 렌더링과 얕은 비교 기준으로 완벽히 동일할 경우 컴포넌트 함수 자체의 재실행을 건너뜁니다.
3.2 useCallback: 함수 참조 동일성 보존
부모 컴포넌트가 자식에게 이벤트 핸들러 함수를 전달할 때, useCallback으로 감싸지 않으면 매 렌더링마다 함수가 새로 생성되어 React.memo로 방어해 둔 자식 컴포넌트까지 무용지물로 만들며 재렌더링을 촉발합니다.
3.3 useMemo: 고비용 연산 결과 캐싱
수천 개의 배열을 필터링하거나 복잡한 수학 연산을 수행할 때, 의존성 배열(Dependencies)의 값이 변경되지 않는 한 이전 연산 결과를 재사용합니다.
3.4 실전 모범 코드 패턴
import React, { useState, useMemo, useCallback } from 'react';
// 1. 자식 컴포넌트: Props가 바뀌지 않으면 렌더링 스킵
const ItemRow = React.memo(({ item, onDelete }) => {
console.log(`ItemRow 렌더링: ${item.id}`);
return (
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0' }}>
<span>{item.name} - {item.price}원</span>
<button onClick={() => onDelete(item.id)}>삭제</button>
</div>
);
});
export default function OptimizedList({ rawItems }) {
const [filterText, setFilterText] = useState('');
const [themeDark, setThemeDark] = useState(false);
// 2. useMemo: 필터링 연산은 filterText나 rawItems가 바뀔 때만 재연산
const filteredItems = useMemo(() => {
console.log("대량 데이터 필터링 연산 실행");
return rawItems.filter(item => item.name.includes(filterText));
}, [rawItems, filterText]);
// 3. useCallback: 함수 참조를 고정하여 ItemRow의 불필요한 재렌더링 방지
const handleDelete = useCallback((id) => {
console.log(`아이템 삭제 요청: ${id}`);
}, []);
return (
<div style={{ background: themeDark ? '#333' : '#fff', color: themeDark ? '#fff' : '#000', padding: 20 }}>
<button onClick={() => setThemeDark(prev => !prev)}>테마 토글</button>
<input
type="text"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
placeholder="검색어 입력..."
/>
<div>
{filteredItems.map(item => (
<ItemRow key={item.id} item={item} onDelete={handleDelete} />
))}
</div>
</div>
);
}
4. 메모이제이션의 맹점: 언제 쓰지 말아야 하는가? (안티패턴)
많은 개발자가 성능 최적화라는 명목으로 모든 변수와 함수에 무분별하게 useMemo와 useCallback을 적용하지만, 이는 오히려 성능을 악화시키는 역효과를 낳습니다.
- 메모이제이션 자체의 비용:
useMemo와useCallback은 공짜가 아닙니다. 매 렌더링마다 이전 의존성 배열의 값들을 순회하며 얕은 비교를 수행해야 하고, 함수와 반환값을 클로저 힙(Heap) 메모리에 캐싱하므로 메모리 사용량과 가비지 컬렉터(GC) 부하가 증가합니다. - 원시값이나 단순 연산에 적용하는 낭비:
const sum = useMemo(() => a + b, [a, b]);와 같은 단순 산술 연산이나 문자열 결합은useMemo자체의 호출 및 비교 비용이 연산 비용보다 훨씬 큽니다. - React.memo가 적용되지 않은 자식 컴포넌트에 함수를 넘길 때: 일반 컴포넌트는 어차피 부모가 렌더링되면 무조건 재렌더링되므로, 여기에 넘겨주는 콜백 함수를
useCallback으로 감싸는 것은 아무런 최적화 효과가 없는 낭비입니다.
5. 결론 및 프론트엔드 렌더링 최적화 실무 체크리스트
리액트 렌더링 최적화는 감에 의존한 무차별 캐싱이 아니라, 프로파일러 도구를 통해 병목 컴포넌트를 정확히 측정하고 타깃팅하는 것에서 시작됩니다. 실무 개발 시 아래 5가지 체크리스트를 점검하시기 바랍니다.
- React DevTools Profiler 측정: 추측으로 코드를 수정하기 전 프로파일러의 "Highlight updates when components render" 옵션을 켜고 실제 불필요하게 번쩍이는 컴포넌트를 확인했는가?
- 고유한 key 프로퍼티 부여: 반복 리스트 렌더링 시 배열 인덱스(Index) 대신 데이터 고유의
id를key로 사용하여 가상 DOM Diffing 낭비를 제거했는가? - 메모이제이션 쌍(Pair) 일치:
useCallback을 적용한 콜백 함수를 전달받는 하위 컴포넌트에React.memo가 빠짐없이 적용되어 있는가? - 상태 위치 최적화(State Colocation): 특정 모달이나 폼 인풋에서만 쓰는 상태를 불필요하게 최상위 부모로 끌어올려 전체 트리를 재렌더링시키지 않고 최하위로 격리했는가?
- 가벼운 연산의 useMemo 남용 배제: 단순 변수 할당이나 가벼운 연산에 불필요하게
useMemo를 씌워 메모리를 낭비하고 있지 않은가?
댓글 쓰기