- Published on
Safari iOS 스크롤 끊김 해결 - Layer·GPU·CSS
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
iOS Safari에서만 스크롤이 끊기고(프레임 드랍, 터치 지연, 순간 멈춤) 다른 브라우저에서는 괜찮다면, 대개 “스크롤 중 메인 스레드가 바빠지는 상황” 또는 “합성 레이어(compositing) 전략이 꼬이는 상황” 중 하나입니다. 특히 iOS는 스크롤이 별도 스레드에서 부드럽게 처리되더라도, 특정 CSS/JS 패턴이 끼어들면 다시 메인 스레드 의존도가 커지면서 체감이 급격히 나빠집니다.
이 글은 iOS Safari 스크롤 끊김을 Layer(합성 레이어), GPU 가속, CSS 비용으로 나눠서 재현하고, 어떤 순서로 고쳐야 “되돌림 없이” 안정화되는지 정리합니다.
또한 렌더링 최적화 전반의 맥락은 CLS 튀는 이유? content-visibility로 렌더링 최적화 글과도 연결되니 함께 보면 원인 파악이 더 빨라집니다.
1) iOS Safari 스크롤이 끊기는 대표 원인 지도
iOS Safari 스크롤 잔상/끊김은 보통 아래 범주에서 발생합니다.
1-1. 스크롤이 메인 스레드에 묶이는 패턴
touchmove/wheel리스너가passive: false이거나, 스크롤 중preventDefault()를 호출- 스크롤 이벤트에서 DOM 읽기와 쓰기를 섞어 레이아웃 스래싱(layout thrashing) 유발
- 스크롤 중 큰 DOM 트리에서
getBoundingClientRect()등 레이아웃 강제 계산 반복
1-2. 합성 레이어가 과도하거나, 레이어 승격이 잘못된 패턴
will-change: transform를 남발해서 레이어가 너무 많아짐(메모리/합성 비용 증가)position: fixed요소가 많고, 그 안에서backdrop-filter,filter,mix-blend-mode같은 고비용 효과 사용- 큰 요소에
transform: translateZ(0)를 무분별하게 걸어 GPU 메모리 압박
1-3. 페인트 비용이 커지는 CSS
box-shadow가 큰 영역에 반복filter: blur()같은 픽셀 기반 효과backdrop-filter(iOS에서 특히 비용이 큼)- 스크롤되는 컨테이너에
background-attachment: fixed유사 효과
1-4. 스크롤 컨테이너 설계 문제
overflow: scroll컨테이너 중첩-webkit-overflow-scrolling: touch사용 시 특정 레이아웃/고정 요소와 충돌- 스크롤 영역 안에서
position: sticky가 다수 + 복잡한 배경/필터
2) 먼저 “측정”부터: 재현·관찰 포인트
최적화는 감으로 하면 실패 확률이 높습니다. iOS Safari는 데스크톱 크롬과 병목 지점이 달라서, 최소한 아래 관찰을 하고 시작하는 게 좋습니다.
2-1. iOS에서 스크롤 중 바쁜 주체를 분리
- 메인 스레드 바쁨: 스크롤 중 터치 반응이 늦고, 스크롤이 끊기며 UI가 동시에 멈춤
- 페인트/합성 바쁨: 터치는 되는데 화면 갱신이 덜컥거림(특히 투명/블러/그림자)
가능하면 Mac에서 Safari 개발자 도구로 iPhone 원격 디버깅을 켜고, 스크롤 중 레이아웃/페인트/스크립트가 튀는지 확인하세요.
2-2. “스크롤 핸들러가 문제인지” 빠르게 확인하는 스위치
스크롤 이벤트에 무거운 로직이 있다면, 일단 통째로 비활성화한 빌드를 만들어 비교합니다. 체감이 확 바뀌면 방향이 정해집니다.
3) Layer 관점: 합성 레이어를 ‘필요한 만큼만’ 쓰기
iOS Safari에서 레이어는 양날의 검입니다.
- 레이어 승격은 페인트를 줄여 스크롤을 매끈하게 만들 수 있음
- 하지만 레이어가 많아지면 합성 비용과 메모리 압박으로 역효과
3-1. will-change 남발 금지, “짧게” 쓰기
will-change 는 “곧 바뀔 것”을 브라우저에 알려 미리 레이어를 준비하게 합니다. 문제는 상시 적용하면 레이어가 고정적으로 늘어나 iOS에서 쉽게 병목이 납니다.
나쁜 예
.card {
will-change: transform;
}
개선 예: 인터랙션 직전에만 부여
function withWillChange(el, prop, fn) {
el.style.willChange = prop;
try {
fn();
} finally {
// 다음 프레임 이후 제거(레이아웃 타이밍에 따라 조정)
requestAnimationFrame(() => {
el.style.willChange = "auto";
});
}
}
const card = document.querySelector(".card");
card.addEventListener("pointerenter", () => {
withWillChange(card, "transform", () => {
card.classList.add("hover");
});
});
3-2. transform: translateZ(0) 는 ‘응급처치’로만
과거에는 GPU 가속을 유도하려고 translateZ(0) 를 많이 썼지만, iOS에서는 큰 요소나 다수 요소에 적용하면 텍스처 메모리가 늘고 합성 단계가 무거워질 수 있습니다.
권장 전략은 다음과 같습니다.
- 정말로 애니메이션되는 요소(작은 영역)에만 적용
- 스크롤 컨테이너 전체 승격 같은 과감한 처방은 피하기
3-3. position: fixed + 블러/투명 조합을 의심
상단 헤더가 position: fixed 이고, 아래처럼 backdrop-filter 를 쓰는 경우 스크롤 때마다 합성 비용이 급증할 수 있습니다.
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
backdrop-filter: blur(12px);
background: rgba(255, 255, 255, 0.6);
}
대안:
- 블러를 제거하고 불투명 배경으로 전환
- 블러가 꼭 필요하면, 블러 영역을 작게 제한하고 스크롤 중에는 블러 강도를 낮추는 등 “동적 품질” 전략 적용
4) GPU 관점: “합성은 빠르다”는 전제가 깨지는 순간들
GPU가 빠른 건 맞지만, 다음 조건이 겹치면 iOS에서 급격히 느려집니다.
- 큰 텍스처(화면을 거의 덮는 요소)
- 투명/블러/필터
- 레이어 수 증가
4-1. 애니메이션은 transform 과 opacity 중심으로
스크롤과 동시 실행되는 애니메이션이 있다면, 레이아웃/페인트를 유발하는 속성은 피해야 합니다.
나쁜 예: top 애니메이션
.toast {
position: fixed;
top: -80px;
transition: top 200ms ease;
}
.toast.show {
top: 12px;
}
좋은 예: transform 애니메이션
.toast {
position: fixed;
transform: translateY(-120%);
transition: transform 200ms ease;
will-change: transform;
}
.toast.show {
transform: translateY(0);
}
4-2. 스크롤 중 JS 애니메이션은 requestAnimationFrame 으로 단일화
스크롤 이벤트에서 바로 DOM 업데이트를 하면 이벤트 폭주가 발생합니다. iOS에서 체감이 특히 나빠지기 쉽습니다.
let ticking = false;
let latestY = 0;
window.addEventListener(
"scroll",
() => {
latestY = window.scrollY;
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
// 읽기
const y = latestY;
// 쓰기(한 번에)
document.documentElement.style.setProperty("--scroll-y", String(y));
ticking = false;
});
},
{ passive: true }
);
핵심은 아래 2가지입니다.
- 리스너는
passive: true로 스크롤을 막지 않기 - DOM 읽기/쓰기를 프레임 단위로 모으기
스크롤 중 long task 를 줄이는 접근은 크롬 기준이긴 하지만 개념은 동일합니다. 더 넓은 성능 진단 관점은 Chrome INP 튐? long task 200ms 줄이는 실전 가이드도 참고할 만합니다.
5) CSS 관점: 페인트를 줄이고, 레이아웃 영향을 격리하기
5-1. content-visibility 와 contain 으로 화면 밖 렌더링 비용 차단
긴 리스트/피드에서 스크롤 끊김이 심하면, “화면 밖” 요소가 계속 렌더링 비용을 내고 있을 확률이 큽니다.
.feedItem {
content-visibility: auto;
contain-intrinsic-size: 800px;
}
content-visibility: auto는 화면 밖 콘텐츠의 렌더링을 늦춥니다.contain-intrinsic-size는 레이아웃 점프를 줄이기 위한 가짜 크기 힌트입니다.
이 주제는 CLS 튀는 이유? content-visibility로 렌더링 최적화에서 더 깊게 다뤘습니다.
5-2. contain 으로 레이아웃/페인트 파급 범위 줄이기
컴포넌트가 내부 변화로 바깥 레이아웃까지 흔들면 스크롤 중 레이아웃 비용이 커집니다.
.card {
contain: layout paint style;
}
다만 contain 은 position sticky, overflow 등과 상호작용이 있으니, 적용 후 레이아웃이 깨지지 않는지 확인이 필요합니다.
5-3. iOS에서 특히 비싼 속성 체크리스트
다음이 스크롤 영역 또는 고정 헤더 등에 들어가 있으면 우선 제거 실험을 권합니다.
backdrop-filter- 큰
box-shadow(blur radius 큼) filter: blur()mix-blend-mode
“디자인을 포기하지 않고” 가려면, 스크롤 중에는 효과를 약화시키고 스크롤이 멈추면 복원하는 방식이 현실적입니다.
let scrollTimer = 0;
const root = document.documentElement;
window.addEventListener(
"scroll",
() => {
root.classList.add("is-scrolling");
window.clearTimeout(scrollTimer);
scrollTimer = window.setTimeout(() => {
root.classList.remove("is-scrolling");
}, 120);
},
{ passive: true }
);
.header {
backdrop-filter: blur(12px);
}
.is-scrolling .header {
backdrop-filter: none;
background: rgba(255, 255, 255, 0.92);
}
6) 스크롤 컨테이너: iOS에서 피해야 할 구조
6-1. 중첩 스크롤을 줄이고, 가능한 body 스크롤로 단순화
overflow: auto 컨테이너 안에 또 다른 스크롤 영역이 있으면 iOS에서 제스처 처리와 repaint가 복잡해집니다. 구조를 단순화하는 것만으로도 체감이 좋아지는 케이스가 많습니다.
6-2. -webkit-overflow-scrolling: touch 는 신중히
이 속성은 관성 스크롤을 주지만, 특정 조합에서 버그성 끊김이나 fixed/sticky와 충돌을 만들기도 합니다.
가능한 접근:
- 꼭 필요한 컨테이너에만 적용
- fixed 헤더가 겹치면 헤더를 컨테이너 밖으로 빼거나, 헤더를 sticky로 전환 검토
7) 실전 디버깅 순서(되돌림 최소화)
아래 순서대로 하면 “한 번에 원인 범주”를 좁히기 좋습니다.
- 스크롤 핸들러 제거 빌드로 비교
- 개선되면 JS/이벤트가 원인
- 고정 헤더/푸터의 블러, 필터, 그림자 제거 실험
- 개선되면 페인트/합성이 원인
will-change,translateZ(0)적용 범위 점검- 레이어 수를 줄이고, 필요한 순간에만 적용
- 긴 리스트면
content-visibility: auto적용- 화면 밖 렌더링 비용 차단
- 스크롤 컨테이너 구조 단순화
- 중첩 스크롤 제거, sticky/fixed 재설계
8) “이렇게 하면 더 느려질 수 있다” 흔한 함정
- 모든 카드에
will-change: transform적용 - 모든 섹션에
transform: translateZ(0)적용 - 스크롤 이벤트마다
getBoundingClientRect()로 여러 요소 측정 - 스크롤 중 상태관리(store setState)로 대규모 리렌더 유발
특히 React 계열에서 스크롤 위치를 state로 올려 전체 렌더가 발생하면 iOS에서 바로 체감이 나빠집니다. 스크롤 값은 CSS 변수나 ref로 흘리고, 화면에 필요한 최소 부분만 업데이트하는 식으로 분리하는 게 안전합니다.
9) 최소 예제로 보는 “스크롤 중 헤더 효과” 안전 패턴
스크롤 중 헤더 배경을 바꾸고 싶을 때, 레이아웃을 건드리지 않고 합성 친화적으로 처리하는 예시입니다.
.header {
position: sticky;
top: 0;
z-index: 10;
background: rgba(255, 255, 255, 0.7);
transition: opacity 160ms ease;
}
.header::before {
content: "";
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.92);
opacity: 0;
transition: opacity 160ms ease;
pointer-events: none;
}
.has-scrolled .header::before {
opacity: 1;
}
const root = document.documentElement;
window.addEventListener(
"scroll",
() => {
// 임계값 기반으로만 토글해서 업데이트 횟수 줄이기
if (window.scrollY > 8) root.classList.add("has-scrolled");
else root.classList.remove("has-scrolled");
},
{ passive: true }
);
포인트는 다음입니다.
top같은 레이아웃 속성 변경 대신 opacity 전환- 스크롤 값에 비례해 연속 업데이트하지 않고 “임계값 토글”로 제한
10) 마무리: iOS Safari 스크롤 최적화의 정답은 “균형”
iOS Safari 스크롤 끊김은 한 가지 비법으로 해결되기보다,
- 스크롤을 막는 이벤트 제거
- 레이아웃/페인트 비용을 줄이는 CSS 정리
- 레이어 승격을 필요한 곳에만 제한
- 화면 밖 렌더링 비용 차단
이 네 가지를 균형 있게 맞추는 게 가장 재현성 높은 해결책입니다.
현재 페이지에서 “고정 헤더 + 블러” 또는 “긴 리스트 + 스크롤 중 리렌더”가 있다면, 그 조합이 iOS에서 가장 흔한 병목이니 그 지점부터 실험해 보세요.