- Published on
Safari iOS 스크롤 끊김 해결 - contain·layer
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
iOS Safari에서만 스크롤이 끊기고(버벅임), 특정 구간에서 프레임이 급격히 떨어지거나 스크롤 중 터치가 먹통처럼 느껴지는 문제를 겪는 경우가 많습니다. 같은 페이지가 Android Chrome/데스크톱에서는 멀쩡한데 iPhone에서만 문제가 재현되면, 대개 레이아웃/페인트 비용이 스크롤 프레임에 얹히거나, 합성(compositing) 레이어가 불안정하게 생성/파괴되면서 스크롤 스레드가 방해받는 패턴입니다.
이 글에서는 iOS Safari 스크롤 끊김을 줄이는 데 실효가 큰 두 축—CSS contain으로 영향 범위 격리와 레이어(합성) 전략으로 스크롤 중 업데이트를 분리—을 중심으로, 재현 포인트와 적용 순서, 그리고 “오히려 악화되는” 함정까지 정리합니다. 웹 성능 관점의 문제 분해는 Chrome INP 급증 원인 - Long Task·Layout Shift 잡기에서 다룬 방식과 유사하게 접근하면 진단이 빨라집니다.
iOS Safari에서 특히 스크롤이 끊기는 대표 원인
1) 스크롤 중 레이아웃(reflow) 유발
스크롤 자체는 브라우저가 최대한 최적화하지만, 다음이 섞이면 스크롤 프레임마다 레이아웃 계산이 다시 들어갈 수 있습니다.
- 스크롤 위치에 따라 DOM 크기/위치가 바뀌는 코드(
scroll이벤트에서offsetHeight,getBoundingClientRect()읽고 곧바로 스타일 변경) - 이미지/폰트 로딩으로 인한 레이아웃 변동(특히
height미지정 이미지) position: sticky가 많은데, 주변 컨테이너가 복잡하고 페인트 범위가 큼
2) 페인트(paint) 범위가 과도
다음 요소는 스크롤 시 페인트 비용을 늘리기 쉽습니다.
- 큰 그림자(
box-shadow)가 반복되는 카드 리스트 backdrop-filter, 블러, 반투명 오버레이mix-blend-mode, 복잡한 그라디언트filter: blur()가 걸린 큰 영역
3) 합성 레이어가 불안정하거나 과다 생성
iOS Safari는 GPU 합성 레이어 정책이 데스크톱 Chrome과 다릅니다. 특히 다음 상황에서 레이어가 자주 재구성되면 끊김으로 체감됩니다.
- 스크롤 컨테이너 내부에
position: fixed/sticky/transform조합 will-change를 남발해 레이어가 너무 많아짐(메모리/타일링 부담)overflow: scroll내부에서 복잡한 고정 헤더/푸터 구현
핵심 전략 1: contain으로 “영향 범위”를 잘라내기
contain은 요소 내부의 레이아웃/페인트/스타일 계산이 바깥으로 전파되지 않도록 격리하는 도구입니다. iOS Safari 스크롤 끊김의 상당수는 “스크롤 중 바뀌는 영역”이 페이지 전체 레이아웃/페인트에 영향을 주는 구조에서 발생하므로, 격리만 잘해도 체감이 크게 좋아집니다.
contain 종류와 실전 선택
contain: layout;레이아웃 영향 격리contain: paint;페인트 범위 격리(클리핑과 유사한 효과)contain: content;= layout + paint + (일부) size/style 포함contain: strict;가장 강력(부작용 가능)
대부분의 UI 컴포넌트(카드, 모달 내부 섹션, 탭 패널, 가상 스크롤 아이템)는 **contain: content 또는 contain: layout paint**가 실용적입니다.
예제: 카드 리스트 스크롤 끊김 완화
아래는 스크롤 중 카드 내부에서 작은 애니메이션/상태 변경이 발생할 때, 페이지 전체에 페인트가 번지는 것을 막는 패턴입니다.
.feed {
/* 페이지 전체 스크롤 */
}
.feed-item {
/* 핵심: 카드 내부 업데이트가 바깥 레이아웃/페인트에 영향 주지 않게 */
contain: layout paint;
/* 카드가 화면 밖에 있을 때 렌더링 비용 줄이기(지원 브라우저에서) */
content-visibility: auto;
contain-intrinsic-size: 200px; /* 대략값: CLS/점프 완화 */
border-radius: 14px;
background: #fff;
box-shadow: 0 8px 24px rgba(0,0,0,.08);
}
주의: contain이 깨뜨릴 수 있는 것
position: sticky가 기대대로 동작하지 않거나 기준이 바뀔 수 있음- 요소 바깥으로 나가는 드롭다운/툴팁이 잘릴 수 있음(
contain: paint는 클리핑 효과가 강함) - 자식의
position: fixed가 의도와 다르게 동작할 수 있음
따라서 **“스크롤 중 자주 업데이트되는 영역”**에만 최소 단위로 적용하세요. 전체 페이지 루트에 무작정 적용하면 오히려 디버깅이 어려워집니다.
핵심 전략 2: 레이어(합성) 전략으로 스크롤과 업데이트 분리
iOS Safari에서 스크롤이 끊길 때 자주 듣는 처방이 transform: translateZ(0) 또는 will-change: transform입니다. 이들은 요소를 별도 합성 레이어로 올려 페인트/합성 경로를 바꾸는 효과가 있지만, “무조건” 좋은 처방은 아닙니다.
레이어를 올려야 하는 전형적인 케이스
- 스크롤 중에도 계속 변하는 요소(예: 상단 헤더의 투명도/그림자 변화, 플로팅 버튼 애니메이션)
position: sticky가 스크롤 중 덜컥거리는 경우(특히 그림자/블러가 있을 때)- 스크롤 컨테이너 내부 오버레이(미니 플레이어, 하단 시트)가 함께 움직이며 repaint를 유발
예제: sticky 헤더의 끊김 줄이기(그림자/블러 포함)
.header {
position: sticky;
top: 0;
z-index: 10;
/* 헤더가 스크롤 중 시각적으로 많이 바뀐다면 레이어 분리 고려 */
will-change: transform;
transform: translateZ(0);
/* 페인트 범위를 줄여 헤더 업데이트가 아래 콘텐츠에 번지지 않게 */
contain: paint;
background: rgba(255,255,255,.85);
backdrop-filter: blur(12px);
}
will-change 남발 금지
will-change는 “곧 바뀔 것”을 브라우저에 힌트로 주는 것이지, 성능을 공짜로 주는 옵션이 아닙니다.
- 레이어가 늘면 GPU 메모리/타일링 비용이 증가
- iOS에서 메모리 압박 시 오히려 끊김/흰 화면/리로드로 이어질 수 있음
권장 패턴은 정말 애니메이션/변형이 있는 소수 요소에만 적용하고, 상시 적용이 필요 없다면 상태에 따라 토글하는 것입니다.
예제: 상호작용 순간에만 will-change 토글
const el = document.querySelector('.fab');
function enableHint() {
el.style.willChange = 'transform';
}
function disableHint() {
el.style.willChange = 'auto';
}
el.addEventListener('pointerdown', () => {
enableHint();
// 애니메이션 시작
el.classList.add('pressed');
});
el.addEventListener('transitionend', () => {
// 애니메이션이 끝나면 힌트 제거
disableHint();
});
스크롤 이벤트/JS가 원인일 때: 읽기-쓰기 분리와 passive
contain·layer를 적용해도 끊김이 남는다면, 스크롤 중 JS가 메인 스레드를 잡아먹고 있을 확률이 큽니다. 특히 iOS Safari는 스크롤과 터치 처리에서 민감하게 체감됩니다.
1) passive: true로 스크롤 블로킹 방지
window.addEventListener(
'scroll',
() => {
// 스크롤 위치 기반 UI 업데이트는 requestAnimationFrame으로 묶기
},
{ passive: true }
);
2) 레이아웃 스래싱(thrashing) 방지: 읽기→쓰기 순서 고정
let latestY = 0;
let ticking = false;
window.addEventListener('scroll', () => {
latestY = window.scrollY;
if (!ticking) {
ticking = true;
requestAnimationFrame(() => {
// (읽기) 필요한 값 계산
const y = latestY;
// (쓰기) 스타일 변경은 한 번에
const header = document.querySelector('.header');
header.style.transform = `translateZ(0) translateY(${Math.min(0, -y)}px)`;
ticking = false;
});
}
}, { passive: true });
이 접근은 “스크롤 이벤트마다 DOM 측정→스타일 변경→측정…”이 반복되는 상황을 줄여, iOS Safari에서 체감되는 덜컥임을 완화합니다.
contain + layer 조합의 권장 적용 순서(실전 체크리스트)
1) 끊기는 영역을 먼저 좁힌다
- 끊김이 특정 섹션(리스트, 갤러리, sticky 헤더)에서만 발생하는지 확인
- 그 섹션에만
contain을 최소 단위로 적용
2) 페인트가 큰 스타일을 의심한다
- 카드 그림자/블러/필터를 줄이거나, sticky/overlay에서 제거
- “스크롤 중 계속 보이는 요소”의 시각 효과가 가장 비쌉니다
3) 레이어 승격은 ‘소수 요소’에만
- sticky 헤더, 플로팅 버튼, 하단 시트처럼 스크롤 중 업데이트되는 요소 위주
will-change는 토글 가능하면 토글
4) JS가 있으면 rAF + passive로 정리
- 스크롤 핸들러에서 레이아웃 측정/스타일 변경 혼합 금지
- 필요하면 IntersectionObserver로 대체(노출 기반 로직)
이런 식으로 병목을 단계적으로 제거하는 과정은 서버/인프라 트러블슈팅에서 “원인 격리→부하원 제거→재발 방지”로 가는 흐름과 동일합니다. 비슷한 문제 해결 사고법은 리눅스 OOM Killer로 프로세스 죽음 원인 추적처럼 ‘증상’이 아니라 ‘자원 경로’를 따라가면 빠르게 정리됩니다.
iOS Safari에서 자주 마주치는 함정과 대안
overflow: hidden + 내부 스크롤의 함정
-webkit-overflow-scrolling: touch;를 사용하는 내부 스크롤은 특정 조합에서 레이어/터치 처리 문제가 생길 수 있습니다. 가능하면 페이지 자체 스크롤을 유지하고, 내부 스크롤은 최소화하세요.
position: fixed 오버레이가 스크롤을 괴롭힐 때
iOS Safari는 주소창/툴바의 확장/축소와 함께 viewport가 변하는데, fixed 요소가 많으면 재레이아웃/재합성이 잦아질 수 있습니다.
- 오버레이는
transform기반으로 띄우고, 배경 dim은contain: paint로 격리 - 필요 시
height: 100dvh같은 동적 viewport 단위를 검토(지원 범위 확인)
이미지/비디오가 많은 페이지
- 이미지에는
width/height또는aspect-ratio로 레이아웃 안정화 - 비디오는 포스터/사이즈 고정, 화면 밖에서는 렌더/재생 중지
.media {
aspect-ratio: 16 / 9;
width: 100%;
height: auto;
contain: paint;
}
마무리: 가장 효과가 큰 2가지만 꼽으면
iOS Safari 스크롤 끊김은 “한 방”으로 끝나는 경우가 드뭅니다. 다만 실무에서 성공 확률이 높은 우선순위는 명확합니다.
- 스크롤 중 업데이트가 일어나는 영역에
contain: layout paint(또는 content)를 걸어 영향 범위를 격리한다. - sticky/overlay처럼 계속 보이는 요소는 레이어 전략(
translateZ(0), 제한적will-change)으로 합성 경로를 안정화한다.
여기에 스크롤 핸들러가 있다면 passive + requestAnimationFrame으로 정리하면, iOS Safari에서만 발생하는 “특유의 덜컥임”이 대부분 완화됩니다. 성능 지표로 체감 품질을 관리하는 관점은 Chrome INP 급증 원인 - Long Task·Layout Shift 잡기 글의 접근처럼, UI 업데이트 경로를 분해해서 병목을 제거하는 방식이 가장 재현성과 유지보수성이 좋습니다.