RETROSPECTIVE · 회고
이벤트 핸들러가 옛날 state를 보는 이유: stale closure
Frontend
여러 배송지를 한 번에 입력하는 모달에서, 엑셀을 업로드해 주소 목록을 채운 뒤 "검증" 버튼을 누르면 빈 목록 기준으로 검증되는 버그가 있었다. 화면에는 주소가 분명히 보이는데 핸들러는 예전 값을 보고 있었다.
원인: 첫 렌더의 값을 붙잡은 콜백
const [addresses, setAddresses] = useState<Address[]>([]);
const validate = useCallback(async () => {
await api.checkDeliverable(addresses); // 항상 []
}, []); // 의존성 누락
함수는 만들어질 때의 변수를 클로저로 기억한다. 의존성 배열이 비어 있으니 validate는 첫 렌더에서 한 번만 만들어지고, 그때의 addresses(빈 배열)를 계속 들고 있다.
해결 방법들
- 의존성을 제대로 채운다.
[addresses]를 넣으면 값이 바뀔 때 새 함수가 만들어진다.react-hooks/exhaustive-deps경고를 끄지 않는 게 가장 좋은 예방책이다. - 이전 값 기반 업데이트는 함수형으로.
setAddresses(prev => [...prev, ...rows])는 클로저의 값이 아니라 최신 state를 받는다. - 최신 값만 읽으면 되는 비동기 콜백은 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로 쓴 검증 조건이 의도와 다르게 동작한 적도 있다. &&가 ||보다 먼저 묶이기 때문이다. 섞어 쓸 때는 괄호로 의도를 드러내는 게 안전하다. ??는 아예 괄호 없이 ||, &&와 섞으면 문법 에러가 난다.