프로젝트를 진행하던 중, 특정 아이템의 선택 상태 최대 개수를 기존 3개에서 5개로 늘리게 되었습니다. 원래는 추가하기 버튼을 클릭할 때 서버로 선택 상태 정보를 전송하고, 내부 로직에서 선택된 아이템 개수와 최대 개수를 비교하여 toast를 띄우는 방식이었습니다.
그런데 요구사항이 변경되어, 이제는 버튼 클릭 시점이 아니라 아이템을 선택하는 즉시 처리해 달라는 요청이 생겼습니다.
const handleFestivalClick = (festivalId: number, isSelected: boolean) => {
if (isSelected) {
removeSelect(festivalId);
} else {
addSelect(festivalId);
}
};
처음에는 위와 같은 핸들러 함수에서 isSelected 값을 기준으로 현재 선택된 카드의 개수가 MAX 값보다 크거나 같아지면 showToast()를 호출하면 된다고 생각했습니다.
하지만 문제는 setState 직후 상태가 즉시 반영되지 않기 때문에, 해제를 클릭했을 때도 토스트가 실행되는 문제가 발생했다는 점입니다.
이를 이해하기 위해서는 React의 상태 업데이트 특징을 먼저 이해해야 합니다.
setState 는 비동기적으로 실행돼요.
이 말은 반은 맞고 반은 틀립니다. 비동기 처럼 보이는 것은 사실이나, 실제로는 비동기 프로미스(async/await) 와는 다르다는 점. 그리고 batchin 과 클로저가 어떻게 작용하는지 알아야 합니다.
const [name, setName] = useState();
async function onClick() {
await setName('TEST'); // ❌ 아무 효과 없음
console.log(name); // ❌ 여전히 이전 값 (undefined)
}
setState()는 Promise를 반환하지 않기 때문에, await을 사용해도 다음 코드가 상태가 반영되기 전에 실행됩니다.
만약 상태 업데이트가 일반적인 비동기 함수처럼 동작했다면, 위 코드는 name을 출력할 때 "TEST"가 나와야 합니다.
const [name,setName] = useState();
async function onClick(){
await setName('TEST');
setTimeout(()=>{
console.log(name); //undefined
},1000);
}
타임아웃이 1000ms 후 실행되더라도, 콘솔에는 여전히 이전 값이 출력됩니다. 그 이유는 name은 함수가 실행된 시점의 클로저에 의해 결정되기 때문입니다.
const [name,setName] = useState();
name은 const로 선언되어 있고, setName을 호출해도 해당 값이 직접 변경되는 것이 아니라, 컴포넌트가 다시 렌더링되며 name이 새롭게 생성됩니다. 즉, 상태는 값이 직접 변경되는 것이 아니라 컴포넌트가 다시 호출되며 새로 정의되는 값입니다.
컴포넌트가 state가 변경될 때 마다 다시 만들어지고 리렌더링 되고 있기에, 값이 업데이트된 name이 새로 만들어지는 겁니다.
이렇게 컴포넌트가 갱신되면서 state 값을 만들고 있기 때문에 setState 를 호출한 이후에 바로 그 값을 사용할 수 없는 것입니다.
더 쉽게 예를 들어보자면,
function MyComponent() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); // ❌ 여전히 이전 count 출력됨
};
return <button onClick={handleClick}>Click me</button>;
}
정리하면, state는 컴포넌트 렌더링마다 다시 정의되는 값이고, setState()는 "다음 렌더링에서 이 값으로 바꿔줘!"라고 예약하는 함수입니다. 따라서 setState() 직후에는 아직 예전 state 값만 사용할 수 있습니다.
클로저는 함수가 자신이 선언될 때의 렉시컬 환경을 기억하는 것입니다.
좀 더 쉽게 말하면, 내부 함수가 외부 함수의 변수에 접근할 수 있는 현상을 클로저라고 합니다.
function outer() {
const message = "안녕 클로저";
function inner() {
console.log(message); // 외부 변수 접근
}
return inner;
}
const closureFunc = outer(); // outer는 끝났지만
closureFunc(); // "안녕 클로저" 출력
inner() 함수는 여전히 outer() 의 지역변수인 message 에 접근할 수 있습니다.이렇게 함수가 자신이 선언되었을 때의 스코프(변수 환경)을 기억하는 것을 클로저라고 합니다.
useEffect 를 사용하여 해결할 수 있습니다.
useEffect(() => {
if (TOTAL_SELECTIONS >= MAX_SELECTIONS) {
showToast();
}
}, [selectedFestivals, TOTAL_SELECTIONS, showToast]);
useEffect 는 리렌더링 이후에 selectedFestivals 이 이전 단계에 비교해서 변경되었다면 실행됩니다.
정확히 말하면 변경되는 것이 아니라 setState() 는 즉시 변경이 아니라 예약 되고, setState를 감지하는 것이 아니라 setState로 인해 리렌더링이 일어난 뒤, 의존성 배열의 값이 이전과 달라졌는지를 기준으로 useEffect 가 실행되는 겁니다.
const handleFestivalClick = (festivalId: number, isSelected: boolean) => {
if (isSelected) {
removeSelect(festivalId);
} else {
addSelect(festivalId);
}
if (TOTAL_SELECTIONS >= MAX_SELECTIONS) {
showToast(); // ❌ 동작 안 함 (클로저로 인해 이전 값을 봄)
}
};
이 시점에서 TOTAL_SELECTION 은 아직 변경 전 값이고, 안에서 상태 값 변경 함수가 실행되어도, 아직 리렌더링도 안됐고, 값도 업데이트 전이기 때문이에요 그리고 그것이 클로저 개념과 관련있고,
원하는 동작을 위해선 useEffect 를 사용하여 토스트의 실행 시점을 리렌더링 이후의 최신 값을 기준으로 실행하도록 하면 되는 겁니다.
setState는 "값을 즉시 변경하는 게 아니라, 변경을 예약"한다.
값이 실제로 반영된 시점은 리렌더링 이후이고,
그 타이밍에 뭔가 하고 싶으면 useEffect를 써야 한다.
그리고 함수 내부의 클로저는 "현재 값"이 아닌 정의된 당시의 값을 기억한다.