Published on

Safari iOS 스크롤 잔상·jank 7가지 원인

Authors
Binance registration banner

모바일 웹에서 “iOS Safari만 유독 스크롤이 끊기고 잔상이 남는다”는 이슈는 흔합니다. 같은 페이지가 Android Chrome에서는 부드러운 반면, iPhone에서는 스크롤 중 텍스트가 흐릿해졌다가 다시 선명해지거나(잔상), 프레임이 뚝뚝 끊기는(jank) 현상이 나타나곤 하죠.

이 문제는 단일 원인이라기보다 레이아웃(레이아웃 스래싱), 페인트/컴포지팅, 스크롤 스레드와 메인 스레드 경쟁, GPU 레이어 관리가 복합적으로 얽힌 결과인 경우가 많습니다. 아래 7가지는 실무에서 가장 자주 만나는 “iOS Safari 스크롤 잔상·jank”의 원인과 해결 패턴입니다.

> 디버깅 팁: iOS Safari는 Mac의 Safari에서 **Develop → (디바이스) → (페이지)**로 원격 디버깅이 가능합니다. 실제 기기에서 확인하세요(시뮬레이터와 다르게 나오는 경우가 많습니다).

1) 스크롤 중 레이아웃 스래싱(Layout Thrashing)

스크롤 이벤트(또는 scroll에 의해 자주 실행되는 로직)에서 **DOM 측정(read)**과 **스타일 변경(write)**을 섞어 실행하면, 브라우저는 레이아웃 계산을 반복 수행합니다. iOS Safari는 이 비용이 특히 체감되기 쉬워 jank가 크게 발생합니다.

전형적 안티패턴

window.addEventListener('scroll', () => {
  // read
  const top = document.querySelector('.header').getBoundingClientRect().top;

  // write
  document.body.style.paddingTop = Math.max(0, -top) + 'px';

  // 다시 read
  document.querySelector('.hero').offsetHeight;
});

해결: rAF로 배치 + read/write 분리

let scheduled = false;
let latestScrollY = 0;

window.addEventListener('scroll', () => {
  latestScrollY = window.scrollY;
  if (scheduled) return;
  scheduled = true;

  requestAnimationFrame(() => {
    // 1) read 단계
    const header = document.querySelector('.header');
    const headerH = header.offsetHeight;

    // 2) write 단계
    document.documentElement.style.setProperty(
      '--scrollY',
      String(latestScrollY)
    );
    document.body.style.paddingTop = headerH + 'px';

    scheduled = false;
  });
}, { passive: true });
  • passive: true로 스크롤 블로킹을 줄입니다.
  • 측정 값은 캐싱하고, 스타일 변경은 CSS 변수로 넘겨서 컴포지팅 단계에서 처리되도록 유도합니다.

2) position: fixed + 복잡한 자식 트리(특히 blur/필터)로 인한 페인트 폭증

iOS Safari에서 position: fixed는 여전히 까다로운 영역입니다. 고정 헤더/툴바 내부에 큰 이미지, box-shadow, backdrop-filter, filter, 마스크 등이 있으면 스크롤마다 페인트 비용이 커져 잔상/끊김이 유발됩니다.

해결 패턴

  • fixed 요소의 내부를 단순화
  • blur/필터는 가능하면 제거하거나 범위를 축소
  • 고정 레이어를 분리하고, 애니메이션은 transform/opacity만 사용
.header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  /* 스크롤 중 레이아웃/페인트 영향을 줄이기 */
  will-change: transform;
  transform: translateZ(0);
}

/* backdrop-filter는 특히 비용이 큼: 필요한 영역만 최소화 */
.header .glass {
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
}

> 주의: will-change 남발은 오히려 메모리/레이어 수를 증가시켜 역효과가 날 수 있습니다. “항상 움직이는 요소”에만 제한적으로 적용하세요.

3) background-attachment: fixed(또는 유사 패럴럭스) 사용

