Published on

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

Authors

모바일에서 스크롤은 곧 UX입니다. 특히 iOS Safari는 같은 코드라도 Android Chrome보다 스크롤이 더 쉽게 끊기거나, 특정 구간에서만 프레임이 떨어지는 현상이 자주 발생합니다. 대부분의 경우 원인은 단순히 자바스크립트가 느려서가 아니라, 스크롤 중에 발생하는 layout 재계산, 과도한 paint, 불필요한 레이어 합성, 그리고 스크롤 스레드와 메인 스레드의 충돌에 있습니다.

이 글은 iOS Safari 스크롤 끊김을 레이어페인트 관점에서 진단하고, 재현 가능한 체크리스트와 코드 레벨의 해결책을 정리합니다.

iOS Safari 스크롤 끊김이 유독 잘 보이는 이유

iOS Safari의 스크롤은 기본적으로 매우 최적화되어 있지만, 아래 조건이 겹치면 스크롤 중 메인 스레드가 바빠져서 프레임 드랍이 발생합니다.

  • 스크롤 이벤트에서 동기적으로 무거운 작업을 수행
  • 스크롤 중 레이아웃을 유발하는 스타일 변경
  • 큰 요소(특히 이미지, 블러, 그림자)의 반복 페인트
  • position: fixed 오버레이, backdrop-filter 같은 고비용 효과
  • 불필요한 합성 레이어 증가로 인한 메모리 및 합성 비용 상승

핵심은 스크롤 도중에 브라우저가 recalculate stylelayoutpaintcomposite 파이프라인을 얼마나 자주, 얼마나 크게 수행하느냐입니다.

먼저 증상을 분류하기: 스크롤 스레드 vs 메인 스레드

스크롤 끊김은 대개 두 가지로 나뉩니다.

1) 스크롤 입력 자체가 버벅임

  • 손가락을 움직여도 화면이 따라오지 않음
  • 원인 후보: 스크롤을 메인 스레드가 가로막는 경우(동기 작업, non-passive 이벤트, 과도한 레이아웃)

2) 스크롤은 되는데 화면 요소가 덜컥거림

  • 스크롤은 진행되지만 헤더, 카드, 배경이 흔들리거나 업데이트가 밀림
  • 원인 후보: 페인트 폭발, 합성 레이어 설계 문제, 고비용 필터와 그림자

이 분류를 해두면 최적화 방향이 명확해집니다.

iOS Safari에서 특히 자주 터지는 원인 8가지

1) scroll 핸들러에서 레이아웃을 건드림

scroll 중에 getBoundingClientRect() 같은 측정 API를 호출하고 곧바로 스타일을 바꾸면, 강제 동기 레이아웃이 발생할 수 있습니다.

나쁜 예:

window.addEventListener('scroll', () => {
  const y = document.querySelector('.hero').getBoundingClientRect().top;
  document.querySelector('.header').style.height = (60 + Math.max(0, -y)) + 'px';
});

개선 방향:

  • 측정과 변경을 같은 프레임에서 섞지 않기
  • requestAnimationFrame으로 배치
  • 가능하면 스크롤 기반 효과를 transform으로만 처리
const header = document.querySelector('.header');
let latestY = 0;
let ticking = false;

window.addEventListener('scroll', () => {
  latestY = window.scrollY;
  if (!ticking) {
    ticking = true;
    requestAnimationFrame(() => {
      // 레이아웃 변경 대신 transform만
      const t = Math.min(latestY, 80);
      header.style.transform = `translate3d(0, ${-t}px, 0)`;
      ticking = false;
    });
  }
}, { passive: true });

포인트는 passive: true로 스크롤을 막지 않고, 프레임당 1회만 업데이트하며, height 같은 레이아웃 속성 대신 transform을 쓰는 것입니다.

2) position: fixed + 큰 배경/필터

iOS Safari에서 position: fixed는 상황에 따라 비용이 커질 수 있습니다. 특히 backdrop-filter: blur(...)를 fixed 오버레이에 올리면 스크롤 중 매 프레임 배경을 다시 샘플링하고 블러 처리해야 하므로 페인트가 폭발합니다.

대안:

  • 블러 강도 낮추기
  • 적용 영역을 작게 자르기
  • 스크롤 중에는 블러를 끄고 정지 시 다시 켜기
.modal {
  position: fixed;
  inset: 0;
  /* backdrop-filter는 매우 비쌈 */
  backdrop-filter: blur(12px);
}

@media (prefers-reduced-motion: reduce) {
  .modal { backdrop-filter: none; }
}

스크롤 중 토글 예시:

let scrollingTimer;
const modal = document.querySelector('.modal');

window.addEventListener('scroll', () => {
  modal.classList.add('is-scrolling');
  clearTimeout(scrollingTimer);
  scrollingTimer = setTimeout(() => {
    modal.classList.remove('is-scrolling');
  }, 120);
}, { passive: true });
.modal.is-scrolling {
  backdrop-filter: none;
}

