IntersectionObserver 활용 현재 페이지 추적 기능 구현하기
안녕하세요. 최근 회사 프로젝트에서 문서 뷰어를 구현하는 과정에서, 사용자가 스크롤을 통해 여러 페이지를 넘나들 때 ‘현재 페이지’가 어디인지 실시간으로 추적하는 기능을 개발할 기회가 있었습니다.
처음에는 IntersectionObserver를 활용해 비교적 빠르게 기능을 구현할 수 있었지만, 중간에 예상치 못한 문제가 발생했고 이를 해결하면서 더 안정적인 방식으로 개선할 수 있었습니다. 이번 글에서는 이 경험을 공유드리고자 합니다.
IntersectionObserver 란?
먼저 IntersectionObserver는 DOM 요소가 뷰포트나 특정 부모 요소와 교차하는지 여부를 비동기적으로 관찰할 수 있는 웹 API입니다. 이를 활용하면 스크롤 이벤트를 직접 추적하는 전통적인 방식보다 훨씬 효율적으로 요소의 가시성 변화를 감지할 수 있습니다.
- 스크롤할 때마다 발생하는
onscroll이벤트와 달리, 요소의 교차 상태가 변할 때만 콜백 함수를 실행 → 브라우저의 메인 스레드 부담 감소 - 뷰포트에 대한 요소의 가시성 비율(Intersection Ratio) 제공 → 요소가 얼마나 보이고 있는지 정밀한 판단 가능
- 기본적으로 뷰포트를 기준으로 교차 여부를 판단하지만,
root옵션을 사용해 특정 스크롤 영역을 기준으로 설정 가능 →rootMargin을 통해 교차 감지 범위 조절 가능
IntersectionObserver 사용 방법
IntersectionObserver인스턴스 생성하기
const observer = new IntersectionObserver(callback, options);
IntersectionObserver 생성자에는 두 가지 인자를 전달합니다.
- callback: 관찰 대상 요소가 지정된
threshold를 통과할 때마다 실행될 함수로,IntersectionObserverEntry객체 배열과Observer자체를 인자로 받습니다. - options: 관찰 방법을 설정하는 객체
root: 교차를 관찰할 뷰포트 역할을 하는 요소 (기본값: 브라우저 뷰포트)rootMargin:root의 경계에 여백을 추가해 교차 영역을 확장/축소해 교차 범위 조정 (ex."0px 0px -50% 0px”→ 뷰포트의 절반 지점에 도달했을 때 감지)threshold: 콜백이 실행될 교차 비율 (ex. 0.5 설정 시 요소의 절반이 보일 때 콜백 함수를 실행하고, [0, 0.5, 1] 설정 시, 0%, 50%, 100% 일 때마다 콜백 함수를 실행합니다.)trackVisiblity(v2부터): 요소가 실제로 사용자에게 보이는지(다른 컨텐츠에 의해 가려졌거나,transform,opacity같은 시각 효과 때문에 보이지 않게 되었는지)에 대해 추적할지 유무 (v2부터 도입)
- 관찰 대상 등록하기
observer.observe(targetElement);
observe 메서드를 사용해 관찰을 시작할 타겟 요소를 지정합니다. 하나의 Observer 인스턴스는 여러 개의 요소를 동시에 추적할 수 있습니다.
- 관찰 중지하기
observer.unobserve(targetElement); // 특정 대상에 대한 관찰 중지
observer.disconnect(); // 모든 대상에 대한 관찰 중지
관찰이 더 이상 필요하지 않을 때는 unobserve()나 disconnect() 메서드를 사용해 불필요한 리소스 낭비를 막아야 합니다.
사용 예시: 문서 페이지 관찰
const observer = new IntersectionObserver(
(entries) => {
console.log(entries);
},
{ threshold: 0.6 },
);
observer.observe(document.querySelector('#page-1'));
observer.observe(document.querySelector('#page-2'));
observer.observe(document.querySelector('#page-3'));
총 3개의 페이지를 IntersectionObserver의 관찰 대상에 등록합니다. threshold를 0.6으로 설정했으므로, 각 페이지 요소가 뷰포트와 60% 이상 교차할 때마다 콜백이 실행됩니다.
페이지가 모두 관찰 대상으로 등록되면, observer의 콜백 함수가 최초로 한 번 실행됩니다. 이때 entries 배열에는 관찰 대상인 3개의 페이지가 모두 포함됩니다.
이때 각 페이지의 IntersectionObserverEntry 값은 아래와 같습니다.
- page-1 의
IntersectionObserverEntry- intersectionRatio: 1 → 뷰포트에 완전 보이고 있음.
- isIntersecting: true → 뷰포트와 교차하고 있음.
- page-2 의
IntersectionObserverEntry- intersectionRatio: 0.172 → 뷰포트에 일부만 보이고 있음.
- isIntersecting: false → 뷰포트와 교차하고 있음.
- page-3 의
IntersectionObserverEntry- intersectionRatio: 0 → 뷰포트 밖에 있음.
- isIntersecting: true → 뷰포트와 교차하지 않음.
이후 사용자가 스크롤을 내려 두 번째 페이지(page-2)의 60% 영역이 뷰포트에 노출되면, 콜백 함수가 다시 호출됩니다.
이때 entries 배열에는 상태가 변경된 페이지들에 대한 IntersectionObserverEntry만 포함됩니다.
page-1의IntersectionObserverEntry- intersectionRatio: 0.58
- isIntersecting: false
page-2의IntersectionObserverEntry- intersectionRatio: 0.63
- isIntersecting: true
이처럼 IntersectionObserver는 threshold를 기준으로 교차 상태가 변할 때마다 콜백을 호출하여, 현재 화면에 보이는 페이지를 효율적으로 추적할 수 있게 해줍니다.
실제 적용: 현재 페이지 추적
React 기반 문서 뷰어에서, 각 페이지를 IntersectionObserver로 관찰해 가장 최근에 교차한 페이지를 현재 페이지로 인식하도록 구현했습니다.
// PageObserverProvider.tsx
export const PageObserverProvider = ({ children }) => {
const pageObserverRef = useRef<IntersectionObserver | null>(null);
const pagesRef = useRef<Set<HTMLDivElement>>(new Set());
const setCurrentPageIdx = useSetAtom(currentPageIdxAtom);
useEffect(() => {
// IntersectionObserver 생성
const observer = new IntersectionObserver(
(entries) => {
const currentPageEntry = entries.find((entry) => entry.isIntersecting);
setCurrentPageIdx((prev) => {
if (currentPageEntry) {
return Number(currentPageEntry.target.getAttribute('data-page-idx'));
}
return prev;
});
},
{ threshold: 0.6 }, // 60% 이상 보여야 "현재 페이지"로 판단
);
pageObserverRef.current = observer;
return () => {
observer.disconnect();
pagesRef.current.clear();
};
}, [setCurrentPageIdx]);
// 각 페이지에 ref 등록
const pageCallbackRef = useCallback((node: HTMLDivElement | null) => {
const observer = pageObserverRef.current;
if (!observer) return;
if (node) {
if (!pagesRef.current.has(node)) {
pagesRef.current.add(node);
observer.observe(node);
}
} else {
// ref가 해제된 경우 unobserve
pagesRef.current.forEach((el) => {
if (!document.body.contains(el)) {
observer.unobserve(el);
pagesRef.current.delete(el);
}
});
}
}, []);
return <ObserverContext.Provider value={{ pageCallbackRef }}>{children}</ObserverContext.Provider>;
};
PageObserverProvider는 IntersectionObserver 인스턴스를 생성하고 pageCallbackRef를 통해 개별 페이지를 관찰 대상으로 등록합니다. threshold: 0.6 옵션은 뷰포트에 60% 이상 보이는 페이지를 현재 페이지로 인식하게 합니다.
// Page.tsx
const Page = ({ idx }: { idx: number }) => {
const { pageCallbackRef } = useContext(ObserverContext);
return (
<div ref={pageCallbackRef} data-page-idx={idx}>
...
</div>
);
};
Page 컴포넌트는 ObserverContext에서 pageCallbackRef를 가져와 div의 ref 속성에 연결합니다. 이렇게 하면 각 Page 컴포넌트가 렌더링될 때마다 pageCallbackRef 콜백 함수가 실행되어 IntersectionObserver에 해당 요소가 등록됩니다.
문제 발생: 페이지 확대 시 추적 실패
이렇게 구현하니 처음에는 기능이 잘 동작했지만, 페이지 확대/축소 기능을 추가하자 문제가 발생했습니다.
페이지가 크게 확대된 상태에서는 다음 페이지가 뷰포트에 들어와도 60% 이상 보이지 않는 경우가 많아, 현재 페이지 인덱스가 갱신되지 않는 상황이 생겼습니다.
해결: threshold 배열 + 최다 노출 페이지 선택
이 문제를 해결하기 위해 단일 threshold 대신, 0%~100%를 일정 간격으로 나눈 threshold 배열을 사용했습니다.
즉, IntersectionObserver가 5% 단위로 콜백을 호출하게 하여 페이지들의 뷰포트 노출 상태를 더 자주 체크하면서, 현재 뷰포트에 보이는 페이지들 중 가장 많이 보이는 페이지를 현재 페이지로 선택하는 방식으로 변경했습니다.