useEffect의 cleanup에서 AbortController.abort()를 호출하도록 짰는데, 요청이 취소되지 않았다. 디버깅해보니 cleanup 안에서 컨트롤러가 계속 null로 찍혔다. 분명히 setAbortController로 값을 넣어줬는데도. 이 글은 그 원인인 stale closure와, 해결책으로 ref를 쓰는 이유를 정리한다.
effect가 반환하는 함수다. 이름 그대로 "정리(clean up)"를 담당한다. 구독 해제, 타이머 clear, 이벤트 리스너 제거, 진행 중인 요청 abort 같은 뒷정리가 여기 들어간다.
useEffect(() => {
const timer = setInterval(tick, 1000);
return () => {
clearInterval(timer); // cleanup
};
}, []);
두 가지 시점이다.
의존성 배열이 []이면 재실행 자체가 없으므로, 이 경우엔 언마운트 때 딱 한 번만 cleanup이 호출된다.
실행 순서를 타임라인으로 보면 이렇다.
mount → effect
↓ (deps 변경)
cleanup → effect (재실행)
↓ (deps 변경)
cleanup → effect (재실행)
↓
unmount → cleanup (마지막 정리)
deps === []이면 가운데 재실행 구간이 통째로 사라지고 mount → effect ... unmount → cleanup만 남는다.
본론 전에 클로저를 한 번 짚고 가자. 함수는 생성될 때 주변 스코프의 변수를 캡처한다.
function makeCounter() {
let count = 0;
return () => ++count; // count를 캡처
}
const counter = makeCounter();
counter(); // 1
counter(); // 2
반환된 함수는 makeCounter가 이미 끝났어도 count에 계속 접근한다. 이 "캡처" 동작이 다음 문제의 핵심이다.
cleanup 함수도 결국 클로저다. 생성된 시점의 값을 캡처한다. 그리고 deps === []이면 cleanup은 첫 렌더 때 딱 한 번 만들어진다.
문제 코드부터 보자.
function useProductList() {
const [abortController, setAbortController] =
useState<AbortController | null>(null);
useEffect(() => {
const controller = new AbortController();
setAbortController(controller);
fetchProducts({ signal: controller.signal });
return () => {
// 이 클로저는 첫 렌더 시점의 abortController를 캡처 → 계속 null
abortController?.abort(); // 아무 일도 안 일어남
};
}, []);
}
cleanup 클로저가 만들어진 첫 렌더 시점에 abortController는 아직 null이다. 이후 setAbortController(controller)로 state를 갱신해도, 클로저가 붙잡은 건 그 순간의 null이다. state가 바뀌면 리렌더는 일어나지만, [] deps라 effect도 cleanup도 다시 만들어지지 않는다. 그래서 언마운트 시 실행되는 cleanup은 여전히 null.abort()를 시도하고, 실제 컨트롤러엔 닿지 못한다.
이렇게 클로저가 낡은 값을 붙잡고 있는 현상을 stale closure라고 부른다.
useState 대신 useRef로 바꾸면 해결된다.
function useProductList() {
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const controller = new AbortController();
abortControllerRef.current = controller;
fetchProducts({ signal: controller.signal });
return () => {
// 클로저가 캡처하는 건 ref '객체'. .current는 실행 시점의 최신값
abortControllerRef.current?.abort(); // 실제로 요청 취소됨
};
}, []);
}
핵심은 클로저가 캡처하는 대상이 바뀌었다는 것이다.
null이라는 값. 첫 렌더의 스냅샷이 그대로 박제된다.abortControllerRef라는 객체 참조. 이 객체는 리렌더 내내 동일하고, 내부의 .current만 갈아끼워진다.cleanup이 실제로 실행되는 시점에 abortControllerRef.current를 읽으면, 그 순간의 최신 컨트롤러가 나온다. 그래서 abort()가 제대로 먹힌다.
| state | ref | |
|---|---|---|
| 값 변경 시 리렌더 | O | X |
| 렌더마다 | 새 스냅샷 | 동일 객체 유지 |
| 클로저가 캡처하는 것 | 그 순간의 값 | 객체 참조 |
| 콜백/cleanup에서 읽으면 | 캡처 시점의 옛날 값 | 실행 시점의 최신값 |
| 언제 쓰나 | 화면에 반영돼야 할 때 | 반영은 불필요 + 최신값만 읽으면 될 때 |
정리하면 이렇다.
useEffect(() => {...}, []) 안에서 최신 state를 참조해야 하는 상황이 생기면, stale closure를 의심하고 ref를 떠올리면 된다. cleanup은 첫 렌더의 클로저를 그대로 물고 실행된다는 점만 기억하면, 왜 값이 안 바뀌는지 헷갈릴 일이 없다.