scroll 이벤트로 짠 무한 스크롤을 갈아엎은 이유를, Intersection Observer로 옮기며 겪은 과정으로 정리한 글입니다.

scroll 이벤트로 만든 무한 스크롤의 함정
무한 스크롤을 처음 만들었을 때 저는 가장 익숙한 방법을 택했습니다. window에 scroll 이벤트를 붙이고, 스크롤이 바닥 근처에 닿으면 다음 페이지를 불러오는 방식이었죠. 상품 목록을 20개씩 끊어 보여 주는 화면이었는데, 로컬에서는 정말 잘 굴러갔습니다. 아래로 내리면 다음 상품이 착착 붙었으니까요.
문제를 눈치챈 건 성능 탭을 열어 본 순간이었습니다. scroll 이벤트는 스크롤하는 내내 초당 수십 번씩 콜백을 호출합니다. 손가락으로 한 번 쭉 내리는 그 짧은 동작에도 콜백이 40번, 50번씩 실행되고 있었죠. 게다가 저는 그 콜백 안에서 매번 getBoundingClientRect()로 목록 끝 요소의 위치를 재고 있었습니다. 이게 문제의 핵심이었습니다. 위치를 읽는 이 호출이 매번 레이아웃 리플로우(reflow)를 강제로 유발했거든요. 아래 도식은 같은 스크롤에 콜백이 얼마나 다르게 실행되는지 보여 줍니다.
방어책으로 throttle을 씌워 콜백을 100ms에 한 번으로 묶었습니다. 데스크톱에서는 그럭저럭 버틸 만했죠. 하지만 QA 단계에서 저사양 안드로이드 기기로 테스트하자 화면이 눈에 띄게 버벅였습니다. 스크롤을 멈출 때마다 프레임이 한 박자 늦게 따라오는, 그 특유의 끊김이었습니다. 성능 탭에는 "Forced reflow"라는 노란 경고가 스크롤을 할 때마다 줄줄이 찍혔고, 한 번의 스크롤 세션에서 그 경고가 스무 개 넘게 쌓이는 걸 보고 나서야 원인이 위치 계산에 있다는 걸 확신했죠.
돌이켜 보면 이 구조에는 근본적인 낭비가 깔려 있었습니다. 사용자가 스크롤을 하는 이유의 대부분은 그냥 콘텐츠를 읽으려는 것이지, 목록 끝에 닿으려는 게 아니거든요. 그런데도 저는 스크롤이 일어나는 모든 순간에 "혹시 지금 바닥인가?"를 계산하고 있었습니다. 정작 의미 있는 순간은 목록 끝에 다다른 그 한 번뿐인데 말이죠.
더 곤란했던 건 같은 페이지가 중복으로 로드되는 버그였습니다. 스크롤을 빠르게 내리면 응답이 오기도 전에 조건이 다시 참이 되어, page 2를 두세 번씩 요청했죠. 목록에는 같은 상품이 중복으로 쌓였고, key가 겹쳐 React 콘솔에는 경고가 도배됐습니다. throttle 간격을 늘리면 중복은 줄었지만 이번엔 로드가 굼떠졌습니다. 간격 하나로 반응성과 중복을 동시에 잡으려니 어느 쪽도 만족스럽지 않았죠. 저는 이 줄다리기가 근본적으로 접근이 틀렸다는 신호라고 느꼈습니다.
Intersection Observer는 무엇이 다른가
돌파구는 IntersectionObserver였습니다. 이 API의 발상은 scroll 이벤트와 방향이 완전히 다릅니다. "스크롤이 얼마나 내려갔는지"를 내가 계속 감시하는 대신, "이 요소가 화면에 보이는지"만 브라우저에게 물어 두는 방식이죠. 관찰 대상(흔히 sentinel이라 부르는 감시용 요소)이 뷰포트에 들어오는 순간에만 콜백이 딱 한 번 불립니다.

