- Published on
Safari iOS 스크롤 끊김 - compositing 레이어 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
모바일 웹에서 “스크롤이 끊긴다”는 피드백은 대개 iOS Safari에서 먼저 터집니다. 같은 페이지가 Android Chrome에서는 그럭저럭 부드럽게 돌아가는데, iPhone에서만 스크롤이 버벅이거나 특정 구간에서 프레임이 뚝뚝 떨어지는 경우가 많습니다.
이 현상은 단순히 “기기 성능이 낮아서”가 아니라, Safari의 렌더링 파이프라인에서 메인 스레드 작업(스타일 계산, 레이아웃, 페인트) 과 컴포지팅(레이어 합성) 이 특정 조합으로 겹치면서 발생하는 경우가 많습니다. 특히 position: sticky, backdrop-filter, filter, -webkit-overflow-scrolling: touch, 과도한 box-shadow, 큰 반투명 영역, 그리고 무분별한 will-change 사용이 레이어를 폭증시키고 GPU 메모리 압박을 만들어 스크롤 jank로 이어지곤 합니다.
이 글에서는 iOS Safari에서 스크롤 끊김을 유발하는 compositing 레이어의 구조적 원인을 이해하고, “레이어를 늘리는 최적화”가 아니라 “레이어를 필요한 만큼만 유지하는 최적화”로 개선하는 방법을 다룹니다.
관련해서 사용자 입력 지연과 롱태스크 추적 관점은 Chrome 사례지만 개념은 그대로 적용됩니다. 병목을 찾는 접근은 Chrome INP 급등? Long Task 추적·해결 가이드도 함께 참고하면 좋습니다.
iOS Safari 스크롤 끊김의 전형적인 시그널
다음 패턴이 보이면 compositing과 페인트가 엮여 있을 확률이 높습니다.
- 스크롤 중 특정 섹션(히어로 배너, 카드 리스트, 모달 오버레이)에서만 프레임 드랍
- 스크롤은 되는데 터치 반응이 늦거나, 스크롤이 “걸리는 느낌”
position: sticky요소가 많은 화면에서 특히 심함- 반투명 오버레이(딤), 블러(
backdrop-filter)가 켜진 상태에서 급격히 악화 - 이미지가 많은 리스트에서 스크롤 시 CPU가 튀고, 화면이 순간적으로 하얗게 깜빡임(리페인트)
compositing 레이어란 무엇이고, 왜 스크롤에 영향을 주나
브라우저는 대략 다음 순서로 화면을 만듭니다.
- 스타일 계산
- 레이아웃(박스 위치/크기 계산)
- 페인트(픽셀 그리기)
- 컴포지팅(여러 레이어를 합성해서 최종 프레임 생성)
레이어(compositing layer)는 “따로 그려서 GPU에서 합성하기 좋은 단위”라고 이해하면 됩니다. 적절한 레이어 분리는 스크롤/애니메이션을 부드럽게 만들 수 있습니다. 문제는 iOS Safari에서 다음 상황이 겹치면 오히려 악화된다는 점입니다.
- 레이어 수가 너무 많아 합성 비용 증가
- 큰 레이어(특히 반투명/블러)가 GPU 메모리를 많이 먹고, 타일 재생성 비용이 증가
- 스크롤 중에 페인트가 자주 발생(레이어가 있어도 내부가 계속 다시 그려짐)
- 메인 스레드에서 이벤트 핸들러가 길게 실행되어 프레임을 놓침
즉, “레이어를 만들면 무조건 부드럽다”가 아니라 레이어를 만들었는데도 내부 페인트가 계속 발생하거나, 레이어가 과해져 합성 비용이 폭증하면 더 끊깁니다.
iOS Safari에서 레이어 폭증을 만드는 흔한 원인
1) will-change 남발
will-change: transform 같은 힌트는 브라우저가 레이어 승격을 고려하도록 만듭니다. 하지만 항상 이득이 아닙니다.
- 리스트 아이템 100개에
will-change를 주면 레이어가 100개가 될 수 있음 - 레이어가 많아지면 합성 단계가 무거워지고 메모리 압박이 커짐
나쁜 예
.card {
will-change: transform;
}
이 코드가 “모든 카드가 hover/애니메이션할 수도 있으니 미리 최적화”라는 의도로 들어가면 iOS에서 역효과가 나기 쉽습니다.
개선 예: 인터랙션 직전에만 적용
const card = document.querySelector('.card');
function enableHint(el) {
el.style.willChange = 'transform';
}
function disableHint(el) {
el.style.willChange = 'auto';
}
card.addEventListener('touchstart', () => enableHint(card), { passive: true });
card.addEventListener('touchend', () => disableHint(card), { passive: true });
핵심은 상시 힌트가 아니라 “짧게” 입니다.
2) backdrop-filter / filter / 큰 반투명 레이어
블러와 필터는 시각적으로 강력하지만, iOS Safari에서 스크롤과 결합되면 비용이 큽니다. 특히 전체 화면 딤 오버레이에 블러를 얹으면, 스크롤 중 배경이 계속 변하므로 재계산/재합성이 무거워질 수 있습니다.
대안 전략
- 블러 대신 단순 반투명으로 디자인 타협
- 블러가 꼭 필요하면 적용 영역을 작게
- 스크롤 가능한 컨테이너 위에 블러를 얹지 말고, 고정 헤더/툴바처럼 변하지 않는 영역에만 제한
/* 비용이 큰 패턴: 전체 화면 블러 */
.modal-dim {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.35);
backdrop-filter: blur(12px);
}
/* 상대적으로 안전한 패턴: 블러 제거 또는 범위 축소 */
.modal-dim {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
/* backdrop-filter: blur(12px); */
}
3) position: sticky의 과도한 사용
sticky는 편하지만, 스크롤 중 위치 계산과 페인트/합성이 엮여 병목이 생길 수 있습니다. 특히 sticky 요소가 중첩되거나(헤더 안에 또 sticky), sticky 요소 위에 그림자/필터가 얹혀 있으면 더 무거워집니다.
최적화 체크리스트
- sticky 요소 개수를 줄이고, 가능한 한 상단 1개로 통합
- sticky에 큰
box-shadow를 피하고, 필요하면 얕은 그림자 또는 border로 대체
/* 무거운 그림자 */
.header {
position: sticky;
top: 0;
box-shadow: 0 12px 30px rgba(0,0,0,0.25);
}
/* 가벼운 대체 */
.header {
position: sticky;
top: 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
/* 또는 border-bottom */
}
4) 스크롤 컨테이너 중첩과 -webkit-overflow-scrolling: touch
iOS에서 내부 스크롤을 부드럽게 하려고 -webkit-overflow-scrolling: touch를 쓰는 경우가 많습니다. 하지만 내부 스크롤 컨테이너가 중첩되거나, 그 위에 고정 요소/필터가 얹히면 합성이 꼬이기 쉽습니다.
- 가능하면 “페이지 전체 스크롤”을 기본으로 유지
- 내부 스크롤이 꼭 필요하면 컨테이너 수를 최소화
.list {
overflow: auto;
-webkit-overflow-scrolling: touch;
}
이 속성 자체가 나쁘다기보다, 레이어/필터/고정 요소와 결합될 때 문제가 커집니다.
진단: 어디서 레이어가 늘고, 페인트가 발생하는지 확인
1) Safari Web Inspector에서 레이어/페인트 관찰
macOS Safari에서 iPhone을 연결한 뒤 Web Inspector로 확인합니다.
- Timelines에서 Rendering 관련 이벤트가 스크롤 중 과도하게 발생하는지
- Layers(또는 Graphics 관련 패널)에서 레이어 수가 급증하는지
- 특정 요소가 스크롤 중 계속 리페인트되는지
Safari 패널 구성은 버전에 따라 다르지만, 목표는 동일합니다.
- “스크롤 중에 페인트가 발생하는가?”
- “합성해야 할 레이어가 과한가?”
2) 스크롤 이벤트 핸들러가 메인 스레드를 잡고 있는지
iOS Safari에서 스크롤 중 scroll 이벤트에 무거운 로직이 있으면 프레임이 쉽게 깨집니다.
나쁜 예: 스크롤마다 레이아웃 트리거
window.addEventListener('scroll', () => {
const el = document.querySelector('.header');
// getBoundingClientRect는 레이아웃/스타일 계산을 유발할 수 있음
const rect = el.getBoundingClientRect();
el.style.opacity = rect.top < 0 ? '0.9' : '1';
});
개선 예: requestAnimationFrame으로 합치고, 읽기/쓰기를 분리
const header = document.querySelector('.header');
let ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
// 읽기
const y = window.scrollY;
// 쓰기
header.style.opacity = y > 10 ? '0.92' : '1';
ticking = false;
});
}
window.addEventListener('scroll', onScroll, { passive: true });
{ passive: true }는 스크롤을 막지 않겠다는 신호로, 입력 지연을 줄이는 데 도움이 됩니다.requestAnimationFrame으로 스크롤 이벤트 폭주를 “프레임 단위”로 정리합니다.
레이어 최적화의 핵심 전략 6가지
1) “무조건 승격”이 아니라 “핵심 요소만 승격”
레이어 승격은 보통 transform 애니메이션을 부드럽게 만들지만, 승격 대상이 많으면 합성 비용이 증가합니다.
- 애니메이션이 실제로 있는 요소만 승격
- 큰 요소(전체 화면 배경, 긴 리스트)는 승격해도 이득이 작고 비용이 큼
/* 필요한 요소만 */
.fab {
transform: translateZ(0);
}
translateZ(0) 또는 translate3d(0,0,0)는 레이어 생성 트리거로 흔히 쓰이지만, 남발하면 독입니다.
2) 큰 반투명 영역을 줄이고, 오버레이 구조를 단순화
- 전체 화면 딤 + 블러 + 그림자 + 고정 헤더 조합은 최악에 가깝습니다.
- 오버레이는 가능한 한 단순한 색상 레이어로 유지하고, 애니메이션은
opacity/transform중심으로 설계합니다.
.modal {
position: fixed;
inset: 0;
display: grid;
place-items: center;
}
.modal__panel {
background: #fff;
border-radius: 12px;
transform: translateY(12px);
opacity: 0;
transition: transform 180ms ease, opacity 180ms ease;
}
.modal.is-open .modal__panel {
transform: translateY(0);
opacity: 1;
}
3) contain으로 페인트/레이아웃 범위를 격리
CSS Containment는 “이 요소 내부의 변화가 바깥에 영향을 덜 주도록” 경계를 설정합니다. Safari 지원은 속성별로 차이가 있지만, contain: content 또는 contain: layout paint가 도움이 되는 케이스가 있습니다.
.card-list {
contain: content;
}
주의할 점은 contain이 레이아웃/포지셔닝에 영향을 줄 수 있으니, 적용 후 UI 깨짐 여부를 반드시 확인해야 합니다.
4) 이미지/리스트는 지연 렌더링과 크기 고정으로 레이아웃 흔들림 제거
스크롤 중 레이아웃이 흔들리면 페인트가 늘고, 합성도 불안정해집니다.
- 이미지에
width/height또는aspect-ratio를 지정 - 가능한 경우
loading="lazy"사용
<img
src="/images/item-1.jpg"
alt="item"
loading="lazy"
width="320"
height="180"
style="max-width: 100%; height: auto;"
/>
iOS Safari의 lazy loading 동작은 버전에 따라 차이가 있으니, “레이아웃 안정화”를 1순위로 두는 게 안전합니다.
5) 스크롤 중 DOM 변경을 최소화하고, 클래스 토글을 배치 처리
스크롤 중에 DOM을 추가/삭제하거나, 많은 노드의 클래스를 토글하면 스타일 계산과 레이아웃이 커집니다.
- IntersectionObserver로 “화면에 들어온 것만” 업데이트
- 업데이트는 한 번에 묶고, 불필요한 reflow 유발 코드를 피함
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add('in-view');
io.unobserve(e.target);
}
}, { rootMargin: '200px 0px' });
document.querySelectorAll('.card').forEach((el) => io.observe(el));
6) 애니메이션 속성은 transform과 opacity 중심으로
레이아웃을 바꾸는 애니메이션(top, left, height)은 스크롤과 맞물릴 때 특히 취약합니다.
/* 나쁜 예: 레이아웃 속성 애니메이션 */
.banner {
transition: height 200ms ease;
}
/* 좋은 예: transform 기반 */
.banner {
transform: translateY(0);
transition: transform 200ms ease;
}
.banner.is-collapsed {
transform: translateY(-40px);
}
실전 케이스: sticky 헤더 + 카드 리스트 스크롤 끊김 잡기
가장 흔한 조합을 예로 들어보겠습니다.
- sticky 헤더에 큰 그림자
- 카드 리스트 각 아이템에
will-change: transform - 스크롤마다 헤더 스타일을
getBoundingClientRect로 계산
이 조합은 iOS에서 레이어/페인트/메인 스레드 부하가 동시에 커지기 쉽습니다.
개선 순서(추천)
- 카드의
will-change제거 또는 인터랙션 직전에만 적용 - 헤더 그림자를 약하게(또는 border로 대체)
- 스크롤 핸들러를
requestAnimationFrame+{ passive: true }로 변경 - 헤더 상태 판단은
scrollY처럼 레이아웃을 덜 건드리는 값으로 단순화
이렇게만 해도 “스크롤이 걸리는 느낌”이 체감상 크게 줄어드는 경우가 많습니다.
체크리스트: iOS Safari 스크롤 jank를 줄이는 빠른 점검표
will-change가 리스트/반복 요소에 광범위하게 적용되어 있지 않은가- 큰
box-shadow,filter,backdrop-filter가 스크롤 영역과 겹치지 않는가 position: sticky가 여러 개 중첩되어 있지 않은가- 스크롤 이벤트에서
getBoundingClientRect같은 레이아웃 트리거를 남발하지 않는가 - 스크롤 이벤트 리스너가
{ passive: true }인가 - 이미지/카드의 크기가 고정되어 레이아웃 시프트가 없는가
마무리: 레이어는 “많을수록 좋다”가 아니라 “통제 가능해야” 한다
iOS Safari 스크롤 끊김은 단일 원인이라기보다, 레이어 폭증 + 비싼 시각 효과 + 스크롤 중 메인 스레드 작업이 겹쳐 발생하는 경우가 많습니다. 해결도 한 방이 아니라, 레이어를 필요한 만큼만 유지하고(특히 will-change 절제), 스크롤 중 페인트를 줄이며, 이벤트 핸들러를 프레임 단위로 정리하는 식으로 접근해야 합니다.
성능 문제는 “감”으로 고치면 재발하기 쉽습니다. Safari Web Inspector로 스크롤 구간을 좁혀 원인을 특정하고, 변경 전후를 비교하면서 레이어 수와 페인트 빈도를 줄이는 방향으로 튜닝하는 것이 가장 확실합니다.
추가로, 사용자 입력 지연과 롱태스크 관점의 진단 프레임은 Chrome INP 급등? Long Task 추적·해결 가이드에서 정리한 방법론을 iOS Safari에도 그대로 이식해볼 수 있습니다.