시간 여행자 State - 스냅샷으로 이해하는 상태 업데이트
리액트 공식문서 기반의 리액트 입문기
들어가며
프론트엔드 개발에서 UI는 끊임없이 변합니다. 사용자의 상호작용에 따라 화면의 정보가 바뀌고, 이 변화를 효율적으로 관리하는 것이 중요합니다. 자바스크립트 개발에 익숙한 우리에게는 변수의 값을 변경하고 DOM을 직접 조작하여 UI를 업데이트하는 방식이 자연스럽습니다. 하지만 리액트에서는 상태(State)를 다루는 방식에 조금 다른 접근법을 사용합니다. 특히, 리액트의 상태는 단순한 변수가 아니라 마치 '시간 여행자'처럼 특정 시점의 '스냅샷'으로 존재한다는 개념을 이해하는 것이 중요합니다. 이번 편에서는 자바스크립트의 즉각적인 상태 변경과 리액트의 스냅샷 기반 비동기 업데이트 방식의 차이점을 카운터 예제를 통해 탐구하고, 그 과정에서 '클로저와 상태'라는 중요한 개념도 함께 살펴보겠습니다.
리액트의 상태 스냅샷 - 시간 여행을 가능하게 하는 비밀
리액트에서 State를 업데이트할 때, 우리는 흔히 setCount(count + 1)과 같이 작성합니다. 하지만 이 코드가 실행되는 순간 count 변수가 즉시 변경된다고 생각하면 오해가 생길 수 있습니다. 리액트의 State는 마치 특정 순간을 포착한 '스냅샷'처럼 존재합니다. 각 렌더링은 그 렌더링 시점에 고정된 props와 state의 스냅샷을 가지며, 컴포넌트 내부의 모든 함수(이벤트 핸들러 포함)는 이 스냅샷에 '갇히게(captured)' 됩니다. 즉, 이벤트 핸들러나 다른 함수 내부에서 count를 참조할 때는 항상 해당 렌더링 시점에 존재했던 count의 값을 읽게 되는 것이죠.
이를 3-3편에서 사용했던 '화이트보드 프레젠테이션' 비유에 적용해 보겠습니다. 각 발표 담당자(컴포넌트)가 화이트보드에 그림을 그리기 위해 자신의 '개인적인 기록 노트'(State)를 참조한다고 가정해 봅시다. 담당자가 그림을 그리는 특정 순간(렌더링)에는 그 시점에 가지고 있던 노트의 내용(State 스냅샷)을 기준으로 작업합니다. 설령 그림을 그리는 도중에 새로운 지시사항(setState 호출)이 들어와 노트의 내용이 바뀌더라도, 현재 그리는 그림은 이미 시작된 순간의 노트 내용에 '고정(captured)'됩니다. 즉, 다음 그림을 그릴 때가 되어서야 비로소 변경된 노트 내용이 반영되는 것이죠.
리액트의 '배치 업데이트(Batch Update)'는 이 비유에서 발표 담당자가 작은 수정 요청(State 업데이트)이 들어올 때마다 즉시 화이트보드를 수정하는 대신, 여러 개의 수정 요청을 모아 한 번에 반영하는 것과 같습니다. 마치 짧은 시간 내에 여러 지시사항이 들어왔을 때, 모든 지시를 한 번에 정리하여 다음 프레젠테이션(다음 렌더링)에 반영함으로써 발표의 흐름을 방해하지 않고 효율적으로 진행하는 것과 유사합니다. 만약 모든 setState 호출이 즉시 UI를 업데이트한다면, 작은 상호작용에도 수많은 리렌더링이 발생하여 발표의 연속성(성능)을 해칠 수 있습니다. 스냅샷은 이러한 비동기적이고 배치 처리되는 업데이트 환경에서 각 렌더링이 일관된 상태를 기준으로 동작하도록 보장하여 UI를 더욱 예측 가능하고 관리하기 쉽게 만듭니다. 이러한 리액트의 동작 방식을 이해하는 것은 복잡한 상태 로직을 다룰 때 '예측 가능성'을 높이는 데 필수적이라고 할 수 있습니다. (배치 처리와 함수형 업데이트는 다음 편 3-5편에서 자세히 이어서 설명합니다.) 이 스냅샷 개념은 리액트의 동시성 렌더링(Concurrent Rendering)과 같은 고급 기능을 가능하게 하는 핵심적인 기반이기도 합니다.
자바스크립트로 즉시 업데이트되는 카운터 만들기
먼저 우리가 늘 사용해온 방식으로, 버튼을 클릭할 때마다 숫자가 즉시 증가하고 화면에 반영되는 간단한 카운터 기능을 자바스크립트로 구현해 보겠습니다. 이를 통해 자바스크립트의 명령형 프로그래밍 방식과 상태 변경의 즉각적인 특성을 확인할 수 있습니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vanilla JavaScript Counter</title>
</head>
<body>
<h1>Vanilla JavaScript Counter</h1>
<p>Count: <span id="countDisplay">0</span></p>
<button id="incrementButton">Increment</button>
<script>
(() => {
let count = 0; // 초기 상태
const countDisplay = document.getElementById("countDisplay");
const incrementButton = document.getElementById("incrementButton");
const updateDisplay = () => {
countDisplay.textContent = count;
};
incrementButton.addEventListener("click", () => {
count = count + 1; // 상태를 직접 변경
console.log("Current count (JS): ", count); // 즉시 변경된 값 확인
updateDisplay(); // UI를 수동으로 업데이트
});
updateDisplay(); // 초기 화면 렌더링
})();
</script>
</body>
</html>위 자바스크립트 코드를 보면 incrementButton을 클릭할 때 count 변수의 값이 즉시 증가하고, console.log에서도 변경된 count 값을 바로 확인할 수 있습니다. updateDisplay() 함수를 직접 호출하여 UI를 수동으로 업데이트하는 것도 특징입니다. 이러한 방식은 우리가 변수를 다루는 일반적인 사고방식과 일치하며, 상태 변화가 즉각적으로 반영되는 것을 명확하게 보여줍니다.
자바스크립트 방식의 특징
- 즉시 반영되는 상태 변경:
count = count + 1;과 같이 변수의 값을 변경하면 해당 변경이 코드 실행 흐름에 따라 즉시 반영됩니다.console.log를 통해 바로 업데이트된 값을 확인할 수 있습니다. - 수동적인 UI 업데이트: 개발자가
updateDisplay()함수를 직접 호출하여 DOM 요소를 선택하고textContent를 변경해야만 UI가 업데이트됩니다. 이는 UI와 상태의 동기화 책임을 개발자가 직접 진다는 의미입니다. - 스코프 관리의 중요성:
count와 같은 상태 변수를 어디에 선언하느냐에 따라 접근 범위와 부작용이 달라질 수 있습니다. 위 예제에서는 IIFE(즉시 실행 함수)를 사용하여 전역 스코프 오염을 피하고 있지만, 복잡한 애플리케이션에서는 상태 관리가 더 어려워질 수 있습니다.
리액트로 동일한 [기능] 만들기
이제 리액트 버전으로 넘어가 보겠습니다. 자바스크립트 카운터와 동일한 기능을 리액트의 useState 훅을 사용하여 구현해 보겠습니다. 이 과정에서 리액트의 '상태 스냅샷' 개념과 '배치 업데이트'의 비동기적 특성을 명확하게 이해할 수 있습니다. 또한, setCount 함수가 어떻게 동작하며, 클로저가 상태 관리에 어떤 영향을 미치는지 살펴보겠습니다.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // State 업데이트 요청
// 이 시점의 `count`는 현재 렌더링의 스냅샷 값이므로, 업데이트 요청 직후 `console.log`로 확인해도 이전 값이 출력됩니다.
console.log('Current count (React - after setState): ', count);
};
return (
<div>
<h1>React Counter</h1>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
</div>
);
}
export default Counter;위 리액트 코드를 실행하고 Increment 버튼을 여러 번 클릭한 후 console.log를 확인해 보면, setCount(count + 1)를 호출한 직후의 count 값은 우리가 기대하는 최신 값이 아닌 이전 렌더링 시점의 값임을 알 수 있습니다. 이는 count가 특정 렌더링에 대한 스냅샷이며, setCount가 비동기적으로 작동하기 때문입니다. 이러한 현상을 '오래된 클로저(Stale Closure)' 문제라고 부르기도 합니다. 각 렌더링은 자신만의 props와 state의 스냅샷을 '기억'하는 클로저를 형성하고, 이벤트 핸들러는 해당 클로저 내부의 count 값을 참조하기 때문에, setState가 다음 렌더링을 위한 상태 업데이트를 예약하더라도 현재 실행 중인 이벤트 핸들러는 이전 렌더링의 count 스냅샷에 묶여 있게 되는 것입니다. 이러한 setState의 비동기적 특성과 상태 스냅샷에 대한 이해는 리액트에서 예측 가능한 UI를 만드는 데 매우 중요합니다.
이처럼 이전 상태 값에 기반하여 다음 상태를 안전하게 계산하는 방법이 필요한데, 이에 대해서는 다음 편(3-5편)인 "상태 업데이트의 대기열 - 배치 처리와 함수형 업데이트"에서 '함수형 업데이트'를 통해 자세히 다룰 예정입니다.
리액트 방식의 특징 (상태 스냅샷)
- 1. 스냅샷으로서의 State: 리액트의
State는 단순한 변수가 아닌, 특정 렌더링 시점에 고정된 값의 **'스냅샷(Snapshot)'**입니다. 컴포넌트가 렌더링될 때마다 새로운props와state스냅샷을 가지며, 이벤트 핸들러를 포함한 모든 컴포넌트 내부 함수는 이 스냅샷에 '갇히게(captured)' 됩니다. 따라서setState호출 직후console.log로 현재State변수를 확인하면 이전 스냅샷 값이 출력됩니다. 상태가 변경될 때마다 새로운 스냅샷을 생성함으로써, 리액트는 상태 변화를 명확히 감지하고 효율적으로 UI를 업데이트할 수 있습니다. - 2. 비동기적 배치 업데이트와 성능 최적화:
setCount와 같은setState함수는State를 즉시 변경하지 않습니다. 대신, 리액트는 여러setState호출을 모아 한 번에 처리하는 **'배치 업데이트(Batch Update)'**를 통해 불필요한 리렌더링 횟수를 최소화하고 애플리케이션의 성능을 최적화합니다. 이 과정은 비동기적으로 진행되어 UI 업데이트의 효율성을 높이며, 사용자 경험의 끊김 없는 부드러움을 제공합니다. 자바스크립트에서 각 DOM 조작마다 리렌더링이 발생하는 것과 달리, 리액트는 효율적인 스케줄링을 통해 전체적인 성능을 관리합니다. - 3. 오래된 클로저(Stale Closure) 문제의 이해: 각 렌더링은 고유한
props와state의 스냅샷을 '기억'하는 **클로저(Closure)**를 형성합니다. 이 때문에setState호출 후에도 현재 실행 중인 코드에서는 이전 렌더링의State스냅샷을 참조하게 되는 '오래된 클로저(Stale Closure)' 문제가 발생할 수 있습니다. 이는 리액트의 상태 관리에서 자주 마주치는 중요한 개념이며, 상태 업데이트의 비동기적 특성과 밀접하게 관련되어 있습니다. 이 개념을 이해함으로써 우리는 리액트 컴포넌트의 동작을 더욱 정확하게 예측하고, 잠재적인 버그를 예방할 수 있습니다. - 4. 함수형 업데이트(Functional Updates)로 안전한 해결: 이전
State값에 기반하여 새로운State를 안전하게 계산해야 할 때, 다음 편(3-5편)에서 자세히 다룰 **'함수형 업데이트(Functional Updates)'**를 사용하면 이러한 스냅샷의 한계를 보완하고 항상 최신State값을 안전하게 참조할 수 있습니다. 함수형 업데이트는setState에 직접 값을 전달하는 대신, 이전 상태를 인자로 받는 함수를 전달하는 방식으로, 상태 변화를 예측 가능하고 일관성 있게 만듭니다. - 5. 예측 가능한 UI와 디버깅 용이성: 리액트는 State 변경을 감지하여 Virtual DOM을 통해 실제 DOM 조작을 대신하므로, 개발자는 State가 '무엇'이 되어야 하는지에 집중할 수 있습니다. 스냅샷 개념과 배치 업데이트는 이러한 UI 업데이트 과정의 예측 가능성을 높이고 불필요한 리렌더링을 줄여 전반적인 애플리케이션 성능을 향상시킵니다. 또한, 각 렌더링이 고정된 상태 스냅샷을 가지므로, 특정 시점의 상태를 추적하고 디버깅하는 것이 훨씬 용이해집니다. 이는 복잡한 웹 애플리케이션 개발에 있어 매우 중요한 이점입니다.
자바스크립트 vs 리액트 차이
| 구분 | 자바스크립트 (즉시 변경) | 리액트 (스냅샷 & 배치 업데이트) |
|---|---|---|
| 상태 정의 | 일반 변수 (let, const) | useState Hook으로 정의 |
| 상태 변경 | 변수 직접 할당, 즉시 변경 | setState 함수 호출, 비동기적 배치 업데이트 |
| UI 업데이트 | 개발자가 DOM 직접 조작하여 수동 업데이트 | React가 State 변경에 따라 자동 렌더링 |
| 동기화 책임 | 개발자가 State와 UI 동기화 책임 | React가 State와 UI 동기화 책임 |
| 상태 참조 | 항상 최신 값 | 특정 렌더링 시점의 스냅샷 값 (오래된 클로저) |
요약
이전 섹션에서 우리는 리액트의 상태가 어떻게 작동하는지, 특히 자바스크립트의 전통적인 변수 변경 방식과 리액트의 스냅샷 및 배치 업데이트 방식이 어떻게 다른지 살펴보았습니다. 이 과정에서 얻은 주요 개념들을 정리하며 리액트 상태 관리의 핵심을 다시 한번 되새겨 보고자 합니다.
- 상태 스냅샷: 특정 렌더링 시점의 고정된 State 값
- 비동기 배치 업데이트: 여러 State 업데이트를 모아 한 번에 처리
- 오래된 클로저:
setState호출 후에도 이전 렌더링의 State 스냅샷 참조 - 함수형 업데이트: 3-5편에서 다룰, 최신 State 기반 계산 방식
- 예측 가능한 UI: Virtual DOM 기반 자동 렌더링으로 예측 및 관리 용이