Published on

Safari iOS scroll jank 해결 - 레이어 최적화

Authors

모바일 웹에서 가장 체감이 큰 성능 문제 중 하나가 iOS Safari의 스크롤 끊김(scroll jank)입니다. 프레임이 떨어지면 사용자는 곧바로 “사이트가 무겁다”라고 판단하고 이탈합니다. 문제는 iOS Safari가 데스크톱 브라우저와 렌더링 파이프라인의 제약이 다르고, 특히 compositing(합성)과 레이어(layer) 관리가 조금만 삐끗해도 스크롤 중에 페인트가 튀거나 메인 스레드가 막히면서 jank가 쉽게 발생한다는 점입니다.

이 글은 “무조건 transform: translateZ(0)” 같은 단편 처방이 아니라, iOS Safari에서 스크롤이 왜 끊기는지 원인을 분해하고, 레이어/합성 최적화로 재현 가능한 해결 루틴을 제시합니다. 스크롤 잔상·깜빡임(ghosting/flicker)까지 동반되는 케이스는 별도 글에서 더 깊게 다뤘으니 함께 참고하세요.

iOS Safari 스크롤 jank의 전형적인 증상

다음 중 하나라도 해당하면, “레이어 합성/페인트 경로가 스크롤과 충돌”하고 있을 가능성이 큽니다.

  • 스크롤 중 특정 섹션에서만 프레임이 급격히 떨어진다
  • fixed 헤더/바텀바가 있는 페이지에서 유독 버벅인다
  • 카드 리스트(이미지, 그림자, 블러)가 많은 화면에서 스크롤이 끊긴다
  • 스크롤 중 텍스트가 흐려졌다가 다시 선명해진다(리래스터라이즈)
  • 스크롤 도중 터치 반응이 늦고, 클릭이 씹힌다

이때 “JS가 무거워서”만이 원인이 아닙니다. CSS 효과 하나(예: filter: blur()), 레이어 하나(예: 과도한 will-change)가 스크롤 동안 페인트 폭탄을 만들 수 있습니다.

원인 모델: 메인 스레드, 페인트, 그리고 합성

브라우저 렌더링을 스크롤 관점에서 단순화하면 다음 3가지가 핵심입니다.

  1. 메인 스레드에서 스타일 계산, 레이아웃, 페인트 작업이 발생한다
  2. 페인트 결과는 비트맵으로 래스터라이즈되고(타일 단위), GPU/합성 스레드에서 레이어를 합성한다
  3. 스크롤은 가능한 한 “합성만으로” 처리되어야 부드럽다

즉, 스크롤 중에 메인 스레드에서 레이아웃/페인트가 자주 발생하면 jank가 생깁니다. iOS Safari는 특히 메모리/타일 관리가 보수적이라, 큰 레이어나 복잡한 효과가 있으면 스크롤 중 타일 재생성이 늘고 프레임이 꺾이기 쉽습니다.

가장 흔한 트리거 7가지

1) 과도한 레이어 승격: will-change 남발

will-change: transform은 “곧 변할 것”을 브라우저에 알려 레이어 승격을 유도합니다. 하지만 무분별하게 쓰면 레이어 수가 폭증하고, 각 레이어가 메모리를 잡아먹어 오히려 타일 스로틀링이 증가합니다. iOS Safari에서 이 패턴이 특히 위험합니다.

나쁜 예:

.card {
  will-change: transform;
}

개선: 실제로 애니메이션/인터랙션이 발생하는 순간에만 부여하고, 끝나면 제거합니다.

const el = document.querySelector('.card');

function enablePromote() {
  el.style.willChange = 'transform';
}

function disablePromote() {
  el.style.willChange = 'auto';
}

el.addEventListener('pointerenter', () => {
  enablePromote();
});

el.addEventListener('pointerleave', () => {
  disablePromote();
});

포인트는 “상시 레이어”가 아니라 “필요할 때만 레이어”입니다.

2) position: fixed + 복잡한 배경/필터

iOS Safari에서 fixed 요소는 스크롤 중 합성 전략이 까다롭습니다. fixed 헤더에 backdrop-filter, 반투명 그라데이션, 큰 그림자 등이 붙으면 스크롤 중에 계속 재합성/재래스터라이즈가 발생할 수 있습니다.

나쁜 예:

.header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  backdrop-filter: blur(16px);
  box-shadow: 0 12px 40px rgba(0,0,0,0.25);
}

개선 전략:

  • iOS에서 backdrop-filter 강도를 낮추거나 비활성화
  • 그림자를 단순화하거나 이미지(프리렌더)로 대체
  • fixed 영역을 가능한 한 작게 유지
