- Published on
Safari 스크롤 잔상? Compositor 레이어 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/백엔드 이슈가 아닌데도 “Safari에서만 스크롤 잔상(ghosting)처럼 보인다”는 제보는 종종 치명적입니다. 특히 고정 헤더, 반투명 오버레이, backdrop-filter, position: sticky, transform이 얽힌 UI에서 재현되기 쉽습니다. 문제는 대개 레이어 합성(compositing) 과정에서 특정 요소가 별도 레이어로 승격되거나, 반대로 승격되지 않아 스크롤 중 재페인트/리샘플링이 꼬이는 현상으로 나타납니다.
이 글에서는 Safari(WebKit)에서 스크롤 잔상/깜빡임을 compositor 레이어 관점으로 진단하고, 실제로 적용 가능한 해결책을 우선순위로 정리합니다. (유사하게 “원인이 여러 갈래로 갈라지는” 디버깅 접근은 리눅스 OOM Kill 원인 추적 - dmesg·cgroup·journalctl 같은 글에서 다루는 방식과 비슷합니다.)
1) Safari 스크롤 잔상은 어떤 증상인가
Safari에서 흔히 보고되는 증상은 다음과 같습니다.
- 스크롤 중 특정 영역이 이전 프레임의 이미지가 잠깐 남아 보임(잔상)
- 고정 헤더/푸터가 스크롤 콘텐츠와 서로 비치거나 찢어짐(tearing)
backdrop-filter: blur()또는 반투명 배경이 스크롤 시 깜빡이거나 지연됨position: sticky요소가 스크롤 중 순간 이동/깜빡임
이런 현상은 “Safari가 느리다”라기보다, 페인트(Paint) → 래스터(Raster) → 합성(Composite) 파이프라인에서 특정 요소가 레이어로 분리되는 방식이 기대와 달라 발생하는 경우가 많습니다.
2) 기본 전제: WebKit compositor와 레이어 승격
브라우저는 모든 요소를 매 프레임마다 다시 그리지 않습니다. 스크롤 같이 빈번한 작업에서는 가능한 한 기존 비트맵을 재사용하고, 필요한 레이어만 움직여 합성합니다.
- 메인 스레드: 레이아웃/스타일 계산, 페인트 명령 생성
- 래스터/타일링: 페인트 결과를 비트맵 타일로 생성
- Compositor: 레이어를 GPU에서 합성해 화면에 출력
문제는 어떤 요소가 독립 compositor 레이어가 되느냐(또는 되지 않느냐)입니다.
transform,opacity,filter,will-change등은 레이어 승격을 유도position: fixed/sticky도 상황에 따라 별도 레이어가 될 수 있음backdrop-filter는 특히 iOS/macOS Safari에서 비용이 크고 경로가 복잡
레이어 승격이 “무조건 좋다”는 뜻은 아닙니다. 레이어가 많아지면 메모리/타일 관리가 불안정해지고, 특정 조합에서 스크롤 중 타일 업데이트 타이밍이 어긋나 잔상처럼 보일 수 있습니다.
3) 재현을 먼저 고정하기: 최소 재현(MRE) 만들기
Safari 잔상은 환경 의존성이 큽니다. 따라서 “어떤 CSS 조합에서 시작되는지”를 빠르게 좁혀야 합니다.
- 문제 페이지에서 의심 요소를 1개씩 제거 (특히
backdrop-filter,filter,transform) - 스크롤 컨테이너를 단순화 (
overflow: autovs body 스크롤) - 고정 요소(
fixed/sticky)를 제거해 비교 - 애니메이션/transition 제거
아래는 잔상이 자주 발생하는 형태의 축약 예시입니다.
<header class="topbar">
<div class="blur">Header</div>
</header>
<main class="content">
<!-- 긴 콘텐츠 -->
</main>
.topbar {
position: sticky;
top: 0;
z-index: 10;
}
.blur {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.content {
height: 200vh;
}
이 조합(sticky + backdrop-filter)은 Safari에서 레이어/타일 업데이트가 흔들리기 쉬운 대표 케이스입니다.
4) Safari에서 레이어/페인트를 보는 도구들
4.1 macOS Safari: Web Inspector의 핵심 패널
macOS Safari(데스크톱)에서는 다음을 우선 확인합니다.
- Timelines: Rendering/Layouts/Paints 관련 이벤트가 스크롤 중 과도한지
- Layers(또는 Graphics 관련 패널): 어떤 요소가 compositor 레이어인지
- Console: 스크롤 이벤트에서 강제 동기 레이아웃을 유발하는 코드가 있는지
Safari 버전에 따라 패널 이름/구성이 다르지만, 핵심은 “스크롤 중 Paint가 계속 터지는지”와 “레이어가 어떻게 쪼개지는지”입니다.
4.2 iOS Safari: 원격 디버깅(Remote Web Inspector)
iOS에서만 재현되는 경우가 많습니다. 이때는 macOS Safari에서 iPhone을 연결해 원격 디버깅을 켭니다.
- iOS 설정: Safari → 고급 → 웹 인스펙터 ON
- macOS Safari: Develop 메뉴에서 디바이스 선택
iOS는 메모리/타일 제한이 더 빡빡해 잔상이 더 잘 드러납니다.
4.3 “레이어가 늘었는지/줄었는지”를 빠르게 추적하는 방법
정식 패널이 불편하면, 다음처럼 의심 요소에 임시로 시각적 힌트를 주는 것도 도움이 됩니다.
/* 디버그용: 레이어 승격 유도 */
.debug-promote {
will-change: transform;
transform: translateZ(0);
}
/* 디버그용: 페인트 영역 확인(간접) */
.debug-outline * {
outline: 1px solid rgba(255, 0, 0, 0.08);
}
translateZ(0)는 레이어 승격을 유도하지만, 과용하면 오히려 악화될 수 있습니다.- outline은 직접 “페인트 사각형”을 보여주진 않지만, 잔상이 나타나는 요소 범위를 좁히는 데 유용합니다.
5) 원인 패턴 6가지와 해결 우선순위
5.1 backdrop-filter/반투명 오버레이의 합성 비용
backdrop-filter는 배경을 샘플링해 블러 처리한 뒤 다시 합성해야 합니다. 스크롤 중 배경이 계속 변하므로, Safari에서 타일 업데이트가 지연되면 잔상처럼 보입니다.
대응
- 가능하면
backdrop-filter사용 범위를 줄이거나, 스크롤 중에는 비활성화 - 블러를 “실시간”이 아닌 “정적 이미지/그라데이션”으로 대체
- 오버레이 영역을 작게 유지
/* 스크롤 중(또는 저사양)에는 blur를 끈다 */
.blur {
background: rgba(255,255,255,0.85);
}
@supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
.blur.enable-blur {
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
}
}
// 스크롤 중 blur 끄기(디바운스)
const el = document.querySelector('.blur');
let t;
window.addEventListener('scroll', () => {
el.classList.remove('enable-blur');
clearTimeout(t);
t = setTimeout(() => el.classList.add('enable-blur'), 120);
}, { passive: true });
5.2 position: sticky + transform 조합
sticky 조상에 transform이 걸리면 containing block/스태킹 컨텍스트가 바뀌어 레이어링이 복잡해집니다. Safari에서 sticky가 별도 레이어로 분리되지 못하거나, 반대로 과도하게 분리되며 깜빡임이 생길 수 있습니다.
대응
- sticky 조상(부모/조부모)에
transform,filter,perspective를 피하기 - sticky 요소 자체에 불필요한
transform제거
/* 안 좋은 예: sticky의 조상에 transform */
.container {
transform: translateZ(0);
}
/* 개선: transform은 sticky 바깥으로 이동 */
.page {
transform: translateZ(0);
}
5.3 스크롤 컨테이너(overflow: auto) 내부의 고정 요소
iOS Safari는 특히 overflow: auto 내부에서 position: fixed/sticky가 기대와 다르게 동작하거나, 합성 경로가 불안정할 수 있습니다.
대응
- 가능하면 body 스크롤을 사용하고 내부 스크롤을 최소화
- 내부 스크롤이 필수라면, 고정 요소를 컨테이너 밖으로 빼고 레이아웃을 재구성
5.4 -webkit-overflow-scrolling: touch의 부작용
과거 iOS에서 관성 스크롤을 위해 쓰던 -webkit-overflow-scrolling: touch는 특정 조합에서 페인트/합성 타이밍 이슈를 만들 수 있습니다.
대응
- 해당 속성이 정말 필요한지 재검토
- 필요한 경우 적용 범위를 최소화하고, 내부의 blur/filter/box-shadow를 줄임
.scroll {
overflow: auto;
/* 필요할 때만 */
-webkit-overflow-scrolling: touch;
}
5.5 큰 box-shadow, filter: drop-shadow()
큰 그림자/필터는 래스터 비용이 커서 타일이 자주 새로 생성됩니다. 스크롤 중 레이어 경계에서 잔상이 나타나기도 합니다.
대응
- 그림자 반경 축소, 불필요한 필터 제거
- 가능하면 pseudo-element로 단순한 그림자 대체
5.6 JS가 스크롤 중 레이아웃 스래싱을 유발
scroll 이벤트에서 getBoundingClientRect() 같은 측정과 스타일 변경을 섞으면 강제 동기 레이아웃이 발생해 렌더링 파이프라인이 흔들립니다. Chrome에서는 티가 덜 나도 Safari에서 잔상/끊김으로 보일 수 있습니다.
대응
- 측정은 rAF에서 모으고, 쓰기는 배치
passive: true로 스크롤 핸들러의 블로킹을 방지
let scheduled = false;
let lastY = 0;
window.addEventListener('scroll', () => {
lastY = window.scrollY;
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
// 읽기(측정)
const y = lastY;
// 쓰기(스타일 변경)
document.documentElement.style.setProperty('--scroll-y', `${y}px`);
scheduled = false;
});
}, { passive: true });
6) “레이어 승격”은 약이 될 수도, 독이 될 수도
흔히 떠올리는 해결책이 transform: translateZ(0) 혹은 will-change입니다. 실제로 Safari에서 잔상이 사라지는 경우도 있습니다. 하지만 다음 기준을 지키는 게 안전합니다.
- 원인 요소 1~2개에만 제한적으로 적용
- 스크롤 영역 전체(특히 긴 리스트)에 무차별 적용 금지
- 적용 전/후로 레이어 수와 메모리 사용량을 확인
/* 제한적으로: 헤더 텍스트/아이콘 같은 작은 요소에만 */
.topbar .title {
will-change: transform;
}
7) 실전 체크리스트(10분 진단 플로우)
- 문제 요소를 격리: blur/filter/shadow/sticky/fixed 중 무엇이 트리거인지 제거하며 확인
- 스크롤 중 Paint 폭증 여부 확인: Timelines에서 스크롤만 해도 paint가 계속 발생하면 원인 후보가 좁혀짐
- sticky 조상에 transform/filter 있는지 점검
- 내부 스크롤 컨테이너 존재 여부 확인 (
overflow: auto) - backdrop-filter 범위 축소/스크롤 중 비활성화
- 레이어 승격을 최소 단위로 실험 (
will-change를 1개 요소에만) - JS 스크롤 핸들러 정리: rAF 배치, passive 적용
이런 식의 “원인 후보를 빠르게 쪼개고 검증”하는 접근은 프론트엔드에서도 똑같이 통합니다. (백엔드 장애에서 원인 분기를 정리하는 방식은 Kubernetes 401 Unauthorized 원인별 해결 가이드 같은 글의 구성과 유사합니다.)
8) 권장 해결 조합 예시: sticky 헤더 + blur를 안정화
아래는 Safari에서 상대적으로 안정적인 패턴입니다.
- sticky는 유지하되, 조상에 transform을 걸지 않기
- blur는 스크롤 중 끄고 정지 시 켜기
- 헤더 내부 요소만 제한적으로 승격
<header class="topbar">
<div class="topbar-bg enable-blur"></div>
<div class="topbar-inner">
<h1 class="title">Title</h1>
</div>
</header>
.topbar {
position: sticky;
top: 0;
z-index: 100;
}
.topbar-bg {
position: absolute;
inset: 0;
background: rgba(255,255,255,0.85);
}
@supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
.topbar-bg.enable-blur {
-webkit-backdrop-filter: blur(14px);
backdrop-filter: blur(14px);
background: rgba(255,255,255,0.6);
}
}
.topbar-inner {
position: relative;
padding: 12px 16px;
}
.title {
will-change: transform; /* 작은 요소만 */
}
const bg = document.querySelector('.topbar-bg');
let timer;
function disableBlurTemporarily() {
bg.classList.remove('enable-blur');
clearTimeout(timer);
timer = setTimeout(() => bg.classList.add('enable-blur'), 150);
}
window.addEventListener('scroll', disableBlurTemporarily, { passive: true });
이 방식은 “스크롤 중에는 compositor가 덜 복잡한 경로로 렌더링”하게 만들고, 정지 후에만 blur를 적용해 시각 품질을 회복합니다.
9) 마무리: Safari 잔상은 ‘레이어 설계’ 문제로 접근하자
Safari 스크롤 잔상은 단순 버그처럼 보이지만, 실제로는 레이어 승격/합성 경로/타일 업데이트가 특정 CSS 조합에서 불안정해지는 경우가 많습니다. 해결의 핵심은:
- 문제를 유발하는 CSS/구조를 최소 재현으로 좁히고
- 스크롤 중 paint/합성 비용을 낮추며
- 레이어 승격은 “필요한 최소 단위”로만 적용하는 것
프론트엔드에서도 이런 식의 원인 추적은 결국 관찰(도구) → 가설(레이어/페인트) → 실험(격리/토글) → 검증의 반복입니다. 캐시/재검증처럼 ‘보이지 않는 상태’가 문제를 만든다는 점에서는 Next.js App Router 캐시 꼬임·재검증 버그 해결에서 다루는 디버깅 감각과도 닮아 있습니다.
다음 단계로는 “내 페이지에서 실제로 어떤 요소가 레이어로 분리되는지”를 캡처(스크린샷/영상)하고, 레이어 수 변화와 함께 CSS 토글 실험 로그를 남겨두면 재발 방지에 큰 도움이 됩니다.