- Published on
Safari iOS 스크롤 끊김 - 레이어·페인트 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
모바일 웹에서 “iOS Safari만 유독 스크롤이 끊긴다”는 이슈는 흔합니다. 같은 페이지가 Android Chrome에서는 부드럽게 움직이는데, iPhone Safari에서는 터치 스크롤 중 프레임이 떨어지고(체감상 버벅임), 고정 헤더가 흔들리거나 이미지가 늦게 따라오는 현상이 발생하곤 합니다.
이 문제는 대개 레이어(compositing layer) 구성, 페인트(paint) 비용, **메인 스레드 점유(특히 스크롤 이벤트/레이아웃 강제)**가 복합적으로 얽혀 생깁니다. iOS Safari는 렌더링 파이프라인과 메모리 정책이 보수적인 편이라, “데스크톱/크롬에서 통하던 트릭”이 오히려 역효과를 내기도 합니다.
이 글에서는 iOS Safari에서 스크롤 끊김이 발생하는 구조적 이유를 짚고, 레이어·페인트 최적화 중심으로 재현/진단/해결 순서의 실전 가이드를 제공합니다. (서버 TTFB/초기 로딩도 영향을 주므로, 초기 응답이 튀는 문제는 Next.js 14 RSC 느림? TTFB 급증 7가지 해결도 함께 참고하면 좋습니다.)
iOS Safari 스크롤 끊김의 핵심 원인 3가지
1) 페인트 영역이 너무 크거나 자주 다시 칠해짐
스크롤 중에 특정 요소가 매 프레임 다시 페인트되면, 합성(compositing) 단계로 넘기기 전에 CPU가 바빠지고 프레임이 떨어집니다. 흔한 트리거는 다음과 같습니다.
box-shadow,filter: blur(),backdrop-filter같은 고비용 효과- 큰 영역의
position: fixed/sticky요소 + 반투명/블러 - 고해상도 이미지를 큰 영역에서 리사이즈하며 표시
- 스크롤 중 스타일 변경(클래스 토글)로 인한 repaint
2) 레이어가 과도하게 생성되거나(또는 생성되지 않아) 합성 비용이 증가
transform/opacity/will-change 등은 요소를 별도 레이어로 승격시키는 계기가 됩니다. 레이어가 적절히 분리되면 스크롤 중 재페인트를 줄일 수 있지만, 과도한 레이어 승격은 메모리/합성 비용을 폭발시켜 오히려 끊김을 유발합니다.
iOS는 GPU 메모리 여유가 제한적이어서 “무조건 will-change: transform” 같은 접근이 위험합니다.
3) 메인 스레드를 점유하는 JS(특히 스크롤 핸들러 + 레이아웃 강제)
스크롤 이벤트에서 getBoundingClientRect(), offsetTop 같은 측정 값을 읽고 곧바로 스타일을 쓰면 **layout thrashing(강제 동기 레이아웃)**이 발생합니다. iOS Safari는 특히 이 영향을 크게 받습니다.
진단: 어디서 프레임이 깨지는지 빠르게 찾는 방법
1) iOS Safari Web Inspector로 레이어/페인트 확인
macOS Safari에서 iPhone을 연결한 뒤 Web Inspector로 들어가면 다음을 확인할 수 있습니다.
- 어떤 요소가 자주 repaint 되는지(페인트 플래시/타임라인)
- 레이어가 과도하게 생겼는지(레이어 트리)
- 스크롤 중 JS가 메인을 점유하는지(타임라인에서 Scripting 비중)
체크 포인트:
- 스크롤 중 Paint가 계속 튀는지
- Composite Layers 단계가 비정상적으로 무거운지
- Scripting이 스크롤 구간마다 반복되는지
2) “문제 요소 격리”로 범위를 좁히기
다음 순서로 기능을 잠시 꺼서 원인을 좁힙니다.
- 헤더 블러/그림자 제거
- 스크롤 애니메이션/패럴랙스 제거
- 이미지 lazy-load/디코딩 옵션 변경
- 스크롤 이벤트 핸들러 제거
원인이 특정 CSS 효과인지 JS인지 빠르게 분리됩니다.
레이어 최적화: 승격은 ‘필요한 만큼만’
1) will-change는 단기적으로만 사용
will-change는 “곧 변할 것”을 브라우저에게 알려 미리 최적화하게 하지만, 남발하면 레이어가 늘고 메모리가 증가합니다. 특히 iOS에서 장시간 유지하면 스크롤 성능이 악화될 수 있습니다.
아래처럼 인터랙션 직전/직후에만 토글하는 패턴이 안전합니다.
// hover가 없는 모바일에서도: 터치 시작~끝 구간에만 will-change 부여
const card = document.querySelector('.card');
function enableWillChange() {
card.style.willChange = 'transform';
}
function disableWillChange() {
card.style.willChange = 'auto';
}
card.addEventListener('touchstart', enableWillChange, { passive: true });
card.addEventListener('touchend', disableWillChange, { passive: true });
card.addEventListener('touchcancel', disableWillChange, { passive: true });
2) 고정 헤더/툴바는 “가벼운 레이어”로 만들기
position: fixed 요소에 그림자/블러/필터가 들어가면 스크롤 중 페인트가 커질 수 있습니다. 가능하면:
- 배경은 불투명(opaque)으로
- 그림자는 얕게(blur radius 줄이기)
backdrop-filter는 최소화(또는 조건부)
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 56px;
background: #fff; /* 투명/블러보다 훨씬 저렴 */
box-shadow: 0 1px 6px rgba(0,0,0,.08); /* 과한 blur 금지 */
transform: translateZ(0); /* 필요할 때만: 합성 레이어로 분리 */
}
translateZ(0) 같은 3D 트릭은 레이어 승격을 유도하지만, 모든 요소에 적용하면 역효과입니다. 스크롤 중 계속 보이는 고정 요소 1~2개 정도에만 적용하세요.
3) contain으로 영향 범위를 줄이기
contain은 레이아웃/페인트 범위를 제한해, 특정 컴포넌트 변화가 페이지 전체로 전파되는 것을 막습니다.
.card-list {
contain: layout paint; /* 내부 변경이 외부 레이아웃/페인트에 덜 영향 */
}
단, contain은 레이아웃에 영향을 줄 수 있으니(예: 요소 크기 계산) 적용 후 UI가 깨지지 않는지 확인이 필요합니다.
페인트 최적화: “스크롤 중 다시 칠하지 않게”
1) 블러/필터/대형 그림자 사용을 재검토
iOS Safari에서 특히 비용이 큰 조합:
- 큰 영역의
backdrop-filter: blur(...) - 스크롤 영역 위에 반투명 레이어 + 그림자
filter가 적용된 요소가 스크롤과 함께 움직임
대안 패턴:
- 블러 대신 반투명 단색
- 그림자는 blur를 줄이고 spread를 최소화
- 시각효과는 스크롤 중이 아니라 정지 상태에서만 적용(예: 스크롤 종료 후)
let scrollTimer;
const header = document.querySelector('.header');
window.addEventListener('scroll', () => {
// 스크롤 중에는 무거운 효과 제거
header.classList.add('is-scrolling');
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
// 스크롤이 멈추면 효과 복원
header.classList.remove('is-scrolling');
}, 120);
}, { passive: true });
.header {
backdrop-filter: blur(10px);
}
.header.is-scrolling {
backdrop-filter: none; /* 스크롤 중 비용 제거 */
}
2) 큰 이미지/리사이즈가 페인트를 잡아먹는 경우
스크롤 리스트에서 큰 이미지를 많이 그리면, 디코딩/리사이즈/페인트가 겹쳐 끊길 수 있습니다.
- 가능하면 표시 크기에 맞는 리소스 제공(
srcset,sizes) loading="lazy"+ 적절한 placeholder- Safari에서 이미지 디코딩 타이밍을 제어하기 위해
decoding="async"시도(지원/효과는 케이스별)
<img
src="/img/card-640.jpg"
srcset="/img/card-640.jpg 640w, /img/card-1280.jpg 1280w"
sizes="(max-width: 430px) 360px, 640px"
loading="lazy"
decoding="async"
alt="..."
/>
3) background-attachment: fixed는 피하기
iOS Safari에서 background-attachment: fixed는 성능/호환성 이슈가 잦습니다. 패럴랙스가 필요하면 transform 기반으로 별도 레이어에서 구현하되, 스크롤 이벤트로 매 프레임 계산하는 방식은 피하고 requestAnimationFrame으로 묶습니다.
스크롤 이벤트/레이아웃 스래싱 방지: rAF + passive
1) 스크롤 이벤트는 passive: true
스크롤을 막을 의도가 없다면 반드시 passive로 등록해 메인 스레드 블로킹을 줄입니다.
window.addEventListener('scroll', onScroll, { passive: true });
2) 측정(read)과 변경(write)을 분리하고 rAF로 합치기
나쁜 패턴(스크롤마다 read→write 반복):
window.addEventListener('scroll', () => {
const top = el.getBoundingClientRect().top; // read (layout)
el.style.transform = `translateY(${top * 0.1}px)`; // write
});
개선 패턴(rAF로 프레임당 1회, read/write 최소화):
const el = document.querySelector('.parallax');
let ticking = false;
let lastY = 0;
function update() {
ticking = false;
// read
const y = lastY;
// write
el.style.transform = `translate3d(0, ${y * 0.1}px, 0)`;
}
window.addEventListener('scroll', () => {
lastY = window.scrollY;
if (!ticking) {
ticking = true;
requestAnimationFrame(update);
}
}, { passive: true });
핵심은 스크롤 이벤트가 폭주해도 실제 DOM 업데이트는 프레임당 1번만 일어나도록 제한하는 것입니다.
iOS Safari에서 특히 자주 터지는 케이스별 처방
1) position: sticky가 많은 리스트에서 끊김
sticky가 여러 개면 스크롤 중 계산 비용이 커질 수 있습니다.
- sticky 요소 수를 줄이기
- sticky 내부의 그림자/필터 제거
- 가능하면 섹션 헤더를 “가짜 sticky”(IntersectionObserver + fixed)로 대체
const header = document.querySelector('.section-header');
const sentinel = document.querySelector('.sentinel');
const io = new IntersectionObserver(([entry]) => {
header.classList.toggle('is-fixed', !entry.isIntersecting);
}, { threshold: 0 });
io.observe(sentinel);
.section-header.is-fixed {
position: fixed;
top: 56px;
left: 0;
right: 0;
}
2) 오버레이/모달이 스크롤과 함께 있을 때
overflow: hidden 토글, 바디 스크롤 잠금 구현이 iOS에서 자주 문제를 만듭니다.
- 모달 오픈 시 레이아웃 점프가 생기지 않게 스크롤 위치를 저장/복원
- 모달 내부 스크롤은
-webkit-overflow-scrolling: touch를 신중히 적용(상황에 따라 관성 스크롤이 이점/단점)
.modal-body {
overflow: auto;
-webkit-overflow-scrolling: touch;
}
3) CSS 애니메이션이 스크롤 중 끊김을 유발
애니메이션은 가능하면 transform/opacity만 사용해 합성 단계에서 처리되게 합니다.
.toast {
transition: transform 180ms ease, opacity 180ms ease;
will-change: transform, opacity; /* 항상 켜두기보다 표시 직전에만 권장 */
}
체크리스트: “iOS Safari 스크롤 끊김” 빠른 점검표
- 스크롤 중 repaint가 계속 발생하는 요소가 있는가? (특히 header/overlay)
-
backdrop-filter,filter, 큰box-shadow를 스크롤 중 유지하고 있지 않은가? -
will-change를 상시 적용해 레이어가 과도하지 않은가? - 스크롤 이벤트에서 레이아웃 read/write가 섞여 있지 않은가?
- 스크롤 핸들러가
passive: true이며 rAF로 제한되어 있는가? - 이미지가 표시 크기보다 과도하게 크지 않은가(
srcset/sizes)? - sticky/fixed 요소 수가 많은가? 꼭 필요한 것만 남겼는가?
마무리: 목표는 “페인트를 줄이고, 합성으로 넘기고, 메인을 비우기”
iOS Safari 스크롤 최적화는 한 가지 트릭으로 해결되기보다, (1) 페인트 비용 줄이기, (2) 레이어를 필요한 만큼만 분리하기, (3) 스크롤 중 메인 스레드 작업을 최소화하기의 합으로 성과가 납니다. 특히 backdrop-filter/큰 그림자/과한 will-change는 iOS에서 체감 성능을 크게 흔드는 대표 요인이므로, “디자인을 유지하면서 비용을 낮추는 대체안”을 함께 설계하는 것이 중요합니다.
추가로, 페이지가 무겁게 느껴질 때는 렌더링 최적화뿐 아니라 초기 응답/데이터 패칭도 함께 봐야 합니다. 서버 렌더링 환경에서 TTFB가 튀면 스크롤 이전부터 이미 사용자 체감이 나빠지므로, 필요하다면 Next.js 14 RSC 느림? TTFB 급증 7가지 해결처럼 백엔드/SSR 병목도 같이 점검해 보세요.