목록 무한스크롤과 race condition, StrictMode, 그리고 rootMargin

park·2026년 7월 10일

React로 무한스크롤을 직접 구현하다가, 버그 하나를 고쳤더니 다음 버그가 드러나고 그걸 고쳤더니 또 다음 문제가 튀어나온 3연속 디버깅 기록.

들어가기 전 개념 정리

이 글에 자주 나오는 세 용어를 짧게 정리하고 시작한다.

race condition (경쟁 상태)

두 개 이상의 비동기 작업이 동시에 진행될 때, "누가 먼저 끝나느냐"에 따라 결과가 달라지는 상황이다. 실행 순서가 보장되지 않기 때문에, 먼저 보낸 요청이 나중에 도착하거나 그 반대가 되면 의도치 않은 결과가 나온다. 이 글에서는 이전 카테고리 요청의 늦은 응답이 새 카테고리 목록에 끼어든 게 바로 race condition이다.

StrictMode

React가 개발 모드에서만 켜는 검사 도구다. 컴포넌트를 일부러 mount → unmount → remount로 한 번 더 껐다 켜서, effect의 cleanup이 제대로 작성됐는지 미리 드러내 준다. 프로덕션 빌드에서는 동작하지 않는다. 즉 "cleanup을 안 짜두면 여기서 터진다"고 미리 경고해 주는 장치다.

rootMargin

IntersectionObserver의 옵션으로, 요소가 "화면에 보이는지"를 판단하는 경계선을 넓히거나 좁힌다. 예를 들어 "200px"을 주면 뷰포트 실제 경계보다 200px 바깥까지를 "보이는 영역"으로 친다. 무한스크롤에서는 바닥에 완전히 닿기 전에 미리 다음 페이지를 불러오는 용도로 쓴다.


시작: 상태를 store로 일원화했다

쇼핑몰 프로젝트에서 상품 목록 페이지를 만들고 있었다. 커서 기반 페이지네이션 + IntersectionObserver로 무한스크롤을 직접 구현했고, 상세 페이지에 갔다가 뒤로 돌아오면 목록과 스크롤 위치가 복원되는 UX도 넣었다.

처음 구조는 이랬다. 목록 데이터(items, cursor, hasNext, totalCount)는 훅의 로컬 useState에 살고, 언마운트될 때 zustand store에 "스냅샷"으로 저장했다가, 다시 마운트되면 그 스냅샷으로 초기값을 복원하는 방식.

그런데 이 구조엔 문제가 있었다. cleanup 함수는 클로저라서 언마운트 시점의 "최신" 값을 못 본다. 그래서 최신값을 저장하려고 미러 ref 4개 + 동기화 effect 4개를 두고 있었다. 같은 데이터가 로컬 state, 미러 ref, store 스냅샷 — 세 곳에 사는 삼중 구조였다. 리뷰에서도 "훅이 책임이 너무 많다"는 지적을 받았다.

그래서 리팩토링했다. 목록 데이터의 단일 출처를 store로. 로컬 state와 미러 ref를 전부 걷어내니 훅이 절반으로 줄었고, 뒤로가기 복원도 "store에 그냥 남아있으니까" 공짜로 됐다. 코드는 확실히 좋아졌다.

그리고 여기서부터 버그가 시작됐다.


사건 1. 다른 카테고리를 눌렀는데 이전 상품이 남아있다

재현 경로는 이랬다.

  1. 식품 카테고리 + 높은가격순으로 스크롤을 쭉 내린다 (다음 페이지 요청이 날아가는 중)
  2. 상품 상세로 들어갔다가 뒤로가기
  3. 다른 카테고리 클릭
  4. 이전 식품 상품 5개 정도가 새 카테고리 목록에 섞여서 남아있다

원인: abort는 인스턴스에, 쓰기는 전역에

원인을 추적해보니 한 문장으로 요약됐다.

요청을 취소하는 장치(AbortController)는 훅 인스턴스에 묶여 있는데, 응답을 쓰는 곳(store의 appendPage)은 전역이다.

타임라인을 펼치면 이렇다.

[식품 마지막 페이지 요청 발사] → 상세 이동(언마운트) → 뒤로가기(리마운트)
→ 카테고리 변경 → resetList() 로 목록 비움 → 새 카테고리 요청 발사
→ 그런데 아까 그 식품 요청이 이제야 도착 → appendPage(식품 5개)  ← 새치기
→ 새 카테고리 응답 도착 → appendPage(20개)  ← 식품 뒤에 붙음

흥미로운 건, 리팩토링 전에는 이 race가 존재했는데도 문제가 안 됐다는 점이다. 목록이 로컬 useState였을 때는 언마운트된 컴포넌트에 setState를 해봤자 React가 조용히 무시했다. 늦게 도착한 응답이 있어도 그냥 버려졌던 것이다.