iOS Safari는 background-attachment: fixed 지원이 제한적이고, 우회 구현도 스크롤 중 페인트를 크게 유발합니다. 패럴럭스 효과를 CSS로 간단히 넣었다가 iOS에서만 심각한 jank가 생기는 대표 케이스입니다.

해결: fixed 배경 대신 transform 기반 패럴럭스

.parallax {
  position: relative;
  overflow: hidden;
}

.parallax .bg {
  position: absolute;
  inset: 0;
  background: url('/bg.jpg') center/cover no-repeat;
  transform: translate3d(0, 0, 0);
  will-change: transform;
}
const bg = document.querySelector('.parallax .bg');

let ticking = false;
window.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;

  requestAnimationFrame(() => {
    const y = window.scrollY;
    // 스크롤에 비례해 배경을 살짝 이동
    bg.style.transform = `translate3d(0, ${y * 0.2}px, 0)`;
    ticking = false;
  });
}, { passive: true });

4) 큰 이미지/동영상 디코딩 지연 + 레이지 로딩 타이밍

스크롤 중 갑자기 프레임이 떨어지는 경우, 원인은 “스크롤 이벤트”가 아니라 이미지 디코딩/리사이즈/비디오 프레임 준비일 수 있습니다. 특히 iOS는 메모리 압박이 오면 디코더/텍스처 업로드 비용이 튀면서 잔상처럼 보이기도 합니다.

체크리스트

  • 이미지에 width/height를 명시해 레이아웃 시프트 방지
  • 너무 큰 원본(예: 4000px)을 모바일에 그대로 내려주지 않기
  • loading="lazy"는 iOS 버전에 따라 동작이 미묘할 수 있어, 핵심 이미지에는 preload나 우선순위 부여
<!-- 레이아웃 안정화: width/height 지정 -->
<img src="/img/card-800.jpg" width="400" height="300" loading="lazy" alt="..." />

<!-- 핵심 히어로 이미지는 preload 고려 -->
<link rel="preload" as="image" href="/img/hero-1200.jpg" imagesrcset="/img/hero-600.jpg 600w, /img/hero-1200.jpg 1200w" imagesizes="100vw">

Next.js를 쓴다면 원격 이미지 최적화 설정 미스로 인해 403/최적화 실패 → 원본 대용량 전송 → 스크롤 jank로 이어지는 경우도 있습니다. 관련해서는 Next.js 이미지 최적화 실패? remotePatterns·403 해결도 함께 점검해보세요.

5) 스크롤 컨테이너 중첩과 -webkit-overflow-scrolling: touch

과거 iOS에서 관성 스크롤을 위해 -webkit-overflow-scrolling: touch를 쓰던 패턴이 남아 있는 프로젝트가 많습니다. 하지만 중첩 스크롤 컨테이너, sticky/fixed 혼합, 높은 DOM 복잡도와 결합되면 잔상/깜빡임/스크롤 끊김이 발생할 수 있습니다.

해결 방향

  • 가능한 한 “페이지 전체 스크롤 1개”로 단순화
  • 꼭 필요한 내부 스크롤만 허용
  • 내부 스크롤 영역에 큰 그림자/필터/블러를 피함
/* 내부 스크롤이 꼭 필요할 때만 */
.sheet {
  overflow: auto;
  -webkit-overflow-scrolling: touch;
  max-height: 70vh;
}

추가로 iOS에서는 overscroll-behavior 지원이 제한적이므로, 스크롤 체인 제어는 JS로 보완해야 할 때도 있습니다.

6) scroll/touchmove 핸들러가 스크롤을 블로킹

touchmove에서 preventDefault()를 호출하거나, passive: false로 등록된 리스너가 많으면 스크롤이 메인 스레드와 강하게 결합되어 jank가 커집니다. 특히 커스텀 제스처/드래그 구현이 들어간 페이지에서 자주 발생합니다.

안티패턴

