Published on

Safari iOS 스크롤 끊김 해결 - passive·RAF

Authors

모바일 웹에서 가장 체감이 큰 성능 이슈 중 하나가 iOS Safari의 스크롤 끊김(Scroll jank)입니다. 화면이 미세하게 멈칫하거나, 스크롤 중에 입력이 씹히고, 특정 구간에서 프레임이 뚝 떨어지는 현상은 대부분 “스크롤 자체가 느리다”기보다 스크롤과 동시에 실행되는 자바스크립트와 레이아웃 계산이 메인 스레드를 점유하기 때문에 발생합니다.

특히 iOS Safari는 터치 기반 스크롤 파이프라인이 민감합니다. touchstart/touchmove 또는 wheel/scroll 이벤트에서 무거운 작업을 수행하거나, 스크롤 중 DOM 측정과 스타일 변경을 섞어 레이아웃 스래싱을 만들면 프레임 드랍이 쉽게 발생합니다. 이 글에서는 실무에서 가장 효과가 큰 두 가지 축인 passive 이벤트와 requestAnimationFrame(이하 rAF) 기반 스케줄링으로 문제를 줄이는 방법을 다룹니다.

또한 “원인을 빨리 좁히는 진단 루틴”까지 함께 정리해 두면, 다른 성능 이슈를 다룰 때도 도움이 됩니다. 예를 들어 서버/인프라 영역에서도 빠른 트러블슈팅 프레임이 중요하듯, 클라이언트 성능에서도 체크리스트가 큰 시간을 절약해 줍니다. 비슷한 접근법으로 문제를 좁히는 글로는 GitHub Actions에서 node_modules 캐시가 안 먹힐 때 같은 글을 참고해도 좋습니다.

iOS Safari에서 스크롤이 끊기는 대표 원인

1) 비-패시브 이벤트로 인한 스크롤 블로킹

브라우저는 touchmove 같은 이벤트에서 preventDefault()가 호출될 가능성이 있으면 스크롤을 “일단 멈춰두고” 자바스크립트 실행 결과를 기다립니다. 이때 리스너가 passive: false(기본값)라면, 스크롤 파이프라인이 JS에 의해 쉽게 막힙니다.

즉, 스크롤 중에 실행되는 이벤트 리스너가 가볍더라도, “차단 가능성” 자체가 오버헤드가 됩니다.

2) scroll 이벤트에서 무거운 작업 수행

scroll은 매우 자주 발생합니다. 이 이벤트에서 DOM 측정(getBoundingClientRect, offsetTop, scrollTop 등)과 DOM 변경(클래스 토글, 스타일 변경)을 섞으면, 레이아웃 계산이 반복되어 프레임 드랍이 발생합니다.

3) 레이아웃 스래싱(layout thrashing)

측정(read)과 변경(write)이 교차하면 브라우저는 “정확한 값을 주기 위해” 강제로 레이아웃을 계산합니다. 스크롤 중엔 이 비용이 매우 큽니다.

4) 메인 스레드 장시간 점유

스크롤 중에 이미지 디코딩, 큰 JSON 파싱, 무거운 애니메이션 계산, 과도한 로그 출력 등이 있으면 프레임이 깨집니다.

빠른 진단: 어디서 프레임이 깨지는지 확인

  1. iOS 실기기 Safari Web Inspector로 연결해 Timeline을 확인합니다.
  2. 스크롤 중 Main Thread에 길게 늘어진 Task가 있는지 봅니다.
  3. scroll/touchmove 핸들러가 있는지, 그 안에서 DOM 측정과 변경이 섞였는지 확인합니다.
  4. 스크롤과 함께 동작하는 애니메이션이 top/left 기반인지, transform 기반인지 확인합니다.

이 과정에서 “원인이 하나가 아니라 여러 개가 겹친 경우”가 많습니다. 그래서 아래의 두 가지(패시브 + rAF)는 조합으로 적용할 때 효과가 큽니다.

1) passive 이벤트로 스크롤 블로킹 제거

핵심 개념

  • passive: true는 “이 리스너에서는 preventDefault()를 호출하지 않겠다”는 선언입니다.
  • 브라우저는 이를 신뢰하고 스크롤을 JS 실행과 독립적으로 더 빠르게 진행할 수 있습니다.

