훅이 발표된 후, 페이스북 내부에서는 새로운 패러다임을 적극적으로 도입하려는 움직임이 일었다. 개발자들은 클래스의 굴레에서 벗어나, 간결하고 직관적인 함수로 UI를 만드는 새로운 방식에 열광했다.
하지만 새로운 도구에는 언제나 예상치 못한 함정이 숨어있는 법.
어느 날, 훅을 막 배우기 시작한 주니어 개발자 마야(Maya)가 난처한 표정으로 자신의 모니터를 들여다보고 있었다. 그녀는 장바구니에 담긴 상품 수량을 조절하는 간단한 UI를 만들고 있었다.
댄이 그녀의 자리 옆을 지나가다, 곤란해하는 그녀의 모습을 보고 걸음을 멈췄다.
“무엇이 잘 안되나요, 마야?”
마야가 댄을 보며 거의 울상이 된 목소리로 말했다.
“댄, 제가 이상한 버그를 발견한 것 같아요. useState가 제대로 작동하지 않아요.”
그녀의 화면에는 다음과 같은 코드가 있었다.
function ItemCounter() {
const [count, setCount] = useState(0);
const handleAddTwo = () => {
// 버튼을 한 번 누르면 수량을 2개씩 늘리고 싶다.
setCount(count + 1);
setCount(count + 1);
};
return (
<div>
<p>수량: {count}</p>
<button onClick={handleAddTwo}>+2 담기</button>
</div>
);
}
“보세요.” 마야가 시연했다. “수량이 0인 상태에서 ‘+2 담기’ 버튼을 눌렀는데, 수량이 1밖에 늘어나지 않아요. setCount를 두 번 호출했는데도요.”
그녀는 미간을 찌푸렸다. 논리적으로 이해할 수 없는 현상이었다. count + 1을 두 번 실행했는데, 왜 결과는 +2가 아닌 +1일까?
댄은 그녀의 코드를 보고 희미하게 미소 지었다. 그는 이미 수많은 개발자들이 이 함정에 빠질 것을 예상하고 있었다. 이것은 훅의 버그가 아니라, 훅의 동작 방식에 대한 가장 흔한 오해 중 하나였다.
“마야, handleAddTwo 함수가 실행되는 바로 그 순간, 이 함수 안의 count 변수 값은 무엇일까요?”
“0이죠.” 마야가 답했다.
“맞아요.” 댄이 고개를 끄덕였다. “그럼 첫 번째 setCount(count + 1)은, 리액트에게 ‘count를 1로 바꿔주세요’라고 요청하는 것과 같죠?”
“네, 그렇죠.”
“그럼 두 번째 setCount(count + 1)은 어떨까요? 이 코드가 실행되는 순간에도, handleAddTwo 함수 안의 count 값은 여전히 0입니다. 이 함수가 실행되는 동안 count 변수 자체는 변하지 않으니까요.”
순간, 마야의 눈이 커졌다.
댄이 설명을 이어갔다.
“setCount는 count 변수를 즉시 바꾸는 명령이 아닙니다. 그것은 리액트에게 ‘이 렌더링 주기가 끝나면, 상태를 이 값으로 업데이트 해주세요’라고 보내는 일종의 ‘예약 주문’과 같습니다. 마야는 지금 리액트에게 ‘count를 1로 만들어주세요’라는 주문을 한 번 보내고, 잠시 뒤에 또 ‘count를 1로 만들어주세요’라는 똑같은 주문을 보낸 셈이죠.”
리액트는 이 두 개의 동일한 주문을 받고, “알았어, count를 1로 바꿔줄게”라고 생각한 뒤, 렌더링이 끝날 때 단 한 번만 상태를 업데이트했다.
마야는 마침내 깨달았다.
“아! count 변수는 렌더링 시점의 값을 그대로 가지고 있는 ‘스냅샷’ 같은 거였군요. 함수가 실행되는 동안에는 절대 변하지 않고요.”
“정확합니다.” 댄이 말했다. “상태 업데이트는 비동기적으로, 그리고 일괄적으로(batched) 처리됩니다. 이것이 리액트의 성능을 유지하는 중요한 비결 중 하나죠.”
마야는 문제의 원인을 이해하고 안도의 한숨을 내쉬었다. 하지만 동시에 새로운 질문이 떠올랐다.
“그렇다면, 이전 상태 값을 기반으로 안전하게 상태를 업데이트하려면 어떻게 해야 하죠?”
댄은 만족스러운 미소를 지었다. 그녀는 이제 문제의 본질을 이해했다. 해답을 들을 준비가 된 것이다.
“아주 좋은 질문이에요. 자, 이제 그것을 올바르게 해결하는 방법을 이야기해 보죠.”


