Published on

Safari iOS 스크롤 끊김 해결 - contain·layer

Authors

서론

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 스크롤 끊김은 “한 방”으로 끝나는 경우가 드뭅니다. 다만 실무에서 성공 확률이 높은 우선순위는 명확합니다.

  1. 스크롤 중 업데이트가 일어나는 영역에 contain: layout paint(또는 content)를 걸어 영향 범위를 격리한다.
  2. sticky/overlay처럼 계속 보이는 요소는 레이어 전략(translateZ(0), 제한적 will-change)으로 합성 경로를 안정화한다.

여기에 스크롤 핸들러가 있다면 passive + requestAnimationFrame으로 정리하면, iOS Safari에서만 발생하는 “특유의 덜컥임”이 대부분 완화됩니다. 성능 지표로 체감 품질을 관리하는 관점은 Chrome INP 급증 원인 - Long Task·Layout Shift 잡기 글의 접근처럼, UI 업데이트 경로를 분해해서 병목을 제거하는 방식이 가장 재현성과 유지보수성이 좋습니다.