- Published on
Safari iOS 17 스크롤 끊김, CSS contain으로 해결하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 네트워크가 아무리 빨라도, 모바일 웹에서 스크롤이 끊기면 사용자는 즉시 “느리다”고 느낍니다. 특히 iOS 17 Safari에서는 특정 UI 조합(고정 헤더 + 카드 리스트 + 그림자/블러 + 동적 높이)이 겹칠 때 스크롤이 미세하게 멈추거나, 손가락을 떼고 관성 스크롤이 이어지는 구간에서 프레임 드랍이 발생하는 사례가 많습니다.
이 글에서는 **Safari iOS 17 스크롤 끊김(scroll jank)**을 **CSS contain**으로 완화/해결하는 방법을 중심으로, 왜 효과가 있는지(레이아웃/페인트/합성 관점), 어떤 경우에 부작용이 생기는지(스티키/오버플로/사이즈), 그리고 실제 적용 체크리스트까지 정리합니다.
관련해서 iOS Safari에서 레이어/페인트 최적화 전반을 먼저 보고 싶다면 아래 글도 함께 참고하면 좋습니다.
iOS 17 Safari에서 유독 끊기는 대표 패턴
다음 조합이 있으면 iOS Safari에서 “왜인지 이 화면만” 끊기는 경우가 많습니다.
- 긴 리스트(수십~수백 개) + 각 아이템이 복잡한 DOM
- 각 아이템에 box-shadow, filter: blur(), backdrop-filter, mix-blend-mode
- 스크롤 중에 높이가 바뀌는 요소(이미지 lazy load로 aspect ratio 미확정, 폰트 로딩으로 reflow)
- 부모 컨테이너에서 position: sticky 또는 fixed 헤더
- 스크롤 이벤트에서 동기 레이아웃 강제(예:
getBoundingClientRect()를 매 프레임 호출)
이런 상황에서 Safari는 스크롤 중에도 레이아웃/페인트 영향이 넓게 전파되면, 매 프레임 처리량이 늘어나면서 프레임이 떨어질 수 있습니다.
핵심 개념: contain은 “렌더링 영향 범위”를 자르는 도구
CSS Containment는 요소를 하나의 “격리된 박스”처럼 취급하게 하여, 그 내부 변화가 바깥 레이아웃/페인트/스타일 계산에 미치는 영향을 제한합니다.
주요 값은 다음과 같습니다.
contain: layout;: 내부 레이아웃 변화가 외부에 영향을 덜 주도록 격리contain: paint;: 페인트 영역을 요소 경계로 제한(클리핑처럼 동작)contain: style;: 스타일 계산 범위를 제한(상속/카운터 등 일부 제약)contain: size;: 요소의 크기가 내부 콘텐츠에 의해 결정되지 않도록(주의 필요)contain: content;:layout + paint(브라우저별 세부는 다를 수 있으나 일반적으로 이 조합)contain: strict;:size + layout + paint + style(가장 강력, 부작용 가능성 큼)
iOS Safari의 스크롤 끊김 완화에 가장 흔히 도움이 되는 건 contain: content 또는 contain: layout paint 입니다. 즉, 리스트 아이템 단위 혹은 섹션 단위로 페인트/레이아웃 전파를 끊어주는 방식입니다.
문제 재현 예시: 카드 리스트 + 그림자 + 동적 콘텐츠
아래는 흔한 UI입니다. 카드마다 그림자와 배경, 내부에 이미지/텍스트가 있고, 리스트가 길어질수록 스크롤이 끊깁니다.
<ul class="feed">
<li class="card">
<img class="thumb" src="/img/1.jpg" alt="" />
<div class="meta">
<h3>Title</h3>
<p>Long description ...</p>
</div>
</li>
<!-- ... many cards ... -->
</ul>
.feed {
margin: 0;
padding: 16px;
list-style: none;
}
.card {
background: #fff;
border-radius: 16px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,.12);
}
.thumb {
width: 100%;
height: auto;
display: block;
border-radius: 12px;
}
이 상태에서 iOS Safari는 스크롤 중 페인트 비용이 누적되거나, 카드 내부 변화(이미지 디코딩/폰트 로딩/라인 브레이크)가 외부로 전파되면 프레임이 흔들릴 수 있습니다.
해결 1: 리스트 아이템에 contain 적용(가장 실전적)
카드 단위로 렌더링 영향을 격리합니다.
.card {
background: #fff;
border-radius: 16px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,.12);
/* 핵심 */
contain: content; /* 또는 contain: layout paint; */
}
왜 효과가 있나?
layoutcontainment로 인해 카드 내부 레이아웃 변화가 상위 컨테이너 전체 레이아웃 재계산으로 번지는 것을 줄입니다.paintcontainment로 인해 카드 내부 페인트가 카드 경계로 제한되어, 스크롤 중 “큰 영역 리페인트”를 줄이는 데 유리합니다.
특히 iOS Safari에서 “리스트가 길고, 각 아이템이 무겁고, 스크롤이 미세하게 끊기는” 케이스에 이 방식이 잘 맞습니다.
해결 2: 섹션 단위로 contain 적용(아이템이 너무 많을 때)
아이템 하나하나에 contain을 주면 오히려 스타일/페인트 경계가 너무 많아져 비용이 생길 수 있습니다. 이럴 때는 “몇 개 카드 묶음” 또는 “섹션”에 적용해 타협합니다.
<section class="feed-chunk">
<!-- 카드 10~30개 묶음 -->
</section>
.feed-chunk {
contain: layout paint;
}
실무에서는 A/B로 확인하는 편이 안전합니다.
- 카드가 매우 복잡하면: 아이템 단위 contain
- 카드가 단순하고 개수가 많으면: 섹션 단위 contain
contain 적용 시 주의할 부작용(중요)
contain은 성능 최적화 도구인 동시에 레이아웃/페인트의 “규칙”을 바꾸는 선언입니다. 다음 이슈를 체크해야 합니다.
1) paint containment는 “튀어나오는 UI”를 잘라낼 수 있음
contain: paint는 페인트를 요소 경계로 제한합니다. 따라서 카드 내부에서 다음이 있으면 잘릴 수 있습니다.
- 드롭다운/툴팁/팝오버가 카드 밖으로 나오는 UI
- box-shadow가 경계 밖으로 크게 퍼지는 디자인(일부 브라우저에서 시각적으로 차이가 날 수 있음)
해결책:
- 팝오버는
position: fixed+ 포털(예:body하위로 이동)로 렌더링 - 또는 카드에는
contain: layout;만 적용하고 paint는 제외
.card {
contain: layout; /* paint 제외 */
}
2) size containment는 웬만하면 피하기
contain: size 또는 contain: strict는 요소 크기가 내부 콘텐츠에 의해 결정되지 않도록 할 수 있어 레이아웃이 깨질 가능성이 큽니다. 스크롤 끊김 해결 목적이라면 보통 필요 없습니다.
3) sticky/fixed와의 조합은 테스트 필수
position: sticky가 들어간 조상/자식 관계에서 contain을 적용하면 기대한 sticky 동작이 달라질 수 있습니다(브라우저 구현/버전에 따라 차이). sticky가 있는 영역에는 다음 우선순위를 권장합니다.
- sticky 자체가 있는 요소에는 contain을 피하거나
- sticky의 “형제”나 “하위 카드”에만 contain을 적용
함께 쓰면 좋은 추가 최적화 3가지
contain만으로도 체감이 좋아지지만, iOS Safari에서는 아래와 조합될 때 더 안정적입니다.
1) 이미지 레이아웃 시프트 제거
이미지 로딩 전후로 높이가 바뀌면 스크롤 중 reflow가 발생할 수 있습니다. width/height 또는 aspect-ratio를 지정해 레이아웃을 고정합니다.
.thumb {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
display: block;
border-radius: 12px;
}
2) 스크롤 중 레이아웃 강제 측정 피하기
스크롤 이벤트/리사이즈에서 매번 getBoundingClientRect()를 호출하면 레이아웃 스래싱이 생길 수 있습니다. 가능하면 requestAnimationFrame으로 묶고, 읽기/쓰기를 분리합니다.
let scheduled = false;
window.addEventListener('scroll', () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
// read
const y = window.scrollY;
// write (class toggle 등)
document.documentElement.classList.toggle('scrolled', y > 10);
scheduled = false;
});
}, { passive: true });
3) 과한 blur/backdrop-filter는 구간 제한
backdrop-filter: blur()는 iOS에서 특히 비싼 편입니다. 전체 화면에 깔기보다 필요한 영역에만 적용하고, 스크롤 컨텐츠 위에 겹치지 않게 레이어 구조를 조정합니다.
디버깅 체크리스트: contain이 “맞는 처방”인지 확인
- 문제가 “특정 화면/특정 리스트”에서만 발생하는가?
- 끊김이 네트워크/데이터 로딩과 무관하게, 이미 렌더링된 상태에서도 재현되는가?
- 카드/섹션 단위로 DOM이 무겁고, 그림자/필터/복잡한 배경이 많은가?
- 이미지/폰트 로딩으로 레이아웃이 흔들리는가?
- 툴팁/팝오버/드롭다운이 카드 밖으로 나오는 UI가 있는가? (있다면
paint는 주의)
이 중 1~3이 해당하면 contain: layout paint 또는 contain: content가 높은 확률로 효과를 냅니다.
적용 전략(권장 순서)
- **가장 무거운 반복 요소(리스트 아이템)**에
contain: content를 우선 적용 - UI가 잘리는 문제가 생기면
contain: layout만 남기고paint를 제거 - 아이템 수가 매우 많아 경계가 과해지면, 섹션(청크) 단위로 contain을 옮겨서 균형 맞추기
- 이미지
aspect-ratio로 레이아웃 시프트 제거 - 스크롤 이벤트 핸들러에서 레이아웃 강제 측정 제거
결론
iOS 17 Safari의 스크롤 끊김은 “한두 가지 속성” 때문이라기보다, 레이아웃/페인트가 넓게 전파되는 구조에서 자주 터집니다. contain은 이 전파를 컴포넌트 경계에서 끊는 데 특화된 도구라서, 긴 리스트/카드 UI에서 특히 효과적입니다.
실무에서는 contain: content를 카드에 적용해 빠르게 이득을 보고, 잘림/스티키 같은 부작용이 있는지 확인한 뒤 layout/paint를 조정하는 방식이 가장 안전합니다. 더 넓은 최적화(레이어 분리, 페인트 비용 절감)가 필요하다면 위 내부 링크의 레이어/페인트 최적화 글도 함께 적용해 보세요.