Published on

Safari iOS 스크롤 끊김? 레이어·컴포지팅 진단

Authors

iOS Safari에서만 스크롤이 유독 끊기거나, 특정 구간에서 프레임이 급격히 떨어지는 현상은 대개 레이아웃 페인트 컴포지팅 중 하나가 스크롤 경로에 끼어들면서 발생합니다. 데스크톱 Chrome에서는 멀쩡한데 iPhone에서만 버벅인다면, 문제는 성능 자체보다도 WebKit의 레이어 승격 기준, 오버스코롤 동작, fixed 요소 처리 방식 같은 구현 차이에서 시작하는 경우가 많습니다.

이 글은 “무엇을 줄여야 하는가”를 감으로 때리는 대신, iOS Safari에서 스크롤 끊김을 레이어컴포지팅 관점으로 진단하고 재현 가능한 방식으로 해결하는 흐름을 제공합니다.

참고로 스크롤 잔상이나 고스팅까지 동반된다면 아래 글도 함께 보면 원인 범위를 더 빨리 좁힐 수 있습니다.

iOS Safari 스크롤 파이프라인: 어디서 끊기나

스크롤이 부드럽게 유지되려면, 스크롤 중에 메인 스레드가 바빠지지 않아야 하고(레이아웃/JS/스타일 계산), 화면 갱신은 가능하면 컴포지터가 레이어를 재조합하는 수준에서 끝나야 합니다.

대략적인 비용 순서는 아래처럼 생각하면 실전에서 도움이 됩니다.

  • 컴포지팅만 변경: 상대적으로 저렴(레이어 위치/투명도 등)
  • 페인트 발생: 비쌈(픽셀 다시 그림)
  • 레이아웃 발생: 더 비쌈(박스 계산, 트리 전파)
  • JS 실행이 스크롤 경로에 포함: 상황에 따라 매우 비쌈(특히 scroll 핸들러)

iOS Safari에서 스크롤 끊김이 나는 대표 패턴은 다음입니다.

  • 스크롤 중 position: fixed 오버레이가 자주 리페인트됨
  • backdrop-filter filter mix-blend-mode 같은 효과가 큰 영역에 적용됨
  • box-shadow 또는 블러가 스크롤 영역과 함께 움직임
  • scroll 이벤트에서 레이아웃을 강제하는 코드(읽기-쓰기 혼합)
  • 이미지/비디오가 디코딩 또는 리사이즈 비용을 유발
  • -webkit-overflow-scrolling: touch 및 내부 스크롤 컨테이너가 복합적으로 얽힘

1단계: “메인 스크롤”인지 “내부 스크롤”인지부터 분리

먼저 끊김이 body 스크롤에서 발생하는지, overflow: auto 컨테이너(내부 스크롤)에서 발생하는지 분리해야 합니다. iOS Safari는 내부 스크롤 컨테이너가 많아질수록 레이어/컴포지팅이 복잡해지고, 이벤트 전달/고무줄 스크롤(오버스코롤)까지 더해져 병목이 생기기 쉽습니다.

체크리스트:

  • 화면에서 실제로 스크롤 되는 요소가 무엇인지 확인
  • 내부 스크롤 컨테이너가 2개 이상 중첩되어 있는지 확인
  • position: fixed 헤더/푸터가 내부 스크롤 위에 얹혀 있는지 확인

가능하면 구조를 단순화합니다.

  • 내부 스크롤 컨테이너를 줄이고, 메인 스크롤로 합치기
  • 꼭 내부 스크롤이 필요하면, 해당 컨테이너 안의 fixed/필터/그림자 효과를 최소화

2단계: iOS Web Inspector로 레이어와 페인트를 눈으로 확인

정확한 진단을 위해서는 iPhone Safari를 Mac Safari의 Web Inspector에 연결하는 흐름이 가장 확실합니다.

준비:

  • iPhone: 설정에서 Safari 고급 Web Inspector 활성화
  • Mac Safari: 개발자 메뉴 활성화
  • 케이블 연결 또는 동일 네트워크로 디버깅

관찰 포인트:

  • 스크롤 중 특정 요소가 계속 “다시 그려지는지”
  • overlay나 헤더 같은 상단 요소가 스크롤과 무관한데도 repaint 되는지
  • 레이어 수가 과도하게 많아지는지(특히 카드 리스트)

실무 팁:

  • “문제가 생기는 화면”에서만 레이어를 줄이는 것이 최적입니다. 전역으로 무작정 transform: translateZ(0) 같은 승격 트릭을 넣으면, 레이어 메모리 증가로 오히려 더 느려질 수 있습니다.

3단계: 스크롤 핸들러가 레이아웃을 강제하는지 확인

