- Published on
Safari iOS 스크롤 끊김 - Layer·Reflow 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
iOS Safari에서 “특정 구간만 스크롤이 뚝뚝 끊긴다”는 이슈는 대개 자바스크립트가 느려서라기보다, 레이아웃(Reflow)·스타일 계산·페인트·합성(Compositing) 경로 중 하나가 스크롤과 경합하면서 발생합니다. 특히 iOS Safari는 메모리/타일링/합성 정책이 데스크톱과 다르고, position: fixed, backdrop-filter, 큰 box-shadow, 과도한 sticky 조합에서 병목이 더 쉽게 드러납니다.
이 글은 “무조건 will-change” 같은 처방이 아니라, Layer를 어디서 만들고(합성), 어디서 Reflow를 줄이고(레이아웃), 어디서 페인트 면적을 줄이는지를 기준으로 접근합니다.
참고: 비슷하게 “원인 가설을 세우고, 관측 지표를 만들고, 재현 가능한 최소 케이스로 좁히는” 디버깅 사고방식은 인프라/백엔드에서도 동일합니다. 예를 들어 Bash set -e가 무시될 때 - pipefail·trap 글의 방식처럼, 조건을 통제하면서 원인을 분리하는 게 핵심입니다.
iOS Safari 스크롤 파이프라인: 어디서 끊기는가
스크롤 “끊김”은 보통 아래 중 하나입니다.
- 메인 스레드가 바쁨: 스크롤 이벤트 처리, 동기 레이아웃 측정, 큰 DOM 업데이트 등으로 프레임 드랍
- 레이아웃(Reflow) 폭발: 스크롤 중에 레이아웃이 자주 재계산되거나, 레이아웃 스래싱(thrashing)이 발생
- 페인트(Paint) 면적 과대: 큰 영역을 매 프레임 다시 그리거나, 블러/그림자 같은 비싼 페인트가 반복
- 합성(Compositing) 비효율: 레이어가 너무 많거나, 불필요하게 레이어가 생성/파괴되며 타일 업로드가 증가
iOS Safari에서 특히 자주 밟는 지뢰는 다음과 같습니다.
- 큰
backdrop-filter: blur(...)또는 반투명 오버레이 + 스크롤 position: sticky가 많은 리스트(특히 헤더가 여러 개)- 스크롤 중
height,top,margin같은 레이아웃 속성 애니메이션 - 스크롤 이벤트에서
getBoundingClientRect()측정 후 곧바로 DOM 변경(강제 동기 레이아웃) - 큰 이미지/비디오가 많고,
object-fit/리사이즈가 잦거나, 디코딩/다운스케일 비용이 큰 경우
진단: “측정 없는 최적화”를 피하는 체크리스트
1) 재현 가능한 최소 케이스 만들기
문제 페이지에서 아래를 순서대로 꺼보며 범위를 줄입니다.
- 스크롤 연동 애니메이션/헤더 효과 제거
backdrop-filter,filter,box-shadow제거position: sticky제거- 리스트 아이템 수를 줄이기(가상 스크롤 여부 확인)
2) Safari Web Inspector로 확인할 포인트
macOS Safari에서 iPhone을 원격 디버깅할 때(Develop 메뉴) 다음을 봅니다.
- Timeline/Performance에서 Layout, Rendering 비중
- 스크롤 중 Recalculate Style 또는 Layout이 반복되는지
- 레이어(Compositing) 변화가 잦은지
도구가 불편하거나 제한적이면, 코드 레벨에서 “의심 지점”을 빠르게 확인하는 것도 좋습니다.
- 스크롤 핸들러 호출 빈도 및 처리 시간(간단한 타이밍 로그)
- 레이아웃 측정 호출 여부(
getBoundingClientRect,offsetHeight,scrollTop등)
Reflow 최적화: 레이아웃 스래싱을 끊어내기
흔한 안티패턴: 스크롤마다 측정하고 즉시 쓰기
아래 코드는 스크롤 중 측정(read) 과 쓰기(write) 가 섞여 강제 레이아웃을 유발할 수 있습니다.
const header = document.querySelector('.header');
window.addEventListener('scroll', () => {
const y = window.scrollY;
const h = header.getBoundingClientRect().height; // read (layout)
// write (style)
header.style.height = `${Math.max(48, 120 - y)}px`;
header.style.opacity = y > h ? '0.9' : '1';
});
문제는 height 같은 레이아웃 속성을 스크롤마다 바꾸는 것 자체도 비싸고, getBoundingClientRect()가 레이아웃을 강제할 수 있다는 점입니다.
개선 1: 레이아웃 속성 대신 transform으로
레이아웃을 바꾸지 말고, 합성에서 처리 가능한 transform/opacity 위주로 바꿉니다.
.header {
position: sticky;
top: 0;
transform: translate3d(0, 0, 0);
will-change: transform, opacity;
}
const header = document.querySelector('.header');
let ticking = false;
window.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const y = window.scrollY;
const clamped = Math.min(72, y);
header.style.transform = `translate3d(0, ${-clamped}px, 0)`;
header.style.opacity = y > 80 ? '0.92' : '1';
ticking = false;
});
}, { passive: true });
핵심은 두 가지입니다.
- 스크롤 이벤트는
requestAnimationFrame으로 프레임에 맞춰 1회만 처리 - 레이아웃을 바꾸는 속성 대신
transform으로 시각 효과 구현
개선 2: read/write 분리(배치)하기
측정이 필요하다면 한 프레임에서 read를 먼저 모아서 하고, 그 다음 write를 한 번에 합니다.
const items = [...document.querySelectorAll('.card')];
let rafId = 0;
function onScroll() {
if (rafId) return;
rafId = requestAnimationFrame(() => {
// 1) read phase
const rects = items.map(el => el.getBoundingClientRect());
// 2) write phase
rects.forEach((r, i) => {
const visible = r.bottom > 0 && r.top < window.innerHeight;
items[i].classList.toggle('is-visible', visible);
});
rafId = 0;
});
}
window.addEventListener('scroll', onScroll, { passive: true });
개선 3: IntersectionObserver로 스크롤 연산 자체를 줄이기
스크롤 핸들러에서 매번 계산하지 말고, 브라우저에게 맡기는 방식이 더 안정적입니다.
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
e.target.classList.toggle('is-visible', e.isIntersecting);
}
}, {
root: null,
threshold: 0.1
});
document.querySelectorAll('.card').forEach(el => io.observe(el));
iOS Safari에서도 IntersectionObserver는 꽤 유용하며, 특히 긴 리스트에서 효과가 큽니다.
Layer(합성) 최적화: “필요한 곳만 레이어”가 정답
will-change를 남발하면 왜 더 느려지나
will-change나 translate3d로 레이어를 강제하면 합성이 쉬워질 수 있지만, 레이어가 늘면:
- GPU 메모리 사용량 증가
- 타일 업로드/관리 비용 증가
- 오히려 스크롤 중 합성 단계가 병목
따라서 will-change는 애니메이션 중인 소수의 요소에만, 그리고 필요한 기간에만 적용하는 것이 좋습니다.
패턴: 상호작용 직전에만 will-change 켜기
const panel = document.querySelector('.panel');
function enable() {
panel.style.willChange = 'transform, opacity';
}
function disable() {
panel.style.willChange = 'auto';
}
panel.addEventListener('pointerenter', enable);
panel.addEventListener('pointerleave', disable);
모바일에서는 pointerenter가 애매할 수 있으니, 실제로는 “열기 버튼 클릭 시 enable, 애니메이션 종료 후 disable” 같은 형태가 더 현실적입니다.
iOS Safari에서 조심할 CSS 조합
backdrop-filter는 스크롤 컨텍스트에서 매우 비쌀 수 있습니다- 큰
box-shadow(특히 blur가 큰 값) + 스크롤은 페인트 비용을 크게 만듭니다 position: fixed가 많은 UI는 합성/페인트 경로가 복잡해질 수 있습니다
대체안 예시:
- 블러가 꼭 필요하면, 블러 영역을 작게 제한하거나 스크롤 중에는 블러를 끄고 정지 시 켜기
- 그림자는 blur를 줄이고, 가능한
filter: drop-shadow(...)도 비교 테스트(상황에 따라 다름)
/* 스크롤 중에는 단순 배경, 멈추면 블러 */
.header {
background: rgba(20, 20, 20, 0.85);
}
.header.is-idle {
backdrop-filter: blur(12px);
}
const header = document.querySelector('.header');
let t;
window.addEventListener('scroll', () => {
header.classList.remove('is-idle');
clearTimeout(t);
t = setTimeout(() => header.classList.add('is-idle'), 120);
}, { passive: true });
이 방식은 “스크롤 중 프레임 유지”를 우선하고, 정지 상태에서만 고급 효과를 적용합니다.
Paint 최적화: 다시 그리는 면적을 줄여라
contain으로 페인트/레이아웃 격리
contain은 컴포넌트 내부 변화가 바깥으로 전파되는 비용을 줄이는 데 유용합니다.
.card {
contain: layout paint;
}
주의: contain은 레이아웃 동작에 영향을 줄 수 있으니, 적용 후 UI 깨짐이 없는지 확인해야 합니다.
content-visibility로 오프스크린 렌더링 스킵
긴 페이지에서 아래는 체감 효과가 큰 편입니다.
.section {
content-visibility: auto;
contain-intrinsic-size: 800px;
}
iOS Safari 버전에 따라 지원이 다를 수 있으니, 지원 범위가 불확실하면 점진적 적용(깨져도 문제 없는 섹션부터)로 접근하세요.
스크롤 이벤트/터치 처리: passive와 메인 스레드 점유 줄이기
passive 이벤트로 스크롤 블로킹 제거
touchmove나 wheel(iOS는 주로 touch)에서 preventDefault()를 쓰지 않는다면, 반드시 { passive: true }를 고려하세요.
window.addEventListener('touchmove', () => {
// 스크롤을 막지 않는 로직만
}, { passive: true });
스크롤 연동 애니메이션은 CSS로 넘기기
가능하면 JS로 “스크롤 위치를 읽어서 애니메이션” 하지 말고, 단순한 효과는 CSS로 대체합니다.
- 헤더 그림자 토글: 특정 지점 넘으면 클래스 토글(IntersectionObserver)
- 패럴랙스: 꼭 필요할 때만, 그리고 transform만 변경
긴 리스트에서의 정답: 가상 스크롤 또는 렌더링 축소
iOS Safari에서 카드 300개, 이미지 300개는 그 자체로 스크롤 비용이 됩니다. 이 경우 Layer/Reflow 최적화만으로는 한계가 있고, DOM 수를 줄이는 전략이 필요합니다.
- 가상 스크롤(react-window 등)
- 페이지네이션/무한스크롤 시, 오래된 DOM 제거
- 이미지 지연 로딩 및 적절한 크기 제공
<img
src="/img/thumb-480.jpg"
srcset="/img/thumb-480.jpg 480w, /img/thumb-960.jpg 960w"
sizes="(max-width: 480px) 480px, 960px"
loading="lazy"
decoding="async"
alt="..."
/>
이미지의 “표시 크기”와 “전송/디코딩 크기”가 크게 다르면 스크롤 중 디코딩/리사이즈 비용이 튀는 경우가 많습니다.
실무 적용 순서(우선순위)
- 스크롤 핸들러 제거 또는
requestAnimationFrame+{ passive: true } - 스크롤 중 레이아웃 변경(
height,top,margin)을transform으로 치환 - 측정(read)과 변경(write) 분리로 레이아웃 스래싱 제거
backdrop-filter, 큰box-shadow등 비싼 페인트 요소 축소(스크롤 중 비활성화 포함)- 레이어 최적화:
will-change는 “소수 요소, 짧은 시간”만 - DOM 수가 많다면 가상 스크롤/섹션 렌더링 스킵(
content-visibility) 검토
자주 묻는 함정 Q&A
Q1. translate3d(0,0,0)를 걸면 무조건 빨라지나요?
아닙니다. 레이어가 늘어 GPU 메모리와 합성 비용이 증가할 수 있습니다. “애니메이션 중인 요소”에만 제한적으로 적용하세요.
Q2. position: sticky가 원인일 수 있나요?
가능합니다. sticky가 많거나, sticky 요소 내부가 복잡(그림자/블러/큰 이미지)하면 스크롤 중 계산/페인트가 증가합니다. sticky 개수를 줄이거나, sticky 내부 효과를 단순화하세요.
Q3. iOS에서만 끊기면 어떻게 접근해야 하나요?
대부분 “메모리/합성/페인트” 차이에서 옵니다. 데스크톱에서 괜찮아도 iOS에서만 비싼 CSS(블러/그림자/고정 요소)부터 의심하고, 스크롤 중 효과를 단계적으로 꺼서 원인을 분리하세요.
마무리
iOS Safari 스크롤 끊김은 한 가지 비법으로 해결되지 않습니다. 대신 프레임을 깨는 원인을 Reflow(레이아웃), Paint(페인트), Layer(합성) 중 어디로 분류할 수 있는지만 잡으면, 해결책은 비교적 명확해집니다.
- Reflow는 “측정과 쓰기 분리 + 레이아웃 속성 변경 금지”
- Paint는 “비싼 효과를 줄이고, 다시 그리는 면적을 최소화”
- Layer는 “필요한 곳만 레이어, will-change 남발 금지”
이 순서로 적용하면, iOS Safari에서도 스크롤이 ‘버벅임’에서 ‘일관된 프레임’으로 바뀌는 경우가 많습니다.