이전 포스팅(브라우저의 렌더링 과정)에서 브라우저는 DOM과 CSSOM을 생성하고 이 둘을 합쳐 Render Tree를 만들고, 이를 기반으로 Layout과 Paint 단계를 거치는 Critical Rendering Path 과정이 실행되어 웹 페이지가 그려지는것을 살펴보았다. 이렇게 생성된 웹 페이지의 업데이트는 어떻게 이루어지고, 어떤 문제점을 해결하기 위해 React가 나왔을까?
여기서 말하는 업데이트란 사용자 인터랙션으로 인해 UI가 실시간으로 변화하는것을 말한다. 이러한 업데이트는 JavaScript가 DOM을 수정하여 발생한다. JavaScript의 DOM Api를 이용해 DOM을 수정하면 브라우저가 이 변경을 감지하고, Render Tree를 재생성하고 Layout을 다시 계산하고 Painting을 다시 수행해서 변화된 UI를 화면에 다시 그려내는 과정으로 업데이트가 진행된다.
이렇게 DOM을 조작해서 업데이트를 진행할 때, 이전 포스팅에서도 언급했지만 Layout과 Painting은 연산이 많이 들어가는 비싼 과정이다. 그래서 Layout을 다시하는 Reflow, Painting을 다시하는 Repaint가 자주 일어나게되면 성능에 큰 악영향을 끼치기때문에 JavaScript로 DOM을 조작할땐 DOM의 수정 횟수를 최대한 줄이는게 매우 중요하다.
let todos = ['장보기', '운동하기', '공부하기'];
// ❌ 비효율적: 각각 개별적으로 DOM에 추가
function addTodosOneByOne() {
const todoList = document.getElementById('todo-list');
todos.forEach(todoText => {
const li = document.createElement('li');
li.textContent = todoText;
todoList.appendChild(li); // DOM 수정 1회
// 브라우저: "DOM이 바뀌었네, 화면 업데이트 준비해야겠다"
// → Reflow/Repaint 대기열에 추가
});
// 총 3번의 DOM 수정 = 3번의 잠재적 Reflow/Repaint
}
Todo 앱으로 간단한 예시를 들어보자면, 3개의 Todo를 추가하는 이 addTodosOneByOne 함수는 Todo 한개당 DOM을 1번 수정한다. 그래서 3개의 Todo를 추가하면 3번의 DOM 수정이 발생하게 된다. Todo가 1000개라면 DOM 수정도 1000번이 발생하게 된다. 1000번의 리플로우/리페인트가 발생해 시간이 매우 오래 걸릴것이고 이 작업이 진행되는 그 시간동안 앱은 멈출것이다. 사용자 경험에 굉장히 치명적이다.
이러한 현상을 방지하기 위해 개발자가 직접 최적화 로직을 구상해야 한다.
// ✅ 효율적: 모든 요소를 준비한 후 한 번에 DOM에 추가
function addTodosAtOnce() {
const todoList = document.getElementById('todo-list');
const fragment = document.createDocumentFragment();
// 메모리에서만 작업 (아직 DOM 건드리지 않음)
todos.forEach(todoText => {
const li = document.createElement('li');
li.textContent = todoText;
fragment.appendChild(li); // 실제 DOM에는 영향 없음
});
// 단 1번만 실제 DOM 수정
todoList.appendChild(fragment);
// 브라우저: "DOM이 1번 바뀌었네, 1번만 화면 업데이트하면 돼"
// → 1번의 Reflow/Repaint
}
변경 사항을 DOM에 바로바로 반영하지않고 계산만 해두었다가 최종 결과를 한번에 DOM에 반영하는 방식으로 최적화 한 예시이다. 이런 식으로 개발자가 항상 DOM을 수정하는 횟수를 최소화하기 위한 로직을 따로 구상해야한다.
또한 DOM의 수정 범위 측면에서도 바닐라 js만으로는 비효율적인 경우가 많다.
let todos = [];
function addTodo(text) {
todos.push({ id: Date.now(), text, completed: false });
renderTodos(); // 전체 리스트 다시 렌더링
}
function toggleTodo(id) {
todos = todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
);
renderTodos(); // 전체 리스트 다시 렌더링
}
function deleteTodo(id) {
todos = todos.filter(todo => todo.id !== id);
renderTodos(); // 전체 리스트 다시 렌더링
}
function renderTodos() {
const todoList = document.getElementById('todo-list');
// 매번 전체 HTML을 다시 생성 → 비효율적
todoList.innerHTML = todos.map(todo => `
<li>
<input type="checkbox" ${todo.completed ? 'checked' : ''}
token interpolation">${todo.id})">
<span ${todo.completed ? 'style="text-decoration: line-through"' : ''}>
${todo.text}
</span>
<buttontoken interpolation">${todo.id})">삭제</button>
</li>
`).join('');
}
// 사용 예시
addTodo('장보기'); // 전체 리스트 렌더링
addTodo('운동하기'); // 전체 리스트 렌더링
addTodo('공부하기'); // 전체 리스트 렌더링
toggleTodo(1); // 전체 리스트 렌더링
deleteTodo(2); // 전체 리스트 렌더링
이 경우의 문제점은 작은 변경에도 UI요소 전체가 다시 렌더링 된다는것이다. 체크박스 하나만 체크해도 renderTodos 함수가 실행되어 전체 리스트를 다시 만든다. 1000개의 Todo 중 1개만 바뀌어도 1000개가 모두 다시 렌더링되는 것이다.
겉모습은 체크박스 하나만 바뀌지만 실제로는 전체 리스트의 DOM 구조가 완전히 새로 만들어지는 것이다. 마치 건물의 벽지 하나만 바꾸려고 건물 전체를 다시 짓는 것과 같은 부담이 발생한다.
// DOM 트리에서 해당 부분만 변경되도록 최적화
function optimizedToggleTodo(id) {
const todo = todos.find(t => t.id === id);
todo.completed = !todo.completed;
// 해당 요소만 찾아서 업데이트
const checkbox = document.querySelector(`input[onchange="toggleTodo(${id})"]`);
const span = checkbox.nextElementSibling;
checkbox.checked = todo.completed;
if (todo.completed) {
span.style.textDecoration = 'line-through';
} else {
span.style.textDecoration = 'none';
}
// 복잡하고 실수하기 쉬운 코드!
}
이런 비효율적인 문제를 해결하려면 이와 같이 개발자가 직접 DOM 변경 부분을 최소화하는 방향으로 별도의 최적화 로직을 구성해야만 한다. 데이터가 바뀌면 개발자가 수동으로 해당 DOM 요소를 찾아서 업데이트하는 이런 로직이 곳곳에 추가되면 코드도 복잡해지고 실수할 가능성도 높아진다.
물론 DOM의 수정 범위 최소화 보다는 DOM 수정 횟수를 줄이는게 훨씬 중요하긴 하지만 DOM 수정 범위를 줄이는것도 UI 성능 및 메모리 효율성 등의 관점에서 도움이 되기 때문에 고려하는게 좋을것이다.
이렇게 DOM의 수정 횟수와 범위를 최소한으로 하기 위한 최적화를 항상 신경쓰는건 이런 간단한 서비스라면 괜찮겠지만, 서비스의 규모가 커질수록 항상 이렇게 최대한 DOM 조작을 줄이려는 방향을 모색하며 개발하기는 힘들다.
React는 바로 이러한 문제를 해결하기위해 등장했다. 개발자가 어떻게 DOM을 변경할지에 대한 명령을 내리는 대신, 무엇을 화면에 보여줄지만 선언적으로 정의하도록 함으로써 렌더링의 복잡성을 추상화했다.
React는 어떻게 이런게 가능한 것일까? 이를 위한 React의 렌더링 프로세스에 대해 살펴보자.
React는 크게 Render Phase와 Commit Phase 라는 두가지 단계를 거쳐 렌더링 프로세스가 진행된다. 그리고 이 과정 속에서 Diffing과 Batching이라는 핵심 개념이 중요한 역할을 한다.
Render Phase는 작성한 React 컴포넌트들을 호출해서 계산하고, 어떤 업데이트가 필요한지 계산하는 단계이다.
보통 React 컴포넌트는 어떠한 로직을 거쳐 최종적으로는 HTML 덩어리(?)를 return한다. 그래서 이런 “컴포넌트를 호출한 결과값” 이라는 것도 이런 HTML 코드일 거 같지만 실제로는 React Element 라는 객체값이 반환된다.
React Element는 컴포넌트가 렌더링하고자하는 UI에 대한 모든 정보를 포함하고있는 객체라고 보면 된다.
// 개발자가 작성하는 JSX
function App() {
return (
<div id="main">
<p>Hello</p>
</div>
)
}
// 실제로는 이렇게 변환됨 (Babel이 변환)
function App() {
return React.createElement(
'div', // 태그 이름
{ id: 'main' }, // props 객체
React.createElement('p', null, 'Hello') // 자식 요소
)
}
이렇게 내부적으로 Babel과 같은 트랜스파일러에 의해 컴포넌트의 JSX 부분(return문)이 React.createElement를 호출하는 코드로 변환되고, React.createElement는 다음과 같은 React Element 객체를 반환한다.
// 위 컴포넌트가 실제로 반환하는 객체
{
type: 'div',
props: {
id: 'main',
children: {
type: 'p',
props: {
children: 'Hello'
},
key: null,
ref: null,
$$typeof: Symbol(react.element)
}
},
key: null,
ref: null,
$$typeof: Symbol(react.element)
}
태그의 종류는 type 필드에, 태그에 지정된 id는 props.id 필드에 지정되어있고 자식 요소는 props.children 필드에 재귀적으로 지정이 된다. 이 외에도 리스트에서의 식별용인 key, DOM 참조용인 ref 등 이 컴포넌트에대한 다양한 정보들을 담고 있는 객체라고 보면 된다.
이 React Element 객체들은 실제 브라우저의 DOM이 아닌, 경량화된 자바스크립트 객체 형태의 가상 DOM(Virtual DOM) 트리를 구성하게 된다. 실제 DOM(Actual DOM)과 같이 메모리에 존재하지만 실제 DOM과 달리 브라우저의 렌더링 엔진과 직접 연결되지 않은 순수한 JS 객체로만 구성되어있기 때문에 생성과 수정, 비교 연산이 빠르고 비용이 적다.
정리하자면, Render Phase에서는 화면에 렌더링되어야 하는 모든 React 컴포넌트들을 호출해서 React Element들을 얻게 된다. 그리고 이렇게 얻은 React Element들을 모아서 가상 DOM이라는 트리 형태로 구조화 시킨다.
UI 업데이트가 발생하면 React는 Render Phase를 다시 진행한다. 컴포넌트들을 다시 호출해서 변경 사항이 반영된 React Element들을 다시 반환받고, 이들로 새로운 가상 DOM을 생성한다. 이렇게 변경 사항이 반영된 새로운 가상 DOM과 기존의 가상 DOM을 비교한다. 이 과정을 Diffing 이라고 한다.

