배경
🪜
개발 •  • 읽는데 7분 소요

iOS 브라우저에서 스크롤과 좌표 계산 API를 함께 썼더니 생긴 일

WebKit 기반 브라우저에서 두 API를 함께 사용할 경우 좌표가 정상적으로 측정되지 않는 현상에 대해 소개하고 해결 방법에 대해 공유합니다.

#Front-End


ios 애플 타도! iOS 타도! Safari 타도!

프론트엔드 개발자라면 누구나 크로스 브라우징 이슈를 겪게 마련입니다. 그 중에서도 iOS 환경 대응은 악명이 높기로 유명하죠.

최근에 회사에서 QA 업무를 하다가 이러한 iOS 이슈를 여러 개 처리하게 됐는데, 그 중에서도 정말 황당하고 어이없는 케이스가 있어서 이렇게 글로 정리하게 되었습니다. (이 억울함을 나만 알 수는 없다!)

제가 담당한 이슈는 iOS Safari 브라우저에서 댓글 입력 중 멘션 기능을 사용할 때 발생했습니다. 사용자가 @를 입력하면 멘션 레이어가 표시되는데, iOS에서만 이 레이어가 화면을 뚫고 나가버리는 것이 문제였죠.

알고 보니 모바일 브라우저에서 스크롤 위치를 고정하기 위해 사용하던 API와 멘션 레이어 위치를 계산하기 위한 API가 연달아 사용되었더니 좌표 계산이 정상적으로 동작하지 않는 것이 문제더라구요.

그런데 그 원인과 해결책을 알고 나니 참… 마음이 참 그렇더라구요. 😂 혹시나 했는데 역시나 여서 WebKit 개발자한테 좀 섭섭하다는 감정도 들었네요.

아무튼 오늘은 해당 버그를 소개하고 그 해결책까지 알아보는 시간을 가져보려고 합니다. 이번 글을 통해 iOS와 Safari(정확히는 WebKit)에 고통받고 있는 프론트엔드 개발자 분들께 도움이 되길 바랍니다.

TL;DR

  • iOS Webkit 기반 브라우저에서 발생하는 문제로 Window.scrollTo API 와 Element.getBoundingClientRect 를 동시에 쓰는 경우에 발생함
  • Window.scrollTo API를 호출할 때 실제 스크롤 가능한 영역보다 더 크거나 작은 값으로 호출을 하면 이것이 절삭된 값으로 반영되는 것이 아닌, Element.getBoundingClientRect 를 통해 얻어오는 좌표에도 영향을 주는 현상이 있음
  • 따라서 스크롤 직후 좌표를 얻어오는 로직을 작성해야 한다면:
    • setTimeout 을 이용해 브라우저에게 레이아웃 재계산을 위한 여유 시간을 주거나
    • 실제 스크롤 가능한 영역까지 최대/최소값으로 절삭하는 로직을 직접 추가해주는 방식을 고려할 수 있음

문제의 발견

멘션 좌측은 페이스북, 우측은 인스타그램. 대충 이러한 멘션 기능(?)을 만들고 있었다.

제가 받은 티켓의 내용을 간단하게 설명하자면, 댓글 입력창에 @를 입력하면 멘션 기능이 활성화되는데 그 멘션 레이어가 iOS에서만 화면을 뚫고 나간다는 이슈였습니다.

멘션 레이어는 사용자가 입력한 @ 글자 바로 위/아래에 나타나야 했기 때문에, 해당 글자의 위치를 좌표로 알아내야 했고 이를 getBoundingClientRect API를 사용해 해결하고 있었습니다.

그런데 저는 예전에 분명히 화면을 뚫고 나가지 않도록 해당 요소의 좌표를 계산해서 최대 높이 제한을 해둔 적이 있었거든요. 그리고 해당 버그가 기존 iOS에서 이미 발생한 적이 있었어서, “아~ iOS에서는 스크롤 직후 좌표 계산 시 타이밍 이슈가 있구나~” 라고 생각하고 이미 setTimeout 을 적용해 해결한 적도 있었습니다.

