scrollIntoView smooth 가 4px 만 가고 멈춘 이유
원인은 scroll anchoring 이었다. 2주 전 지운 rAF 두 번이 다시 필요했던 날.
스크롤을 시켰는데 화면이 조금 움찔하고 멈추면, 보통 스크롤이 안 걸렸다고 본다. 나도 그랬다.
2026-08-31 저녁, 아트페어 관람객 화면(/f/{페어})을 고쳤다. 그때까지는 도면과 갤러리 명부가 같은 자리에서 버튼으로 갈아 끼워지는 구조였는데, 이걸 도면 아래에 명부가 늘 펼쳐지는 구조로 바꿨다. 명부 한 줄을 누르면 도면에서 그 부스를 누른 것과 똑같이 굴러야 한다 — 줌이 당겨지고 정보 창이 열린다.
여기서 되감기가 필요해졌다. 명부가 167줄(제일 큰 페어는 175줄)이라, 맨 아래까지 훑다가 누르면 도면은 이미 화면 밖이다. 줌이 당겨져도 정보 창이 떠도, 사람 눈에는 아무 일도 안 일어난다.
그래서 클릭 핸들러에 한 줄을 넣었다.
setSelectedBoothId(boothNumber);
mapRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
주석까지 이렇게 써 뒀다.
되감을 자리는 도면 위쪽이라 정보 창이 끼어들어도 위치가 안 밀린다 — 8/14에 있던 rAF 두 번짜리 보정이 여기서는 필요 없다.
이 주석에는 배경이 있다. 2주 전(8/14) 같은 화면에서 정확히 이 문제를 rAF 두 번으로 넘긴 코드가 있었다. 그날 명부를 「누르면 상세 페이지로 나가는 것」으로 바꾸면서 되감기 자체가 없어졌고, 그 보정 코드도 같이 지웠다. 지우면서 남긴 커밋 주석이 "되감기에 쓰던 rAF 두 번(문서 높이가 바뀌어 스크롤이 495px 에서 서던 것)도 같이 없어졌다" 였다.
이번에 되감기를 되살리면서 「이번엔 구조가 달라서 필요 없다」고 판단하고 안 넣었다.
4px 움직이고 멈췄다
브라우저에서 눌러 봤다. 화면이 4px 움직이고 멈췄다 (스크롤 1273 → 1269).
처음엔 「스크롤이 안 걸렸다」고 봤다. 확인해 보니 아니었다. position: sticky 도 정상이었고, 스크롤 조상 중에 overflow: hidden 을 가진 것도 없었다(체인을 전부 찍어서 확인했다). scrollIntoView 는 호출됐고 시작도 했다. 시작한 뒤에 끊겼다.
내 가정은 "끼어드는 요소가 되감을 자리보다 아래에 있으니 위치가 안 밀린다" 였다. 위치는 실제로 안 밀렸다. 그런데 끊긴 이유가 위치가 아니었다.
부스를 고르면 정보 창이 도면과 명부 사이에 끼어든다. 그 자리는 그때 화면 위쪽, 스크롤 바깥이다. 크롬은 그럴 때 「보던 자리」를 지키려고 스크롤 위치를 스스로 보정한다(scroll anchoring). 그리고 그 보정이 방금 시작한 smooth 스크롤 애니메이션을 끊는다.
8/14에 만났던 것과 원인 문구는 다른데(그때는 문서 높이, 이번엔 앵커 보정) 처방은 같은 함정이었다. 「구조가 달라졌으니 필요 없다」는 판단이, 원인을 한 겹 얕게 읽은 결과였다.
방아쇠를 값이 아니라 횟수로 걸었다
스크롤을 DOM 이 다시 그려진 다음으로 미뤘다.
const [scrollTick, setScrollTick] = useState(0);
const handleSelectFromList = (boothNumber) => {
setSelectedBoothId(boothNumber);
setScrollTick((n) => n + 1); // 값이 아니라 「누른 횟수」가 방아쇠다
};
useEffect(() => {
if (scrollTick === 0) return;
let inner = 0;
const outer = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => {
mapRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
return () => { cancelAnimationFrame(outer); cancelAnimationFrame(inner); };
}, [scrollTick]);
방아쇠를 선택된 값이 아니라 누른 횟수로 건 것이 두 번째 판단이다. 값으로 걸면 같은 줄을 다시 눌렀을 때 값이 안 바뀌어서 아무 일도 안 일어난다. 명부에서 이미 고른 줄을 다시 누르는 건 「거기로 되돌아가고 싶다」는 뜻이라, 그때도 되감겨야 한다.
실측: 스크롤 3000 → 116 으로 되감긴다. (116 = 도면 위치 184 − scroll-mt 68)
같은 날, 반대 방향으로 한 번 더 틀렸다
고침을 넣고 다시 눌렀다. 스크린샷은 여전히 안 움직인 화면을 보여줬다. 3초를 기다린 뒤에 찍었는데도 그랬다. 「아직 안 된다」고 두 번 판정했다.
그러다 스크린샷 대신 값을 직접 읽었다.
window.scrollY // → 116
이미 되고 있었다. 브라우저 자동화 확장 화면 캡처가 한 프레임 늦었을 뿐이다. 바로 다음에 찍은 스크린샷은 제대로 나왔다.
하루에 「안 된다」를 두 번 판정했는데, 한 번은 원인 진단이 틀렸고 한 번은 사실 되고 있었다. 공통점은 둘 다 화면을 보고 판정했다는 것이다.
남긴 규칙 두 줄
- 되돌리기 코드를 지울 때는 「왜 넣었나」를 원인 층위까지 읽는다. 증상 문구가 달라졌다고 같은 함정이 아닌 게 아니다. 8/14 주석은 "문서 높이가 바뀌어" 라고 적혀 있었고, 이번 원인은 "앵커 보정" 이었다. 문구만 보면 다른 문제고, 처방은 같은 문제였다.
- 애니메이션이 걸린 화면은 스크린샷으로 판정하지 않는다 — 값을 읽는다.
window.scrollY한 줄이 스크린샷 세 장보다 빨랐다.