3) 큰 그림자, 특히 box-shadow가 많은 카드 리스트

카드 UI를 30개 이상 렌더링하고 각 카드에 큰 블러 그림자를 넣으면, 스크롤 중 페인트 영역이 커지고 비용이 증가합니다.

개선 팁:

  • 그림자 블러 반경 줄이기
  • 그림자를 filter: drop-shadow(...)로 바꾸는 것이 항상 이득은 아니지만 경우에 따라 합성에 유리
  • 스크롤 컨테이너 내부 요소에 과도한 그림자 중복 금지
.card {
  /* 나쁜 예: 큰 blur */
  /* box-shadow: 0 20px 60px rgba(0,0,0,.18); */

  /* 개선: blur 축소 */
  box-shadow: 0 10px 24px rgba(0,0,0,.14);
}

4) background-attachment: fixed

iOS Safari에서 사실상 금기 패턴에 가깝습니다. 패럴랙스처럼 보이게 하려다 스크롤이 끊기는 대표 원인입니다.

대안:

  • 배경을 fixed로 두지 말고, 별도 레이어를 만들어 transform으로 움직이기
.parallax-bg {
  position: absolute;
  inset: 0;
  transform: translate3d(0, 0, 0);
  will-change: transform;
}

5) 이미지 디코딩과 리사이즈가 스크롤 중 발생

스크롤로 새로운 이미지가 나타날 때, 큰 이미지를 즉시 디코딩하고 리사이즈하면 메인 스레드가 바빠질 수 있습니다.

개선 팁:

  • loading="lazy" 활용
  • 이미지 크기 명시로 레이아웃 변동 방지
  • 가능하면 적절한 해상도의 리소스 제공
<img
  src="/img/card-640.jpg"
  srcset="/img/card-640.jpg 640w, /img/card-1280.jpg 1280w"
  sizes="(max-width: 480px) 90vw, 420px"
  width="420"
  height="280"
  loading="lazy"
  decoding="async"
  alt="preview"
/>

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

will-change: transform은 성능 치트키가 아니라, 레이어를 늘려 메모리와 합성 비용을 올릴 수 있는 양날의 검입니다. iOS는 메모리 압박에 민감해서, 레이어가 많아지면 오히려 끊김이 늘 수 있습니다.

원칙:

  • 애니메이션이 실제로 있는 소수 요소에만 적용
  • 일회성 애니메이션이면 애니메이션 직전에 켰다가 끝나면 제거
const el = document.querySelector('.toast');

function animateToast() {
  el.style.willChange = 'transform, opacity';
  el.classList.add('show');

  el.addEventListener('transitionend', () => {
    el.style.willChange = 'auto';
  }, { once: true });
}

7) overflow: scroll 내부 스크롤과 고정 헤더 조합

내부 스크롤 컨테이너를 만들고 그 위에 fixed 헤더나 sticky 요소를 올리면, iOS Safari에서 합성과 페인트 경로가 복잡해질 수 있습니다.

대안:

  • 가능하면 바디 스크롤을 유지
  • 내부 스크롤이 필요하면, sticky 요소 수를 최소화하고 페인트 비용이 낮은 스타일 사용

8) 스크롤 중 텍스트 렌더링 비용 증가

서브픽셀 안티앨리어싱, 폰트 로딩, 큰 텍스트 그림자 등이 겹치면 텍스트 페인트가 커집니다.

개선 팁:

  • 웹폰트는 font-display: swap 고려
  • 텍스트에 큰 text-shadow 남발 금지

레이어·페인트 최적화의 실전 패턴

1) 애니메이션은 transformopacity로 제한

스크롤과 함께 움직이는 요소는 다음을 피하는 것이 좋습니다.

  • top, left, height, width 변경: 레이아웃 유발
  • box-shadow 애니메이션: 페인트 비용 큼

대신:

.header {
  transform: translate3d(0, 0, 0);
}

.header.is-hidden {
  transform: translate3d(0, -100%, 0);
  transition: transform 180ms ease;
}

2) content-visibility는 iOS에서 기대만큼 안 먹을 수 있음

Chrome에서는 큰 리스트 최적화에 유리하지만, iOS Safari는 지원/효과가 제한적일 수 있습니다. 그래서 iOS 대응은 여전히 기본기가 중요합니다.

  • 렌더링 자체를 줄이려면 가상 스크롤(virtualization) 고려
  • 이미지/컴포넌트 지연 로딩

3) 페인트 범위를 줄이는 contain 활용

contain은 레이아웃/페인트 영향 범위를 제한해 성능에 도움을 줄 수 있습니다.

.card-list {
  contain: layout paint;
}

