Published on

Safari iOS 스크롤 끊김 - Layer·Reflow 최적화

Authors

iOS Safari에서 “특정 구간만 스크롤이 뚝뚝 끊긴다”는 이슈는 대개 자바스크립트가 느려서라기보다, 레이아웃(Reflow)·스타일 계산·페인트·합성(Compositing) 경로 중 하나가 스크롤과 경합하면서 발생합니다. 특히 iOS Safari는 메모리/타일링/합성 정책이 데스크톱과 다르고, position: fixed, backdrop-filter, 큰 box-shadow, 과도한 sticky 조합에서 병목이 더 쉽게 드러납니다.

이 글은 “무조건 will-change” 같은 처방이 아니라, Layer를 어디서 만들고(합성), 어디서 Reflow를 줄이고(레이아웃), 어디서 페인트 면적을 줄이는지를 기준으로 접근합니다.

참고: 비슷하게 “원인 가설을 세우고, 관측 지표를 만들고, 재현 가능한 최소 케이스로 좁히는” 디버깅 사고방식은 인프라/백엔드에서도 동일합니다. 예를 들어 Bash set -e가 무시될 때 - pipefail·trap 글의 방식처럼, 조건을 통제하면서 원인을 분리하는 게 핵심입니다.

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

스크롤 “끊김”은 보통 아래 중 하나입니다.

  • 메인 스레드가 바쁨: 스크롤 이벤트 처리, 동기 레이아웃 측정, 큰 DOM 업데이트 등으로 프레임 드랍
  • 레이아웃(Reflow) 폭발: 스크롤 중에 레이아웃이 자주 재계산되거나, 레이아웃 스래싱(thrashing)이 발생
  • 페인트(Paint) 면적 과대: 큰 영역을 매 프레임 다시 그리거나, 블러/그림자 같은 비싼 페인트가 반복
  • 합성(Compositing) 비효율: 레이어가 너무 많거나, 불필요하게 레이어가 생성/파괴되며 타일 업로드가 증가

iOS Safari에서 특히 자주 밟는 지뢰는 다음과 같습니다.

  • backdrop-filter: blur(...) 또는 반투명 오버레이 + 스크롤
  • position: sticky가 많은 리스트(특히 헤더가 여러 개)
  • 스크롤 중 height, top, margin 같은 레이아웃 속성 애니메이션
  • 스크롤 이벤트에서 getBoundingClientRect() 측정 후 곧바로 DOM 변경(강제 동기 레이아웃)
  • 큰 이미지/비디오가 많고, object-fit/리사이즈가 잦거나, 디코딩/다운스케일 비용이 큰 경우

진단: “측정 없는 최적화”를 피하는 체크리스트

1) 재현 가능한 최소 케이스 만들기

문제 페이지에서 아래를 순서대로 꺼보며 범위를 줄입니다.

  • 스크롤 연동 애니메이션/헤더 효과 제거
  • backdrop-filter, filter, box-shadow 제거
  • position: sticky 제거
  • 리스트 아이템 수를 줄이기(가상 스크롤 여부 확인)

2) Safari Web Inspector로 확인할 포인트

macOS Safari에서 iPhone을 원격 디버깅할 때(Develop 메뉴) 다음을 봅니다.

  • Timeline/Performance에서 Layout, Rendering 비중
  • 스크롤 중 Recalculate Style 또는 Layout이 반복되는지
  • 레이어(Compositing) 변화가 잦은지

도구가 불편하거나 제한적이면, 코드 레벨에서 “의심 지점”을 빠르게 확인하는 것도 좋습니다.

  • 스크롤 핸들러 호출 빈도 및 처리 시간(간단한 타이밍 로그)
  • 레이아웃 측정 호출 여부(getBoundingClientRect, offsetHeight, scrollTop 등)

Reflow 최적화: 레이아웃 스래싱을 끊어내기

흔한 안티패턴: 스크롤마다 측정하고 즉시 쓰기

아래 코드는 스크롤 중 측정(read)쓰기(write) 가 섞여 강제 레이아웃을 유발할 수 있습니다.

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

window.addEventListener('scroll', () => {
  const y = window.scrollY;
  const h = header.getBoundingClientRect().height; // read (layout)

  // write (style)
  header.style.height = `${Math.max(48, 120 - y)}px`;
  header.style.opacity = y > h ? '0.9' : '1';
});

문제는 height 같은 레이아웃 속성을 스크롤마다 바꾸는 것 자체도 비싸고, getBoundingClientRect()가 레이아웃을 강제할 수 있다는 점입니다.

개선 1: 레이아웃 속성 대신 transform으로

