Published on

iOS Safari 스크롤 끊김 - 레이어·리페인트 최적화

Authors

iOS Safari에서 스크롤이 끊기는(jank) 증상은 단순히 requestAnimationFrame 최적화만으로 해결되지 않는 경우가 많습니다. 특히 화면에 고정된 헤더, 블러/투명도 효과, 큰 그림자, 복잡한 그라데이션, position: sticky가 섞이면 레이아웃·페인트·합성 단계가 스크롤과 동시에 과부하를 일으키기 쉽습니다.

이 글은 “왜 iOS Safari에서 유독 스크롤이 버벅이는가”를 레이어와 리페인트 관점에서 풀고, 실제로 적용 가능한 CSS/JS 패턴을 중심으로 정리합니다.

관련해서 원인 스펙트럼을 넓게 훑고 싶다면 Safari iOS 스크롤 잔상·jank 7가지 원인도 함께 보시면 진단 속도가 빨라집니다. 또한 레이아웃 변화로 인한 사용자 체감 악화(예: CLS)를 추적하는 관점은 Chrome Rendering 탭으로 CLS 급증 원인 추적법도 참고할 만합니다.

iOS Safari 스크롤 파이프라인을 레이어 관점으로 보기

브라우저 렌더링은 대략 다음 흐름으로 진행됩니다.

  • 스타일 계산
  • 레이아웃(geometry)
  • 페인트(paint)
  • 합성(composite, 레이어를 GPU에서 합성)

스크롤 중 프레임이 끊기는 대표적인 패턴은 둘 중 하나입니다.

  1. 스크롤할 때마다 페인트 영역이 넓게 발생한다

  2. 레이어가 과도하게 분할되거나, 반대로 필요한 요소가 레이어로 분리되지 않아 합성 비용이 커진다

iOS Safari에서는 특히 다음이 민감하게 작동하는 편입니다.

  • backdrop-filter 및 다양한 필터 계열
  • box-shadow/filter: drop-shadow()
  • 반투명 오버레이(알파 블렌딩)
  • position: fixed/sticky가 많은 화면
  • 스크롤 컨테이너 중첩(내부 스크롤 + 바깥 스크롤)

핵심은 “스크롤 시 변하는 영역을 최소화하고(리페인트 격리), 합성 단계에서 빠르게 처리되도록(레이어/합성 최적화) 설계”하는 것입니다.

1) 리페인트 범위를 격리하는 contain과 레이아웃 경계

contain으로 페인트 전파를 끊기

스크롤 중 특정 컴포넌트가 자주 갱신되면(예: 애니메이션, 카운터, 진행바) 그 변화가 주변으로 페인트를 전파시키지 않도록 격리하는 것이 중요합니다.

/* 자주 갱신되는 위젯을 레이아웃/페인트 경계로 격리 */
.widget {
  contain: layout paint;
}
  • contain: paint는 페인트가 바깥으로 새지 않도록 경계를 만듭니다.
  • contain: layout은 레이아웃 영향 범위를 줄입니다.

주의: 격리하면 내부 요소의 position: fixed 같은 동작이 기대와 달라질 수 있으니, 작은 단위부터 적용하며 확인하세요.

content-visibility는 “스크롤 성능”보다 “초기 렌더”에 유리

content-visibility: auto는 화면 밖 콘텐츠의 렌더 비용을 줄이는 데 도움이 되지만, iOS Safari 지원/효과는 케이스에 따라 편차가 있습니다. 긴 리스트에서 초기 렌더를 줄이는 데는 고려할 수 있으나, 스크롤 중 합성/페인트 병목이 주원인이라면 우선순위는 contain/레이어 설계가 더 높습니다.

2) 레이어 승격을 “필요한 곳에만” 적용하기

무분별한 translateZ(0)는 역효과가 날 수 있음

과거에는 transform: translateZ(0)로 레이어 승격을 유도해 스크롤을 부드럽게 하는 팁이 널리 쓰였습니다. 하지만 레이어가 너무 많아지면 합성 단계에서 오히려 비용이 증가합니다.

권장 접근:

  • 스크롤 중 계속 보이는 고정 UI(헤더, 하단 탭바) 중에서 페인트가 비싼 요소만 제한적으로 승격
  • 애니메이션이 있는 요소에만 승격
  • 리스트 아이템 전체를 승격하는 방식은 피하기

