- Published on
Safari iOS 스크롤 잔상·깜빡임 해결 - Compositing
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
iOS Safari에서 스크롤할 때 텍스트가 순간적으로 흐려지거나, 카드가 번쩍이거나, 배경이 잔상처럼 남는 현상은 생각보다 흔합니다. 특히 position: sticky 헤더, 반투명 오버레이, backdrop-filter, transform 애니메이션이 섞이면 재현 확률이 급격히 올라갑니다.
이 글은 그 현상을 “그냥 iOS 버그”로 끝내지 않고, Safari의 렌더링 파이프라인에서 compositing(레이어 합성) 이 어떤 식으로 개입해 문제를 만들고, 또 어떻게 활용하면 문제를 줄일 수 있는지에 집중합니다.
관련 원인들을 넓게 훑고 싶다면 먼저 아래 글을 권합니다.
iOS Safari에서 잔상·깜빡임이 생기는 지점
브라우저는 대략 다음 단계를 거쳐 화면을 그립니다.
- 스타일 계산
- 레이아웃(박스 위치/크기 결정)
- 페인트(픽셀을 비트맵으로 그림)
- 합성(compositing, 여러 레이어를 GPU에서 조합)
문제는 스크롤이 들어오면 매 프레임마다 1부터 4를 모두 하고 싶지 않다는 데 있습니다. 그래서 브라우저는 일부 요소를 합성 레이어 로 올려서(대개 GPU 텍스처로) 스크롤 중에는 “레이어를 재배치만” 하고 페인트를 덜 하려 합니다.
하지만 iOS Safari에서는 다음 상황에서 합성 경계가 불안정해지며 깜빡임/잔상으로 관찰되곤 합니다.
- 스크롤 컨테이너 내부에
position: sticky와transform이 섞임 backdrop-filter또는 반투명 레이어가 스크롤 중 계속 재합성됨- 큰 박스 그림자, 블러, 마스크 등 비싼 페인트 가 스크롤과 함께 발생
- 특정 요소가 레이어로 승격되었다가(또는 강제로 승격시켰다가) 다시 내려오는 thrash
핵심은 “스크롤 중에 페인트가 자주 일어나거나, 합성 레이어 구성이 프레임마다 흔들리는지”입니다.
compositing 관점에서의 해결 전략 4가지
1) 스크롤 중 바뀌는 요소를 레이어로 고정시키기
스크롤 중 계속 시각적으로 업데이트되는 요소(예: sticky 헤더, 상단 그라데이션, 떠있는 FAB)가 매번 페인트 되면 잔상/깜빡임이 관찰되기 쉽습니다. 이때는 해당 요소를 합성 레이어로 올려 “그림은 한 번만 그리고, 이후엔 GPU 합성으로만” 처리되게 만드는 접근이 유효합니다.
다만 레이어 승격은 메모리 비용이 있으니 최소 범위로 적용해야 합니다.
/* sticky 헤더가 스크롤 중 깜빡이거나 텍스트가 흔들릴 때 */
.header {
position: sticky;
top: 0;
z-index: 100;
/* 합성 레이어 유도 */
will-change: transform;
transform: translateZ(0);
/* 텍스트/레이어 경계 안정화에 도움되는 경우가 있음 */
-webkit-font-smoothing: antialiased;
}
포인트는 will-change 를 “항상 켜두는 만능 옵션”으로 쓰지 않는 것입니다. 정말 스크롤 중 문제를 일으키는 요소에만 제한적으로 적용하세요.
2) 스크롤 컨테이너와 합성 레이어의 경계를 단순화하기
iOS Safari에서 특히 문제가 되는 조합 중 하나는 다음입니다.
- 부모가
overflow: auto또는-webkit-overflow-scrolling: touch - 자식이
position: sticky - 자식 또는 조상이
transform을 가짐
이 조합은 sticky의 기준 좌표계와 transform의 새로운 containing block이 얽히면서, 스크롤 중 레이어 계산이 흔들리는 케이스가 있습니다.
실전에서 자주 통하는 정리 방법은:
- 스크롤 컨테이너에는
transform을 주지 않는다 - sticky 요소의 조상에도
transform을 주지 않는다 - 꼭 애니메이션이 필요하면 sticky와 분리된 형제 레이어로 분리한다
예시 구조:
<div class="page">
<div class="sticky-header">...</div>
<div class="scroll-area">...</div>
</div>
.page {
position: relative;
}
/* sticky 레이어는 스크롤 영역과 분리 */
.sticky-header {
position: sticky;
top: 0;
z-index: 10;
will-change: transform;
transform: translateZ(0);
}
/* 스크롤 영역 자체는 transform을 피한다 */
.scroll-area {
overflow: auto;
-webkit-overflow-scrolling: touch;
}
이렇게 “sticky를 스크롤 컨테이너 안에서 해결”하려고 하지 말고, 레이어 경계를 단순하게 가져가면 iOS에서 잔상이 줄어드는 경우가 많습니다.
3) backdrop-filter/블러/큰 shadow는 스크롤 중엔 피하거나 격리
backdrop-filter: blur(...) 는 보기엔 깔끔하지만, 스크롤 중에는 배경이 계속 변하므로 합성 단계에서 비용이 커지고, iOS Safari에서는 깜빡임의 트리거가 되기도 합니다.
대안은 크게 3가지입니다.
- 스크롤 중에는 blur를 끄고, 스크롤이 멈추면 다시 켠다
- blur가 필요한 영역을 작게 줄인다
- blur를 써야 한다면 별도 레이어로 격리하고 repaint 범위를 최소화한다
스크롤 중 blur를 끄는 예시(간단한 디바운스):
// 스크롤 중에는 blur를 끄고, 멈추면 다시 켠다
let t: number | undefined;
const root = document.documentElement;
const onScroll = () => {
root.classList.add("is-scrolling");
if (t) window.clearTimeout(t);
t = window.setTimeout(() => {
root.classList.remove("is-scrolling");
}, 120);
};
window.addEventListener("scroll", onScroll, { passive: true });
.frosted {
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.is-scrolling .frosted {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
UI 품질과 성능 사이의 타협이지만, “스크롤 중 깜빡임”을 제품 이슈로 겪는 경우 체감 개선 폭이 큽니다.
4) contain 과 레이어 격리로 repaint 전파를 끊기
스크롤 중 특정 카드 하나의 내용이 업데이트되거나(예: lazy 이미지 로딩), 뱃지 숫자가 바뀌거나, skeleton이 사라지는 순간 주변까지 덩달아 repaint가 전파되면 잔상처럼 보일 수 있습니다.
이때는 CSS Containment로 영향을 격리하는 것이 도움이 됩니다.
.card {
/* 레이아웃/페인트 영향 범위를 카드 내부로 제한 */
contain: layout paint;
/* 큰 리스트에서 오프스크린 렌더 비용 절감 */
content-visibility: auto;
contain-intrinsic-size: 200px 300px;
}
주의할 점:
content-visibility는 iOS Safari 지원이 제한적일 수 있어(버전에 따라 상이) 점진적 적용이 필요합니다.contain은 자식의position: fixed/sticky동작과 상호작용이 있을 수 있으니, 카드/셀 같은 “독립 컴포넌트”에 우선 적용하세요.
실전 디버깅: 무엇이 합성 레이어로 올라갔는지 확인하기
원인을 제대로 잡으려면 “내가 의도한 레이어”와 “브라우저가 만든 레이어”가 일치하는지 봐야 합니다.
- macOS Safari에서 Web Inspector를 열고 iOS 디바이스에 원격 연결
- Timelines/Rendering 관련 패널에서 페인트가 과도한지 확인
- 레이어/컴포지팅 관련 뷰에서 특정 요소가 별도 레이어인지 확인
관찰 포인트:
- 스크롤 중 특정 요소가 계속 repaint 되는가
- sticky 헤더가 프레임마다 다시 그려지는가
- blur/그림자 영역이 넓게 페인트되는가
- 레이어가 스크롤 중 생성/파괴를 반복하는가
이 과정에서 “will-change를 추가하니 좋아졌다”가 아니라, 왜 좋아졌는지 를 레이어 관점에서 확인하면 재발을 막기 쉽습니다.
추천 패턴: iOS Safari에 강한 스크롤 UI 구성
아래는 잔상/깜빡임 이슈를 덜 만들기 위한 구성 가이드입니다.
패턴 A: sticky는 단순하게, 애니메이션은 분리
- sticky 요소는 가능한 단순한 박스로 유지
- 애니메이션이 필요하면 sticky 내부에서
transform을 남발하지 말고, 별도 래퍼에 한정
<header class="header">
<div class="header-inner">...</div>
</header>
.header {
position: sticky;
top: 0;
z-index: 10;
transform: translateZ(0);
will-change: transform;
}
/* 애니메이션은 inner에만 적용 */
.header-inner {
will-change: opacity, transform;
}
패턴 B: 리스트 셀은 repaint 격리
- 셀 단위로
contain: paint또는contain: layout paint고려 - 이미지 로딩 시 레이아웃 점프를 막기 위해 고정 비율 박스 사용
.cell {
contain: layout paint;
}
.thumb {
width: 100%;
aspect-ratio: 16 / 9;
background: #eee;
overflow: hidden;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
흔한 오해: translateZ(0) 는 만능이 아니다
transform: translateZ(0) 또는 will-change: transform 은 레이어 승격을 유도해 문제를 완화할 수 있지만, 남용하면 다른 문제가 생깁니다.
- 레이어가 늘어나 GPU 메모리 사용량 증가
- 텍스트 렌더링이 미묘하게 달라짐(특히 서브픽셀 관련)
- 레이어가 많아져 합성 비용이 오히려 증가
따라서 다음 원칙이 안전합니다.
- 깜빡임이 실제로 발생하는 요소에만 적용
- 큰 컨테이너 전체를 올리기보다 “작은 고정 UI” 위주로 적용
- 적용 전후를 원격 디버깅으로 확인
체크리스트: compositing으로 잔상 줄이기
- sticky/overlay 같은 고정 UI는 합성 레이어로 안정화했는가
- 스크롤 컨테이너 및 조상에 불필요한
transform이 없는가 - blur, 큰 shadow, mask 같은 비싼 페인트가 스크롤 중 계속 발생하지 않는가
- 카드/셀 단위로 repaint 전파를
contain으로 끊을 수 있는가 - 레이어 승격을 남용하지 않았는가(메모리/합성 비용 확인)
마무리
iOS Safari의 스크롤 잔상·깜빡임은 “특정 CSS 하나를 바꾸면 끝”인 문제가 아니라, 페인트와 합성의 경계가 스크롤 중 흔들릴 때 나타나는 증상인 경우가 많습니다. 따라서 해결도 compositing 관점에서 접근하는 편이 재현율이 높습니다.
먼저 문제 요소를 최소 단위로 분리한 뒤, 레이어 승격을 신중하게 적용하고, 비싼 시각 효과를 스크롤 경로에서 제거하거나 격리하세요. 같은 UI라도 “레이어를 어떻게 나누느냐”에 따라 iOS Safari에서의 체감 품질이 크게 달라집니다.