코드를 좀 더 살펴보니 setTimeout 을 적용한 부분이 리팩토링을 하면서 사라진 것을 확인했고, 다시 해당 코드를 복원하는 방식으로 간단하게 해결하려고 했습니다.

그런데… 로직의 연관된 부분을 찾아보다가 뭔가 이상한 걸 발견했습니다. 모바일 브라우저에서는 가상 키보드가 떠오르기 때문에 인풋 위치를 키보드 위로 고정시키기 위해 매 타이핑마다 스크롤 API를 호출하고 있었는데요. 이 스크롤 값을 이리저리 조절해보니… 어떨 때는 정상적으로 동작하는 경우가 있는 것이었습니다.

호기심이 생겨 조금 더 살펴보았더니 문제는 바로 스크롤 값이 음수로 설정되고 있는 경우 에만 발생하는 것이었습니다. 이 음수 스크롤 값이 뭔가 이상한 일을 일으키고 있었던 거죠.

실제 데모

데모 직접 만든 데모 페이지

이것이 실존하는 문제인지를 명확하게 보여드리기 위해 Next.js로 간단한 데모 페이지를 만들어봤습니다.

우선 제일 위에 있는 핑크색 상자는 getBoundingClientRect를 이용해 뷰포트 최상단으로부터의 top 값을 보여주도록 설정했습니다.

또한 아래에 있는 세 개의 회색 버튼은 클릭 시 scrollTo API와 getBoundingClientRect API를 연달아 호출합니다. 첫 번째는 0, 두 번째는 -1000, 세 번째는 -5000만큼 Y값을 조정하도록 했습니다. 그리고 반환된 DOMRecttop 을 별도 상태에 저장하게 했죠.

불필요한 스타일 등을 제거하고 핵심 로직만 남긴 코드는 다음과 같습니다.

const [top, setTop] = useState(0);
const divRef = useRef<HTMLDivElement>(null);

const handleClick = (top: number) => {
  // top으로 스크롤 이동
  window.scrollTo({ top });

  // 그 직후 divRef의 top 값을 상태에 저장
  setTop(divRef.current?.getBoundingClientRect().top ?? 0);
};

return (
  <>
    <div ref={divRef}>getBoundingClientRect.top: {top}px</div>
    <div onClick={() => handleClick(0)}>window.scrollTop(0)</div>
    <div onClick={() => handleClick(-1000)}>window.scrollTop(-1000)</div>
    <div onClick={() => handleClick(-5000)}>window.scrollTop(-5000)</div>
  </>
);

좌측이 PC Chrome, 우측이 PC Safari

PC Chrome과 Safari에서는 두 번째, 세 번째 버튼을 눌러도 getBoundingClientRect로 얻어온 top 값이 그대로 유지됩니다. 왜냐하면 우리가 스크롤을 아무리 음수로 설정한다고 해도, 실제로 스크롤이 0보다 작아질 수는 없다는 것을 직관적으로 알고 있으니까요.

이는 사실 scroll 스펙에도 명시된 내용입니다. 실제 스크롤 가능한 영역보다 더 크거나 작은 값을 넣으면 절삭(clamp)되는 것이 DOM API에 정의된 브라우저의 기본 동작이거든요.

스펙 분명히 브라우저 자체에서 min/max 처리가 되어야 함. 그래야만 하는데…

그런데 똑같은 코드를 iOS에서 실행해보면…

??

스크롤을 음수로 설정하면 그 값이 실제 좌표에도 영향을 주는 것을 확인할 수 있었습니다. 즉, scrollTo({ top: -5000 }) 을 호출하게 되면 실제 스크롤은 0 까지만 이동하지만, 핑크색 상자의 top 좌표는 5098px 로 측정이 됩니다.

