- Published on
Safari iOS 스크롤 끊김 - 레이어·리페인트 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
iOS Safari에서만 유독 스크롤이 끊기고, 특정 섹션에서 프레임이 급격히 떨어지거나 터치 스크롤이 "걸리는" 느낌이 난다면 대부분 원인은 레이어 합성(compositing)과 리페인트(repaint) 비용이 스크롤 중에 폭발하기 때문입니다. 특히 iOS는 메모리와 GPU 타일링 제약이 강하고, position: sticky·필터·블러·대형 박스 섀도우 같은 효과가 스크롤 파이프라인에 직접 압력을 줍니다.
이 글에서는 "왜 iOS Safari에서만" 문제가 두드러지는지, 어떤 CSS와 JS가 스크롤 중 리페인트를 유발하는지, 그리고 레이어를 "늘리는" 최적화가 왜 오히려 역효과가 될 수 있는지까지 실무 관점으로 정리합니다.
관련해서 브라우저 성능 지표와 Long Task를 다룬 글도 함께 보면 좋습니다. 스크롤 잔상이나 끊김이 메인 스레드 점유와 연결되는 경우가 많습니다.
iOS Safari 스크롤 끊김의 전형적인 증상
다음 중 하나라도 해당하면 레이어·리페인트 이슈를 의심할 만합니다.
- 스크롤 중 특정 섹션(히어로, 카드 리스트, 배너)에서만 프레임 드랍
sticky헤더가 등장하는 순간 끊김- 배경에 블러(
backdrop-filter)가 있는 영역에서 스크롤이 무거움 - 스크롤 이벤트에서 뭔가를 계산하거나 DOM을 건드리는 순간 버벅임
- 이미지가 많은 페이지에서 "살짝 멈췄다가" 따라오는 느낌
핵심은 스크롤이 부드럽게 유지되려면 스크롤 중에 가능한 한 레이아웃(layout)과 페인트(paint)를 피하고, GPU 합성 단계에서 해결될 수 있는 변경만 일어나야 한다는 점입니다.
렌더링 파이프라인 관점: 무엇이 스크롤을 무겁게 만드는가
브라우저 렌더링을 단순화하면 다음 흐름입니다.
- 스타일 계산
- 레이아웃
- 페인트
- 합성
스크롤 중 이상적인 상태는 합성만으로 화면이 갱신되는 것입니다. 예를 들어 transform으로 이동하거나 opacity를 바꾸는 것은 보통 합성에서 처리되기 때문에 스크롤 중에도 비교적 안전합니다.
반대로 아래는 스크롤 중 비용이 큽니다.
- 레이아웃을 다시 해야 하는 변경:
width,height,top,left,font-size등 - 페인트가 커지는 변경: 큰 영역의
box-shadow,filter,backdrop-filter,border-radius가 큰 마스크,background-attachment: fixed - "클리핑"과 "마스크"가 많은 UI: 큰
overflow: hidden컨테이너 다수, 둥근 카드가 화면 전체를 덮는 구성
iOS Safari는 데스크톱보다 GPU 메모리 여유가 적고, 합성 레이어가 많아지면 타일 캐시가 자주 비워져서 오히려 페인트가 늘어나는 패턴이 흔합니다.
1) position: sticky와 헤더/탭바 최적화
sticky는 편리하지만 iOS에서 조합에 따라 리페인트를 크게 유발할 수 있습니다. 특히 sticky 요소 자체가 반투명 배경, 블러, 그림자, 하위에 복잡한 DOM을 갖고 있으면 스크롤 중 계속 다시 그려야 합니다.
권장 패턴
sticky요소는 가능한 단순한 DOM으로 유지- 그림자나 블러는 스크롤 중 계속 변하지 않도록 구조 분리
- 헤더 내부 텍스트/아이콘 애니메이션은
transform·opacity중심으로
예시: 그림자를 별도 요소로 분리하고, 헤더 본체는 페인트가 가벼운 속성만 사용
.header {
position: sticky;
top: 0;
z-index: 100;
background: rgba(255, 255, 255, 0.98);
/* blur는 iOS에서 특히 비쌈: 가능하면 제거하거나 범위를 최소화 */
}
.header-shadow {
position: absolute;
left: 0;
right: 0;
bottom: -12px;
height: 12px;
pointer-events: none;
/* 큰 box-shadow 대신 그라데이션 이미지나 linear-gradient로 대체 권장 */
background: linear-gradient(to bottom, rgba(0,0,0,0.12), rgba(0,0,0,0));
}
sticky 대신 transform 고정이 유리한 경우
스크롤 위치에 따라 헤더를 숨기거나 축소해야 한다면, top을 바꾸는 방식보다 transform: translateY(...)가 일반적으로 안전합니다.
const header = document.querySelector('.header');
let lastY = 0;
let ticking = false;
function onScroll() {
lastY = window.scrollY;
if (!ticking) {
window.requestAnimationFrame(() => {
const hide = lastY > 80;
header.style.transform = hide ? 'translateY(-100%)' : 'translateY(0)';
ticking = false;
});
ticking = true;
}
}
window.addEventListener('scroll', onScroll, { passive: true });
여기서 중요한 점은 scroll 핸들러에서 DOM 측정과 DOM 변경을 섞지 않는 것입니다. 측정이 필요하다면 requestAnimationFrame 내부에서 한 번에 처리하고, 레이아웃을 유발하는 속성 변경은 피합니다.
2) backdrop-filter·filter·큰 box-shadow가 만드는 페인트 폭탄
iOS Safari에서 스크롤 끊김의 "단골"은 다음 조합입니다.
- 상단
sticky바에backdrop-filter: blur(...) - 카드가 많고 각 카드에 큰
box-shadow - 이미지 위에 반투명 오버레이 + 블러
이들은 스크롤 중 매 프레임 혹은 자주 다시 페인트될 수 있습니다.
대체 전략
- 블러는 가능한 제거하거나, 고정된 배경 이미지로 "가짜 블러" 처리
- 큰 그림자는
drop-shadow나 작은 그림자로 축소, 또는 9-slice 이미지/그라데이션으로 대체 - 스크롤 중에는 효과를 끄고, 스크롤이 멈췄을 때만 켜는 방식도 실무에서 효과적
스크롤 중 효과를 끄는 예시
let scrollTimer;
document.documentElement.classList.remove('is-scrolling');
window.addEventListener('scroll', () => {
document.documentElement.classList.add('is-scrolling');
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
document.documentElement.classList.remove('is-scrolling');
}, 120);
}, { passive: true });
/* 스크롤 중에는 비싼 효과를 비활성화 */
.card {
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
html.is-scrolling .card {
box-shadow: none;
}
/* backdrop-filter도 동일하게 토글 가능 */
.header {
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
html.is-scrolling .header {
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
이 방식은 "정지 시 고급 효과"와 "스크롤 시 부드러움"을 트레이드오프로 해결합니다.
3) will-change는 만능이 아니다: 레이어 과다 생성의 역효과
will-change: transform이나 translateZ(0)는 요소를 합성 레이어로 올려 스크롤 중 페인트를 줄이는 데 도움이 될 수 있습니다. 하지만 iOS에서는 레이어가 많아지면 GPU 메모리 압박으로 타일이 자주 폐기되고, 결과적으로 더 많은 페인트가 발생할 수 있습니다.
실무 가이드
- 상시 애니메이션되는 소수 요소에만 제한적으로 적용
- 스크롤 중 잠깐만 필요하면 "필요할 때만" 붙였다가 제거
- 리스트 아이템 전체에 일괄 적용은 피하기
function promote(el) {
el.style.willChange = 'transform';
}
function demote(el) {
el.style.willChange = 'auto';
}
const hero = document.querySelector('.hero');
// 예: 인터랙션 시작 시에만 레이어 승격
hero.addEventListener('pointerenter', () => promote(hero));
hero.addEventListener('pointerleave', () => demote(hero));
4) 스크롤 이벤트와 메인 스레드: passive와 requestAnimationFrame
iOS Safari에서 스크롤이 끊기는 또 다른 큰 원인은 스크롤 중 메인 스레드가 바쁜 경우입니다. 대표적으로 다음이 문제를 만듭니다.
scroll이벤트에서 연산이 많음- 스크롤 중 DOM을 읽고(
getBoundingClientRect) 곧바로 쓰는 패턴 - 동기적 상태 업데이트가 잦음(리액트 setState 연타 등)
최소 원칙
addEventListener('scroll', ..., { passive: true })- 스크롤 중에는 상태 업데이트를 최소화하고, 화면 반영은
requestAnimationFrame으로 배치 - IntersectionObserver로 대체 가능한 것은 대체
IntersectionObserver로 스크롤 계산 제거
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add('in-view');
io.unobserve(e.target);
}
}
}, { root: null, threshold: 0.1 });
document.querySelectorAll('.fadein').forEach((el) => io.observe(el));
.fadein {
opacity: 0;
transform: translateY(12px);
transition: opacity 240ms ease, transform 240ms ease;
}
.fadein.in-view {
opacity: 1;
transform: translateY(0);
}
이 조합은 스크롤 핸들러 자체를 없애고, 합성 친화적인 속성으로만 애니메이션합니다.
5) overflow: scroll·중첩 스크롤 컨테이너가 만드는 함정
iOS는 중첩 스크롤(예: 모달 내부 스크롤, 가로 캐러셀 + 세로 스크롤)이 많은 화면에서 스크롤 제스처 처리 비용이 커질 수 있습니다.
- 가능한 한 스크롤 컨테이너 수를 줄이기
- 모달 내부 스크롤이 필요하면
-webkit-overflow-scrolling: touch를 검토하되, 이 또한 조합에 따라 레이어/리페인트 이슈가 생길 수 있어 기기 실측이 중요
.modal-body {
overflow: auto;
-webkit-overflow-scrolling: touch;
}
또한 overscroll-behavior는 iOS Safari에서 제한적으로 동작하거나 기대와 다를 수 있으니, 제스처 충돌이 있다면 CSS만으로 해결하려고 고집하기보다 UI 구조를 단순화하는 쪽이 성공 확률이 높습니다.
6) 큰 이미지·배경 고정·비디오: "타일" 관점에서 최적화
iOS Safari는 큰 비트맵을 스크롤 중 계속 합성하거나 페인트해야 하면 급격히 느려집니다.
- 히어로 섹션의 큰 배경 이미지는 해상도를 과도하게 올리지 않기
background-attachment: fixed는 모바일에서 특히 비용이 큼. 가능하면 사용하지 않기- 비디오는 오버레이/마스크 조합을 단순화
반응형 이미지 예시
<img
src="/img/hero-960.jpg"
srcset="/img/hero-640.jpg 640w, /img/hero-960.jpg 960w, /img/hero-1280.jpg 1280w"
sizes="(max-width: 420px) 640px, (max-width: 768px) 960px, 1280px"
width="1280"
height="720"
loading="lazy"
decoding="async"
alt="Hero"
/>
width·height를 명시해 레이아웃 시프트를 줄이는 것도 스크롤 체감에 직접적으로 도움이 됩니다.
7) 디버깅 방법: 무엇을 먼저 의심하고 어떻게 좁힐까
iOS Safari 스크롤 끊김은 "원인 하나"가 아니라 여러 비용이 합쳐져 나타나는 경우가 많습니다. 다음 순서로 좁히면 빠릅니다.
1단계: 비싼 시각 효과 제거 A/B
backdrop-filter제거- 큰
box-shadow제거 filter제거sticky제거 또는 단순화
이 단계에서 개선 폭이 크면 페인트/합성 이슈입니다.
2단계: 스크롤 중 JS 실행량 제거
- 스크롤 핸들러를 임시로 제거
- 상태 업데이트를 끄고 CSS만 남기기
- IntersectionObserver로 대체
이 단계에서 개선 폭이 크면 메인 스레드 점유 문제입니다. 이 경우 Long Task 관점으로도 함께 보는 게 좋습니다.
3단계: 레이어 최적화는 "선별적으로"
- 꼭 필요한 요소에만
will-change - 애니메이션 요소는
transform·opacity중심 - 레이어가 늘수록 좋아진다는 가정은 버리기
8) 실전 체크리스트: iOS Safari 스크롤 부드럽게 만들기
- 스크롤 중 변경은
transform·opacity위주로 구성 sticky요소는 DOM을 얇게, 효과는 분리backdrop-filter는 최후의 수단, 가능하면 스크롤 중 비활성화- 큰
box-shadow를 그라데이션/이미지로 대체 scroll이벤트는passive: true+requestAnimationFrame배치- IntersectionObserver로 스크롤 계산 제거
- 중첩 스크롤 컨테이너 최소화
- 이미지 해상도·용량 최적화,
width·height명시 will-change남발 금지, 필요할 때만 적용
마무리: "스크롤 중 페인트를 없애는" 방향으로 설계하기
iOS Safari 스크롤 끊김은 대개 "스크롤 중에 다시 그려야 할 것"이 많아서 생깁니다. 따라서 해결의 핵심은 화려한 효과를 무조건 포기하는 것이 아니라, 스크롤 중에는 합성 친화적인 속성만 움직이게 만들고, 비싼 페인트 작업은 정지 상태나 제한된 영역에서만 수행하도록 설계를 바꾸는 것입니다.
페이지에서 가장 끊기는 구간 하나를 골라 backdrop-filter와 큰 그림자부터 제거해 보세요. 개선이 확인되면 그 다음에 sticky 구조 단순화, 스크롤 핸들러 정리, IntersectionObserver 전환 순으로 진행하면 iOS에서도 체감이 크게 좋아집니다.