핵심 차이는 세 가지입니다.
- 호출 시점: scroll처럼 계속 부르는 게 아니라, 교차 상태가 바뀌는 순간에만 콜백이 실행됩니다.
- 계산 주체: 교차 판정을 내가 JS로 하지 않고 브라우저가 메인 스레드 밖에서 처리합니다. 그래서
getBoundingClientRect()같은 위치 계산이 필요 없죠. - 리플로우 부담: 레이아웃을 강제로 다시 읽지 않으니, 스크롤이 아무리 격해도 메인 스레드가 한가합니다.
기본 사용법은 놀랄 만큼 단순합니다. 감시할 요소와 콜백만 넘기면 끝이죠.
// 관찰 대상이 뷰포트에 들어오면 콜백 1회 실행
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadNextPage(); // 다음 페이지 불러오기
}
});
});
observer.observe(sentinelEl); // 목록 끝 감시용 요소 관찰 시작
entry.isIntersecting이 관찰 대상의 화면 진입 여부입니다. scroll 이벤트에 있던 좌표 계산, throttle, 바닥 판정 로직이 이 한 줄로 사라진 셈이죠. 처음 이 코드를 돌려 보고 "이렇게 짧아도 되나" 싶어 잠시 멍했던 기억이 납니다.
옵션도 딱 필요한 것만 있어 부담이 없었습니다. root로 기준이 될 스크롤 컨테이너를 지정할 수 있고(생략하면 뷰포트), threshold로 대상이 몇 퍼센트 보였을 때 콜백을 부를지 정할 수 있죠. 무한 스크롤에서는 sentinel이 조금이라도 보이면 바로 로드해야 하니 기본값인 0으로 충분했습니다. 브라우저 지원도 걱정거리가 아니었습니다. 요즘 쓰는 주요 브라우저는 모두 이 API를 지원하니까요.
실제로 바꾸며 챙긴 것들
이론은 깔끔했지만, 실제 목록에 얹으려니 몇 가지를 손봐야 했습니다. 제가 순서대로 챙긴 것들을 정리하면 이렇습니다.

첫째, 리스트 맨 끝에 빈 sentinel div를 두고 그것을 관찰했습니다. 상품 카드 자체를 관찰하면 어느 카드를 기준 삼을지 애매해지지만, 목록 끝에 높이 1px짜리 감시용 요소를 하나 심어 두니 "여기까지 내려왔다"는 신호가 명확해졌죠.
둘째, 중복 로드를 막는 isLoading 플래그를 콜백 맨 앞에 세웠습니다. 옛 코드를 괴롭히던 중복 요청의 뿌리가 여기였거든요.
const observer = new IntersectionObserver((entries) => {
if (!entries[0].isIntersecting) return;
if (isLoading || isLast) return; // 로딩 중이거나 마지막이면 중단
isLoading = true;
fetchPage(page++).then((items) => {
append(items);
isLoading = false;
if (items.length === 0) { // 더 없으면
observer.unobserve(sentinelEl);// 관찰 종료
isLast = true;
}
});
}, { rootMargin: "200px" });
셋째, rootMargin으로 미리 로드했습니다. "200px"을 주면 sentinel이 실제로 화면에 닿기 200px 전에 콜백이 먼저 불립니다. 사용자가 목록 끝에 도달하기 전에 다음 페이지가 이미 붙어 있어, 빈 화면을 마주칠 일이 거의 없어졌죠.
넷째, 마지막 페이지에서 unobserve로 관찰을 껐습니다. 응답이 빈 배열이면 더 부를 데이터가 없다는 뜻이니, 감시를 멈춰 불필요한 호출을 원천 차단했습니다. 여기에 더해, 컴포넌트가 화면에서 사라질 때 observer.disconnect()를 호출해 관찰을 완전히 정리하는 것도 잊지 않았습니다. 이걸 빼먹으면 이미 사라진 목록을 계속 감시하며 메모리를 붙잡고 있게 되거든요. 실제로 한 번 이 정리를 빠뜨렸다가, 페이지를 오갈 때마다 옵저버가 하나씩 늘어나 같은 요청이 여러 번 나가는 걸 겪은 뒤로는 반드시 챙기는 습관이 됐습니다.
전환 뒤 결과는 기대 이상이었습니다. 그 저사양 기기에서 프레임이 늦게 따라오던 버벅임이 사라져 스크롤이 손가락에 착 붙었고, 성능 탭에 빨갛게 뜨던 강제 리플로우 경고도 자취를 감췄죠. 무엇보다 중복 로드가 완전히 없어져, 콘솔의 key 경고와 함께 마음의 짐도 내려놓을 수 있었습니다. 개인적으로는, 방어 코드를 덕지덕지 덧대며 증상을 누르기보다 도구 자체를 바꾸는 편이 훨씬 깔끔하다는 걸 이 작업에서 배웠습니다. throttle 간격을 몇 밀리초로 잡을지 며칠을 고민했던 게 무색할 만큼, 애초에 그 계산이 필요 없는 구조로 옮기니 문제가 통째로 증발했으니까요. 지금이라면 무한 스크롤은 고민 없이 IntersectionObserver부터 꺼내겠죠.