Published on

Safari iOS 17 스크롤 끊김, CSS contain으로 해결하기

Authors

서버나 네트워크가 아무리 빨라도, 모바일 웹에서 스크롤이 끊기면 사용자는 즉시 “느리다”고 느낍니다. 특히 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; */
}

왜 효과가 있나?

  • layout containment로 인해 카드 내부 레이아웃 변화가 상위 컨테이너 전체 레이아웃 재계산으로 번지는 것을 줄입니다.
  • paint containment로 인해 카드 내부 페인트가 카드 경계로 제한되어, 스크롤 중 “큰 영역 리페인트”를 줄이는 데 유리합니다.

특히 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이 “맞는 처방”인지 확인

  1. 문제가 “특정 화면/특정 리스트”에서만 발생하는가?
  2. 끊김이 네트워크/데이터 로딩과 무관하게, 이미 렌더링된 상태에서도 재현되는가?
  3. 카드/섹션 단위로 DOM이 무겁고, 그림자/필터/복잡한 배경이 많은가?
  4. 이미지/폰트 로딩으로 레이아웃이 흔들리는가?
  5. 툴팁/팝오버/드롭다운이 카드 밖으로 나오는 UI가 있는가? (있다면 paint는 주의)

이 중 1~3이 해당하면 contain: layout paint 또는 contain: content가 높은 확률로 효과를 냅니다.

적용 전략(권장 순서)

  1. **가장 무거운 반복 요소(리스트 아이템)**에 contain: content를 우선 적용
  2. UI가 잘리는 문제가 생기면 contain: layout만 남기고 paint를 제거
  3. 아이템 수가 매우 많아 경계가 과해지면, 섹션(청크) 단위로 contain을 옮겨서 균형 맞추기
  4. 이미지 aspect-ratio로 레이아웃 시프트 제거
  5. 스크롤 이벤트 핸들러에서 레이아웃 강제 측정 제거

결론

iOS 17 Safari의 스크롤 끊김은 “한두 가지 속성” 때문이라기보다, 레이아웃/페인트가 넓게 전파되는 구조에서 자주 터집니다. contain은 이 전파를 컴포넌트 경계에서 끊는 데 특화된 도구라서, 긴 리스트/카드 UI에서 특히 효과적입니다.

실무에서는 contain: content를 카드에 적용해 빠르게 이득을 보고, 잘림/스티키 같은 부작용이 있는지 확인한 뒤 layout/paint를 조정하는 방식이 가장 안전합니다. 더 넓은 최적화(레이어 분리, 페인트 비용 절감)가 필요하다면 위 내부 링크의 레이어/페인트 최적화 글도 함께 적용해 보세요.