State 업데이트 큐잉과 배치 처리 - 함수형 업데이트로 상태 관리 심화
리액트 공식문서 기반의 리액트 입문기
들어가며
이전 편에서는 리액트에서 상태(State)가 스냅샷처럼 작동하며, 즉시 업데이트되는 것처럼 보이지만 실제로는 다음 렌더링을 위해 스냅샷이 생성된다는 점을 살펴보았습니다. 이러한 스냅샷의 개념은 리액트가 UI를 업데이트하는 방식에 대한 깊은 이해를 제공해 주었죠.
이번 편에서는 한 걸음 더 나아가, 여러 번의 상태 업데이트가 동시에 일어날 때 리액트가 어떻게 이를 효율적으로 처리하는지 알아보려고 합니다. 특히, 리액트의 '배치 처리(Batching)', 'State 업데이트 큐잉(Queueing)', 그리고 '함수형 업데이트(Functional Updates)'라는 세 가지 중요한 개념을 통해, 우리가 예상치 못한 방식으로 상태가 업데이트되는 경우를 이해하고, 이를 보다 예측 가능하게 다루는 방법을 탐구해 볼 것입니다. 우리가 자바스크립트에서 직접 DOM을 조작하며 겪었던 문제점들과 비교해 보면서, 리액트가 제공하는 이러한 메커니즘이 얼마나 강력하고 유용한지 함께 느껴보는 시간이 되었으면 좋겠습니다.
리액트의 효율적인 상태 관리: 배치 처리, 큐잉, 함수형 업데이트
State 업데이트 큐잉: 상태 변화의 순서와 처리
우리가 setCount(newValue)와 같은 방식으로 상태를 업데이트하려고 할 때, 리액트는 이 명령을 즉시 실행하여 화면을 바꾸지 않습니다. 대신, 마치 쇼핑 목록에 물건을 추가하듯이 **'업데이트 큐(Queue)'**에 해당 업데이트 요청을 추가합니다. 이 큐는 리액트가 다음 렌더링을 준비할 때 처리할 모든 상태 변경 요청들을 순서대로 저장해 놓는 곳입니다.
예를 들어, 이벤트 핸들러 내부에서 setCount(count + 1)를 세 번 호출하면, 각각의 setCount는 큐에 독립적인 업데이트 요청으로 쌓이게 됩니다. 리액트는 한 번의 렌더링 주기 동안 이 큐에 쌓인 모든 업데이트를 처리하며, 이 과정에서 가장 최신 상태를 기반으로 다음 상태를 계산하게 됩니다. 이처럼 리액트가 업데이트를 큐에 넣어 관리하는 방식은 비동기적인 상태 업데이트 상황에서 일관성과 예측 가능성을 유지하는 데 중요한 역할을 합니다.
렌더링 최적화의 핵심: 배치 처리(Batching)
리액트가 여러 상태 업데이트를 한 번의 리렌더링으로 묶어 처리하는 '배치 처리'는 사용자 경험과 성능 최적화에 중요한 역할을 합니다. 자바스크립트에서 우리가 직접 DOM을 조작할 때는 각 업데이트마다 화면이 즉시 변경되어, 불필요한 렌더링이 자주 발생할 수 있었죠. 하지만 리액트는 이벤트 핸들러 내부에서 여러 setState 호출이 발생해도 이를 하나로 묶어 처리함으로써, 불필요한 리렌더링을 줄이고 애플리케이션의 응답성을 향상시킵니다. (React 18부터는 대부분의 비동기 경로에서 자동 배치가 적용되므로, 타이머/프로미스/네트워크 응답 등에서도 연속 업데이트가 자동으로 묶입니다.)
예를 들어, 하나의 이벤트 안에서 setCount(c => c + 1)과 setFlag(f => !f)가 동시에 호출된다면, 리액트는 이 두 상태 업데이트를 한 번의 렌더링 주기로 처리하여 UI를 한 번만 업데이트합니다. 이러한 방식은 특히 복잡한 애플리케이션에서 렌더링 성능을 크게 개선하는 데 도움을 줍니다.
예측 가능한 상태 업데이트: 함수형 업데이트(Functional Updates)
또 다른 리액트의 강력한 기능은 바로 '함수형 업데이트'입니다. setCount(c => c + 1)처럼 상태 업데이트 함수에 직접 새 값을 전달하는 대신, 이전 상태 값을 인자로 받는 함수를 전달하는 방식이죠. 이는 특히 여러 번의 상태 업데이트가 비동기적으로 발생하거나, 이전 상태 값에 의존하여 다음 상태를 계산해야 할 때 매우 유용합니다.
이전에 3-4편에서 '스냅샷으로서의 State'를 다루면서 오래된 클로저(stale closure) 문제를 잠깐 언급했었습니다. setCount(count + 1)와 같이 상태 값을 직접 참조하여 업데이트하는 방식은 클로저에 의해 캡처된 count의 '오래된' 스냅샷 값을 사용하게 됩니다. 이 때문에 이벤트 발생 시점의 count 값만 계속 참조하게 되어, 여러 번 연속으로 호출될 경우 우리가 기대하는 최종 결과와 다르게 작동할 수 있습니다.
하지만 함수형 업데이트는 항상 최신 상태 값을 기반으로 다음 상태를 계산하므로, 이러한 오래된 클로저(stale closure) 문제를 해결하고 보다 안정적이고 예측 가능한 방식으로 상태를 관리할 수 있게 해줍니다. 이는 복잡한 로직이나 동시성 업데이트 상황에서 버그를 줄이는 데 큰 도움이 됩니다.
자바스크립트로 여러 번의 카운터 업데이트 처리하기
먼저 우리가 익숙한 바닐라 자바스크립트 방식으로 여러 번의 카운터 업데이트를 처리하는 상황을 살펴보겠습니다. 여기서는 버튼을 클릭하면 카운트가 한 번에 3씩 증가하도록 구현해 보겠습니다. updateCount 함수 내에서 count 값을 세 번 직접 증가시키고, 각 증가마다 화면을 업데이트하는 코드를 작성해 볼까요?
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JavaScript Multiple Counter Update</title>
</head>
<body>
<h1>JavaScript 카운터 (여러 번 업데이트)</h1>
<p>현재 카운트: <span id="countDisplay">0</span></p>
<button id="incrementButton">3씩 증가!</button>
<script>
(function () {
let count = 0;
const countDisplay = document.getElementById("countDisplay");
const incrementButton = document.getElementById("incrementButton");
function updateCount() {
// count 값을 세 번 증가시킵니다.
count = count + 1;
countDisplay.textContent = count;
console.log("첫 번째 업데이트:", count);
count = count + 1;
countDisplay.textContent = count;
console.log("두 번째 업데이트:", count);
count = count + 1;
countDisplay.textContent = count;
console.log("세 번째 업데이트:", count);
}
incrementButton.addEventListener("click", updateCount);
})();
</script>
</body>
</html>위 자바스크립트 코드는 incrementButton이 클릭될 때마다 updateCount 함수를 실행합니다. 이 함수 안에서는 count 변수를 세 번 증가시키고, 매번 countDisplay의 textContent를 업데이트하고 있습니다. 콘솔에도 각 업데이트 시점의 count 값을 출력하여 변화를 추적하도록 했습니다.
자바스크립트 방식의 특징
- 즉각적인 DOM 업데이트:
textContent를 직접 변경하는 코드가 실행될 때마다 브라우저의 DOM이 즉시 업데이트됩니다. 이는 우리가 코드에서 변경을 지시하는 대로 화면에 반영된다는 의미입니다. - UI 변경 과정의 직접 제어: UI의 변경 과정을 우리가 직접 세세하게 '어떻게(How)' 처리할지 지시합니다. 카운트 값을 증가시킨 후
textContent를 변경하는 일련의 과정을 모두 작성해야 합니다다. - 성능 저하 가능성: 작은 변경이라도 DOM에 직접 접근하여 수정하는 작업은 비용이 많이 들 수 있습니다. 만약 더 복잡한 로직에서 여러 번의 DOM 조작이 동시에 발생한다면, 불필요한 리페인트(repaint)나 리플로우(reflow)를 야기하여 성능 저하로 이어질 수 있습니다.
- 수동적인 동기화 책임: 애플리케이션의 상태(여기서는
count변수)와 화면(DOM)을 항상 일치시키기 위한 동기화 책임을 온전히 개발자가 져야 합니다. 만약 특정 업데이트를 놓치면 화면과 실제 상태가 불일치하는 버그가 발생할 수 있습니다. - 예측의 어려움: 복잡한 이벤트나 비동기 로직이 얽히면 여러 곳에서 상태를 변경하는 코드가 생기기 쉽습니다. 이 경우 상태 변화의 순서나 최종 결과가 예상과 달라지는 경우가 발생할 수 있어 디버깅이 어려워질 수 있습니다.
리액트로 동일한 카운터 업데이트 만들기
이제 리액트 버전으로 넘어가 보겠습니다. 리액트에서는 useState 훅을 사용해 상태를 관리하고, 이 상태를 업데이트할 때 함수형 업데이트를 활용하여 여러 번의 업데이트를 안정적으로 처리할 수 있습니다. 여기서는 버튼을 클릭할 때마다 카운트가 3씩 증가하지만, 이를 함수형 업데이트로 처리하여 리액트의 배치 처리와 함께 어떻게 동작하는지 알아보겠습니다.
리액트 코드
import { useState } from 'react';
function CounterWithBatching() {
const [count, setCount] = useState(0);
function handleClick() {
// 세 번의 함수형 업데이트를 큐에 추가합니다.
// 각 업데이트는 이전 업데이트의 결과 값을 기반으로 다음 값을 계산합니다.
setCount(c => {
const nextC = c + 1;
console.log(`첫 번째 함수형 업데이트: 이전 값 (${c}), 다음 값 (${nextC})`);
return nextC;
});
setCount(c => {
const nextC = c + 1;
console.log(`두 번째 함수형 업데이트: 이전 값 (${c}), 다음 값 (${nextC})`);
return nextC;
});
setCount(c => {
const nextC = c + 1;
console.log(`세 번째 함수형 업데이트: 이전 값 (${c}), 최종적으로 업데이트될 값 (${nextC})`);
return nextC;
});
console.log('클릭 시점의 count (업데이트 전 스냅샷):', count); // 이 시점의 count는 아직 업데이트되기 전의 스냅샷입니다.
}
return (
<div>
<h1>React 카운터 (배치 처리와 함수형 업데이트)</h1>
<p>현재 카운트: {count}</p>
<button onClick={handleClick}>3씩 증가!</button>
</div>
);
}
export default CounterWithBatching;위 리액트 코드는 CounterWithBatching 컴포넌트 내에서 useState(0)을 사용하여 count 상태를 관리합니다. handleClick 함수 내부에서는 setCount를 세 번 호출하고 있는데, 이때 c => c + 1과 같은 함수형 업데이트 방식을 사용하고 있습니다.
이제 각 setCount 호출 내부에 console.log를 추가하여, 함수형 업데이트가 어떻게 이전 상태 값을 기반으로 순차적으로 다음 상태 값을 계산해 나가는지 명확하게 확인할 수 있습니다. 클릭 시점의 count가 보여주는 스냅샷 값과 달리, 각 함수형 업데이트 내부의 c는 리액트가 큐에 쌓인 업데이트를 처리할 때의 최신 pending state 값을 나타냅니다. 결국 마지막 함수형 업데이트에서 계산된 nextC 값이 최종적으로 반영되어 화면에 3이 표시될 것입니다. 리액트는 이 세 번의 업데이트를 효율적으로 '배치 처리'하여, 실제 DOM 업데이트는 단 한 번만 일어납니다.
배치 처리, 큐잉, 함수형 업데이트의 특징
- 1. 배치 처리(Batching)와 State 업데이트 큐잉을 통한 렌더링 최적화의 심화: 리액트의 **State 업데이트 큐잉(Queueing)**은 우리가
setState를 호출할 때마다 업데이트 요청을 즉시 처리하는 대신, 이들을 하나의 **큐(Queue)**에 차례대로 쌓아둡니다. 그리고 **배치 처리(Batching)**는 이 큐에 쌓인 여러setState호출을 하나의 리렌더링으로 묶어 처리하는 메커니즘입니다. 이는 자바스크립트에서 각 DOM 조작마다 즉각적인 리렌더링이 발생하여 불필요한 연산과 성능 저하를 야기할 수 있는 것과 대조적입니다. 리액트 18부터는 대부분의 비동기 경로(타이머, 프로미스, 네트워크 응답 등)에서도 자동 배치가 적용되어, 개발자가 명시적으로 관리하지 않아도 애플리케이션의 응답성과 렌더링 성능을 최적화하는 데 크게 기여합니다. 이는 리액트가 UI 업데이트의 '어떻게(How)'를 효율적으로 관리하여 개발자가 '무엇(What)'을 보여줄지에 집중하게 하는 핵심적인 철학을 보여줍니다. - 2. 함수형 업데이트(Functional Updates)로 예측 가능한 상태 관리 확립:
setCount(c => c + 1)과 같이 이전 상태 값을 인자로 받는 함수를 사용하는 함수형 업데이트는 항상 최신 상태 값을 기반으로 업데이트가 이루어지도록 보장합니다. 이는 큐에 쌓인 업데이트들이 순차적으로 처리될 때, 이전 업데이트의 결과 값을 정확하게 참조하여 다음 상태를 계산하게 합니다. 3-4편에서 다룬 '오래된 클로저(Stale Closure)' 문제(setCount(count + 1)처럼 스냅샷 값을 직접 참조하는 방식)를 해결하고, 여러 번의 비동기적인 상태 업데이트 상황에서도 우리가 예상하는 정확하고 일관된 결과를 보장합니다. 복잡한 로직이나 동시성 업데이트 상황에서 버그 발생 가능성을 줄여주어, 애플리케이션의 안정성과 예측 가능성을 높이는 데 결정적인 역할을 합니다. - 3. 자동적인 UI 동기화와 개발 편의성:
useState를 통해 관리되는 상태는 리액트에 의해 자동으로 UI와 동기화됩니다. 상태가 변경되면 리액트가 알아서 컴포넌트를 리렌더링하고, 변경된 부분을 DOM에 반영합니다. 이는 자바스크립트에서 개발자가 수동으로 DOM을 조작하고 상태와 UI의 일치를 관리해야 하는 부담을 완전히 해소하여 개발자가 오직 상태 정의와 UI 묘사에만 집중할 수 있게 합니다. 결과적으로 개발 생산성과 애플리케이션 개발 속도를 크게 향상시킵니다. - 4. 디버깅 용이성 향상과 안정적인 애플리케이션: State 업데이트 큐잉, 함수형 업데이트와 배치 처리는 상태 변화의 예측 가능성을 극대화합니다. 특히 스냅샷 값을 직접 참조하는 방식에서 발생할 수 있는 혼란을 줄여주기 때문에, 복잡한 애플리케이션에서도 상태 관련 버그를 더 쉽게 추적하고 해결할 수 있습니다. 이러한 특성들은 애플리케이션의 안정성을 높이고, 장기적인 관점에서 유지보수를 용이하게 하는 데 기여합니다. 리액트의 이러한 메커니즘을 이해하는 것은 더욱 견고하고 신뢰성 있는 웹 애플리케이션을 구축하는 데 필수적입니다.
자바스크립트 vs 리액트: 상태 업데이트 비교
| 구분 | 자바스크립트 | 리액트 |
|---|---|---|
| 상태 정의 | 전역 변수 또는 클로저를 통한 지역 변수 | useState 훅을 통한 컴포넌트 내부 상태 |
| 상태 변경 | count = count + 1와 같이 직접 값 할당 | setCount(c => c + 1)와 같은 함수형 업데이트 활용 |
| UI 업데이트 | textContent 직접 변경으로 즉각적인 DOM 조작 | setCount 호출 시 리액트가 자동 및 배치 처리 |
| 동기화 책임 | 개발자가 상태와 DOM의 일치를 수동으로 관리 | 리액트가 Virtual DOM을 통해 자동으로 동기화 관리 |
| 업데이트 제어 | 각 변경마다 브라우저 리렌더링 발생 가능 | 여러 업데이트를 묶어 한 번의 최적화된 리렌더링 수행 |
요약
이번 편에서는 자바스크립트 개발자로서 리액트의 상태 업데이트 방식 중 '배치 처리'와 '함수형 업데이트'에 대해 탐구해 보았습니다. 우리가 공부한 핵심 내용들을 정리해보면 다음과 같습니다.
- State 업데이트 큐잉:
setState호출 시 업데이트 요청을 큐에 쌓아두는 메커니즘으로, 리액트가 다음 렌더링 시 순차적으로 처리 - 배치 처리: 여러
setState호출을 하나의 리렌더링으로 묶어 처리하여 성능 최적화 - 함수형 업데이트:
setCount(c => c + 1)처럼 이전 상태를 기반으로 다음 상태를 계산하여 예측 가능한 상태 관리 가능 - 동기화 책임: 리액트가 상태와 UI 동기화를 자동 관리하여 개발자의 부담 경감
이처럼 리액트의 상태 업데이트 큐잉과 함수형 업데이트는 우리가 더 견고하고 효율적인 애플리케이션을 만들 수 있도록 돕는 중요한 도구입니다. 다음 편에서는 객체 상태의 불변성이라는 또 다른 핵심 개념을 통해 리액트의 상태 관리를 더욱 깊이 이해하는 시간을 가져보겠습니다.