scroll·resize 이벤트를 IntersectionObserver·ResizeObserver로 교체하기: Forced Reflow 없애는 법
프론트엔드를 개발하다 보면 어느 순간 Performance 탭을 열고 멍하니 바라보게 되는 순간이 찾아옵니다. 스크롤할 때마다 노란 막대가 가득 찬 그 장면 말이죠. 저도 처음엔 "디바운스 넣으면 되겠지"라고 생각했다가, 근본 원인이 이벤트 핸들러 자체가 아니라 그 안에서 DOM을 읽는 방식에 있다는 걸 뒤늦게 깨달았습니다. getBoundingClientRect()를 scroll 핸들러 안에서 호출하는 순간, 브라우저는 최신 레이아웃 값을 반환하기 위해 강제로 레이아웃 재계산을 돌립니다. 이게 60fps 스크롤 속도로 반복되면 메인 스레드는 버틸 재간이 없죠.
IntersectionObserver와 ResizeObserver로 전환하고 나서 달라진 건 단순히 코드가 깔끔해진 게 아니었습니다. Chrome DevTools에서 "Recalculate Style"·"Layout" 항목이 눈에 띄게 줄었고, 스크롤 중 프레임 드롭도 사라졌습니다. scroll·resize 이벤트 핸들러 안에서 DOM을 읽는 패턴을 Observer API로 교체하는 건 디바운스 같은 임시방편보다 훨씬 근본적인 성능 개선이고, 코드도 오히려 단순해집니다.
이 글에서는 두 API가 왜 빠른지 원리부터 짚고, 이미지 지연 로딩·무한 스크롤·반응형 컴포넌트 같은 실제 시나리오에서 어떻게 적용하는지, 그리고 실무에서 흔히 마주치는 함정까지 솔직하게 풀어보겠습니다. 프론트엔드 개발 경험이 1년 이상이라면 지금 당장 적용해볼 수 있는 내용들입니다.
핵심 개념
기존 방식이 느린 진짜 이유
문제의 코드부터 보겠습니다.
window.addEventListener('scroll', () => {
const rect = el.getBoundingClientRect(); // 강제 레이아웃 재계산 발생
if (rect.top < window.innerHeight) showElement();
});
window.addEventListener('resize', () => {
recalcLayout(); // 드래그 1초에 수백 번 호출
});getBoundingClientRect()는 브라우저에게 "지금 당장 정확한 좌표를 알려줘"라고 요구하는 함수입니다. 브라우저 입장에서는 그 시점까지 쌓인 스타일 변경 사항을 전부 반영한 뒤 레이아웃을 계산해야 응답할 수 있어요. 이걸 Forced Reflow(강제 리플로우) 라고 부르는데, 이게 scroll 이벤트 안에서 반복되면 프레임마다 레이아웃→페인트→컴포지팅 파이프라인 전체가 재가동됩니다.
Forced Reflow: JS에서 레이아웃 관련 속성(
offsetWidth,getBoundingClientRect()등)을 읽을 때 브라우저가 강제로 레이아웃을 재계산하는 현상. 특히 스타일 변경(쓰기)과 크기 조회(읽기)가 반복 교차될 때 Layout Thrashing으로 이어지며, 프레임 버짓 16ms를 훌쩍 넘기게 됩니다.
Observer API가 다른 이유
IntersectionObserver와 ResizeObserver는 브라우저가 교차·크기 변화를 내부적으로 감지해 비동기 배치 방식으로 메인 스레드에 콜백을 전달합니다. "측정 자체를 다른 스레드로 넘긴다"는 게 아니라, 브라우저가 알아서 최적 타이밍을 잡아 콜백을 몰아 처리하는 구조입니다. 덕분에 매 스크롤 이벤트마다 메인 스레드가 깨어날 필요가 없어지죠.
| API | 무엇을 감지하나 | 대표 사용처 |
|---|---|---|
IntersectionObserver |
요소가 뷰포트(또는 상위 컨테이너)와 교차하는지 | 이미지 지연 로딩, 무한 스크롤, 진입 애니메이션 |
ResizeObserver |
요소의 content/border box 크기 변화 | 반응형 컴포넌트, 캔버스 해상도 조정, 동적 레이아웃 |
ResizeObserver는 레이아웃 계산 이후·페인트 이전 단계에서 실행됩니다. 이 타이밍이 중요한 이유는, 레이아웃이 이미 완료된 직후에 크기를 읽기 때문에 Forced Reflow를 유발하지 않는다는 점입니다. 크기 값이 콜백 인자(contentRect)로 직접 전달되니 따로 조회할 필요 자체가 사라지는 거죠.
컴포지터 스레드: 브라우저가 레이어를 합성해 화면에 출력하는 별도 스레드입니다. 메인 스레드가 바빠도 스크롤이나 CSS
transform애니메이션이 부드럽게 동작하는 건 이 스레드 덕분이에요.
브라우저 지원 현황 (2026년 기준)
"이거 구형 브라우저에서 돼요?"가 제일 먼저 나오는 질문이더라고요. 결론부터 말하면 polyfill 없이 써도 됩니다.
| API | Chrome | Firefox | Safari |
|---|---|---|---|
IntersectionObserver |
51+ | 55+ | 12.1+ |
ResizeObserver |
64+ | 69+ | 13.1+ |
IE는 논외고, 2026년 기준으로 저 버전 이하를 지원해야 하는 프로젝트라면 다른 문제가 더 많을 겁니다.
실전 적용
원리를 알았으니 이제 실제 코드를 보겠습니다. 난이도와 활용 범위가 점진적으로 넓어지도록 다섯 가지 시나리오를 준비했습니다. 예시 13은 순수 JavaScript로, 예시 45는 TypeScript와 프레임워크 패턴으로 작성했습니다.
예시 1: 이미지 지연 로딩 (Lazy Loading)
<img src="..."> 대신 data-src에 실제 URL을 넣어두고, 뷰포트에 진입하는 순간 src를 교체하는 패턴입니다. 가장 고전적인 활용이지만 실제로 가장 효과가 큰 최적화이기도 합니다. 네트워크 패널에서 효과가 즉시 보여서 팀 설득에도 도움이 됩니다.
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
}, { rootMargin: '200px' });
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));| 옵션/메서드 | 역할 |
|---|---|
rootMargin: '200px' |
뷰포트보다 200px 앞에서 미리 감지 → 사용자가 이미지를 보기 전에 로드 완료 |
observer.unobserve(img) |
로드 후 즉시 관찰 해제 → 메모리 낭비 없음 |
entry.isIntersecting |
교차 여부를 나타내는 boolean |
예시 2: 무한 스크롤
목록 맨 아래에 보이지 않는 sentinel 요소를 하나 두고, 그 요소가 뷰포트에 들어오면 다음 페이지를 요청합니다. 페이지네이션보다 구현이 단순한데 UX는 훨씬 자연스럽게 나옵니다.
실무에서 자주 맞닥뜨리는 상황인데, fetchNextPage() 호출 중에 또 sentinel이 교차되는 경우가 있어요. 중복 요청이 발생하면 데이터가 꼬이거나 API를 과호출하게 됩니다. isLoading 플래그를 함께 관리하는 게 이 함정을 피하는 가장 확실한 방법입니다.
const sentinel = document.querySelector('#sentinel');
let isLoading = false;
const observer = new IntersectionObserver(async ([entry]) => {
if (entry.isIntersecting && !isLoading) {
isLoading = true;
await fetchNextPage();
isLoading = false;
}
}, { threshold: 0.1 });
observer.observe(sentinel);
threshold: 0.1은 sentinel이 10% 이상 뷰포트에 들어왔을 때 콜백을 트리거합니다. 0에 가까울수록 요소가 살짝만 걸쳐도 감지됩니다.
예시 3: ResizeObserver로 반응형 컴포넌트
미디어 쿼리는 뷰포트 기준이라 컨테이너 크기를 알 수 없습니다. 사이드바가 접혔을 때 카드 레이아웃을 바꾸고 싶다면 ResizeObserver가 유일한 JS 해법이었는데, 지금은 CSS Container Queries가 순수 레이아웃 변경을 많이 커버해줍니다. 런타임 상태 전환이나 캔버스처럼 CSS만으론 안 되는 경우에 ResizeObserver를 쓰는 게 좋습니다.
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
const { width } = entry.contentRect;
entry.target.classList.toggle('compact', width < 480);
}
});
ro.observe(document.querySelector('.card'));contentRect에 이미 너비·높이가 담겨 있어서 getBoundingClientRect() 호출이 필요 없습니다. 크기 변화가 없으면 콜백 자체가 발동하지 않으니 불필요한 연산도 없고요.
예시 4: React 커스텀 훅
React에서는 useEffect로 Observer를 생성하고 cleanup에서 disconnect()하는 패턴이 사실상 표준 관용구로 자리를 잡았습니다. 한 가지 주의할 점이 있는데, 의존성 배열에 [ref]를 넣으면 안 됩니다. ref 객체 자체는 렌더마다 동일한 참조를 유지하기 때문에 ref.current가 바뀌어도 Effect가 재실행되지 않거든요. SSR 환경이나 조건부 렌더링에서 Observer가 연결되지 않는 문제가 생길 수 있습니다. []를 쓰되, cleanup에서는 Effect 내부 지역 변수로 복사한 node를 사용하는 패턴이 안전합니다.
import { useState, useEffect, RefObject } from 'react';
function useElementSize(ref: RefObject<Element>) {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const node = ref.current; // cleanup에서 동일한 참조를 보장
if (!node) return;
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
setSize({ width, height });
});
ro.observe(node);
return () => ro.disconnect();
}, []); // ref 객체는 렌더마다 동일하므로 []
return size;
}직접 만들기 번거롭다면 라이브러리를 활용하는 것도 충분히 좋은 선택입니다.
| 패키지 | 특징 |
|---|---|
react-intersection-observer |
useInView 훅 + <InView> 컴포넌트, TypeScript 지원 |
use-resize-observer |
648B(gzip), 단일 Observer 공유로 메모리 절약 |
@react-hook/resize-observer |
앱 전체에 하나의 ResizeObserver 인스턴스 공유 |
예시 5: 스크롤 진입 애니메이션
요소가 화면에 들어올 때 페이드인·슬라이드업 같은 효과를 주는 패턴입니다. scroll 이벤트로 구현하면 매 프레임마다 모든 요소의 좌표를 계산해야 했지만, IntersectionObserver는 실제로 교차가 일어날 때만 콜백이 옵니다.
const observer = new IntersectionObserver((entries) => {
entries.forEach(({ target, isIntersecting }) => {
target.classList.toggle('visible', isIntersecting);
});
}, { threshold: 0.2 });
document.querySelectorAll<Element>('.animate-on-scroll').forEach(el => observer.observe(el));애니메이션을 한 번만 재생하고 싶다면 isIntersecting이 true일 때 observer.unobserve(target)을 호출하면 됩니다.
장단점 분석
단점 및 주의사항
솔직히 단점보다 장점이 훨씬 많은 API인데, 그래도 실제로 걸려본 함정들이 있어서 정리해봤습니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| ResizeObserver 루프 에러 | 콜백 안에서 관찰 중인 요소 크기를 변경하면 피드백 루프 발생 | requestAnimationFrame으로 다음 프레임에 위임 |
| IntersectionObserver 정밀도 한계 | 픽셀 단위 정확한 위치 추적에는 부적합 | drag-and-drop, sticky offset 계산은 scroll + getBoundingClientRect() 조합 사용 |
| CSS Container Queries와 중복 | 스타일만 바꾸는 경우라면 JS 개입 자체가 과한 선택 | @container 쿼리로 대체 → CLS(레이아웃 이동) 위험도 없음 |
CSS Container Queries:
@container규칙으로 부모 컨테이너 크기에 따라 자식 요소 스타일을 분기하는 CSS 기능입니다. 2023년 2월부터 모든 주요 브라우저에서 지원되며, 2025년 12월부터는 Container Scroll Queries도 Chrome·Edge에 추가되었습니다. 순수 레이아웃 변경이라면ResizeObserver대신 이걸 쓰는 게 더 유지보수적입니다.
실무에서 가장 흔한 실수
-
disconnect()없이 컴포넌트를 언마운트하는 것 — Observer가 살아있으면 이미 사라진 DOM 요소를 계속 참조해 메모리 누수가 생깁니다. 저도 실제로disconnect()빠뜨려서 메모리 누수 알람 받은 적 있습니다. React라면useEffectcleanup에, Vue라면onUnmounted에 꼭 넣어두는 게 좋습니다. -
콜백 안에서 관찰 중인 요소의 크기를 직접 변경하는 것 —
ResizeObserver loop completed with undelivered notifications경고의 주범입니다. 크기 변경이 필요하다면requestAnimationFrame으로 다음 프레임으로 미뤄주면 루프가 끊깁니다.javascript// ❌ 피드백 루프 유발 ro.observe(el); const ro = new ResizeObserver(([entry]) => { el.style.height = entry.contentRect.width + 'px'; }); // ✅ 다음 프레임으로 위임 const ro = new ResizeObserver(([entry]) => { requestAnimationFrame(() => { el.style.height = entry.contentRect.width + 'px'; }); }); -
threshold와rootMargin을 혼동하는 것 —threshold는 교차 비율(0~1)을 기준으로 콜백을 트리거하고,rootMargin은 감지 영역 자체를 확장/축소합니다. 이미지 프리로딩에는rootMargin, 요소가 절반 이상 보일 때 애니메이션을 트리거하려면threshold: 0.5처럼 역할을 나눠 쓰는 게 좋습니다.
마치며
이 글을 읽고 나면 "이게 메모리 누수인지 scroll 이벤트 폭주인지"를 구분하는 시각이 생깁니다. Observer API로 전환하면 디바운스 로직을 유지할 필요가 없어지고, DOM 조회 시점을 따로 고민하지 않아도 되며, 컴포넌트 언마운트 시 cleanup만 신경 쓰면 됩니다. 이 세 가지 결정을 이전과 다르게 내릴 수 있게 되는 거죠.
지금 바로 시작해볼 수 있는 단계를 나눠보면:
- 먼저 현재 상태를 측정해보는 것을 권장합니다 — Chrome DevTools에서 페이지를 스크롤하며 기록을 남기고, "Recalculate Style"·"Layout" 항목이 얼마나 자주 뜨는지 확인해보면 됩니다. 개선 전후 비교의 기준선이 됩니다.
- 이미지 지연 로딩부터 적용해볼 수 있습니다 —
img[data-src]패턴은 기존 코드를 거의 건드리지 않고 도입할 수 있고, 네트워크 패널에서 효과가 즉시 보여서 팀 설득에도 도움이 됩니다. React를 쓰고 있다면react-intersection-observer라이브러리로 시작하면 더 수월합니다. - ResizeObserver를 도입하기 전에 CSS Container Queries로 해결 가능한지 먼저 확인해볼 만합니다 —
@container쿼리로 스타일 분기가 가능하다면 JS 없이 렌더 엔진이 직접 처리하므로 CLS 발생 위험도 없고 코드도 훨씬 간결해집니다. JS가 꼭 필요한 경우(캔버스 해상도, 런타임 상태 전환 등)에만ResizeObserver를 투입하는 식으로 역할을 나눠보는 걸 권장합니다.
참고 자료
- Intersection Observer API | MDN Web Docs
- Resize Observer API | MDN Web Docs
- Use Intersection Observer instead of Scroll Events | Jonathan Lau Blog
- Alternatives to the resize event with better performance | Tiger Oakes
- Performant Alternative to addEventListener for Scroll and Resize Events | GIT ER OPTIMIZED
- Using the ResizeObserver API in React for responsive designs | LogRocket
- Lazy loading using the Intersection Observer API | LogRocket
- Resolving "ResizeObserver Loop Limit Exceeded" Errors | DhiWise
- Avoiding pitfalls with the resize event in JavaScript | OpenReplay
- use-resize-observer | GitHub (ZeeCoder)
- react-intersection-observer | Vercel