- Published on
iOS Safari 스크롤 끊김 - 레이어·리페인트 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
iOS Safari에서 스크롤이 끊기는(jank) 증상은 단순히 requestAnimationFrame 최적화만으로 해결되지 않는 경우가 많습니다. 특히 화면에 고정된 헤더, 블러/투명도 효과, 큰 그림자, 복잡한 그라데이션, position: sticky가 섞이면 레이아웃·페인트·합성 단계가 스크롤과 동시에 과부하를 일으키기 쉽습니다.
이 글은 “왜 iOS Safari에서 유독 스크롤이 버벅이는가”를 레이어와 리페인트 관점에서 풀고, 실제로 적용 가능한 CSS/JS 패턴을 중심으로 정리합니다.
관련해서 원인 스펙트럼을 넓게 훑고 싶다면 Safari iOS 스크롤 잔상·jank 7가지 원인도 함께 보시면 진단 속도가 빨라집니다. 또한 레이아웃 변화로 인한 사용자 체감 악화(예: CLS)를 추적하는 관점은 Chrome Rendering 탭으로 CLS 급증 원인 추적법도 참고할 만합니다.
iOS Safari 스크롤 파이프라인을 레이어 관점으로 보기
브라우저 렌더링은 대략 다음 흐름으로 진행됩니다.
- 스타일 계산
- 레이아웃(geometry)
- 페인트(paint)
- 합성(composite, 레이어를 GPU에서 합성)
스크롤 중 프레임이 끊기는 대표적인 패턴은 둘 중 하나입니다.
스크롤할 때마다 페인트 영역이 넓게 발생한다
레이어가 과도하게 분할되거나, 반대로 필요한 요소가 레이어로 분리되지 않아 합성 비용이 커진다
iOS Safari에서는 특히 다음이 민감하게 작동하는 편입니다.
backdrop-filter및 다양한 필터 계열- 큰
box-shadow/filter: drop-shadow() - 반투명 오버레이(알파 블렌딩)
position: fixed/sticky가 많은 화면- 스크롤 컨테이너 중첩(내부 스크롤 + 바깥 스크롤)
핵심은 “스크롤 시 변하는 영역을 최소화하고(리페인트 격리), 합성 단계에서 빠르게 처리되도록(레이어/합성 최적화) 설계”하는 것입니다.
1) 리페인트 범위를 격리하는 contain과 레이아웃 경계
contain으로 페인트 전파를 끊기
스크롤 중 특정 컴포넌트가 자주 갱신되면(예: 애니메이션, 카운터, 진행바) 그 변화가 주변으로 페인트를 전파시키지 않도록 격리하는 것이 중요합니다.
/* 자주 갱신되는 위젯을 레이아웃/페인트 경계로 격리 */
.widget {
contain: layout paint;
}
contain: paint는 페인트가 바깥으로 새지 않도록 경계를 만듭니다.contain: layout은 레이아웃 영향 범위를 줄입니다.
주의: 격리하면 내부 요소의 position: fixed 같은 동작이 기대와 달라질 수 있으니, 작은 단위부터 적용하며 확인하세요.
content-visibility는 “스크롤 성능”보다 “초기 렌더”에 유리
content-visibility: auto는 화면 밖 콘텐츠의 렌더 비용을 줄이는 데 도움이 되지만, iOS Safari 지원/효과는 케이스에 따라 편차가 있습니다. 긴 리스트에서 초기 렌더를 줄이는 데는 고려할 수 있으나, 스크롤 중 합성/페인트 병목이 주원인이라면 우선순위는 contain/레이어 설계가 더 높습니다.
2) 레이어 승격을 “필요한 곳에만” 적용하기
무분별한 translateZ(0)는 역효과가 날 수 있음
과거에는 transform: translateZ(0)로 레이어 승격을 유도해 스크롤을 부드럽게 하는 팁이 널리 쓰였습니다. 하지만 레이어가 너무 많아지면 합성 단계에서 오히려 비용이 증가합니다.
권장 접근:
- 스크롤 중 계속 보이는 고정 UI(헤더, 하단 탭바) 중에서 페인트가 비싼 요소만 제한적으로 승격
- 애니메이션이 있는 요소에만 승격
- 리스트 아이템 전체를 승격하는 방식은 피하기
예시: 고정 헤더 중 “블러/투명도”가 있는 경우
.header {
position: sticky;
top: 0;
z-index: 10;
/* 필요할 때만 레이어 힌트 */
will-change: transform;
}
/* 스크롤 중 효과가 바뀌는 내부 요소만 따로 */
.header__bg {
will-change: opacity;
}
will-change는 “미리 최적화 준비”를 하게 만들지만, 남발하면 메모리/합성 비용이 늘어납니다. 따라서 스크롤이 끝나면 제거하는 방식이 안전합니다.
// 스크롤 중에만 will-change를 켜고, 멈추면 끈다
const header = document.querySelector('.header');
let t = 0;
function enableHint() {
header.style.willChange = 'transform';
clearTimeout(t);
t = window.setTimeout(() => {
header.style.willChange = 'auto';
}, 200);
}
window.addEventListener('scroll', enableHint, { passive: true });
3) backdrop-filter/블러는 “스크롤 최악의 친구”일 수 있음
iOS Safari에서 backdrop-filter: blur(...)는 시각적으로는 훌륭하지만, 스크롤 중에는 매우 비싼 경우가 많습니다. 이유는 배경을 샘플링하고 블러 처리한 결과를 매 프레임 갱신해야 할 수 있기 때문입니다.
대안 패턴:
- 블러 대신 반투명 단색 + 약한 그라데이션
- 스크롤 중에는 블러를 끄고, 스크롤 종료 후 다시 켜기
.header__glass {
background: rgba(255, 255, 255, 0.85);
/* 기본은 블러를 끈 상태로 시작 */
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
.header__glass.is-idle {
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
const glass = document.querySelector('.header__glass');
let idleTimer = 0;
function onScroll() {
glass.classList.remove('is-idle');
clearTimeout(idleTimer);
idleTimer = window.setTimeout(() => {
glass.classList.add('is-idle');
}, 150);
}
window.addEventListener('scroll', onScroll, { passive: true });
이 방식은 “스크롤 중 프레임 유지”를 우선하고, 스크롤이 멈췄을 때만 고급 효과를 복원하는 전략입니다.
4) 큰 그림자와 반투명 오버레이가 만드는 페인트 폭발
box-shadow는 면적이 커질수록 비용이 급증
특히 카드 UI에서 큰 블러 반경의 그림자를 다수 사용하면 페인트가 무거워집니다.
개선 팁:
- 그림자 블러 반경/스프레드를 줄이기
- 그림자 대상 요소 수를 줄이기
- 가능하면 그림자를 이미지/그라데이션으로 대체
/* 나쁜 예: 큰 블러/큰 확산 */
.card--heavy {
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
}
/* 개선 예: 블러/확산을 줄이고 대비를 약하게 */
.card {
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
}
전체 화면 오버레이는 “합성”은 쉬워도 “블렌딩”이 비쌀 수 있음
모달 오픈 시 전체 화면 반투명 오버레이를 깔면, 스크롤 중 배경과 알파 블렌딩이 지속되어 비용이 커질 수 있습니다. 모달이 열려 있을 때는 배경 스크롤을 막는 것이 일반적으로 성능에도 유리합니다.
body.modal-open {
overflow: hidden;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
}
iOS Safari에서 overflow: hidden만으로 스크롤이 완전히 고정되지 않는 케이스가 있어, 배경을 position: fixed로 잠그는 패턴을 함께 쓰기도 합니다.
let scrollY = 0;
function lockScroll() {
scrollY = window.scrollY;
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollY}px`;
document.body.style.left = '0';
document.body.style.right = '0';
}
function unlockScroll() {
document.body.style.position = '';
document.body.style.top = '';
window.scrollTo(0, scrollY);
}
5) 스크롤 이벤트, 터치 이벤트는 반드시 passive로
스크롤 끊김의 아주 흔한 원인 중 하나는 touchmove/wheel/scroll 핸들러가 메인 스레드를 막는 것입니다. iOS Safari에서 특히 touchmove에 preventDefault()가 얽히면 스크롤 성능이 급락할 수 있습니다.
window.addEventListener('scroll', () => {
// DOM 측정(getBoundingClientRect)이나 강제 동기 레이아웃을 피한다
}, { passive: true });
또한 스크롤 중 DOM 측정과 DOM 변경을 섞으면 레이아웃 스래싱이 발생합니다. 측정은 한 번에 모으고, 변경은 다음 프레임에 처리하세요.
let scheduled = false;
function onScroll() {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
// 측정
const y = window.scrollY;
// 변경(클래스 토글 등)
document.documentElement.classList.toggle('scrolled', y > 10);
});
}
window.addEventListener('scroll', onScroll, { passive: true });
6) position: sticky와 스크롤 컨테이너 중첩을 단순화
iOS Safari에서 sticky는 케이스에 따라 페인트/합성 비용이 커질 수 있습니다. 특히 다음 조합은 위험 신호입니다.
sticky요소가 많은 리스트- 부모에
overflow: auto/scroll이 걸린 내부 스크롤 sticky내부에 블러/필터/큰 그림자
가능하면:
- 페이지 전체는 하나의 스크롤(바디 스크롤)로 유지
- 내부 스크롤은 꼭 필요할 때만 사용
sticky는 “핵심 1~2개”로 제한
내부 스크롤이 꼭 필요하다면 -webkit-overflow-scrolling: touch를 고려할 수 있지만, 이 옵션은 동작/버그가 얽힐 수 있어 실제 기기에서 충분히 확인해야 합니다.
.panel {
overflow: auto;
-webkit-overflow-scrolling: touch;
}
7) 이미지/비디오: 디코딩과 리사이즈가 스크롤을 망친다
스크롤 중 갑자기 프레임이 깨지는 경우, 원인이 “렌더링”이 아니라 “미디어 디코딩/리사이즈”일 수 있습니다.
체크리스트:
- 이미지에
width/height를 명시해 레이아웃 변동을 막기 - 지나치게 큰 원본 이미지를 CSS로 축소하지 않기
- 리스트 썸네일은 적절한 해상도로 제공
<img
src="/thumb-320.jpg"
width="160"
height="160"
loading="lazy"
alt=""
/>
loading="lazy"는 iOS Safari에서 동작은 하지만, 모든 케이스에서 완벽히 동일하게 동작하지 않을 수 있습니다. 중요한 것은 “적정 해상도”와 “레이아웃 공간 예약”입니다.
실전 디버깅 루틴: 어디서 리페인트가 터지는지 찾기
- 스크롤 중 끊김이 느껴지는 화면에서 “비싼 효과”를 하나씩 끄기
- 블러/필터 제거
- 그림자 제거
- 투명 오버레이 제거
sticky를static으로 바꾸기
- 끊김이 사라지는 지점을 찾고, 그 효과를 “스크롤 중에만 제한”하는 전략 적용
- 스크롤 중
backdrop-filter끄기 - 스크롤 중 애니메이션 중단
- 스크롤 중
will-change를 필요한 요소에만 잠깐 적용
- 리스트/피드 화면이면 “아이템 단위 격리”
.feed-item {
contain: layout paint;
}
- 스크롤 핸들러 점검
passive: true적용- 스크롤 중 DOM 측정/변경 최소화
requestAnimationFrame로 배치
마무리: 레이어·리페인트 최적화의 우선순위
iOS Safari 스크롤 끊김은 대체로 “한 방”이 아니라, 다음의 누적 비용이 임계점을 넘을 때 발생합니다.
- 비싼 시각 효과(블러/필터/큰 그림자)
- 스크롤 중 잦은 리페인트
- 레이어 설계 실패(과도한 승격 또는 미승격)
- 스크롤 이벤트로 인한 메인 스레드 점유
가장 효과가 큰 순서로 정리하면 다음이 실무적으로 안전합니다.
- 스크롤 중 비싼 효과를 끄거나 단순화
contain으로 리페인트 전파를 차단will-change는 “스크롤 중에만, 필요한 요소만”- 이벤트는
passive+requestAnimationFrame배치 - 스크롤 컨테이너/
sticky구조 단순화
이 루틴대로 적용하면, 동일한 UI라도 iOS Safari에서 체감 프레임이 크게 개선되는 경우가 많습니다.