단, contain은 레이아웃 동작을 바꿀 수 있으니 적용 후 UI 깨짐 여부를 반드시 확인해야 합니다.

4) 스크롤 이벤트는 반드시 passive

스크롤을 막는 대표 원인이 touchmovewheel 이벤트에서 preventDefault() 가능성 때문에 브라우저가 스크롤을 대기하는 경우입니다.

window.addEventListener('touchmove', () => {
  // 읽기 전용 처리
}, { passive: true });

주의: 실제로 스크롤을 제어해야 하는 UI(예: 드래그로 열리는 바텀시트)에서는 passive를 무조건 켜면 안 됩니다. 그 경우는 스크롤 제어 구간을 최소화하고, 평상시에는 passive로 두는 식의 설계가 필요합니다.

iOS Safari에서의 디버깅 루틴

iOS 성능 문제는 “감”으로 고치기 어렵습니다. 최소한 아래 루틴으로 원인을 좁히는 것이 좋습니다.

1) 재현 환경 고정

  • 동일 기기, 동일 iOS 버전, 동일 페이지 상태
  • 저전력 모드 여부 확인
  • 백그라운드 앱 정리

2) 스크롤 중 변경되는 CSS 효과를 하나씩 끄기

가장 빠른 방법은 의심되는 고비용 스타일을 토글해보는 것입니다.

  • backdrop-filter 제거
  • 그림자 제거
  • fixed 오버레이 제거
  • 애니메이션 제거

이 과정을 통해 “페인트가 문제인지, 레이아웃이 문제인지”를 먼저 구분합니다.

3) 페인트 원인 추적 습관

Chrome에서는 Paint Flashing으로 리페인트를 쉽게 시각화할 수 있습니다. iOS Safari 자체 도구만으로 한계가 있을 때, 동일 UI를 데스크톱 Chrome에서 먼저 페인트 패턴을 파악한 뒤 iOS에 이식해 확인하면 효율적입니다.

또한 사용자 체감 지표 관점에서 Long Task나 레이아웃 시프트가 동반되는지도 같이 보는 게 좋습니다.

케이스 스터디: iOS에서만 끊기는 고정 헤더

문제 패턴

  • 상단에 position: fixed 헤더
  • 헤더 배경에 반투명 + 블러
  • 본문은 카드 리스트 + 그림자

이 조합은 iOS에서 흔히 스크롤 끊김을 만들 수 있습니다.

해결 순서(효과 큰 순)

  1. 헤더의 backdrop-filter 제거 또는 스크롤 중 비활성화
  2. 카드 그림자 블러 반경 축소
  3. 헤더 애니메이션이 있다면 top 변경 대신 transform
  4. will-change를 헤더 하나에만 제한적으로 적용

예시:

.header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 56px;
  background: rgba(255,255,255,.92);
  /* 기본은 끄고 */
  backdrop-filter: none;
  transform: translate3d(0, 0, 0);
}

.header.is-idle {
  /* 스크롤이 멈췄을 때만 켜기 */
  backdrop-filter: blur(10px);
}
const header = document.querySelector('.header');
let t;

function markScrolling() {
  header.classList.remove('is-idle');
  clearTimeout(t);
  t = setTimeout(() => header.classList.add('is-idle'), 150);
}

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

체크리스트: 적용 전후로 반드시 확인할 것

  • 스크롤 중 layout 유발 속성 변경이 있는가
  • 스크롤 핸들러가 프레임당 1회로 제한되어 있는가(requestAnimationFrame)
  • 이미지 크기(width, height)가 명시되어 레이아웃 변동이 없는가
  • backdrop-filter, 큰 그림자, 큰 블러가 스크롤 중 켜져 있는가
  • will-change가 남발되어 레이어가 과도하게 늘지 않았는가
  • 내부 스크롤 컨테이너(overflow: scroll)를 꼭 써야 하는가

마무리

iOS Safari 스크롤 끊김은 대개 “레이어를 늘리면 빨라진다” 같은 단일 처방으로 해결되지 않습니다. 스크롤 중에 발생하는 layoutpaint를 줄이고, 합성 레이어는 필요한 만큼만 쓰며, 고비용 시각 효과를 스크롤 구간에서 제어하는 것이 가장 확실한 접근입니다.

정리하면 다음 3가지만 먼저 적용해도 체감 개선이 큰 경우가 많습니다.

  • 스크롤 기반 UI는 transform 중심으로 재설계
  • 스크롤 이벤트는 passive + requestAnimationFrame
  • backdrop-filter와 과한 그림자는 스크롤 중 비활성화 또는 축소

이후에도 끊김이 남는다면, 문제 화면을 구성 요소 단위로 분해해서 “어떤 요소가 페인트를 터뜨리는지”를 찾아내는 방식으로 접근하면 가장 빠르게 원인을 좁힐 수 있습니다.