레이아웃을 바꾸지 말고, 합성에서 처리 가능한 transform/opacity 위주로 바꿉니다.

.header {
  position: sticky;
  top: 0;
  transform: translate3d(0, 0, 0);
  will-change: transform, opacity;
}
const header = document.querySelector('.header');
let ticking = false;

window.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;

  requestAnimationFrame(() => {
    const y = window.scrollY;
    const clamped = Math.min(72, y);

    header.style.transform = `translate3d(0, ${-clamped}px, 0)`;
    header.style.opacity = y > 80 ? '0.92' : '1';

    ticking = false;
  });
}, { passive: true });

핵심은 두 가지입니다.

  • 스크롤 이벤트는 requestAnimationFrame으로 프레임에 맞춰 1회만 처리
  • 레이아웃을 바꾸는 속성 대신 transform으로 시각 효과 구현

개선 2: read/write 분리(배치)하기

측정이 필요하다면 한 프레임에서 read를 먼저 모아서 하고, 그 다음 write를 한 번에 합니다.

const items = [...document.querySelectorAll('.card')];
let rafId = 0;

function onScroll() {
  if (rafId) return;
  rafId = requestAnimationFrame(() => {
    // 1) read phase
    const rects = items.map(el => el.getBoundingClientRect());

    // 2) write phase
    rects.forEach((r, i) => {
      const visible = r.bottom > 0 && r.top < window.innerHeight;
      items[i].classList.toggle('is-visible', visible);
    });

    rafId = 0;
  });
}

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

개선 3: IntersectionObserver로 스크롤 연산 자체를 줄이기

스크롤 핸들러에서 매번 계산하지 말고, 브라우저에게 맡기는 방식이 더 안정적입니다.

const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    e.target.classList.toggle('is-visible', e.isIntersecting);
  }
}, {
  root: null,
  threshold: 0.1
});

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

iOS Safari에서도 IntersectionObserver는 꽤 유용하며, 특히 긴 리스트에서 효과가 큽니다.

Layer(합성) 최적화: “필요한 곳만 레이어”가 정답

will-change를 남발하면 왜 더 느려지나

will-changetranslate3d로 레이어를 강제하면 합성이 쉬워질 수 있지만, 레이어가 늘면:

  • GPU 메모리 사용량 증가
  • 타일 업로드/관리 비용 증가
  • 오히려 스크롤 중 합성 단계가 병목

따라서 will-change애니메이션 중인 소수의 요소에만, 그리고 필요한 기간에만 적용하는 것이 좋습니다.

패턴: 상호작용 직전에만 will-change 켜기

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

function enable() {
  panel.style.willChange = 'transform, opacity';
}
function disable() {
  panel.style.willChange = 'auto';
}

panel.addEventListener('pointerenter', enable);
panel.addEventListener('pointerleave', disable);

모바일에서는 pointerenter가 애매할 수 있으니, 실제로는 “열기 버튼 클릭 시 enable, 애니메이션 종료 후 disable” 같은 형태가 더 현실적입니다.

iOS Safari에서 조심할 CSS 조합

  • backdrop-filter는 스크롤 컨텍스트에서 매우 비쌀 수 있습니다
  • box-shadow(특히 blur가 큰 값) + 스크롤은 페인트 비용을 크게 만듭니다
  • position: fixed가 많은 UI는 합성/페인트 경로가 복잡해질 수 있습니다

대체안 예시:

  • 블러가 꼭 필요하면, 블러 영역을 작게 제한하거나 스크롤 중에는 블러를 끄고 정지 시 켜기
  • 그림자는 blur를 줄이고, 가능한 filter: drop-shadow(...)도 비교 테스트(상황에 따라 다름)
/* 스크롤 중에는 단순 배경, 멈추면 블러 */
.header {
  background: rgba(20, 20, 20, 0.85);
}

.header.is-idle {
  backdrop-filter: blur(12px);
}
const header = document.querySelector('.header');
let t;

window.addEventListener('scroll', () => {
  header.classList.remove('is-idle');
  clearTimeout(t);
  t = setTimeout(() => header.classList.add('is-idle'), 120);
}, { passive: true });

이 방식은 “스크롤 중 프레임 유지”를 우선하고, 정지 상태에서만 고급 효과를 적용합니다.

Paint 최적화: 다시 그리는 면적을 줄여라

contain으로 페인트/레이아웃 격리

contain은 컴포넌트 내부 변화가 바깥으로 전파되는 비용을 줄이는 데 유용합니다.

.card {
  contain: layout paint;
}

주의: contain은 레이아웃 동작에 영향을 줄 수 있으니, 적용 후 UI 깨짐이 없는지 확인해야 합니다.