그런데 쓰기 대상이 전역 store가 되자, 언마운트 후에도 쓰기가 실제로 반영된다. 늦은 응답을 React가 막아주던 암묵적 방어가 사라진 것이다. 게다가 뒤로가기로 리마운트되면 abortControllerRef는 새 인스턴스의 ref라서, 구 인스턴스에서 발사된 요청에는 손이 닿지 않는다. 그 요청은 클로저로 전역 store의 appendPage를 붙잡은 채 살아있다가, resolve되는 순간 목록에 끼어든다.

"왜 하필 5개만 남았나"도 설명이 된다. 커서 페이지네이션에서 마지막 꼬리 페이지는 limit(20)보다 작을 수 있다. 스크롤을 끝까지 내렸을 때 날아가던 요청이 바로 그 ~5개짜리 페이지였던 것이다.

해결: 낡은 응답은 버린다

fetch를 시작할 때 만든 AbortController를 지역 변수로 잡아두고, 응답을 store에 반영하기 직전에 확인한다.

const controller = new AbortController();
abortControllerRef.current = controller;

const response = await fetchProductList({ ..., signal: controller.signal });

// 응답이 도착한 사이에 abort된 요청이면 (필터 변경, 언마운트 등)
// 낡은 응답이므로 반영하지 않고 버린다
if (controller.signal.aborted) return;
appendPage(response.data);

요청마다 새로 만든 controller가 클로저에 잡혀 있으니, "이 응답이 아직 유효한 요청의 것인가"를 응답 시점에 판단할 수 있다. 시퀀스 토큰 같은 더 큰 장치도 고려했지만, 이 가드 하나로 충분했다.

여기서 얻은 교훈: 좋은 리팩토링이 새 버그를 만들 수 있다. 정확히는, 프레임워크가 암묵적으로 해주던 방어를 걷어내면 그 방어를 명시적으로 다시 세워야 한다.


사건 2. 새로고침하면 스켈레톤이 영원히 돈다

사건 1을 고치면서 "언마운트 시 진행 중 요청을 abort하는 cleanup"도 함께 넣었다. 방어를 한 겹 더 두자는 생각이었다. 그랬더니 새 문제가 생겼다.

새로고침하거나 주소를 직접 쳐서 들어가면 초기 요청이 취소되고, 스켈레톤이 영원히 멈춰 있다.

원인: StrictMode × 로딩 가드의 합작

React의 StrictMode는 개발 모드에서 컴포넌트를 일부러 mount → unmount → remount 시킨다. "cleanup이 제대로 작성됐는지" 검증하기 위한 의도된 동작이다. 문제는 여기에 우리의 로딩 가드(isLoadingRef)가 얽히면서 생겼다.

1. 마운트 #1: fetchPage(null) 실행 → isLoadingRef = true, 요청 시작
2. StrictMode 언마운트: cleanup이 abort()
   — 하지만 abort의 rejection 처리는 마이크로태스크라 아직 실행 안 됨
   → finally가 못 돌아서 isLoadingRef는 여전히 true
3. 재마운트: fetchPage(null) 재호출
   → if (isLoadingRef.current) return 에 걸려 즉시 리턴
   (React의 cleanup → 재실행은 동기 블록. 그 사이에 마이크로태스크가 낄 틈이 없다)
4. 그제야 요청 1의 finally가 isLoadingRef = false로 되돌리지만,
   이미 재요청해줄 주체가 없다

결과: 첫 fetch는 취소됐고, 재마운트의 fetch는 가드에 삼켜졌고, 아무도 다시 요청하지 않는다. 스켈레톤만 무한히 돈다.

처음엔 "StrictMode의 가짜 언마운트와 진짜 이탈을 구분하면 되지 않나?" 생각했는데, 이건 안티패턴이다. React가 권장하는 방향은 반대다 — cleanup에서 몇 번을 취소당해도 다시 시작할 수 있게 만드는 것.

해결: 요청의 소유권을 effect에 둔다

fetch를 시작한 effect가 자기 cleanup에서 abort와 가드 리셋까지 책임진다.

useEffect(() => {
  if (!isFetched) fetchPage(null);
  return () => {
    abortControllerRef.current?.abort();
    isLoadingRef.current = false; // abort와 가드 해제를 동기적으로 함께
  };
}, []);

이러면 mount → unmount → remount가 몇 번 반복돼도, 재마운트 시점의 가드는 항상 풀려 있어서 재요청이 성립한다.

하나 더. 가드를 cleanup에서 풀어버리면 반대 방향의 구멍이 생긴다 — 취소당한 옛 요청의 finally가 뒤늦게 돌면서 진행 중인 새 요청의 가드를 풀어버릴 수 있다. 그래서 finally에 소유권 체크를 넣었다.

finally {
  // 내가 아직 최신 요청일 때만 가드를 해제한다
  if (abortControllerRef.current === abortController) {
    isLoadingRef.current = false;
    setIsLoading(false);
  }
}

