- Published on
Safari 17 스크롤 jank의 진짜 원인 - compositing
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Safari 17에서 특정 페이지는 CPU가 넉넉해도 스크롤이 끊기고, 터치패드 관성 스크롤이 "걸리는" 느낌이 나곤 합니다. 이때 흔히 scroll 이벤트나 무거운 React 렌더를 의심하지만, 실제로는 compositing(레이어 합성) 경로가 불안정해져서 스크롤 중에 paint(재도색) 와 layout(재배치) 이 과도하게 발생하는 케이스가 많습니다.
이 글은 Safari 17에서 스크롤 jank가 생기는 대표적인 compositing 원인을 "무엇이 레이어를 만들고, 무엇이 레이어를 깨는지" 관점에서 정리합니다. 또한 재현 코드, Safari Web Inspector에서의 진단 포인트, 그리고 실무에서 효과가 컸던 완화 패턴을 함께 제공합니다.
관련해서 iOS Safari에서의 잔상/버벅임을 더 빠르게 해결하는 체크리스트는 아래 글도 참고할 수 있습니다.
Safari 17 스크롤 파이프라인: 왜 compositing이 핵심인가
브라우저 스크롤이 매끄럽게 느껴지려면, 스크롤 중에 매 프레임마다 메인 스레드가 레이아웃/페인트로 바쁘지 않아야 합니다. 이상적으로는 다음이 성립해야 합니다.
- 스크롤은 compositor thread 에서 처리(메인 스레드 개입 최소)
- 스크롤 중 변화는 transform/opacity 중심(재레이아웃 없이 합성만)
- 고정 요소, 오버레이, 블러 같은 효과가 있어도 레이어 구성이 안정적
하지만 Safari 17에서는 특정 CSS 조합에서 레이어가 자주 생성/파괴되거나, 스크롤 중에 페인트 영역이 급격히 커지면서 compositor 이점이 사라지고 메인 스레드가 끌려 들어옵니다. 그 결과가 우리가 체감하는 scroll-jank입니다.
가장 흔한 compositing 유발/파괴 패턴 7가지
아래는 Safari 17에서 특히 자주 문제를 만들었던 패턴들입니다. (모든 브라우저 공통도 있지만, Safari에서 증상이 더 크게 체감되는 경우가 많습니다.)
1) position: sticky + 복잡한 배경/필터/마스크
position: sticky 자체가 문제라기보다는, sticky 요소가 다음과 결합될 때 레이어 및 페인트 비용이 급증합니다.
backdrop-filter: blur(...)filter: blur(...)또는drop-shadow(...)mask-image/-webkit-mask-image- 반투명 배경 + 큰 영역
특히 backdrop-filter 는 스크롤 시 배경을 계속 샘플링해야 하므로, 요소 크기가 크거나 겹치는 레이어가 많으면 스크롤 중 페인트가 늘어납니다.
2) overflow: hidden/auto 컨테이너 내부의 sticky
Safari는 중첩 스크롤 컨테이너에서 sticky 처리를 까다롭게 하는 편입니다. 다음 구조에서 jank가 잘 납니다.
- 외부는
body스크롤 - 내부에
overflow: auto영역 - 그 안에 sticky 헤더/툴바
이때 스크롤 체인이 복잡해지고, 레이어 경계가 자주 흔들리며 repaint가 커집니다.
3) 큰 박스 그림자/그라데이션이 스크롤 영역 전체를 덮는 경우
다음 스타일은 보기엔 단순해도 paint 비용이 큽니다.
- 큰
box-shadow(blur radius가 큰 경우) - 여러 겹의
linear-gradient배경 background-attachment: fixed(Safari에서 특히 비권장)
스크롤 시 이들이 포함된 요소가 큰 영역을 차지하면, 매 프레임마다 페인트 면적이 커져 jank로 이어집니다.
4) mix-blend-mode, isolation, 합성 모드
mix-blend-mode 는 합성 단계에서 추가 비용이 들어가며, 겹치는 요소가 많을수록 비용이 커집니다. 스크롤 중에 겹침이 변하면 레이어가 재구성될 수 있습니다.
5) 스크롤 중 DOM이 "살짝" 바뀌는 패턴(무한 스크롤, lazy render)
스크롤 이벤트에 반응해 다음을 수행하면, 프레임 드랍이 쉽게 발생합니다.
- 높이가 변하는 요소를 추가/삭제
- 이미지 로딩 후 레이아웃이 변함(placeholder 없음)
scrollTop읽고 곧바로 스타일 변경(레이아웃 스래싱)
Safari는 레이아웃/페인트가 걸리는 순간 스크롤이 확 끊겨 보이는 경향이 있습니다.
6) contain 미사용으로 페인트 전파가 과도한 경우
컴포넌트 단위로 contain: layout paint 또는 content-visibility 를 적절히 쓰면 리플로우/리페인트 전파를 줄일 수 있습니다. Safari의 지원 범위는 확인이 필요하지만, contain 은 실무에서 체감이 큰 편입니다.
7) 레이어를 "강제" 하려다 오히려 역효과 나는 경우
will-change: transform 을 남발하면 레이어가 과도하게 늘고 메모리/합성 비용이 증가합니다. Safari에서 특히 다음이 흔한 실수입니다.
- 리스트 아이템 수백 개에
will-change적용 - hover/scroll과 무관한 요소까지 레이어 승격
재현 코드: sticky + backdrop-filter 조합이 만드는 jank
아래 예시는 Safari에서 스크롤 중 비용이 커지기 쉬운 조합을 단순화한 것입니다.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background: #0b1020;
color: #e8ecff;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
padding: 14px 16px;
background: rgba(20, 24, 40, 0.55);
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.card {
margin: 14px 16px;
padding: 16px;
background: rgba(255, 255, 255, 0.06);
border-radius: 14px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
}
.bg {
position: fixed;
inset: 0;
background:
radial-gradient(800px 500px at 20% 10%, rgba(120, 90, 255, 0.35), transparent 60%),
radial-gradient(700px 500px at 70% 30%, rgba(0, 180, 255, 0.25), transparent 60%),
linear-gradient(#070a14, #0b1020);
z-index: -1;
}
</style>
</head>
<body>
<div class="bg"></div>
<div class="topbar">Sticky + backdrop-filter + big shadows</div>
<div id="list"></div>
<script>
const list = document.getElementById('list');
for (let i = 0; i < 120; i++) {
const el = document.createElement('div');
el.className = 'card';
el.textContent = `Row ${i} - scroll to test jank`;
list.appendChild(el);
}
</script>
</body>
</html>
이 예시에서 jank를 유발하는 포인트는 하나가 아니라 조합입니다.
- sticky 헤더가 스크롤 중 계속 화면에 남음
backdrop-filter로 인해 배경 샘플링 비용 증가- 카드의 큰
box-shadow로 페인트 비용 증가 - 고정 배경 그라데이션으로 레이어/페인트 경계가 복잡해짐
Safari Web Inspector로 원인 좁히기
Safari에서 원인을 "감" 으로 추측하면 끝이 없습니다. 아래 순서로 좁히면 빠릅니다.
1) Timelines에서 Rendering 관련 스파이크 확인
Safari Web Inspector의 Timelines에서 스크롤 중 다음이 반복적으로 튀는지 봅니다.
- Layout
- Recalculate Style
- Paint
- Composite Layers
특히 Paint와 Composite Layers가 번갈아 튀면 레이어 구성이 안정적이지 않거나, 합성만으로 못 끝내고 페인트가 끼어드는 상황일 가능성이 큽니다.
2) "어떤 요소가" 큰 페인트 영역을 만드는지 찾기
페인트가 큰 프레임을 클릭하고, 관련된 DOM 노드를 역추적합니다.
- sticky 헤더가 화면 상단 전체를 계속 repaint하는지
- box-shadow가 큰 요소가 매 프레임 다시 그려지는지
- 투명/블러/블렌드가 겹쳐서 페인트가 확장되는지
3) 레이어 수가 과도한지 점검
will-change 나 transform: translateZ(0) 같은 최적화 트릭이 실제로는 레이어를 폭증시켜 합성 비용을 증가시키는 경우가 있습니다.
개선 전략: "합성만" 하게 만들고, 페인트 면적을 줄인다
아래는 Safari 17에서 특히 효과가 좋았던 실무 패턴입니다.
1) backdrop-filter 를 "작게" 쓰거나 대체하기
- 블러 영역을 헤더 전체가 아닌, 내부 작은 컨테이너로 제한
- 스크롤 중에는 블러를 끄고, 스크롤이 멈추면 다시 켜는 전략도 가능
스크롤 중 블러 토글은 이벤트 처리 비용이 생기므로, 반드시 requestAnimationFrame 기반으로 디바운스합니다.
let scrolling = false;
let timer = 0;
const header = document.querySelector('.topbar');
function setBlur(on) {
header.style.webkitBackdropFilter = on ? 'blur(16px)' : 'none';
header.style.backdropFilter = on ? 'blur(16px)' : 'none';
}
window.addEventListener('scroll', () => {
if (!scrolling) {
scrolling = true;
setBlur(false);
}
clearTimeout(timer);
timer = window.setTimeout(() => {
scrolling = false;
setBlur(true);
}, 120);
}, { passive: true });
주의: 이 방식은 "스크롤 중 시각 효과를 포기" 하는 트레이드오프입니다. 하지만 사용자 체감은 대체로 더 좋아집니다.
2) 큰 box-shadow 를 줄이고, 그림자를 이미지/그라데이션으로 대체
- blur radius를 줄이거나
- shadow를 얇게
- 또는 pseudo-element로 작은 영역에만 적용
.card {
position: relative;
box-shadow: none;
}
.card::before {
content: "";
position: absolute;
inset: 0;
border-radius: 14px;
pointer-events: none;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
}
핵심은 "그림자 계산이 큰 영역에 매 프레임 적용" 되는 것을 피하는 것입니다.
3) 스크롤 중 레이아웃이 변하지 않게: placeholder, 고정 높이
이미지/카드가 로딩되며 높이가 바뀌면 스크롤이 튑니다. Safari는 이 변화에 더 민감하게 보일 수 있습니다.
- 이미지에
width/height지정 - skeleton placeholder로 높이 고정
- 무한 스크롤은 한 번에 추가하는 DOM 양을 제한
4) contain 으로 레이아웃/페인트 전파 차단
컴포넌트 경계가 명확한 카드/섹션에는 다음이 도움이 됩니다.
.card {
contain: layout paint;
}
이렇게 하면 내부 변경이 외부 레이아웃까지 전파되는 것을 줄여, 스크롤 중 우발적인 reflow 비용을 낮출 수 있습니다.
5) will-change 는 "짧게, 적게" 적용
애니메이션 직전에만 켜고, 끝나면 끄는 방식이 안전합니다.
const el = document.querySelector('.topbar');
function promoteForAnimation() {
el.style.willChange = 'transform';
requestAnimationFrame(() => {
// 애니메이션 수행
el.style.transform = 'translateY(0px)';
// 애니메이션 종료 후 해제(대략적)
window.setTimeout(() => {
el.style.willChange = 'auto';
}, 300);
});
}
리스트 전체에 상시 적용하는 것은 피합니다.
체크리스트: Safari 17 scroll-jank를 빠르게 줄이는 순서
- 스크롤 중
backdrop-filter/filter/mask/mix-blend-mode가 있는지 확인 - sticky 요소가 큰 영역을 덮는지, 그리고 투명/블러/그림자와 겹치는지 확인
- 큰
box-shadow와 다중 그라데이션 배경을 축소 - 이미지/카드 높이 고정으로 레이아웃 변동 제거
will-change남발 제거, 필요한 곳만 제한적으로contain으로 페인트/레이아웃 전파를 차단
마무리: "JS 최적화" 전에 합성 경로부터 안정화하기
Safari 17의 스크롤 jank는 이벤트 핸들러의 무거움보다, 레이어 합성 경로가 깨져서 paint가 끼어드는 구조적 문제 인 경우가 많습니다. 특히 sticky + blur/필터/그림자 조합은 보기 좋은 UI를 만들기 쉽지만, 스크롤 성능의 대가를 치르기 쉽습니다.
먼저 Web Inspector에서 Paint/Composite Layers 스파이크를 확인하고, "스크롤 중에는 합성만" 일어나도록 스타일을 단순화하는 것이 가장 빠른 해결책입니다. 그 다음에야 가상화, 렌더링 최적화, 이벤트 최적화가 의미 있게 먹힙니다.
추가로 iOS Safari까지 포함해 증상을 빠르게 정리하려면 다음 글도 함께 보면 좋습니다.