- Published on
Safari iOS 스크롤 잔상·jank 7가지 원인
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
모바일 웹에서 “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;
}
빠르게 원인 좁히는 실전 점검 순서
현장에서 시간을 아끼려면 아래 순서로 “가설을 빠르게 제거”하는 방식이 좋습니다.
- 스크롤 중 실행되는 JS 제거(A/B): 스크롤 핸들러, 애니메이션, 관측 로직을 임시로 비활성화
- fixed/sticky 제거: 헤더/푸터/플로팅 버튼을 잠깐 static으로 바꿔보기
- 필터/블러/그림자 제거:
backdrop-filter,filter, 큰box-shadow를 off - 이미지 크기/포맷 점검: 과대 원본, 디코딩 스파이크, lazy 타이밍
- 내부 스크롤 컨테이너 단순화: 중첩 overflow 제거
- 레이어 최적화 재검토:
will-change남발 제거 - (가능하면) 기기/OS별 분기 테스트: iOS 버전에 따라 회귀가 존재
결론: iOS Safari 스크롤 문제는 “페인트·레이아웃·레이어”의 합
iOS Safari의 스크롤 잔상·jank는 단순히 “성능이 나빠서”가 아니라, 레이아웃 스래싱, fixed/필터 중심의 페인트 폭증, 이미지 디코딩 스파이크, 스크롤 컨테이너 중첩, 비패시브 리스너로 인한 스크롤 블로킹, 레이어 과다 생성 같은 구체적인 원인으로 설명되는 경우가 대부분입니다.
한 번에 모든 최적화를 하려 하기보다, 위 7가지를 기준으로 **원인을 하나씩 제거(A/B)**하면서 가장 큰 병목부터 해결하면 iOS에서도 체감이 확 좋아집니다.
추가로 프런트엔드에서 캐시/재검증이 꼬여 “최적화가 적용되지 않은 리소스”가 내려오며 성능이 나빠지는 케이스도 있으니, 배포 환경이 Next.js라면 Next.js App Router 캐시 꼬임·재검증 버그 해결도 함께 확인해두면 좋습니다.