iOS Safari에서 가장 흔한 끊김 원인 중 하나는 scroll 이벤트에서 레이아웃을 강제하는 코드입니다. 특히 DOM 값을 읽은 직후 스타일을 쓰면, 브라우저가 중간에 레이아웃/스타일 계산을 강제로 수행하게 됩니다.

나쁜 예: scroll마다 레이아웃 강제

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

window.addEventListener('scroll', () => {
  // 읽기: 레이아웃 값
  const y = window.scrollY;
  const h = header.getBoundingClientRect().height;

  // 쓰기: 스타일 변경
  header.style.transform = `translateY(${Math.min(0, -y)}px)`;

  // 또 읽기: 혼합되면 강제 동기 레이아웃 위험
  if (document.body.offsetHeight > 2000) {
    // ...
  }
});

개선 1: rAF로 쓰기 단일화, 읽기/쓰기 분리

const header = document.querySelector('.header');
let latestY = 0;
let ticking = false;

window.addEventListener('scroll', () => {
  latestY = window.scrollY;
  if (!ticking) {
    ticking = true;
    requestAnimationFrame(() => {
      // 쓰기만 수행
      header.style.transform = `translateY(${Math.min(0, -latestY)}px)`;
      ticking = false;
    });
  }
}, { passive: true });

개선 2: IntersectionObserver로 스크롤 연산 제거

헤더 축소/노출, 섹션 하이라이트 같은 로직은 scroll 이벤트 대신 IntersectionObserver로 대체하면 메인 스레드 부하를 크게 줄일 수 있습니다.

const sentinel = document.querySelector('#sentinel');
const header = document.querySelector('.header');

const io = new IntersectionObserver(([entry]) => {
  header.classList.toggle('is-stuck', !entry.isIntersecting);
});

io.observe(sentinel);

CSS는 컴포지팅 친화적으로 구성합니다.

.header {
  will-change: transform;
  transform: translateZ(0);
}
.header.is-stuck {
  transform: translate3d(0, -12px, 0);
}

주의: will-change는 남용하면 레이어 메모리와 관리 비용이 증가합니다. “스크롤 중 실제로 애니메이션되는 요소”에만 제한적으로 사용합니다.

4단계: 페인트 폭탄 유발 CSS를 의심한다

iOS Safari에서 스크롤 끊김을 만들기 쉬운 CSS 조합은 “큰 영역 + 비싼 효과 + 스크롤과 함께 이동”입니다.

특히 아래는 위험 신호입니다.

  • 큰 요소에 filter: blur(...) 또는 backdrop-filter
  • box-shadow(블러 반경이 큰 그림자)
  • 반투명 레이어가 여러 겹 겹침(오버드로우)
  • mix-blend-mode 사용
  • mask-image 또는 복잡한 클리핑

대안 패턴

  1. blur/필터는 가능한 “작은 영역”에만 적용

  2. 그림자는 스크롤 컨테이너 전체가 아니라 카드 내부로 제한

  3. 반투명 배경은 단일 레이어로 합치고, 중첩을 줄이기

  4. 고정 헤더에 blur가 필요하면, 스크롤 중에는 blur를 끄고 스크롤 종료 후 다시 켜는 전략도 고려

스크롤 중에만 효과를 낮추는 예시:

let timer;
const root = document.documentElement;

window.addEventListener('scroll', () => {
  root.classList.add('is-scrolling');
  clearTimeout(timer);
  timer = setTimeout(() => root.classList.remove('is-scrolling'), 120);
}, { passive: true });
.header {
  backdrop-filter: blur(14px);
}
.is-scrolling .header {
  backdrop-filter: none;
}

이 방식은 “완벽한 미학”보다 “체감 성능”이 중요한 화면에서 특히 효과가 큽니다.

5단계: 레이어 승격은 약이지만 과다복용이 있다

스크롤 끊김을 보고 가장 먼저 translateZ(0) 같은 GPU 승격 트릭을 넣는 경우가 많은데, iOS Safari에서는 다음 문제가 생길 수 있습니다.

  • 레이어 수 증가로 컴포지터 메모리 압박
  • 텍스트 렌더링이 흐릿해지는 부작용(특히 transform 적용 시)
  • 레이어 간 합성 비용 증가(오버드로우)

언제 승격이 도움이 되나

  • 스크롤과 독립적으로 움직이는 fixed 헤더/툴바
  • opacity/transform 애니메이션이 있는 요소
  • 스크롤 중 repaint가 반복되는 요소를 별도 레이어로 격리하고 싶을 때

추천 패턴: “작은 오버레이만” 승격

.floating-toolbar {
  position: fixed;
  left: 16px;
  right: 16px;
  bottom: 16px;
  transform: translate3d(0, 0, 0);
  will-change: transform;
}