교훈: 과한 방어가 새 버그를 만든다. 그리고 비동기 가드를 다룰 때는 항상 "지금 이 상태의 소유자가 누구인가"를 물어야 한다.


사건 3. 스크롤이 한 페이지만 로드하고 멈춘다

같은 시기에 또 하나. 바닥까지 스크롤하면 다음 페이지가 한 번 로드되고 끝이다. 더 내려갈 곳도 없는데 다음 페이지가 안 온다. (위로 스크롤했다가 다시 내리면 로드된다 — 이게 결정적 힌트였다.)

원인: 버그인 줄 알았던 것이 기능이었다

이 이야기의 시작은 사실 더 전으로 거슬러 간다. 원래 코드에는 "중복 호출 버그"가 있었다.

loadMore의 deps: [cursor, hasNext, fetchPage]
→ cursor가 바뀔 때마다 loadMore가 새 참조로 재생성
→ 옵저버 effect(deps: [loadMore])가 disconnect → observe 반복
→ observe() 직후엔 항상 초기 콜백이 1회 발화
→ sentinel이 이미 화면에 보이면 즉시 실행 → 중복 fetch

그래서 ref 패턴으로 고쳤다. 콜백을 ref에 미러링하고 옵저버는 마운트 시 1회만 등록. 재등록이 사라지니 중복 호출도 사라졌다. 여기까진 좋았다.

그런데 IntersectionObserver는 교차 상태가 '변할 때'만 발화한다. 스켈레톤 20개가 실제 상품 20개로 교체되면 문서 높이가 거의 그대로라, 바닥에 있던 sentinel은 계속 보이는 상태 그대로다. 교차에 '변화'가 없으니 콜백은 두 번 다시 불리지 않는다.

그럼 예전엔 어떻게 연쇄 로딩이 됐을까? 그 "중복 호출 버그"가 트리거였다. 페이지가 로드되면 cursor가 바뀌고 → loadMore가 재생성되고 → 옵저버가 재등록되고 → 재등록 직후의 초기 콜백이 발화해서 → 다음 페이지를 불렀던 것이다. 우리가 버그라고 부르며 제거한 재등록이, 사실은 연쇄 로딩의 동력을 겸하고 있었다.

해결: rootMargin

재등록을 되살리거나 강제 재발화 트릭을 넣는 대신, 옵저버 옵션 하나로 풀었다.

new IntersectionObserver(callback, { rootMargin: "200px" });

감지 영역이 뷰포트 아래 200px까지 확장된다. 그러면 사이클이 자연스럽게 돌아간다 — 페이지가 append되면 sentinel이 한 페이지 높이만큼 아래로 밀려 감지 영역을 벗어나고(교차 변화 발생), 스크롤이 다시 접근하면 재발화한다. 덤으로 바닥에 닿기 200px 전에 미리 로드되니 UX도 좋아졌다.

교훈: 버그를 고치기 전에, 그 동작이 무엇에 기여하고 있었는지 물어야 한다. 그리고 옵저버는 "상태"가 아니라 "변화"에 반응한다는 걸 정확히 이해해야 무한스크롤을 제대로 만들 수 있다.


정리: 최종 방어 체계

세 사건을 지나고 나니, 무한스크롤 하나에 이런 방어선들이 서 있었다.

계층장치막는 것
옵저버ref 패턴 + 1회 등록재등록 즉시 발화로 인한 중복 호출
옵저버rootMarginsentinel 정체로 인한 연쇄 로딩 중단
fetchisLoadingRef 가드동시 중복 요청
fetchfinally 소유권 체크낡은 finally가 새 요청 가드를 해제
응답signal.aborted 가드낡은 응답의 store 오염 (race)
effectcleanup에서 abort + 가드 리셋StrictMode 재마운트에서 초기 요청 실종

에필로그: 그리고 이 모든 것을 TanStack Query가 한다

위 표의 방어선 대부분을 TanStack Query의 useInfiniteQuery는 기본으로 제공한다.

  • 중복 요청과 로딩 상태 → 내부에서 관리
  • 낡은 응답(race) → queryKey(카테고리+정렬)가 다르면 자동 폐기
  • StrictMode 대응 → 라이브러리가 처리
  • isFetched / isLoading 구분 → 상태 플래그로 제공

"그럼 처음부터 라이브러리를 쓰지 그랬냐"고 할 수 있다. 하지만 직접 구현하며 각 방어선이 왜 필요한지를 몸으로 겪었기 때문에, 이제 RQ의 기능 하나하나가 무엇을 대신해주는지 정확히 안다. queryKey가 race를 막는다는 문장이 문서 속 한 줄이 아니라, 카테고리 잔상 버그의 기억으로 읽힌다.

다음 글은 이 코드를 TanStack Query로 마이그레이션하는 이야기가 될 것 같다.

profile
park

0개의 댓글