이 현상은 양수 방향으로도 적용되더라구요. 즉, y를 10000, 20000 같은 큰 값으로 설정하면 top 좌표가 음수 방향으로 -9902px, -19902px 로 측정이 됩니다.

해결 방법

자, 이제 문제는 확인했으니 해결책을 찾아야겠죠. 이러한 문제가 발생하는 것 자체는 브라우저의 버그이기 때문에 어쩔 수가 없는 것이고… 어쨌든 사용자에게 버그처럼 보이지 않도록 우리가 직접 해결해야 합니다. 제가 찾은 해결방법은 총 2개입니다.

setTimeout 으로 여유 시간 주기

scrollTo API 호출 직후에 해당 문제가 발생하기 때문에, 레이아웃을 재계산할 시간을 브라우저에게 충분히 부여하는 방법입니다. 저는 setTimeout을 적당히 주었더니 문제가 발생하지 않더라구요.

const handleClick = (top: number) => {
  window.scrollTo({ top });

  setTimeout(() => {
    setTop(divRef.current?.getBoundingClientRect().top ?? 0);
  }, 100); // 적당한 지연 시간
};

setTimeout 으로 적당한 시간 주기

다만 이 방법은 순간적이긴 하지만 화면에 깜빡임을 발생시킬 수 있다는 단점이 있습니다. 또한 setTimeout의 지연 시간을 얼마나 줘야 하는지도 명확하지 않아서 약간 불안정할 수 있어요.

스크롤 값에 min/max 직접 적용하기

조금 더 확실한 방법은 스크롤 불가능한 영역으로 API가 호출되지 않도록 직접 최대/최소값 절삭 로직을 추가하는 겁니다. 브라우저가 자동으로 해줘야 하는 걸 우리가 직접 하는 거죠.

스크롤 범위를 직접 제한

저는 음수 방향의 스크롤만 고려해서 일단 최소값이 0이 되도록 설정해두었는데요, 만약 양수 방향의 스크롤도 함께 고려한다면 아래와 같이 작성하시면 됩니다.

// 최소값: 페이지 최상단
Math.max(
  0,
  // 최대값: 페이지 최하단 - 뷰포트 높이
  Math.min(top, document.body.scrollHeight - window.innerHeight)
);

두 방법 중에서는 방법 2가 더 안정적입니다. setTimeout은 타이밍 이슈가 있을 수 있지만, 직접 값을 절삭하는 방식은 확실하게 문제를 방지할 수 있거든요.

최종 결론

혹시나 해서 이 문제가 iOS 내 다른 브라우저에서도 발생하는지 확인해봤습니다. 결과는… 예상대로 iOS Chrome에서도 동일한 문제가 발생하더라구요. 단순히 Safari만의 문제가 아니라는 뜻이죠. 이는 곧 두 브라우저에서 공통으로 사용되는 브라우저 엔진인 WebKit의 문제라고 볼 수 있습니다.

실제로 WebKit 버그 트래커에서 비슷한 오류 제보를 찾을 수 있었는데, 아직까지 대응이 되지 않은 걸 보니 당분간은 직접 해결해줘야 할 것 같습니다.

펀치 꿀밤펀치

프론트엔드 개발자 입장에서는 이런 브라우저 엔진 버그까지 직접 처리해야 한다는 게… 참 답답한 일입니다. 그래도 이걸 수정하는 PR을 올리고 팀원분들께 설명드렸더니 다들 경악하시면서 공감해주셔서 나름의 위로를 얻었습니다. 😂

하지만 어쩔 수 있나요, 이게 프론트엔드 개발자의 숙명이니까요. 혹시 비슷한 문제를 겪고 계신 분들께 이 글이 도움이 되길 바라며 글을 마칩니다.

이 포스트가 유익하셨다면?




프로필 사진

👨‍💻 정종윤

글 쓰는 것을 좋아하는 프론트엔드 개발자입니다. 온라인에서는 재그지그라는 닉네임으로 활동하고 있습니다.


Copyright © 2025, All right reserved.

Built with Gatsby