반대로, 리스트의 모든 카드에 will-change를 주는 식의 일괄 승격은 피합니다.

6단계: 이미지/비디오가 스크롤 중 디코딩 병목을 만들지 확인

iOS에서 스크롤 끊김은 “그래픽 효과”뿐 아니라 “미디어 처리”에서도 자주 발생합니다.

  • 큰 이미지를 CSS로 축소해서 표시
  • 스크롤 중에 이미지가 뒤늦게 디코딩
  • 비디오 썸네일이 여러 개 동시에 갱신

대응:

  • 실제 표시 크기에 맞는 이미지 제공(srcset sizes)
  • lazy loading 사용
  • placeholder를 단순화(블러 플레이스홀더도 비용이 될 수 있음)

예시:

<img
  src="/images/card-640.jpg"
  srcset="/images/card-640.jpg 640w, /images/card-1280.jpg 1280w"
  sizes="(max-width: 430px) 100vw, 430px"
  loading="lazy"
  decoding="async"
  alt="card"
/>

7단계: 스크롤 체인을 끊는 요소를 제거한다

iOS Safari는 스크롤 체인(부모-자식 스크롤 전파)과 오버스코롤 동작이 결합되면, 특정 조합에서 체감 성능이 급격히 나빠질 수 있습니다.

점검 항목:

  • 불필요한 overflow: hidden이 상위에 걸려 있는지
  • 내부 스크롤 영역에서 바운스가 과도하게 발생하는지
  • 모달이 열렸을 때 배경 스크롤 락을 과하게 구현했는지

모달 배경 스크롤 락을 JS로 구현할 때는 레이아웃 점프를 최소화해야 합니다.

let scrollTop = 0;

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

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

여기서도 백틱 템플릿 문자열을 썼다면 MDX에서 안전하지만, 본문에 부등호가 노출되지 않도록 코드 블록 안에서만 사용해야 합니다.

8단계: “문제 화면만” 실험 플래그로 빠르게 A/B 한다

성능 이슈는 원인이 복합적이라, 한 번에 여러 변경을 섞으면 무엇이 효과였는지 알기 어렵습니다. 작은 플래그를 만들어 원인 후보를 빠르게 토글하는 방식이 효과적입니다.

예시: URL 파라미터로 효과 끄기

const params = new URLSearchParams(location.search);
const noBlur = params.has('noBlur');
const noShadow = params.has('noShadow');

document.documentElement.classList.toggle('no-blur', noBlur);
document.documentElement.classList.toggle('no-shadow', noShadow);
.no-blur .header { backdrop-filter: none; }
.no-shadow .card { box-shadow: none; }

이렇게 하면 iPhone 실기기에서 ?noBlur 같은 방식으로 즉시 체감 차이를 확인할 수 있어, 디버깅 시간이 크게 줄어듭니다.

자주 나오는 원인별 빠른 처방전

1) fixed 헤더가 스크롤 중 떨림/끊김

  • 헤더를 별도 레이어로 격리(transform: translate3d(0,0,0))
  • 헤더에 적용된 blur/그림자 비용 축소
  • 헤더 내부에서 폰트/아이콘이 자주 바뀌는 애니메이션 제거

2) 리스트 화면에서만 끊김

  • 카드 전체에 그림자/블러/반투명 중첩이 있는지 확인
  • 이미지 크기 최적화 및 lazy loading
  • 스크롤 중 DOM 높이가 변하는 요소(광고, 측정 후 렌더) 제거

3) 스크롤 중 JS가 무겁다

  • scroll 핸들러를 requestAnimationFrame로 제한
  • passive: true 적용
  • 가능하면 IntersectionObserver로 전환

마무리: 목표는 “레이아웃/페인트를 스크롤 경로에서 제거”

Safari iOS 스크롤 끊김은 결국 “스크롤 중에 메인 스레드가 할 일이 생기는가”와 “컴포지터가 감당 가능한 레이어 구조인가”로 귀결됩니다. 해결 순서는 다음처럼 가져가면 재현성과 효율이 좋습니다.

  1. 메인 스크롤 vs 내부 스크롤 분리
  2. Web Inspector로 repaint/레이어 변화를 관찰
  3. scroll 핸들러 제거 또는 rAF로 제한
  4. blur/그림자/블렌딩 같은 비싼 페인트 요인 축소
  5. 레이어 승격은 필요한 곳에만 제한적으로 적용

같은 “렌더링 파이프라인 진단”이라는 관점은 프론트엔드뿐 아니라 캐시/상태 꼬임 같은 문제를 풀 때도 유사하게 적용됩니다. Next.js에서 재현이 어려운 캐시 불일치로 화면이 이상해질 때는 아래 글도 참고할 만합니다.