Intersection Observer로 만든 채팅 역방향 무한 스크롤 트러블슈팅
채팅 UI에 무한 스크롤(역방향 페이지네이션)을 붙이는 작업이었다. 화면 맨 위로 스크롤이 닿으면 이전 메세지를 불러와 위쪽에 끼워 넣는 패턴이다.
조건은 셋이었다.
- 최초 스크롤 위치는 맨 아래
- 스크롤 감지 타겟이 맨 위에 배치
- 다음 페이지로 리패칭할 때 메세지 UI가 자연스럽게 위치하도록
Intersection Observer로 타겟 감시
Intersection Observer는 타겟 요소와 viewport(또는 지정한 root 요소) 사이의 교차 상태 변화를 비동기로 관찰하는 브라우저 API다. 콜백은 (1) 처음 관찰을 시작하는 시점, (2) 타겟의 교차 비율이 지정한 threshold를 넘을 때마다 호출된다. 콜백은 메인 스레드에서 실행되니 무겁게 두면 안 된다.
훅으로 한 번 감싸 두면 사용처에서 ref만 꽂으면 된다.
import { useCallback, useEffect, useRef } from 'react';
type IO = {
callback: (
entry?: IntersectionObserverEntry,
observer?: IntersectionObserver,
) => void;
options?: IntersectionObserverInit;
};
const useIntersectionObserver = ({
callback,
options = { threshold: 0.5 },
}: IO) => {
const targetRef = useRef<HTMLDivElement>(null);
const handleIntersection = useCallback(
([entry]: IntersectionObserverEntry[], observer: IntersectionObserver) => {
if (entry.isIntersecting) {
callback(entry, observer);
}
},
[callback],
);
useEffect(() => {
const observer = new IntersectionObserver(handleIntersection, options);
const target = targetRef.current;
if (target) {
observer.observe(target);
}
return () => {
if (target) {
observer.unobserve(target);
}
};
}, [handleIntersection, options]);
return { targetRef };
};
export default useIntersectionObserver;옵션은 다음과 같이 동작한다.
| 옵션 | 의미 |
|---|---|
threshold: 0 | 타겟이 1픽셀이라도 보이면 콜백 호출 |
threshold: 0.5 | 타겟의 50%가 보이면 콜백 호출 |
rootMargin: '0px' | root 경계 보정값 (기본값과 동일) |
root: null | viewport를 root로 사용 |
isIntersecting === true | 타겟이 root와 교차 상태로 전이된 시점 |
훅을 짜고 보니 의존성 배열에 options 객체를 그대로 넣은 게 마음에 걸렸다. useEffect는 의존성을 Object.is로 비교하는데, 호출처에서 객체 리터럴을 매 렌더 새로 만들어 넘기면 매번 다른 참조로 평가돼 옵저버가 매 렌더 재생성된다. 호출처에서 useMemo로 감싸거나, 원시 값만 의존성에 두는 게 안전하다는 걸 React 공식 문서에서 확인했다.
사용처 — 타겟이 잡혔을 때 이전 메세지 가져오기
타겟은 메세지 리스트의 맨 위에 배치한다. 사용자가 스크롤을 올려 타겟이 보이는 순간 fetchNextMessages()로 이전 페이지를 받아 위쪽에 끼워 넣는다.
const [isMovedInitialScroll, setIsMovedInitialScroll] = useState(false);
const chatContainerRef = useRef<HTMLDivElement | null>(null);
const chatScrollRef = useRef<HTMLDivElement | null>(null);
const { targetRef } = useIntersectionObserver({
callback: () => {
if (!isFetching) {
if (!chatContainerRef.current) return;
const ref = chatContainerRef.current;
const prevScroll = ref.scrollHeight;
fetchNextMessages();
const curScroll = ref.scrollHeight;
ref.scrollTo({
top: curScroll - prevScroll + 300,
behavior: 'instant' as ScrollBehavior,
});
}
},
options: {
root: null,
rootMargin: '0px',
threshold: 0,
},
});
useEffect(() => {
if (!isFetched) return;
setTimeout(() => {
if (!chatScrollRef.current) return;
chatScrollRef.current.scrollIntoView({
behavior: 'instant' as ScrollBehavior,
});
setIsMovedInitialScroll(true);
}, 100);
}, [isFetched]);여기서 scrollTo와 scrollIntoView를 둘 다 쓴다. 둘은 역할이 다르다.
Element.scrollTo({ top })— 호출 대상 컨테이너 자기 자신을 지정한 좌표로 스크롤한다. "컨테이너를 픽셀 좌표로 옮기는" 동작이다.Element.scrollIntoView()— 호출 대상 요소가 보이도록 조상 컨테이너를 스크롤한다. "특정 자식을 화면에 띄우는" 동작이다.
조건 1(최초 스크롤 위치 맨 아래)을 만족시키는 게 아래쪽 useEffect다. 첫 패치가 끝나면 setTimeout 100ms 뒤 맨 아래에 배치된 chatScrollRef를 scrollIntoView로 화면 안에 끌어들인다. 그 결과 컨테이너가 맨 아래까지 내려간다.
behavior: 'instant'와 as ScrollBehavior 캐스팅
scrollTo와 scrollIntoView 양쪽에 behavior: 'instant' as ScrollBehavior 캐스팅이 들어가 있다. 처음엔 왜 굳이 캐스팅이 필요한지 헷갈렸다.
CSSOM View Module 사양상 ScrollBehavior enum은 "auto" | "instant" | "smooth" 세 값을 정의한다. 'instant'는 애니메이션 없이 단일 점프로 스크롤하라는 옵션이고 Chrome·Firefox·Safari 15.4+ 모두 런타임에서 지원한다. 즉 표준 위반은 아니다.
문제는 TypeScript 쪽이었다. lib.dom.d.ts의 ScrollBehavior 타입은 'auto' | 'smooth'만 정의돼 있어 'instant'가 빠져 있는 알려진 누락(microsoft/TypeScript #46654, #47441)이 있다. 컴파일러를 통과시키려고 실무에서 as ScrollBehavior 캐스팅이 자주 등장하는 이유다.
보정 코드의 함정 — scrollHeight 측정 타이밍
조건 3을 만족시키려고 넣은 게 callback 안의 prevScroll/curScroll diff 보정이다. 의도는 "새 메세지가 위쪽에 끼워지면서 늘어난 콘텐츠 높이만큼 컨테이너 scrollTop을 더해 시각 위치를 그대로 유지"였다.
const prevScroll = ref.scrollHeight;
fetchNextMessages();
const curScroll = ref.scrollHeight;
ref.scrollTo({ top: curScroll - prevScroll + 300, ... });이게 의도대로 동작하는지 다시 짚어 보니 함정이 있었다. Element.scrollHeight는 호출 시점의 콘텐츠 높이를 동기로 반환한다. fetchNextMessages()가 네트워크 요청이라면 그 직후 줄에서 다시 잰 curScroll은 새 메세지가 아직 DOM에 붙기 전 값이다. 결과적으로 curScroll - prevScroll = 0이 되고 scrollTo({ top: 300 }) 한 줄과 같은 동작이 된다.
순서를 도식으로 보면 이렇다.
보정은 새 노드가 DOM에 반영된 이후 시점에서 해야 한다. 응답 then 안, 메세지 배열 변화를 감지하는 useEffect, requestAnimationFrame 같은 방법으로 미뤄야 한다.
+ 300이 어디서 온 값인지는 따로 적어두지 않아 단정할 근거가 없다. 헤더 높이나 시각 여유로 끼워 넣은 값으로 보이지만 출처가 없는 매직 넘버다.
CSS overflow-anchor라는 다른 길
브라우저가 viewport 위쪽으로 콘텐츠가 추가/제거될 때 사용자의 읽는 위치를 자동 보존해주는 CSS 기능이 따로 있다. overflow-anchor: auto가 기본값이고 none으로 옵트아웃한다. MDN은 채팅·피드·라이브 업데이트처럼 위쪽에 콘텐츠가 들어오는 케이스의 표준 예시로 이 기능을 든다. 단, 모든 주요 브라우저에서 baseline으로 동작하지는 않아 fallback이 필요하다.
scrollHeight diff 방식이든 overflow-anchor든, 공통 원리는 같다. 새 노드가 위로 들어오면 사용자의 시각 위치가 흔들리지 않게 컨테이너를 그만큼 아래로 밀어준다는 것. 이번 코드는 그 원리는 맞췄지만 측정 타이밍에서 어긋났다.