React State Update의 비동기성과 Closure 관련 이슈 디버깅
증상
SNS 서비스의 피드 목록에 무한 스크롤을 구현했어요. 스크롤이 바닥에 닿으면 다음 데이터를 가져오는 기능이었는데, 스크롤을 조금만 빠르게 내리면, API 요청이 순식간에 3~4번씩 중복으로 전송되는 현상이 발생했어요. 결과적으로 받아온 데이터가 리스트에 중복으로 쌓이면서 Warning: Encountered two children with the same key 에러가 발생했고, 사용자 경험도 뚝뚝 끊기는 느낌이 들었죠.
첫 시도
이벤트가 너무 자주 발동되나 싶어서 lodash의 throttle을 걸어봤어요.
const handleScroll = throttle(() => {
// 스크롤 감지 로직
}, 500);하지만 이건 IntersectionObserver를 사용하는 현재 로직이랑은 맞지 않았고, 오히려 사용자가 바닥에 닿았는데도 로딩이 늦게 뜨는 등 반응성만 나빠졌어요. 그다음엔 isLoading 상태를 만들어서 방어 코드를 짰어요.
if (inView && !isLoading) {
setIsLoading(true);
fetchNextPage();
}하지만 여전히 빠르게 스크롤 하면 요청이 두세 번씩 호출이 됐어요.
원인
로그를 찍어보고 리액트 공식 문서를 다시 보며 깨달았어요. 원인은 State Update의 비동기성과 Closure(클로저) 때문이었어요.
setIsLoading(true)를 호출한다고 해서 즉시 true가 되는 게 아니었어요. 리액트가 리렌더링을 스케줄링하는 그 찰나의 순간 동안, isLoading은 여전히 false 상태죠. 그 짧은 찰나 사이에 IntersectionObserver의 콜백이 한 번 더 실행되어 버린 거예요.
게다가 useEffect 안에서 생성된 옵저버 콜백 함수는 처음 생성될 당시의 isLoading 값인 false만을 기억하고 있죠.
해결책
리렌더링과 상관없이 즉시 값을 변경하고 참조할 수 있는 useRef를 사용해서 해결했어요.
const isFetching = useRef(false);
const loadMore = async () => {
if (isFetching.current) return;
isFetching.current = true;
try {
const data = await api.getPosts(page);
setPosts((prev) => [...prev, ...data]);
} catch (e) {
console.error(e);
} finally {
isFetching.current = false;
}
};
useEffect(() => {
if (inView) loadMore();
}, [inView]);useRef의 값은 변경 즉시 반영되므로, 아무리 스크롤이 빨라도 첫 번째 요청이 시작되는 순간 isFetching.current가 true가 되어 후속 요청들을 확실하게 차단할 수 있었어요.
재발 방지를 위한 대책
비동기 요청 제어엔 Ref 고려하기: 단순히 UI를 보여주는 용도가 아니라, 로직의 실행 여부를 제어하는 변수는 useState보다 useRef가 훨씬 안전하다는 걸 배웠어요.
라이브러리의 힘 빌리기: 사실 이 모든 고민을 해결해 주는 React Query의 useInfiniteQuery가 있죠. 복잡한 기능을 구현할 때에는 이 기능을 이미 구현해놓은 라이브러리가 있는지 찾아보는 것도 좋은 것 같아요.
사실 지금 생각하면 정말 기초적인 부분인데, 당시에는 "분명 완벽한데 왜 오류가 나지?"라고 생각하며 끙끙 앓았던 기억이 있어요. 하지만 덕분에 React의 기초 동작 원리부터 다시 공부하는 시간을 가질 수 있어서 좋았어요.