.header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  background: rgba(255,255,255,0.92);
  box-shadow: 0 6px 18px rgba(0,0,0,0.12);
}

@supports (-webkit-touch-callout: none) {
  .header {
    /* iOS Safari 타겟팅: 필요 시 효과 축소 */
    backdrop-filter: none;
  }
}

3) 큰 이미지/비디오가 스크롤 중 리사이즈되는 레이아웃

이미지가 레이아웃에 영향을 주면 스크롤 중 레이아웃 계산이 빈번해질 수 있습니다. 특히 “이미지 높이가 늦게 확정되는” 구조는 스크롤 타이밍에 레이아웃 스래싱을 유발합니다.

개선: width/height를 명시하거나 aspect-ratio로 자리 먼저 잡기.

<img src="/hero.jpg" width="1200" height="800" loading="lazy" alt="hero" />
.thumb {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

4) 스크롤 컨테이너 중첩: overflow: auto 남발

중첩 스크롤은 iOS Safari에서 이벤트/합성 경로가 복잡해지고, 스크롤 체인이 깨지면서 프레임이 흔들리는 경우가 많습니다. 특히 모달 내부 스크롤, 바텀시트, 캐러셀 등에서 자주 발생합니다.

개선 체크:

  • 가능하면 페이지 스크롤 하나로 단순화
  • 스크롤 컨테이너는 최소화
  • 필요 시 -webkit-overflow-scrolling: touch를 신중히 적용(부작용도 존재)
.modalBody {
  overflow: auto;
  -webkit-overflow-scrolling: touch;
}

-webkit-overflow-scrolling: touch는 관성 스크롤을 주지만, 특정 조합에서 깜빡임/레이어 이슈를 유발할 수 있으니 실제 기기에서 확인이 필수입니다.

5) 스크롤 중 동작하는 JS: scroll 이벤트에서 DOM 측정/수정

스크롤 이벤트에서 getBoundingClientRect()로 측정하고 곧바로 스타일을 바꾸면, 레이아웃 강제 동기화가 발생할 수 있습니다.

나쁜 예:

window.addEventListener('scroll', () => {
  const rect = document.querySelector('.hero').getBoundingClientRect();
  document.querySelector('.header').style.opacity = rect.top < 0 ? '1' : '0.6';
});

개선: requestAnimationFrame으로 배치하고, 측정과 변경을 분리합니다.

const hero = document.querySelector('.hero');
const header = document.querySelector('.header');

let ticking = false;

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

  requestAnimationFrame(() => {
    const top = hero.getBoundingClientRect().top;
    header.style.opacity = top < 0 ? '1' : '0.6';
    ticking = false;
  });
}

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

여기서 { passive: true }는 스크롤을 막지 않겠다는 힌트로, 터치 스크롤 지연을 줄이는 데 도움이 됩니다.

6) 비싼 CSS 효과: filter, backdrop-filter, 큰 box-shadow

iOS Safari에서 다음은 “스크롤 중 비용이 급증하기 쉬운” 대표 효과입니다.

  • filter: blur(), drop-shadow()
  • backdrop-filter
  • 반경이 큰 box-shadow
  • 큰 영역의 mask-image, clip-path (구현에 따라)

해결은 단순히 “쓰지 마라”가 아니라, “스크롤 중에 재래스터라이즈가 일어나지 않게” 만드는 것입니다.

대안:

  • 블러 배경은 정적 이미지로 프리렌더
  • 그림자는 강도를 낮추고 범위를 줄이기
  • 효과가 필요한 컴포넌트만 제한적으로 적용

7) 레이어 크기 폭발: 풀스크린 요소에 transform 적용

transform은 애니메이션에 좋지만, 큰 요소(예: 전체 페이지 래퍼, 풀스크린 배경)에 적용하면 레이어가 커지고 타일 메모리가 늘어납니다. iOS에서 레이어가 커질수록 스크롤 중 타일 교체 비용이 커져 jank가 발생할 수 있습니다.

개선:

  • transform은 작은 컴포넌트 단위로
  • 페이지 전체에 적용하는 패럴랙스/스케일 효과를 지양

실전 해결 루틴: “레이어를 줄이고, 페인트를 막고, 스크롤을 건드리지 말기”

1) 먼저 의심할 컴포넌트를 격리한다