document.addEventListener('touchmove', (e) => {
  // 어떤 조건에서든 preventDefault → 스크롤이 막히거나 끊김
  e.preventDefault();
}, { passive: false });

해결: 기본 스크롤을 살리고, 필요한 제스처만 분기

const el = document.querySelector('.carousel');
let isDragging = false;

el.addEventListener('touchstart', () => { isDragging = true; }, { passive: true });
el.addEventListener('touchend',   () => { isDragging = false; }, { passive: true });

document.addEventListener('touchmove', (e) => {
  if (!isDragging) return; // 드래그 중일 때만 제어
  // 이 경우에도 가능하면 preventDefault 최소화
  e.preventDefault();
}, { passive: false });

또한 스크롤 중 실행되는 로직은 IntersectionObserver로 대체하면 메인 스레드 부담을 크게 줄일 수 있습니다.

7) 합성 레이어(Compositing) 과다 생성: transform: translateZ(0) 남발

iOS Safari에서 “GPU 가속을 켜면 부드러워진다”는 조언이 퍼지면서 translateZ(0), will-change: transform을 무분별하게 적용하는 경우가 있습니다. 하지만 레이어가 과도하게 늘어나면:

  • 텍스처 메모리 증가 → 메모리 압박
  • 레이어 간 합성 비용 증가
  • 스크롤 중 레이어 승격/강등이 발생하며 깜빡임/잔상

해결: 레이어 승격은 ‘필요한 것만’

  • 애니메이션되는 요소(특히 transform/opacity)로 범위를 제한
  • 긴 리스트의 모든 아이템에 will-change를 주지 말 것
/* 나쁜 예: 리스트 아이템 전체에 will-change */
/* .item { will-change: transform; } */

/* 좋은 예: 실제로 애니메이션되는 요소만 */
.toast {
  will-change: transform, opacity;
}

.toast.is-open {
  transform: translate3d(0, 0, 0);
  opacity: 1;
}

빠르게 원인 좁히는 실전 점검 순서

현장에서 시간을 아끼려면 아래 순서로 “가설을 빠르게 제거”하는 방식이 좋습니다.

  1. 스크롤 중 실행되는 JS 제거(A/B): 스크롤 핸들러, 애니메이션, 관측 로직을 임시로 비활성화
  2. fixed/sticky 제거: 헤더/푸터/플로팅 버튼을 잠깐 static으로 바꿔보기
  3. 필터/블러/그림자 제거: backdrop-filter, filter, 큰 box-shadow를 off
  4. 이미지 크기/포맷 점검: 과대 원본, 디코딩 스파이크, lazy 타이밍
  5. 내부 스크롤 컨테이너 단순화: 중첩 overflow 제거
  6. 레이어 최적화 재검토: will-change 남발 제거
  7. (가능하면) 기기/OS별 분기 테스트: iOS 버전에 따라 회귀가 존재

결론: iOS Safari 스크롤 문제는 “페인트·레이아웃·레이어”의 합

iOS Safari의 스크롤 잔상·jank는 단순히 “성능이 나빠서”가 아니라, 레이아웃 스래싱, fixed/필터 중심의 페인트 폭증, 이미지 디코딩 스파이크, 스크롤 컨테이너 중첩, 비패시브 리스너로 인한 스크롤 블로킹, 레이어 과다 생성 같은 구체적인 원인으로 설명되는 경우가 대부분입니다.

한 번에 모든 최적화를 하려 하기보다, 위 7가지를 기준으로 **원인을 하나씩 제거(A/B)**하면서 가장 큰 병목부터 해결하면 iOS에서도 체감이 확 좋아집니다.

추가로 프런트엔드에서 캐시/재검증이 꼬여 “최적화가 적용되지 않은 리소스”가 내려오며 성능이 나빠지는 케이스도 있으니, 배포 환경이 Next.js라면 Next.js App Router 캐시 꼬임·재검증 버그 해결도 함께 확인해두면 좋습니다.