예시: 고정 헤더 중 “블러/투명도”가 있는 경우

.header {
  position: sticky;
  top: 0;
  z-index: 10;

  /* 필요할 때만 레이어 힌트 */
  will-change: transform;
}

/* 스크롤 중 효과가 바뀌는 내부 요소만 따로 */
.header__bg {
  will-change: opacity;
}

will-change는 “미리 최적화 준비”를 하게 만들지만, 남발하면 메모리/합성 비용이 늘어납니다. 따라서 스크롤이 끝나면 제거하는 방식이 안전합니다.

// 스크롤 중에만 will-change를 켜고, 멈추면 끈다
const header = document.querySelector('.header');
let t = 0;

function enableHint() {
  header.style.willChange = 'transform';
  clearTimeout(t);
  t = window.setTimeout(() => {
    header.style.willChange = 'auto';
  }, 200);
}

window.addEventListener('scroll', enableHint, { passive: true });

3) backdrop-filter/블러는 “스크롤 최악의 친구”일 수 있음

iOS Safari에서 backdrop-filter: blur(...)는 시각적으로는 훌륭하지만, 스크롤 중에는 매우 비싼 경우가 많습니다. 이유는 배경을 샘플링하고 블러 처리한 결과를 매 프레임 갱신해야 할 수 있기 때문입니다.

대안 패턴:

  • 블러 대신 반투명 단색 + 약한 그라데이션
  • 스크롤 중에는 블러를 끄고, 스크롤 종료 후 다시 켜기
.header__glass {
  background: rgba(255, 255, 255, 0.85);
  /* 기본은 블러를 끈 상태로 시작 */
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}

.header__glass.is-idle {
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
}
const glass = document.querySelector('.header__glass');
let idleTimer = 0;

function onScroll() {
  glass.classList.remove('is-idle');
  clearTimeout(idleTimer);
  idleTimer = window.setTimeout(() => {
    glass.classList.add('is-idle');
  }, 150);
}

window.addEventListener('scroll', onScroll, { passive: true });

이 방식은 “스크롤 중 프레임 유지”를 우선하고, 스크롤이 멈췄을 때만 고급 효과를 복원하는 전략입니다.

4) 큰 그림자와 반투명 오버레이가 만드는 페인트 폭발

box-shadow는 면적이 커질수록 비용이 급증

특히 카드 UI에서 큰 블러 반경의 그림자를 다수 사용하면 페인트가 무거워집니다.

개선 팁:

  • 그림자 블러 반경/스프레드를 줄이기
  • 그림자 대상 요소 수를 줄이기
  • 가능하면 그림자를 이미지/그라데이션으로 대체
/* 나쁜 예: 큰 블러/큰 확산 */
.card--heavy {
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
}

/* 개선 예: 블러/확산을 줄이고 대비를 약하게 */
.card {
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
}

전체 화면 오버레이는 “합성”은 쉬워도 “블렌딩”이 비쌀 수 있음

모달 오픈 시 전체 화면 반투명 오버레이를 깔면, 스크롤 중 배경과 알파 블렌딩이 지속되어 비용이 커질 수 있습니다. 모달이 열려 있을 때는 배경 스크롤을 막는 것이 일반적으로 성능에도 유리합니다.

body.modal-open {
  overflow: hidden;
}

.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.4);
}

iOS Safari에서 overflow: hidden만으로 스크롤이 완전히 고정되지 않는 케이스가 있어, 배경을 position: fixed로 잠그는 패턴을 함께 쓰기도 합니다.

let scrollY = 0;

function lockScroll() {
  scrollY = window.scrollY;
  document.body.style.position = 'fixed';
  document.body.style.top = `-${scrollY}px`;
  document.body.style.left = '0';
  document.body.style.right = '0';
}

function unlockScroll() {
  document.body.style.position = '';
  document.body.style.top = '';
  window.scrollTo(0, scrollY);
}

5) 스크롤 이벤트, 터치 이벤트는 반드시 passive

스크롤 끊김의 아주 흔한 원인 중 하나는 touchmove/wheel/scroll 핸들러가 메인 스레드를 막는 것입니다. iOS Safari에서 특히 touchmovepreventDefault()가 얽히면 스크롤 성능이 급락할 수 있습니다.

window.addEventListener('scroll', () => {
  // DOM 측정(getBoundingClientRect)이나 강제 동기 레이아웃을 피한다
}, { passive: true });

