
무한 스크롤(Infinite Scroll), scroll 이벤트로 짰다가 혼났습니다
`window.addEventListener('scroll')`로 무한 스크롤을 구현하면 성능이 박살 납니다. Intersection Observer API로 리팩토링하여 CPU 사용량을 90% 줄이는 방법.

`window.addEventListener('scroll')`로 무한 스크롤을 구현하면 성능이 박살 납니다. Intersection Observer API로 리팩토링하여 CPU 사용량을 90% 줄이는 방법.
매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

시각 장애인, 마우스가 고장 난 사용자, 그리고 미래의 나를 위한 배려. `alt` 태그 하나가 만드는 큰 차이.

인스타그램 같은 무한 스크롤 피드를 만들었습니다.
처음엔 window.addEventListener('scroll', handler)를 썼습니다.
스크롤이 바닥에 닿으면 다음 데이터를 불러오게 했죠.
그런데 데이터가 100개, 200개 쌓이니까 스크롤이 버벅대기 시작했습니다(Jank). 모바일에서는 발열까지 심했습니다. 커뮤니티에서 만난 경험 많은 개발자가 코드를 보더니 한숨을 쉬었습니다. "누가 요즘 스크롤 이벤트에 리스너를 바로 다나? 쓰로틀링(Throttling)은 했어? 아니, 그냥 옵저버 써."
저는 스크롤 이벤트가 "픽셀 단위로 발생할 수도 있다"는 걸 간과했습니다.
사용자가 손가락으로 한 번 휙 내리면, scroll 이벤트는 수백 번 발생합니다.
그때마다 getBoundingClientRect() 같은 걸로 위치 계산을 하면, 브라우저는 Reflow(레이아웃 다시 계산)를 하느라 죽어납니다.
underscore.throttle 같은 걸 써서 0.2초마다 실행하게 막아도,
스크롤 위치 계산(scrollTop + clientHeight >= scrollHeight) 자체가 비싼 연산인 건 변함이 없습니다.
이걸 "경비원과 CCTV"에 비유하니 이해가 됐습니다.
우리는 "스크롤이 얼마나 됐는지(How much)"가 궁금한 게 아닙니다. "바닥에 있는 투명한 선(Target)이 화면에 보였는지(Is Visible)"만 알면 됩니다. 이걸 브라우저 C++ 레벨에서 최적화해 둔 게 Intersection Observer입니다.
리스트의 맨 마지막에 보이지 않는(Empty) 요소를 하나 둡니다.
return (
<div>
{items.map(item => <Post key={item.id} data={item} />)}
{/* 감시 대상 (Sentinel) */}
<div ref={ref} style={{ height: '20px', background: 'transparent' }} />
</div>
);
직접 구현해도 되지만, React에서는 ref 관리가 귀찮으니 커스텀 훅을 씁니다.
import { useEffect, useRef, useState } from 'react';
export function useIntersectionObserver(callback: () => void) {
const targetRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!targetRef.current) return;
const observer = new IntersectionObserver((entries) => {
// 화면에 들어왔고(isIntersecting), 관찰 중인 요소라면
if (entries[0].isIntersecting) {
callback();
}
}, { threshold: 0.5 }); // 50% 보이면 실행
observer.observe(targetRef.current);
return () => observer.disconnect();
}, [callback]);
return targetRef;
}
const loadMore = () => {
if (!isLoading) fetchNextPage();
};
const ref = useIntersectionObserver(loadMore);
return (
<div>
{/* ... 리스트 ... */}
<div ref={ref} style={{ height: '50px' }}>Loading...</div>
</div>
);
이제 스크롤을 아무리 아무리 빠르게 비벼도, 자바스크립트는 쿨쿨 잡니다.
오직 바닥 요소가 빼꼼히 보일 때만 loadMore가 실행됩니다.
Infinite Scroll을 구현하면 필연적으로 만나는 문제가 있습니다. DOM 노드가 너무 많아진다는 것입니다.
인스타그램 피드를 1,000개 로딩하면, DOM 요소가 수만 개(<div>, <img>, <span>...)가 생성됩니다.
브라우저는 이걸 다 관리하느라 메모리를 1GB씩 잡아먹고, 결국 크래시(Crash)가 납니다.
이때 Virtual Scroll (Windowing)을 써야 합니다.
react-window나 tanstack-virtual 같은 라이브러리는 "화면에 보이는 10개만 실제로 렌더링하고, 위아래 안 보이는 건 빈 공간(padding)으로 채우는" 기술입니다.
List[0]을 죽이고 List[11]을 새로 그립니다.Infinite Scroll이 "데이터를 계속 불러오는(Fetch)" 기술이라면, Virtual Scroll은 "성능을 위해 DOM을 재활용하는(Render)" 기술입니다. 둘을 같이 써야 진정한 대용량 리스트 처리가 가능합니다.
채팅 앱은 위로 스크롤하면 과거 데이터, 아래로 스크롤하면 최신 데이터를 불러와야 합니다. 즉, Observer가 맨 위(Top Sentinel)와 맨 아래(Bottom Sentinel) 두 군데 있어야 합니다.
이때 가장 큰 문제는 "스크롤 튀는 현상(Scroll Jump)"입니다. 과거 메시지 20개를 불러와서 리스트 앞에(Prepend) 붙이면, 갑자기 리스트 길이가 길어지면서 현재 보고 있던 위치가 아래로 밀려납니다.
사용자는 "어? 나 어디 보고 있었어?" 하고 길을 잃습니다.
useLayoutEffect로 위치 보정데이터를 붙인 직후(DOM 업데이트 후, 화면이 그려지기 전)에 스크롤 위치를 복구해야 합니다.
// 데이터 로드 전
const oldHeight = listRef.current.scrollHeight;
const oldTop = listRef.current.scrollTop;
// 데이터 로드 후 (Dom 업데이트 직후)
useLayoutEffect(() => {
const newHeight = listRef.current.scrollHeight;
// 늘어난 높이만큼 스크롤을 아래로 이동시켜서 시각적 위치 고정
listRef.current.scrollTop = oldTop + (newHeight - oldHeight);
}, [messages]);
최신 브라우저는 overflow-anchor: auto 속성으로 이걸 어느 정도 자동으로 해주지만, 완벽하지 않아서 보통 수동 계산이 필요합니다.
무한 스크롤 리스트에서 100번째 아이템을 클릭해서 상세 페이지를 보고, '뒤로가기'를 눌렀습니다. 그런데 리스트가 맨 위(초기화)로 돌아가 있으면 사용자는 화가 납니다 (Rage Quit).
이유: useEffect(() => items = [], []) 처럼 컴포넌트 마운트 시 리스트를 초기화하기 때문입니다.
scrollTop)를 sessionStorage에 저장했다가, 다시 마운트될 때 복구합니다. react-router의 <ScrollRestoration /> 기능이나 tanstack-query의 캐싱 기능을 쓰면 더 쉽게 해결됩니다.제가 겪은 실화입니다. 사내 어드민 페이지에서 로그(Log)를 보여주는 화면이 있었습니다. 로그가 실시간으로 계속 쌓이는데, 사용자가 퇴근할 때 페이지를 켜놓고 갔습니다. 다음 날 출근해보니 크롬 탭이 "Aw, Snap!" 하고 죽어있었습니다. DOM 노드가 10만 개를 넘어가면서 메모리 부족(OOM)이 발생한 겁니다.
해결:
react-virtuoso 라이브러리를 도입해서 Virtuoso 컴포넌트로 감쌌습니다.
이제 로그가 100만 줄이 쌓여도, 화면에는 딱 50줄만 그립니다. 메모리 사용량이 1.5GB에서 50MB로 줄었습니다.
이때 "보이는 것만 그린다"는 원칙이 프론트엔드 성능 최적화의 1순위라는 걸 뼈저리게 느꼈습니다.
초보자는 Offset 기반 페이지네이션을 씁니다. (page=1, page=2)
하지만 실시간 SNS나 댓글창에서는 치명적인 버그가 있습니다.
해결책: Cursor Based Pagination
"마지막으로 본 글의 ID"를 기준으로 "그 다음 글 10개 줘"라고 요청합니다. (last_id=1283)
이러면 새 글이 몇 개가 추가되든, 삭제되든 상관없이 정확히 다음 데이터를 가져옵니다.
무한 스크롤 구현 시 백엔드 개발자에게 "Cursor 방식으로 짜주세요"라고 부탁하세요.
ScrollController에 집착하지 마세요많은 분들이 ScrollController.addListener()를 달아서 픽셀 계산을 합니다.
if (offset >= maxScrollExtent - 200) 이런 식으로요.
이 방식은 스크롤 이벤트가 1초에 60번 발생하기 때문에, 성능에 악영향을 줍니다. (Throttle 필요) 그리고 리스트 아이템의 높이가 제각각이면 계산이 틀어지기도 합니다.
그냥 flutter_animate의 VisibilityDetector나 IntersectionObserver 로직을 쓰는 게 훨씬 깔끔하고 선언적(Declarative)입니다.
무한 스크롤을 한참 내렸는데, 다시 위로 올라가려면 손가락이 아픕니다. 보통 스크롤이 500px 이상 내려가면 '위로 가기' 버튼을 보여줍니다.
// FloatingActionButton
FloatingActionButton(
onPressed: () {
scrollController.animateTo(
0,
duration: Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
// 무한 스크롤 데이터 초기화가 필요할 수도 있음!
},
child: Icon(Icons.arrow_upward),
)
이때 중요한 건, "데이터를 새로고침할 것인가, 그냥 위치만 이동할 것인가?"를 기획 단계에서 정해야 합니다.
보통은 위치만 이동하고, 사용자가 RefreshIndicator(당겨서 새로고침)를 하게 유도하는 게 좋습니다.
Q: Observer가 로딩되자마자 실행돼요.
A: 감시 대상(div)의 높이가 0이거나, 빈 리스트일 때 화면에 바로 보이기 때문입니다. 초기 데이터 로딩 중에는 Observer를 끄거나, 데이터가 있을 때만 div를 렌더링하세요.
Q: 이미지가 깜빡거려요.
A: 이미지 height를 지정하지 않아서 이미지가 로딩되면서 높이가 변하기 때문입니다(Layout Shift). 이미지 컨테이너에 aspect-ratio나 고정 height를 주세요.
Q: Intersection Observer는 IE 지원하나요? A: IE는 죽었습니다. 보내주세요. (굳이 필요하다면 Polyfill이 있긴 합니다.)
Intersection Observer는 무한 스크롤뿐만 아니라 이미지 지연 로딩에도 쓰입니다.
Next/Image 컴포넌트 내부도 이걸로 구현되어 있습니다.
"이미지가 화면에 들어오면 그때 src를 할당한다."
const Img = ({ src }) => {
const [isInView, setIsInView] = useState(false);
const ref = useIntersectionObserver(() => setIsInView(true));
return (
<div ref={ref} style={{ minHeight: '200px' }}>
{isInView && <img src={src} />}
</div>
);
};
브라우저가 잘하는 건 브라우저에게 맡기고, 우리는 비즈니스 로직에 집중합시다.