setCount((prev) => prev + 1);
리액트를 하다 보면 이러한 형태의 setter 함수를 쓰게 된다.
이는 `useState`의 setter 함수에서 콜백 형태로 상태를 업데이트 하는 것인데, 이를 '함수형 업데이트' 방식이라고 한다.
이번에는 값 기반 업데이트와 함수형 업데이트의 차이, 그리고 함수형 업데이트에 담긴 '클로저'에 대해 이해해보려고 한다. (사실 후자가 목적)
1. 값 기반 업데이트 (단순히 '새 값'으로 상태 변경)
const CountExample = () => {
const [count, setCount] = useState(0);
const action = async () => {
const n = await new Promise((resolve) => setTimeout(resolve, 1000));
setCount(count + n);
setCount(count + n);
// setCount((prev) => prev + 1);
// setCount((prev) => prev + 1);
};
return (
<main>
<p>{count}</p>
<button onClick={action}>action</button>
</main>
);
};
이 코드를 실행하면 버튼을 한 번 클릭했을 때, `count`가 1만 올라간다.
이는 리액트의 'Automatic Batching', 줄여서 '배치'라는 특성 때문인데, 리액트는 성능 최적화를 위해 같은 상태가 여러번 업데이트 된다면 이를 모두 하나로 묶어 업데이트한다.
그래서 여기서는 2번의 `setCount`가 1번의 `count` 업데이트로 배치(batch)된 것이다.
결과적으로 React는 동일한 상태값으로 두 번 설정하는 것은 무시하고, 결국 `count`는 1만 증가한 것처럼 보이는 것이다.
✅ React의 Auto Batching이란?
자동 배치 처리는 React 18부터 도입된 기능으로, 여러 개의 setState 호출을 하나의 렌더링으로 묶어 처리해주는 최적화 기법이다.
예전에는 setState가 이벤트 핸들러 안에서만 자동으로 배치 처리되었지만,
React 18부터는 `setTimeout`, `fetch`, `async/await` 등 비동기 코드 안에서도 자동으로 배치 처리된다.
=> `setCount(count + 1)`처럼 이전 상태를 기반으로 하지 않는 방식은 동일한 값으로 두 번 업데이트되면 덮어씌워짐.
2. '함수형' 업데이트
다음은 setCount가 이전 상태를 기억하도록 콜백을 사용한 방식이다.
// 위아래 코드는 생략...
const action = async () => {
const n = await new Promise((resolve) => setTimeout(resolve, 1000));
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
};
이 코드를 실행하면 버튼을 한 번 클릭했을 때 `count`가 2로 누적된다.
여기서 '클로저'의 개념이 적용되는데, `setCount` 함수가 자신이 호출된 당시의 렉시컬 환경을 기억하고 있는 것이다.
당시 렉시컬 환경에 있던 `prev`의 값을 첫번째 `setCount` 내부의 클로저가 기억하고,
그 호출문에서 업데이트된 `prev` 값은 두번째 `setCount`가 호출될 당시의 렉시컬 환경에 담겨있어서 두번째 `setCount`의 내부 클로저가 해당 `prev`를 정상적으로 이어받아 값을 누적시키는 방식이다.
+ 그럼 prev가 렉시컬 환경에 저장되는 원리는..?
🔍 prev => prev + 1의 작동 원리와 렉시컬 환경
setCount(prev => prev + 1);
이 구문에서 `prev => prev + 1`은 익명 함수(arrow function)이고, `prev`는 이 함수의 매개변수다.
📌 여기서의 렉시컬 환경:
- 이 함수가 정의되는 순간, 자바스크립트는 이를 하나의 클로저로 간주.
- `prev`는 이 함수의 매개변수이자 지역 변수로 자신만의 렉시컬 환경에 저장.
- 이후 React가 `setCount`를 실행하면서 이 클로저를 호출.
- 그때 React는 내부적으로 현재 상태값을 `prev`에 인자로 전달하여 이 함수를 실행.
- 클로저는 자신이 기억하고 있는 환경에서 `prev`를 참조해 계산을 수행.
React는 내부적으로 어떻게 현재 상태값을 prev로 전달할까?
function setCount(update) {
const currentState = ... // 내부적으로 저장된 count의 현재 값
let nextState;
if (typeof update === 'function') {
nextState = update(currentState); // 여기서 currentState가 prev로 전달됨!
} else {
nextState = update;
}
// 상태 갱신 및 리렌더링
...
}
여기서 React는 함수형 업데이트(`setCount(prev => prev + 1);`)가 전달되면 내부적으로 `update(currentState)`를 호출하고, 이때 `currentState`가 `prev` 인자로 전달된다.
그래서 `prev => prev + 1`의 prev는 React가 넘겨준 현재 상태값이 되는 것이다.
다시 본론으로 돌아와서,
setCount((prev) => prev + 1); // prev: 0
setCount((prev) => prev + 1); // prev: 1
여기서 `setCount((prev) => prev + 1)` 내부의 `(prev) => prev + 1`이 클로저이고,
클로저는 `prev`라는 변수로 최신 상태값에 접근할 수 있게 해준다.
따라서 이렇게 두 번 호출해도 상태가 누적되어 정확하게 증가하는 이유는, 각각의 클로저가 최신 상태값을 기억하고 참조하고 있기 때문이다.
✅ 정리
- `setCount(count + 1)` 방식은 리액트의 auto batching으로 인해 두 번 호출해도, 최종적으로 한 번만 실행된 것처럼 보인다.
(상태 업데이트 함수는 여러 번 실행됐지만, React는 마지막 상태만 반영함) - `setCount(prev => prev + 1)`는 각 호출이 최신 상태(prev)를 기반으로 동작하기 때문에 누적으로 업데이트가 된다.
- 따라서 함수형 업데이트의 prev는 현재 상태값이라고 요약할 수 있다.
⭐️ setState와 클로저의 관계는?
핵심 요점: setState는 상태를 보존하는 클로저다. (setState === 클로저)
React는 컴포넌트마다 자체적인 상태 저장소를 만들고, 그 안에 상태를 저장한다. setState는 그 저장소와 연결된 "클로저"다.
React 내부를 간단히 모델링하면 다음과 같다. (실제 코드 X)
function useState(initialValue) {
let state = initialValue;
function setState(newState) {
if (typeof newState === 'function') {
state = newState(state); // 함수형 업데이트
} else {
state = newState; // 값으로 직접 업데이트
}
render(); // 상태 바뀌었으니 리렌더링!
}
return [state, setState]; // 이때 setState는 state에 접근 가능한 클로저
}
여기서 주목할 부분은,
- setState는 state를 자신의 상위 렉시컬 환경에서 참조한다.
- 그래서 state가 함수 바깥으로 나가도 사라지지 않고, 계속 유지된다.
- 이렇게 구현된 setState가 바로 클로저다.
✅ 실제 React에서는 이렇게 관리된다
- React는 각 컴포넌트 인스턴스마다 "상태 슬롯" 같은 구조를 갖고 있고,
- setState는 이 슬롯을 가리키는 포인터 역할을 하는 클로저이다.
즉, 컴포넌트가 여러번 리렌더링되어도 setState는 항상 같은 상태 슬롯(컴포넌트)을 참조하기 때문에 상태 업데이트가 안전하게 이뤄질 수 있다.
🎯 useState가 클로저로 만들어진 이유
| 상태 유지(캡슐화) | 상태가 컴포넌트 바깥으로 나가지 않고 안전하게 캡슐화됨 |
| 함수형 업데이트 지원 | `prev => prev + 1` 같은 클로저 기반 상태 계산 가능 |
| 컴포넌트 독립성 | 각 컴포넌트마다 상태가 분리되어 격리 가능 |
⚖️ 언제 함수형 업데이트를 해야 할까?
| 상황 | 함수형 업데이트 필요 여부 |
| 단순히 특정 값으로 상태를 바꿈 | ❌ 필요 없음 → `setCount(3)` 같이 직접 대입해도 OK |
| 이전 상태를 기반으로 새 상태를 계산 | ✅ 꼭 사용해야 함 → `setCount(prev => prev + 1)` |
| 여러 개의 상태 업데이트가 연달아 일어날 수 있는 경우 (ex. 이벤트 핸들러 내부) | ✅ 권장 |
| 비동기 로직 안에서 상태를 업데이트할 때 | ✅ 강력히 권장 |
+ ”prev는 이 함수의 매개변수이자 지역 변수로 자신만의 렉시컬 환경에 저장된다”의 뜻
이는 자바스크립트 엔진이 함수 실행 시 어떻게 변수들을 다루는지에 대한 내용이다.
예를 들어,
const fn = (prev) => {
return prev + 1;
};
이 함수가 실행되면, 자바스크립트는 다음과 같은 단계를 거친다.
- 함수 실행 컨텍스트 생성
- 실행에 필요한 정보를 담은 공간
- 렉시컬 환경 생성
- 이 함수에서 사용할 지역 변수, 매개변수(prev), 상위 스코프를 참조할 수 있게 구성
- prev에 인자 값 저장
- 예: fn(2)라고 하면, 렉시컬 환경 내 prev = 2로 저장
👉 즉, prev는 이 함수의 매개변수이자, 함수 실행 시 생성되는 렉시컬 환경(지역 스코프)에 저장되는 변수다.
💬 비유적으로 말하면...
- React는 `setCount(prev => prev + 1)`을 보면 “오, 상태 업데이트 함수를 넘겼네. 그럼 현재 상태를 prev에 넣고 실행해볼게.”
- 자바스크립트는 `prev => prev + 1`을 실행하면서 “음, 이 함수엔 매개변수 하나 있고, 그 값을 prev로 기억해 둘 렉시컬 환경을 만들자.”
'React.js' 카테고리의 다른 글
| Navigate vs useNavigate (0) | 2025.05.15 |
|---|---|
| 투두리스트로 간단하게 알아보는 useReducer (0) | 2025.05.13 |
| 브라우저가 리액트를 실행하는 과정 (간단 요약) (0) | 2025.05.08 |
| 배열 state 변경할 때, Spread 문법을 써야 하는 이유 (중요) (0) | 2025.05.05 |
| 리액트를 쓰는 이유 (a.k.a. 개념 정리) (0) | 2025.05.01 |