← 프로젝트 회고 · Frontend
RETROSPECTIVE · 회고

이벤트 핸들러가 옛날 state를 보는 이유: stale closure

Frontend

여러 배송지를 한 번에 입력하는 모달에서, 엑셀을 업로드해 주소 목록을 채운 뒤 "검증" 버튼을 누르면 빈 목록 기준으로 검증되는 버그가 있었다. 화면에는 주소가 분명히 보이는데 핸들러는 예전 값을 보고 있었다.

원인: 첫 렌더의 값을 붙잡은 콜백

const [addresses, setAddresses] = useState<Address[]>([]);

const validate = useCallback(async () => {
await api.checkDeliverable(addresses); // 항상 []
}, []); // 의존성 누락

함수는 만들어질 때의 변수를 클로저로 기억한다. 의존성 배열이 비어 있으니 validate는 첫 렌더에서 한 번만 만들어지고, 그때의 addresses(빈 배열)를 계속 들고 있다.

해결 방법들

  1. 의존성을 제대로 채운다. [addresses]를 넣으면 값이 바뀔 때 새 함수가 만들어진다. react-hooks/exhaustive-deps 경고를 끄지 않는 게 가장 좋은 예방책이다.
  2. 이전 값 기반 업데이트는 함수형으로. setAddresses(prev => [...prev, ...rows])는 클로저의 값이 아니라 최신 state를 받는다.
  3. 최신 값만 읽으면 되는 비동기 콜백은 ref. 타이머나 외부 라이브러리 콜백처럼 함수를 다시 만들기 곤란할 때 ref.current로 최신 값을 읽는다.

비동기 검증의 또 다른 함정: 응답 순서

주소를 빠르게 여러 번 바꾸면 늦게 보낸 요청의 응답이 먼저 올 수 있다. 마지막 요청의 결과만 반영하도록 요청 번호를 비교했다.

const seq = useRef(0);

async function validate(list: Address[]) {
const my = ++seq.current;
const result = await api.checkDeliverable(list);
if (my !== seq.current) return; // 더 최신 요청이 있음
setResult(result);
}

덤: 조건식 우선순위

같은 모달에서 a && b || c로 쓴 검증 조건이 의도와 다르게 동작한 적도 있다. &&가 ||보다 먼저 묶이기 때문이다. 섞어 쓸 때는 괄호로 의도를 드러내는 게 안전하다. ??는 아예 괄호 없이 ||, &&와 섞으면 문법 에러가 난다.

Gunmo Lee
이벤트 핸들러가 옛날 state를 보는 이유: stale closure | Gunmo's Dev Life