Diffing은 마치 두 장의 그림을 놓고 "어떤 부분이 달라졌는지"를 찾아내는 것과 같다. React의 Diffing 알고리즘은 두 가상 DOM 트리 사이의 최소한의 변경 사항을 효율적으로 식별한다. 이 과정에서 컴포넌트의 타입이 변경되었는지, 속성이 변경되었는지, 자식 요소들의 순서가 바뀌었는지 등을 정교하게 분석한다.
Diffing의 단순한 비교를 넘어 실제 DOM에 적용해야 할 가장 효율적인 변경 사항 목록을 만드는 것이 궁극적인 핵심 목표이다. 즉, 전체를 다시 그리는 것이 아니라, 단 한 픽셀이라도 바뀐 부분만 찾아내는 고도로 최적화된 과정인 셈이다. 이 단계까지는 실제 DOM에 대한 어떠한 조작도 일어나지 않는다.
이렇게 Diffing 과정을 통해 DOM에서 어떤 부분이 변했는지를 찾고 Commit Phase로 넘어가서 실제 DOM에 발견한 변경사항들을 한번에 반영한다.
Diffing 과정을 통해 실제 DOM에 반영할 최소한의 변경 사항 목록이 준비되면, 이제 이 변경 사항들을 실제 DOM에 적용하는 Commit Phase가 시작된다.

