스크롤 위치, 크롬은 멀쩡한데 사파리는 덜컹거림

스크롤 위치를 구할 때 크롬(Chrome)에서는 정수 값을 반환하는데, 사파리(Safari)에서는 예상치 못한 소수점 값을 반환하는 경우가 있다.

이 특성을 모르면 웹 개발 중 스크롤 기반 애니메이션이나 기능을 구현할 때, 크롬에서는 멀쩡한데 사파리에서는 덜컹거리거나, 의도대로 안돼서 화딱지 날 수 있다.

이 “사소하지만 중요한” 차이점에 대해 깊이 분석하고, 어떻게 효과적으로 대처할 수 있을지 알아본다.

크롬 vs. 사파리, 스크롤 값의 차이

웹 페이지에서 스크롤 위치를 가져올 때, 주로 window.scrollY (또는 window.pageYOffset)나 특정 요소의 element.scrollTop 같은 속성을 사용한다.

  • 크롬 (Chrome) 및 대부분의 Chromium 기반 브라우저 (Edge, Brave 등):
    이 브라우저들은 스크롤 위치를 반환할 때 정수(Integer) 값을 주는 것이 일반적이다. 예를 들어, 스크롤을 조금 내리면 100, 101, 102 와 같이 정수로 나타난다. // Chrome에서 스크롤 중 console.log(window.scrollY); // 100, 101, 102 ...
  • 사파리 (Safari):
    반면, 사파리 (특히 macOS 환경)에서는 스크롤 위치가 소수점(Float) 값을 포함한 실수로 반환될 수 있다. 스크롤이 부드럽게 진행되는 중간 과정뿐만 아니라, 스크롤이 멈춘 후에도 소수점 값을 유지하는 경우가 있다. // Safari에서 스크롤 중 console.log(window.scrollY); // 100.33333333333333, 101.875, 102.5 ...

이 차이는 특히 픽셀 단위로 정밀한 계산이 필요한 스크롤 트리거 애니메이션이나 특정 위치 도달 시 로직 실행 등을 구현할 때 예상치 못한 버그의 원인이 되기도 한다.

왜 이런 차이가 발생하는가?

브라우저 제조사들이 내부 구현 로직을 상세히 공개하지는 않지만, 다음과 같은 합리적인 추론이 가능하다:

  1. 렌더링 엔진의 철학 차이:
    • 크롬 (Blink 엔진): 픽셀 스냅핑(pixel-snapping)을 통해 정수 단위로 위치를 계산하고 반환하는 것을 선호할 수 있다. 이는 예측 가능성과 단순성을 높인다.
    • 사파리 (WebKit 엔진): 서브픽셀 렌더링(sub-pixel rendering)을 통해 더욱 부드럽고 정교한 시각적 표현을 추구하는 경향이 있다. 이로 인해 스크롤 위치 또한 내부적으로 서브픽셀 단위로 계산되고, 이 값이 그대로 노출될 수 있다. macOS 자체의 부드러운 애니메이션 처리 방식과도 연관이 있을 수 있다.
  2. 스무스 스크롤링(Smooth Scrolling) 구현 방식:
    두 브라우저 모두 부드러운 스크롤링을 지원하지만, 이를 구현하는 내부 애니메이션 로직이 다를 수 있다. 사파리는 애니메이션 중간 단계의 미세한 위치 변화를 더 정밀하게 표현하여 소수점 값을 반환할 가능성이 있다.
  3. 디바이스 및 운영체제 특성 반영:
    특히 Apple 기기(Mac, iPhone, iPad)의 고해상도 디스플레이(Retina)와 터치 인터페이스, 트랙패드 제스처 등은 매우 부드럽고 연속적인 스크롤 경험을 제공한다. 사파리는 이러한 환경에 최적화되어 스크롤 위치를 더 세밀하게 다룰 수 있다.

개발자에게 미치는 영향과 해결 방안

이러한 차이점을 인지하지 못하면, 다음과 같은 문제에 직면할 수 있다:

  • 정확한 값 비교의 함정:
    if (window.scrollY === 300) 과 같은 조건문은 사파리에서 window.scrollY300.123과 같은 값을 가질 때 false를 반환하여 의도대로 동작하지 않을 수 있다.
  • 애니메이션 및 계산 오차:
    스크롤 위치를 기반으로 요소의 위치를 변경하거나 복잡한 계산을 수행할 때, 소수점 값으로 인해 미세한 오차가 누적되거나 예상치 못한 시각적 떨림(jittering)이 발생할 수 있다.

이 문제를 해결하기 위한 방안은 다음과 같다.

1. 값 정규화

가장 일반적이고 효과적인 방법은 스크롤 값을 사용하기 전에 정수형으로 변환하는 것이다.

  • 반올림 (Math.round()): 가장 보편적으로 사용된다. const currentScrollY = Math.round(window.scrollY); if (currentScrollY === 300) { // 로직 실행 }
  • 버림 (Math.floor()): 특정 지점을 “넘어섰는지” 확인할 때 유용하다. const currentScrollY = Math.floor(window.scrollY); if (currentScrollY >= 300) { // 300px 또는 그 이상 스크롤했을 때 }
  • 올림 (Math.ceil()): 특정 지점에 “도달하기 직전” 또는 “갓 도달했을 때”를 판단할 때 사용할 수 있다. (사용 빈도는 상대적으로 낮다)
    javascript const currentScrollY = Math.ceil(window.scrollY); if (currentScrollY <= 300) { // 300px 또는 그 이전 스크롤 위치일 때 }

2. 범위(Range) 체크

정확한 값 대신 특정 범위 내에 있는지 확인하는 것도 좋은 방법이다. 이는 약간의 오차를 허용하여 유연성을 높인다.

const targetPosition = 300;
const threshold = 1; // 1px 오차 허용

if (Math.abs(window.scrollY - targetPosition) < threshold) {
    // 스크롤 위치가 targetPosition 근처에 있을 때 (예: 299.x ~ 300.x)
    console.log("거의 300px 위치에 도달했습니다!");
}

3. 헬퍼 함수(Helper Function) 작성

스크롤 값을 자주 사용한다면, 일관된 값을 반환하는 헬퍼 함수를 만들어 사용하는 것이 좋다.

function getNormalizedScrollY() {
    return Math.round(window.scrollY);
}

// 사용 예시
const currentScrollY = getNormalizedScrollY();
if (currentScrollY > 500) {
    // ...
}

4. CSS 활용 고려 (Scroll-driven Animations)

단순히 스크롤에 따라 스타일을 변경하는 것이 목적이라면, 최신 CSS 기능인 Scroll-driven Animations를 고려해볼 수 있다.

이는 JavaScript의 개입 없이 브라우저가 최적화된 방식으로 애니메이션을 처리하므로 성능상 이점이 있으며, 이러한 미세한 값 차이 문제를 회피할 수도 있다.

크롬과 사파리의 스크롤 값 반환 방식 차이는 사소해 보일 수 있지만, 디테일이 중요한 웹 개발 환경에서는 예기치 않은 문제를 일으킬 수 있다.

  • 크롬은 주로 정수, 사파리는 실수를 반환할 수 있다는 점
  • Math.round() 등의 함수로 값을 정규화하거나, 범위 체크를 통해 유연하게 대처해야 한다는 점

본인의 경우 해당 문제는 범위를 늘려서 해결했다.