content-visibility로 오프스크린 렌더링 스킵

긴 페이지에서 아래는 체감 효과가 큰 편입니다.

.section {
  content-visibility: auto;
  contain-intrinsic-size: 800px;
}

iOS Safari 버전에 따라 지원이 다를 수 있으니, 지원 범위가 불확실하면 점진적 적용(깨져도 문제 없는 섹션부터)로 접근하세요.

스크롤 이벤트/터치 처리: passive와 메인 스레드 점유 줄이기

passive 이벤트로 스크롤 블로킹 제거

touchmovewheel(iOS는 주로 touch)에서 preventDefault()를 쓰지 않는다면, 반드시 { passive: true }를 고려하세요.

window.addEventListener('touchmove', () => {
  // 스크롤을 막지 않는 로직만
}, { passive: true });

스크롤 연동 애니메이션은 CSS로 넘기기

가능하면 JS로 “스크롤 위치를 읽어서 애니메이션” 하지 말고, 단순한 효과는 CSS로 대체합니다.

  • 헤더 그림자 토글: 특정 지점 넘으면 클래스 토글(IntersectionObserver)
  • 패럴랙스: 꼭 필요할 때만, 그리고 transform만 변경

긴 리스트에서의 정답: 가상 스크롤 또는 렌더링 축소

iOS Safari에서 카드 300개, 이미지 300개는 그 자체로 스크롤 비용이 됩니다. 이 경우 Layer/Reflow 최적화만으로는 한계가 있고, DOM 수를 줄이는 전략이 필요합니다.

  • 가상 스크롤(react-window 등)
  • 페이지네이션/무한스크롤 시, 오래된 DOM 제거
  • 이미지 지연 로딩 및 적절한 크기 제공
<img
  src="/img/thumb-480.jpg"
  srcset="/img/thumb-480.jpg 480w, /img/thumb-960.jpg 960w"
  sizes="(max-width: 480px) 480px, 960px"
  loading="lazy"
  decoding="async"
  alt="..."
/>

이미지의 “표시 크기”와 “전송/디코딩 크기”가 크게 다르면 스크롤 중 디코딩/리사이즈 비용이 튀는 경우가 많습니다.

실무 적용 순서(우선순위)

  1. 스크롤 핸들러 제거 또는 requestAnimationFrame + { passive: true }
  2. 스크롤 중 레이아웃 변경(height, top, margin)을 transform으로 치환
  3. 측정(read)과 변경(write) 분리로 레이아웃 스래싱 제거
  4. backdrop-filter, 큰 box-shadow 등 비싼 페인트 요소 축소(스크롤 중 비활성화 포함)
  5. 레이어 최적화: will-change는 “소수 요소, 짧은 시간”만
  6. DOM 수가 많다면 가상 스크롤/섹션 렌더링 스킵(content-visibility) 검토

자주 묻는 함정 Q&A

Q1. translate3d(0,0,0)를 걸면 무조건 빨라지나요?

아닙니다. 레이어가 늘어 GPU 메모리와 합성 비용이 증가할 수 있습니다. “애니메이션 중인 요소”에만 제한적으로 적용하세요.

Q2. position: sticky가 원인일 수 있나요?

가능합니다. sticky가 많거나, sticky 요소 내부가 복잡(그림자/블러/큰 이미지)하면 스크롤 중 계산/페인트가 증가합니다. sticky 개수를 줄이거나, sticky 내부 효과를 단순화하세요.

Q3. iOS에서만 끊기면 어떻게 접근해야 하나요?

대부분 “메모리/합성/페인트” 차이에서 옵니다. 데스크톱에서 괜찮아도 iOS에서만 비싼 CSS(블러/그림자/고정 요소)부터 의심하고, 스크롤 중 효과를 단계적으로 꺼서 원인을 분리하세요.

마무리

iOS Safari 스크롤 끊김은 한 가지 비법으로 해결되지 않습니다. 대신 프레임을 깨는 원인을 Reflow(레이아웃), Paint(페인트), Layer(합성) 중 어디로 분류할 수 있는지만 잡으면, 해결책은 비교적 명확해집니다.

  • Reflow는 “측정과 쓰기 분리 + 레이아웃 속성 변경 금지”
  • Paint는 “비싼 효과를 줄이고, 다시 그리는 면적을 최소화”
  • Layer는 “필요한 곳만 레이어, will-change 남발 금지”

이 순서로 적용하면, iOS Safari에서도 스크롤이 ‘버벅임’에서 ‘일관된 프레임’으로 바뀌는 경우가 많습니다.