적용 대상

  • touchstart, touchmove, wheel 같은 입력 이벤트
  • 스크롤을 직접 막을 의도가 없는 대부분의 핸들러

예제: touchmove를 passive로 등록

const onTouchMove = (e) => {
  // 여기서 e.preventDefault()를 호출하면 안 됩니다.
  // 스크롤 중 해야 하는 일은 최소화하세요.
};

window.addEventListener('touchmove', onTouchMove, { passive: true });

예제: wheel도 passive로

const onWheel = () => {
  // 스크롤 위치를 읽거나, 상태만 갱신하는 정도로 제한
};

window.addEventListener('wheel', onWheel, { passive: true });

주의: 정말로 스크롤을 막아야 하는 경우

예를 들어 커스텀 드래그 UI에서 스크롤을 막아야 한다면 passive: false가 필요합니다. 하지만 그 경우에도 “스크롤이 필요한 영역”과 “드래그 영역”을 분리하고, CSS touch-action으로 해결 가능한지 먼저 검토하는 편이 안정적입니다.

2) requestAnimationFrame으로 스크롤 작업 스케줄링

왜 rAF가 효과적인가

scroll 이벤트는 프레임보다 더 자주 발생할 수 있습니다. 이벤트마다 DOM 변경을 하면 프레임 예산(보통 16.6ms)을 금방 초과합니다.

rAF는 “다음 페인트 직전에 한 번만 실행”되도록 묶어주기 때문에,

  • 이벤트 폭주를 1프레임 1회로 디바운스
  • read/write 타이밍을 정리
  • 불필요한 중복 계산 감소 효과가 있습니다.

나쁜 예: scroll 이벤트마다 DOM 변경

window.addEventListener('scroll', () => {
  const y = window.scrollY;
  document.querySelector('.header').style.transform = `translateY(${Math.min(0, -y)}px)`;
});

좋은 예: scroll은 상태만 저장하고 rAF에서 렌더

let latestY = 0;
let scheduled = false;

function render() {
  scheduled = false;
  const header = document.querySelector('.header');

  // read는 여기서 한 번만
  const y = latestY;

  // write도 여기서 한 번만
  header.style.transform = `translateY(${Math.max(-60, -y)}px)`;
}

window.addEventListener(
  'scroll',
  () => {
    latestY = window.scrollY;
    if (!scheduled) {
      scheduled = true;
      requestAnimationFrame(render);
    }
  },
  { passive: true }
);

이 패턴은 “스크롤 이벤트 폭주”를 프레임 단위로 정리해 주고, 스타일 변경도 한 곳에서만 일어나게 만들어 레이아웃 스래싱 가능성을 줄입니다.

3) 레이아웃 스래싱 방지: read/write 분리

스크롤 중에 특히 위험한 패턴은 아래처럼 측정과 변경이 번갈아 나오는 경우입니다.

나쁜 예: read/write 교차

window.addEventListener('scroll', () => {
  const el = document.querySelector('.card');
  const rect = el.getBoundingClientRect(); // read
  el.style.height = `${rect.width}px`;     // write
  const rect2 = el.getBoundingClientRect(); // read (강제 레이아웃 유발 가능)
  console.log(rect2.height);
});

개선: rAF에서 한 번에 처리하고, 측정은 캐시

const el = document.querySelector('.card');
let scheduled = false;
let lastWidth = 0;

function render() {
  scheduled = false;
  // read
  const rect = el.getBoundingClientRect();
  const width = rect.width;

  if (width !== lastWidth) {
    lastWidth = width;
    // write
    el.style.height = `${width}px`;
  }
}

window.addEventListener(
  'scroll',
  () => {
    if (!scheduled) {
      scheduled = true;
      requestAnimationFrame(render);
    }
  },
  { passive: true }
);

핵심은 “스크롤 이벤트 핸들러 내부에서 즉시 DOM을 만지지 말고”, rAF로 모아서 처리하는 것입니다.

4) iOS Safari에서 특히 잘 먹히는 추가 팁

1) 애니메이션은 transformopacity 위주로

top, left, height 같은 레이아웃 속성 변경은 비용이 큽니다. 가능하면 transform: translate3d(0, 0, 0) 같은 방식으로 합성 레이어에서 처리되도록 유도합니다.

.header {
  will-change: transform;
  transform: translateZ(0);
}