Commit Phase는 설계도(Diffing 결과)를 받아 실제로 건물을 짓는 과정과 같다. React는 Diffing을 통해 파악한 모든 변경 사항(새로운 요소 추가, 기존 요소 삭제, 속성 수정 등)을 한데 모아 단 한 번의 효율적인 실제 DOM 조작으로 브라우저에 전달한다. 여러 번의 논리적인 UI 변경이 있었더라도, React는 이들을 한 번의 실제 DOM 업데이트로 일괄 처리함으로써, 브라우저의 부담을 획기적으로 줄여준다. 이를 React의 배치 업데이트(Batching)라고 한다.
Batching은 React가 UI 업데이트 성능을 최적화하는 매우 중요한 부분이며 앞서 말했듯이 “단 한 번의 효율적인 실제 DOM 조작”을 가능하게 하는 핵심 원리이다.
Batching은 하나의 인터랙션 안에서 여러 개의 상태(state) 업데이트가 발생했을 때, 이 모든 업데이트를 모아서 단 한 번의 렌더링으로 처리하는 최적화 메커니즘을 의미한다. 상태가 업데이트 된다는건 곧 UI가 업데이트 된다는 것인데 이 상태 업데이트가 여러개가 발생한 것을 일괄적으로 처리한다는 것이다.
// 예시: 버튼 클릭 하나로 여러 상태가 바뀌는 상황
function TodoApp() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');
const [count, setCount] = useState(0);
const handleAddTodo = () => {
setTodos([...todos, newTodo]); // 1번째 상태 변경
setCount(count + 1); // 2번째 상태 변경
setFilter('active'); // 3번째 상태 변경
// 이 3개 변경이 "동시에" 발생한 업데이트
};
return (
<div>
<TodoList todos={todos} filter={filter} /> {/* todos, filter 의존 */}
<Counter count={count} /> {/* count 의존 */}
<FilterButtons filter={filter} /> {/* filter 의존 */}
</div>
);
}
아까의 Todo 앱 예시로 들어보자면, 이렇게 하나의 클릭 이벤트가 발생했을 때 그 이벤트 핸들러에서 여러개의 state를 업데이트하는 경우를 흔히 볼 수 있다.
단순히 생각하면 이제껏 살펴본 React의 렌더링 프로세스가 각각의 state가 바뀔때마다 별개로 진행되어, 위의 경우에서는 총 3번의 렌더링 프로세스가 진행된다고 생각할 수 있다. 하지만 실제로는 이렇게 동시에 발생한 state의 변화는 한번에 배치 처리(일괄 처리)가 된다.
여기서 “동시에” 라는 의미는 정말 시간적으로 동시에가 아니라 하나의 이벤트나 상태 변경으로 인해 연쇄적으로 발생하는 모든 업데이트를 의미한다. 간단하게 그냥 하나의 인터랙션 내에서 발생한 업데이트들이라고 생각해도 무방하다.
위 예시 상황을 만약 바닐라 JS로 구현한다면
// 개발자가 수동으로 각각 업데이트
function handleAddTodo() {
// 1. 데이터 변경
todos.push(newTodo);
count++;
filter = 'active';
// 2. 각각의 DOM 업데이트 (개발자가 수동으로)
updateTodoList(); // DOM 조작 1번
updateCounter(); // DOM 조작 2번
updateFilterButtons(); // DOM 조작 3번
// 총 3번의 DOM 조작이 개별적으로 발생
}
대략 이렇게 구현할 수 있을 것이다. UI로 보여주기 위한 데이터가 변경될 때마다 그 변경된 데이터에 해당하는 UI 업데이트(DOM 조작)가 각각 발생한다. 심지어 그 각각의 DOM 조작이 DOM 변경 부분을 최소화하는 최적화 로직이 없다면 매 DOM 업데이트마다 전체 DOM을 다시 생성할 것이다.
이를 React에서는 간단히
function handleAddTodo() {
// 상태만 변경하고 끝
setTodos([...todos, newTodo]);
setCount(count + 1);
setFilter('active');
// React가 자동으로:
// 1. 모든 변경사항을 하나의 렌더링 사이클로 묶음
// 2. 새로운 Virtual DOM 생성
// 3. 이전과 비교해서 실제로 바뀐 부분만 찾아냄
// 4. 실제 DOM을 1번만 업데이트
}
state만 변경해주면 DOM 트리에서 변경된 부분만을 조작하고 이 DOM 조작 자체도 한번만으로 끝내는 최적화된 DOM 업데이트가 자동으로 실행된다. 개발자는 DOM 조작의 효율성은 신경쓰지않고 데이터만 선언적으로 변경하면 되는것이다.
이러한 Batching은 앞서 말했듯 하나의 이벤트 핸들러나 액션 내에서 발생하는 상태 변경들만 적용된다. 별개의 액션이라면 그 액션들이 아무리 빠르게 연속적으로 이루어져도 별개의 업데이트로 처리된다.
일괄 처리되는 경우(Batching)
function MyComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// ✅ 하나의 이벤트 핸들러 내 → 일괄 처리
const handleClick = () => {
setCount(1); // 배치에 추가
setName('John'); // 배치에 추가
setCount(2); // 배치에 추가
// → 총 1번의 리렌더링으로 count=2, name='John'
};
return <button onClick={handleClick}>클릭</button>;
}
일괄 처리되지 않는 경우
function MyComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// ❌ 별개의 액션들 → 각각 처리
const handleFirstClick = () => {
setCount(1); // 1번째 렌더링 사이클
};
const handleSecondClick = () => {
setName('John'); // 2번째 렌더링 사이클
};
// 사용자가 버튼을 아주 빠르게 연속으로 클릭해도
// 각각은 별개의 렌더링 사이클
return (
<div>
<button onClick={handleFirstClick}>첫 번째</button>
<button onClick={handleSecondClick}>두 번째</button>
</div>
);
}
또한, 하나의 인터랙션 내에서라도 상태 업데이트가 시간차를 두고 진행되는 경우라면 배칭이 적용되지 않는다.
타이밍에 따른 차이
function MyComponent() {
const [count, setCount] = useState(0);
// ✅ 같은 이벤트 핸들러 내 → 일괄 처리
const handleSyncUpdates = () => {
setCount(c => c + 1); // 배치에 추가
setCount(c => c + 1); // 배치에 추가
setCount(c => c + 1); // 배치에 추가
// → 1번의 리렌더링으로 count가 3 증가
};
// ❌ 시간차를 두고 호출 → 각각 처리
const handleAsyncUpdates = () => {
setCount(c => c + 1); // 1번째 렌더링
setTimeout(() => {
setCount(c => c + 1); // 2번째 렌더링 (별개의 액션)
}, 0);
Promise.resolve().then(() => {
setCount(c => c + 1); // 3번째 렌더링 (별개의 액션)
});
};
return (
<div>
<button onClick={handleSyncUpdates}>동기 업데이트</button>
<button onClick={handleAsyncUpdates}>비동기 업데이트</button>
</div>
);
}
React 18버전부터는 더 많은 상황에서 자동 배치(Automatic Batching)가 된다고 한다.
// React 17 이하: 비동기에서는 배치 안됨
const handleClick = () => {
fetch('/api').then(() => {
setCount(c => c + 1); // 1번째 렌더링
setName('John'); // 2번째 렌더링
});
};
// React 18: 비동기에서도 배치됨
const handleClick = () => {
fetch('/api').then(() => {
setCount(c => c + 1); // 배치에 추가
setName('John'); // 배치에 추가
// → 1번의 리렌더링
});
};
Reflow, Repaint를 최소한으로 발생시키기 위해서 실제 DOM 수정을 최소화하는것은 React에서 내부적으로 이렇게 구현되어있기 때문에, 개발자는 이러한 과정을 신경쓰지 않아도 React 컴포넌트만 작성하면 이런 최적화를 자동으로 제공받을 수 있다.
이렇게 React에 의해 최적화된 형태로 실제 DOM이 변경되면, 브라우저는 이 DOM 변경을 감지한다. 그리고 앞서 이전 포스팅에서 다루었던 브라우저의 렌더링 과정(Critical Rendering Path)의 일부가 진행된다.
주로 레이아웃(리플로우)과 페인팅(리페인트) 단계가 진행된다. 하지만 React 덕분에 불필요한 리플로우나 리페인트는 최소화되고, 오직 변경된 부분에 대해서만 효율적으로 화면이 업데이트되어 최종적으로 사용자에게 매끄러운 UI 업데이트가 보여지게 된다.
이렇게 Diffing 과정을 통해 두개의 가상 DOM을 비교하여 변경된 부분을 찾고, 이 변경 사항들을 모아서 실제 DOM에 한번에 반영하는 Batching 작업까지의 전체 프로세스를 Reconciliation(재조정)이라고 한다.
React의 Reconciliation은 이 두 가지 최적화 기법을 통해 바닐라 JS의 비효율적인 DOM 조작 문제를 해결한다.
정리하자면, React는 개발자가 state만 선언적으로 관리하면 내부적으로 가상 DOM과 Diffing, Batching 등의 최적화 메커니즘을 통해 최소한의 DOM 조작으로 효율적인 렌더링을 자동으로 제공한다.
이러한 React의 렌더링 최적화 덕분에 개발자는 복잡한 DOM 조작이나 성능 최적화를 직접 구현할 필요 없이, 컴포넌트의 로직과 UI 구조에만 집중할 수 있게 된다. 결국 React의 진정한 가치는 단순히 가상 DOM을 사용한다는 것이 아니라, 이 모든 과정을 추상화하여 개발자 경험을 혁신적으로 개선했다는 점에 있다고 볼 수 있다.
하지만 React가 모든 상황에서 최선의 선택은 아니다. React의 가상 DOM과 복잡한 렌더링 과정은 작은 규모의 앱에서는 오히려 연산 오버헤드가 될 수 있고, 번들 크기나 런타임 성능 면에서 부담이 될 수 있다.
이러한 이유로 최근 프론트엔드 생태계에서는 Svelte, Solid 등 다양한 대안들이 주목받고 있다. 프로젝트의 규모, 성능 요구사항, 팀의 상황에 따라 다른 도구가 더 적합할 수 있다는 점도 염두에 두고 기술 선택을 하는 것이 중요할 것 같다.
React.js의 렌더링 방식 살펴보기 - 이정환
리액트 배칭(Batching)의 모든 것
What are the advantages of React.js ?