Published on

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

Authors

모바일 웹에서 “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 캐시 꼬임·재검증 버그 해결도 함께 확인해두면 좋습니다.