또한 스크롤 중 DOM 측정과 DOM 변경을 섞으면 레이아웃 스래싱이 발생합니다. 측정은 한 번에 모으고, 변경은 다음 프레임에 처리하세요.

let scheduled = false;

function onScroll() {
  if (scheduled) return;
  scheduled = true;

  requestAnimationFrame(() => {
    scheduled = false;

    // 측정
    const y = window.scrollY;

    // 변경(클래스 토글 등)
    document.documentElement.classList.toggle('scrolled', y > 10);
  });
}

window.addEventListener('scroll', onScroll, { passive: true });

6) position: sticky와 스크롤 컨테이너 중첩을 단순화

iOS Safari에서 sticky는 케이스에 따라 페인트/합성 비용이 커질 수 있습니다. 특히 다음 조합은 위험 신호입니다.

  • sticky 요소가 많은 리스트
  • 부모에 overflow: auto/scroll이 걸린 내부 스크롤
  • sticky 내부에 블러/필터/큰 그림자

가능하면:

  • 페이지 전체는 하나의 스크롤(바디 스크롤)로 유지
  • 내부 스크롤은 꼭 필요할 때만 사용
  • sticky는 “핵심 1~2개”로 제한

내부 스크롤이 꼭 필요하다면 -webkit-overflow-scrolling: touch를 고려할 수 있지만, 이 옵션은 동작/버그가 얽힐 수 있어 실제 기기에서 충분히 확인해야 합니다.

.panel {
  overflow: auto;
  -webkit-overflow-scrolling: touch;
}

7) 이미지/비디오: 디코딩과 리사이즈가 스크롤을 망친다

스크롤 중 갑자기 프레임이 깨지는 경우, 원인이 “렌더링”이 아니라 “미디어 디코딩/리사이즈”일 수 있습니다.

체크리스트:

  • 이미지에 width/height를 명시해 레이아웃 변동을 막기
  • 지나치게 큰 원본 이미지를 CSS로 축소하지 않기
  • 리스트 썸네일은 적절한 해상도로 제공
<img
  src="/thumb-320.jpg"
  width="160"
  height="160"
  loading="lazy"
  alt=""
/>

loading="lazy"는 iOS Safari에서 동작은 하지만, 모든 케이스에서 완벽히 동일하게 동작하지 않을 수 있습니다. 중요한 것은 “적정 해상도”와 “레이아웃 공간 예약”입니다.

실전 디버깅 루틴: 어디서 리페인트가 터지는지 찾기

  1. 스크롤 중 끊김이 느껴지는 화면에서 “비싼 효과”를 하나씩 끄기
  • 블러/필터 제거
  • 그림자 제거
  • 투명 오버레이 제거
  • stickystatic으로 바꾸기
  1. 끊김이 사라지는 지점을 찾고, 그 효과를 “스크롤 중에만 제한”하는 전략 적용
  • 스크롤 중 backdrop-filter 끄기
  • 스크롤 중 애니메이션 중단
  • 스크롤 중 will-change를 필요한 요소에만 잠깐 적용
  1. 리스트/피드 화면이면 “아이템 단위 격리”
.feed-item {
  contain: layout paint;
}
  1. 스크롤 핸들러 점검
  • passive: true 적용
  • 스크롤 중 DOM 측정/변경 최소화
  • requestAnimationFrame로 배치

마무리: 레이어·리페인트 최적화의 우선순위

iOS Safari 스크롤 끊김은 대체로 “한 방”이 아니라, 다음의 누적 비용이 임계점을 넘을 때 발생합니다.

  • 비싼 시각 효과(블러/필터/큰 그림자)
  • 스크롤 중 잦은 리페인트
  • 레이어 설계 실패(과도한 승격 또는 미승격)
  • 스크롤 이벤트로 인한 메인 스레드 점유

가장 효과가 큰 순서로 정리하면 다음이 실무적으로 안전합니다.

  1. 스크롤 중 비싼 효과를 끄거나 단순화
  2. contain으로 리페인트 전파를 차단
  3. will-change는 “스크롤 중에만, 필요한 요소만”
  4. 이벤트는 passive + requestAnimationFrame 배치
  5. 스크롤 컨테이너/sticky 구조 단순화

이 루틴대로 적용하면, 동일한 UI라도 iOS Safari에서 체감 프레임이 크게 개선되는 경우가 많습니다.