- Published on
Safari iOS 스크롤 끊김 해결 - 레이어·합성 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
모바일에서만 유독 스크롤이 끊기는 UI가 있습니다. 특히 iOS Safari는 스크롤이 메인 스레드와 합성 스레드의 협업으로 동작하는데, 특정 CSS 조합이 들어가면 합성 단계에서 과도한 레이어 분리, 페인트 영역 증가, 오프스크린 렌더링이 발생해 프레임이 떨어집니다. 이 글은 "왜" 끊기는지(레이어와 합성 파이프라인)부터 "어떻게" 고치는지(재현, 진단, 수정 패턴)까지 실전 중심으로 정리합니다.
iOS Safari 스크롤 끊김의 핵심: 레이어와 합성
브라우저 렌더링은 대략 다음 흐름을 가집니다.
- 스타일 계산
- 레이아웃
- 페인트(픽셀 생성)
- 합성(compositing, 레이어를 GPU로 조합)
스크롤은 매 프레임마다 화면이 움직이기 때문에, 이상적인 상태는 "스크롤 중에는 페인트를 최소화하고, 합성만으로 움직임을 처리"하는 것입니다. 하지만 다음 조건이 겹치면 스크롤 중에도 페인트가 발생하거나(메인 스레드 바쁨), 레이어가 너무 많아져 합성 비용이 증가합니다(GPU/메모리 압박).
iOS Safari에서 자주 터지는 조합
position: sticky가 많은 리스트(특히 헤더, 탭, 필터 바)backdrop-filter,filter,mix-blend-mode같은 고비용 효과- 반투명 오버레이와 큰
box-shadow - 스크롤 컨테이너 내부의
transform/will-change남발 overflow: auto내부에서-webkit-overflow-scrolling: touch를 쓰는 구조- 스크롤 중에 DOM을 계속 업데이트(무한 스크롤, sticky 상태 계산 등)
이 중 다수는 "레이어 분리"를 유도합니다. 레이어 분리는 무조건 나쁜 게 아니라, 잘 쓰면 페인트를 줄이는 최적화가 됩니다. 문제는 레이어가 너무 커지거나, 너무 많아지거나, 매 프레임 다시 페인트되는 레이어가 생기는 경우입니다.
재현을 위한 최소 예제: 끊기는 패턴
아래처럼 상단에 흐림 효과가 있는 고정 헤더를 두고, 내부에 긴 리스트를 두면 iOS Safari에서 스크롤 끊김이 쉽게 재현됩니다.
<header class="topbar">
<div class="topbar__inner">Search</div>
</header>
<main class="content">
<ul class="list">
<!-- li 200개 정도 생성 -->
</ul>
</main>
.topbar {
position: sticky;
top: 0;
z-index: 10;
backdrop-filter: blur(14px);
background: rgba(255, 255, 255, 0.7);
}
.topbar__inner {
padding: 12px 16px;
border-bottom: 1px solid rgba(0,0,0,0.08);
}
.list li {
padding: 14px 16px;
border-bottom: 1px solid #eee;
}
backdrop-filter 는 뒤의 픽셀을 샘플링해 블러 처리하기 때문에, 스크롤로 배경이 계속 바뀌면 비용이 커집니다. 여기에 sticky 가 붙으면 스크롤 중 합성 단계에서 처리해야 할 일이 늘어나고, 상황에 따라 페인트까지 유발합니다.
진단: 어떤 레이어가 문제인지 확인하는 방법
iOS 실기기에서 가장 좋은 방법은 macOS Safari의 Web Inspector로 원격 디버깅하는 것입니다.
1) 레이어 수와 큰 레이어(대면적) 확인
- Web Inspector에서 Layers(또는 Rendering 관련 패널)로 레이어 트리를 확인합니다.
- 스크롤할 때 레이어가 계속 생성되거나, 화면보다 훨씬 큰 레이어가 보이면 경고 신호입니다.
2) 스크롤 중 페인트가 발생하는지 확인
- 페인트 플래시(페인트 영역 표시) 기능이 있다면 켜고 스크롤합니다.
- 스크롤만 하는데도 넓은 영역이 계속 페인트된다면 합성-only가 깨진 상태입니다.
3) Long Task와 메인 스레드 작업 확인
스크롤 끊김이 합성 문제가 아니라 JS 때문인 경우도 많습니다. 스크롤 이벤트에서 레이아웃을 강제로 읽는 코드(예: getBoundingClientRect)가 있으면 레이아웃 스래싱이 발생합니다. 이 부분은 Chrome INP 폭증 원인 찾기 - Long Task 분해에서 소개한 방식처럼 "긴 작업을 쪼개고" 프레임 예산(약 16ms)을 지키는 관점으로 같이 점검하는 게 좋습니다.
해결 전략 1: 고비용 효과(backdrop-filter, filter) 범위 줄이기
문제
backdrop-filter 는 적용 영역이 클수록, 그리고 뒤 배경이 자주 변할수록 비용이 커집니다.
해결
- 블러 영역을 작은 박스로 제한
- 가능한 경우 블러 대신 반투명 배경으로 대체
- 스크롤 중에는 블러를 끄고, 스크롤이 멈춘 뒤 다시 켜는 방식도 고려
/* 대체안: blur 대신 반투명 + 경계선 */
.topbar {
position: sticky;
top: 0;
z-index: 10;
background: rgba(255, 255, 255, 0.92);
border-bottom: 1px solid rgba(0,0,0,0.06);
}
/* 꼭 blur가 필요하다면 범위를 줄이기 */
.topbar__inner {
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.7);
}
해결 전략 2: will-change와 transform은 "필요한 순간"에만
문제
will-change: transform 은 "곧 바뀔 테니 레이어를 준비해"라는 힌트입니다. 남발하면 레이어가 늘고 메모리를 많이 먹습니다. iOS는 메모리 압박 시 오히려 성능이 급락하거나 탭이 리로드되기도 합니다.
해결
- 애니메이션/전환 직전에만
will-change를 켜고, 끝나면 제거 - 항상 떠 있는 헤더 전체에
transform: translateZ(0)같은 강제 레이어 분리를 걸기 전에, 실제로 이득이 있는지 확인
function withWillChange(el, prop, fn) {
const prev = el.style.willChange;
el.style.willChange = prop;
try {
fn();
} finally {
// 애니메이션 종료 후 정리(다음 프레임에 제거하면 더 안전)
requestAnimationFrame(() => {
el.style.willChange = prev;
});
}
}
const sheet = document.querySelector('.bottom-sheet');
document.querySelector('#open').addEventListener('click', () => {
withWillChange(sheet, 'transform', () => {
sheet.classList.add('is-open');
});
});
.bottom-sheet {
transform: translateY(100%);
transition: transform 240ms ease;
}
.bottom-sheet.is-open {
transform: translateY(0);
}
핵심은 "항상 레이어"가 아니라 "필요할 때만 레이어"입니다.
해결 전략 3: sticky 남발 줄이고, 구조를 단순화
문제
position: sticky 는 편하지만, 스크롤 컨테이너가 중첩되거나 sticky 요소가 많으면 비용이 증가합니다.
해결
- sticky 요소 개수를 줄이고, 하나의 상단 컨테이너로 통합
- sticky 내부에서 높이가 자주 바뀌는 요소(이미지 로딩, 폰트 스왑)를 최소화
- sticky 요소 내부에
filter, 큰box-shadow를 피하기
/* 큰 shadow는 페인트 비용이 커질 수 있음 */
.card {
/* box-shadow: 0 20px 60px rgba(0,0,0,0.2); */
box-shadow: 0 6px 18px rgba(0,0,0,0.12);
}
해결 전략 4: 스크롤 컨테이너를 쪼개지 말고, 가능하면 body 스크롤 유지
문제
overflow: auto 로 만든 내부 스크롤 영역은 iOS에서 조합이 까다롭습니다. 특히 모달 안 스크롤, 바텀시트 안 스크롤, 리스트 안 리스트 같은 구조는 스크롤 체인이 복잡해지고, 페인트/합성 최적화가 깨지기 쉽습니다.
해결
- 가능하면 페이지 스크롤은
body하나로 유지 - 모달은 내부 스크롤 대신 전체 페이지를 고정하고 모달 콘텐츠만 최소 영역 스크롤
- iOS에서 내부 스크롤이 꼭 필요하면
-webkit-overflow-scrolling: touch적용 여부를 테스트하고, sticky와의 조합을 피함
.modal__content {
overflow: auto;
-webkit-overflow-scrolling: touch;
max-height: 80vh;
}
이 설정이 항상 정답은 아닙니다. 켰을 때 더 부드러워지는 경우도 있지만, sticky나 필터 효과가 섞이면 오히려 끊김이 커질 수 있어 A/B로 확인해야 합니다.
해결 전략 5: 스크롤 이벤트에서 레이아웃 읽기 금지, passive 기본화
문제
스크롤 중 JS가 레이아웃을 읽고(예: offsetHeight, getBoundingClientRect) 곧바로 스타일을 쓰면 강제 동기 레이아웃이 발생합니다. 또한 touchmove 나 wheel 이벤트를 non-passive로 등록하면 스크롤이 JS에 의해 블로킹될 수 있습니다.
해결
- 스크롤 이벤트에서는 상태만 기록하고, DOM 읽기/쓰기는
requestAnimationFrame에서 배치 - 이벤트 리스너는 기본적으로
passive: true
let latestY = 0;
let scheduled = false;
window.addEventListener(
'scroll',
() => {
latestY = window.scrollY;
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
// DOM 업데이트는 여기서
document.documentElement.style.setProperty('--scroll-y', String(latestY));
});
},
{ passive: true }
);
해결 전략 6: 큰 리스트는 가상화(virtualization)로 DOM/페인트 자체를 줄이기
레이어/합성 최적화만으로 해결이 안 되면, 결국 "그릴 게 너무 많다"가 원인일 수 있습니다. 특히 iOS Safari는 대량 DOM에서 페인트 비용이 급격히 커질 때가 있습니다.
- React라면
react-window류의 가상화를 적용 - 이미지가 많다면
loading="lazy"와 적절한 크기 지정으로 리플로우를 줄이기
<img src="/img/item.jpg" loading="lazy" width="120" height="120" alt="" />
실전 체크리스트: iOS Safari 스크롤 끊김 빠른 점검
backdrop-filter가 있는가? 있다면 범위를 줄이거나 대체 가능한가?- sticky 요소가 여러 개인가? 하나로 통합 가능한가?
will-change를 상시로 걸어둔 요소가 있는가? 애니메이션 순간에만 적용 가능한가?- 내부 스크롤 컨테이너가 중첩되어 있는가? body 스크롤로 단순화 가능한가?
- 스크롤 중 JS가 레이아웃을 읽고/쓰는가?
requestAnimationFrame으로 배치했는가? - 큰 shadow, filter, blend가 스크롤 영역에 걸쳐 있는가?
마무리: 레이어는 "최적화"이자 "부채"가 될 수 있다
iOS Safari의 스크롤 끊김은 단순히 "GPU 가속을 켜면 된다"로 해결되지 않습니다. 레이어 분리는 페인트를 줄이는 강력한 도구지만, 남발하면 합성 비용과 메모리 비용이 커져 역효과가 납니다.
가장 효과적인 접근은 다음 순서입니다.
- 고비용 효과(
backdrop-filter, 큰box-shadow)를 줄여 페인트/오프스크린 비용을 낮춘다 - 레이어 힌트(
will-change,transform)는 필요한 순간에만 쓴다 - sticky/내부 스크롤 구조를 단순화한다
- 스크롤 중 JS 작업을 프레임 친화적으로 바꾼다
스크롤은 사용자가 가장 자주 체감하는 UX입니다. iOS Safari에서만 끊긴다면, "합성-only 스크롤"을 깨는 요소가 무엇인지 레이어 관점에서 하나씩 제거해보면, 대부분은 명확한 원인이 드러납니다.