스크롤이 끊기는 구간을 찾았으면, 그 구간에 있는 요소를 하나씩 제거(또는 CSS 효과를 끄기)하면서 원인을 좁히는 게 가장 빠릅니다.

  • fixed 헤더를 잠시 position: static으로 바꿔보기
  • backdrop-filter, filter, box-shadow를 하나씩 제거
  • 애니메이션을 끄고 스크롤만 테스트

이 과정에서 “특정 효과 하나가 범인”인 경우가 매우 많습니다.

2) 레이어 승격은 최소한으로, 짧게

  • 기본값: 아무 것도 승격하지 않는다
  • 필요 시: 인터랙션 직전에 will-change 부여
  • 끝나면: 반드시 원복

특히 리스트 아이템 수십 개에 will-change를 주는 패턴은 iOS에서 역효과가 날 확률이 높습니다.

3) 애니메이션 속성은 transform/opacity로 제한

레이아웃을 흔드는 속성(top, left, height) 애니메이션은 스크롤과 경쟁합니다.

.sheet {
  transform: translateY(100%);
  transition: transform 220ms ease;
}

.sheet.isOpen {
  transform: translateY(0);
}

4) contain으로 페인트/레이아웃 영향 범위를 좁힌다

contain은 컴포넌트가 바깥 레이아웃/페인트에 영향을 덜 주게 만들어 성능에 도움이 될 수 있습니다.

.card {
  contain: layout paint;
}

단, contain은 레이아웃 동작을 바꿀 수 있으니(특히 size 포함 시) 적용 범위를 조심하고 UI 깨짐을 확인해야 합니다.

5) 스크롤 이벤트는 “읽기만”, 변경은 rAF에서

  • scroll 핸들러는 최소 작업
  • 측정과 변경을 한 프레임에 몰아넣지 않기
  • 가능하면 IntersectionObserver로 대체
const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    if (e.isIntersecting) {
      e.target.classList.add('in');
    }
  }
}, { rootMargin: '200px 0px' });

document.querySelectorAll('.lazySection').forEach(el => io.observe(el));

iOS Safari에서 특히 자주 쓰는 “안전한” 패턴 모음

터치 스크롤 지연 줄이기: passive 이벤트

document.addEventListener('touchmove', () => {
  // do nothing
}, { passive: true });

주의: 실제로 스크롤을 막아야 하는 UI(예: 드래그 중 스크롤 방지)에서는 passive가 오히려 문제를 만들 수 있습니다. 그 경우엔 해당 구간에서만 선택적으로 non-passive를 사용하세요.

스크롤 중 재페인트 줄이기: 단순한 시각 효과로 교체

/* 비싼 blur 대신 반투명 배경 + 얕은 그림자 */
.glass {
  background: rgba(255,255,255,0.9);
  border: 1px solid rgba(0,0,0,0.06);
  box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}

sticky가 fixed보다 나은 경우도 있다

헤더가 “항상 고정”이어야 하는 게 아니라 “특정 구간에서만 붙으면” position: sticky가 더 단순한 합성 경로를 만들 때가 있습니다.

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

디버깅 체크리스트(기기 실측 중심)

iOS Safari 성능은 “로컬 맥 크롬”에서 재현이 안 되는 경우가 많습니다. 가능하면 실제 iPhone에서 원격 디버깅(Web Inspector)을 붙여 확인하세요.

  1. 끊김이 발생하는 화면을 최소 재현으로 축소
  2. fixed/sticky/overflow 조합부터 의심
  3. filter/backdrop-filter/큰 box-shadow 제거 후 비교
  4. will-change 제거 후 비교(의외로 많이 해결됨)
  5. 스크롤 이벤트에서 DOM 측정/변경 여부 확인
  6. 이미지/폰트 로딩으로 레이아웃 변동이 있는지 확인

결론: iOS Safari 스크롤은 “합성만으로 끝내는” 게 목표

Safari iOS scroll jank는 대개 “스크롤 중 메인 스레드가 레이아웃/페인트로 바빠지는 상황”에서 발생합니다. 해결의 핵심은 다음 3가지로 수렴합니다.

  • 레이어를 필요 이상으로 만들지 않는다(will-change 남발 금지)
  • 스크롤 중 비싼 효과(filter, backdrop-filter, 큰 그림자)를 최소화한다
  • 스크롤 이벤트에서 DOM 측정/변경을 자제하고, 하더라도 requestAnimationFrame으로 제어한다

스크롤 잔상/깜빡임까지 동반되는 경우는 합성 튜닝 관점에서 추가로 손봐야 할 포인트가 많습니다. 아래 글에서 iOS Safari 특유의 compositing 문제를 더 구체적으로 다뤘으니 이어서 보면 원인 파악이 훨씬 빨라질 겁니다.