will-change는 남발하면 메모리를 먹으니, 스크롤 중에만 적용하거나 핵심 요소에만 제한합니다.

2) 스크롤 컨테이너 분리와 -webkit-overflow-scrolling

내부 스크롤 영역이 필요한 경우 iOS에서 관성 스크롤을 위해 아래를 쓰는 경우가 있습니다.

.panel {
  overflow: auto;
  -webkit-overflow-scrolling: touch;
}

다만 이 속성은 조합에 따라 페인팅/레이어링 부작용이 생길 수 있어, 적용 후 반드시 실기기에서 스크롤과 터치 이벤트 충돌 여부를 확인하세요.

3) 스크롤 중 불필요한 작업 제거

  • 스크롤 중 console.log 제거
  • 이미지 lazy-load가 과도한 동기 작업을 만들지 않는지 점검
  • 큰 리스트는 가상 스크롤(virtualization) 검토

5) 실전 패턴: 스크롤 기반 헤더 축소를 안정적으로 구현

아래는 iOS Safari에서 흔히 쓰는 “스크롤하면 헤더가 접히는 UI”를 passive + rAF로 구현한 예시입니다.

const header = document.querySelector('.header');
const MAX_HIDE = 64;

let latestY = 0;
let ticking = false;

function update() {
  ticking = false;

  const y = latestY;
  const hide = Math.min(MAX_HIDE, y);

  // transform만 변경해서 레이아웃 영향을 최소화
  header.style.transform = `translateY(${-hide}px)`;
}

window.addEventListener(
  'scroll',
  () => {
    latestY = window.scrollY;
    if (!ticking) {
      ticking = true;
      requestAnimationFrame(update);
    }
  },
  { passive: true }
);
.header {
  position: sticky;
  top: 0;
  will-change: transform;
  transform: translateZ(0);
}

이 조합은

  • 스크롤 이벤트로 인한 메인 스레드 압박 감소
  • 페인트 타이밍과 동기화된 업데이트
  • 레이아웃 변화 최소화 로 iOS에서 체감이 크게 좋아지는 경우가 많습니다.

6) 체크리스트: 적용 후에도 끊기면 무엇을 더 볼까

  1. 스크롤 중 실행되는 작업을 모두 찾기
  • scroll, touchmove, resize, intersection observer 콜백
  • 애니메이션 루프(특히 setInterval 기반)
  1. DOM 측정 호출 빈도 줄이기
  • getBoundingClientRect 결과 캐시
  • 측정은 rAF에서 1회
  1. 렌더링 비용 줄이기
  • 그림자, 블러, 큰 fixed 배경 등 페인트가 무거운 CSS 점검
  • 큰 이미지는 적절한 크기로 제공
  1. 네트워크/데이터 처리로 메인 스레드가 막히지 않는지 확인
  • 큰 JSON 파싱, 동기식 로직, 과도한 상태 관리 연산

문제 원인을 좁힐 때는 “가능한 후보를 나열하고 하나씩 제거”하는 방식이 가장 빠릅니다. 이런 접근은 런타임 이슈를 진단할 때도 유사하게 적용할 수 있는데, 예를 들어 비동기 런타임에서 블로킹 호출로 문제가 생기는 케이스를 다룬 Tokio runtime 패닉 - blocking_in_place 원인·해결 같은 글도 같은 결의 트러블슈팅 관점을 제공합니다.

마무리

iOS Safari 스크롤 끊김은 “스크롤 이벤트가 많아서”가 아니라, 스크롤 중 메인 스레드를 잡아먹는 코드가 있고 브라우저가 입력 처리와 렌더링을 제때 못 해서 생기는 경우가 대부분입니다.

  • passive: true로 스크롤 블로킹 가능성을 제거하고
  • requestAnimationFrame으로 스크롤 연동 작업을 프레임 단위로 묶고
  • DOM read/write를 분리해 레이아웃 스래싱을 피하면

대부분의 스크롤 끊김은 눈에 띄게 완화됩니다. 적용 후에도 남는 끊김은 CSS 페인트 비용, 리스트 크기, 이미지/데이터 처리 같은 “다른 병목”이 숨어 있을 확률이 높으니, Web Inspector로 Main Thread의 긴 Task부터 다시 확인해 보세요.