Published on

Safari iOS 스크롤 끊김 - compositing 원인·해결

Authors

모바일 웹에서 “iOS Safari만 스크롤이 끊긴다”는 이슈는 생각보다 자주 발생합니다. 같은 페이지가 Android Chrome에서는 부드럽게 움직이는데, iPhone에서는 특정 구간에서만 프레임이 떨어지거나(체감상 스크롤이 걸리는 느낌), 스크롤 중 요소가 순간적으로 깨져 보이거나, 고정 헤더가 떨리는 현상이 나타나곤 합니다.

이 문제는 단순히 “Safari가 느려서”가 아니라, 스크롤 중 브라우저가 어떤 작업을 메인 스레드에서 처리해야 하는지, 그리고 어떤 요소가 별도 레이어로 분리되어(compositing) GPU 합성 경로를 타는지에 따라 크게 좌우됩니다. 특히 iOS Safari는 스크롤이 빠르게 진행될 때 메인 스레드가 조금만 바빠져도 체감이 즉시 드러나기 쉽습니다.

이 글에서는 iOS Safari 스크롤 끊김을 compositing 관점에서 분해해 보고, 재현과 진단 방법, 그리고 CSS/JS에서 바로 적용 가능한 해결 패턴을 정리합니다.

성능 문제를 “증상”이 아니라 “원인 추적”으로 접근하는 방식은 서버/인프라에서도 동일합니다. 예를 들어 느린 응답을 TTFB와 캐시 정책으로 쪼개서 보는 접근은 Next.js App Router TTFB 느림 - RSC 캐시·fetch 설정 글과도 결이 같습니다.

iOS Safari 스크롤 끊김의 핵심: 메인 스레드 vs 합성 스레드

브라우저 렌더링 파이프라인을 아주 단순화하면 다음 흐름입니다.

  • 스타일 계산
  • 레이아웃(리플로우)
  • 페인트(픽셀 그리기)
  • 레이어 분리(필요 시)
  • 합성(compositing): 레이어를 GPU에서 합쳐 화면에 표시

여기서 스크롤이 부드럽게 유지되려면, 스크롤 중에 가능한 한 많은 작업이 합성 단계에서만 일어나야 합니다. 즉, 스크롤로 인해 화면이 움직이더라도 “이미 그려진 레이어를 위치만 바꿔서” 보여줄 수 있으면 프레임이 잘 나옵니다.

반대로 아래 작업이 스크롤 중에 자주 발생하면 끊김(jank)이 생깁니다.

  • 레이아웃이 다시 계산됨(특히 큰 DOM)
  • 큰 영역이 다시 페인트됨(특히 블러, 그림자, 반투명)
  • 스크롤 이벤트 핸들러가 메인 스레드를 점유함(동기 작업)
  • position: fixed 요소가 특정 조건에서 메인 스레드 페인트를 유발

iOS Safari는 특히 레이어 경계가 복잡하거나, 필터/블러/반투명/마스크가 섞여 페인트가 비싸지거나, 고정 요소와 변환(transform)이 얽히는 경우에 취약한 편입니다.

compositing이 문제를 만드는 대표 패턴 6가지

1) backdrop-filter / filter: blur() / 반투명 오버레이

iOS Safari에서 backdrop-filter는 매우 매력적이지만, 스크롤 중 배경이 계속 바뀌는 상황에서는 합성 단계에서 해결되지 않고 지속적인 재샘플링과 페인트 비용이 발생할 수 있습니다.

특히 아래 조합은 위험합니다.

  • 고정 헤더 + backdrop-filter + 큰 배경 이미지
  • 모달 오버레이 + 반투명(rgba) + blur

해결 방향은 대체로 “효과의 범위를 줄이거나”, “스크롤 중에는 효과를 끄고 정지 시 다시 켜기”입니다.

2) position: fixed와 복잡한 자식(특히 그림자/블러)

고정 헤더/탭바는 흔하지만, iOS Safari에서 position: fixed는 상황에 따라 별도 레이어로 분리되거나, 스크롤 중 페인트가 발생하는 방식이 달라집니다. 여기에 box-shadow가 크거나, 내부에 애니메이션이 있거나, 반투명 배경이 있으면 비용이 커집니다.

3) overflow: hidden + border-radius + 큰 컨텐츠

라운드 처리된 카드/컨테이너에 overflow: hidden을 걸고 내부에 이미지/비디오/큰 리스트가 들어가면, 클리핑 비용이 커지고 레이어 분리가 비효율적으로 일어날 수 있습니다.

특히 스크롤 컨테이너 자체가 클리핑을 유발하면 스크롤 중에 합성만으로 해결되지 않는 케이스가 생깁니다.

4) 스크롤 컨테이너 중첩(내부 스크롤)

overflow: auto인 내부 스크롤 영역을 페이지 스크롤과 함께 쓰면, iOS Safari에서 터치 스크롤 처리와 레이어 합성이 꼬여 프레임 드랍이 생기기 쉽습니다.

가능하면 페이지 스크롤 하나로 통일하고, 정말 필요할 때만 내부 스크롤을 사용하세요.

5) 스크롤 이벤트에서 레이아웃을 강제하는 코드

아래 패턴은 스크롤 중 “레이아웃 스래싱(layout thrashing)”을 유발합니다.

  • 스크롤 이벤트에서 getBoundingClientRect() 호출
  • 그 직후 스타일 변경(style.top = ... 등)
  • 다시 측정

측정과 변경이 교차되면 브라우저가 레이아웃을 강제로 확정해야 해서(동기 리플로우) 끊김이 발생합니다.

6) 과도한 레이어 승격(무분별한 will-change)

will-change: transform은 “이 요소를 레이어로 올려서 합성으로 처리해 달라”는 힌트입니다. 하지만 남발하면 레이어가 너무 많아져 메모리와 합성 비용이 증가하고, 오히려 스크롤이 더 끊길 수 있습니다.

Safari iOS에서 원인 찾는 실전 진단 루틴

1) iPhone 실제 기기 + Mac Safari Web Inspector

가장 정확합니다.

  • iPhone 설정에서 Safari 웹 검사 활성화
  • Mac Safari에서 Develop 메뉴로 iPhone 연결
  • Performance/Timelines에서 스크롤 구간을 기록

여기서 보고 싶은 것은 “스크롤 중에 Paint가 계속 발생하는지”, “Layout이 반복되는지”, “Scripting 시간이 튀는지”입니다.

2) 빠른 체크: 효과를 꺼서 범인 좁히기

아래를 한 번에 끄는 토글 CSS를 만들어 원인을 좁힙니다.

  • blur/filter 계열 제거
  • 그림자 제거
  • position: fixedsticky로 교체
  • 애니메이션 중단

효과를 하나씩 복구하면서 “어느 조합에서만 끊기는지”를 찾으면 해결이 빨라집니다.

해결책 1: blur/반투명 효과를 스크롤 중 비활성화

가장 체감 효과가 큰 패턴입니다. 스크롤 중에는 blur를 끄고, 스크롤이 멈춘 뒤 다시 켭니다.

CSS

.header {
  position: sticky;
  top: 0;
  z-index: 1000;
  background: rgba(255, 255, 255, 0.72);
  -webkit-backdrop-filter: blur(14px);
  backdrop-filter: blur(14px);
}

/* 스크롤 중에는 필터 제거 */
.is-scrolling .header {
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  background: rgba(255, 255, 255, 0.92);
}

JS (passive + 타이머)

let scrollTimer = null;

const root = document.documentElement;

window.addEventListener(
  'scroll',
  () => {
    root.classList.add('is-scrolling');

    if (scrollTimer) clearTimeout(scrollTimer);
    scrollTimer = setTimeout(() => {
      root.classList.remove('is-scrolling');
    }, 120);
  },
  { passive: true }
);

포인트는 다음입니다.

  • 이벤트 리스너는 반드시 passive: true로 등록해 스크롤을 막지 않게 합니다.
  • 스크롤 중에는 시각 효과를 단순화해 페인트/합성 부담을 줄입니다.

해결책 2: position: fixed 대신 position: sticky 우선

고정 헤더가 필요할 때 fixed를 먼저 쓰기보다 sticky를 우선 검토하세요. sticky는 스크롤 컨텍스트 안에서 동작해, iOS Safari에서 일부 케이스에 더 안정적으로 합성되는 경우가 많습니다.

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

단, 부모에 overflow: hidden 또는 overflow: auto가 걸려 있으면 sticky가 의도대로 동작하지 않을 수 있으니 레이아웃 구조를 함께 점검해야 합니다.

해결책 3: 스크롤 중 DOM 측정 최소화, requestAnimationFrame으로 배치

스크롤 이벤트에서 측정과 스타일 변경을 즉시 수행하지 말고, 한 프레임에 한 번만 처리하도록 묶습니다.

let ticking = false;

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

  requestAnimationFrame(() => {
    // 측정은 한 번만
    const y = window.scrollY;

    // 스타일 변경도 한 번만
    document.documentElement.style.setProperty('--scroll-y', String(y));

    ticking = false;
  });
}

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

그리고 CSS는 합성 친화적인 속성 위주로 반영합니다.

.fab {
  transform: translate3d(0, calc(var(--scroll-y) * 0px), 0);
  will-change: transform;
}

주의할 점은 will-change를 “필요한 소수 요소에만” 적용하는 것입니다.

해결책 4: 큰 그림자/블러/라운드 클리핑 범위 줄이기

다음은 iOS Safari에서 비용이 커지기 쉬운 조합입니다.

  • box-shadow(블러 반경 큼)
  • 큰 영역의 border-radius + overflow: hidden
  • 반투명 레이어가 여러 겹

개선 팁:

  • 그림자를 단순화하거나, 스크롤 중에는 제거
  • 라운드 클리핑이 꼭 필요하면 컨텐츠 전체를 자르기보다 “겉 래퍼만” 라운드 처리하고 내부는 자르지 않기
  • 반투명 레이어 겹침을 줄이기

예: 카드에서 내부 전체 클리핑을 피하는 구조

<div class="card">
  <div class="card-surface">
    <!-- content -->
  </div>
</div>
.card {
  border-radius: 16px;
  overflow: visible; /* 내부 전체를 자르지 않음 */
}

.card-surface {
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}

해결책 5: 내부 스크롤을 줄이고, 필요 시 -webkit-overflow-scrolling 점검

내부 스크롤이 꼭 필요하다면 iOS에서 관성 스크롤을 위해 다음을 쓰는 경우가 많습니다.

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

하지만 이것도 만능은 아닙니다. 내부 스크롤 영역 위에 고정 요소/블러/복잡한 transform이 겹치면 오히려 문제가 심해질 수 있습니다.

권장 순서:

  1. 내부 스크롤을 없앨 수 있으면 제거
  2. 꼭 필요하면 내부 스크롤 영역의 시각 효과를 최대한 단순화
  3. 내부 스크롤 위에 position: fixed 요소를 겹치지 않게 레이아웃 재구성

해결책 6: 이미지/비디오가 스크롤 중 페인트를 유발하지 않게 하기

  • 큰 이미지에는 적절한 크기의 리소스를 제공(srcset)
  • 화면 밖 컨텐츠는 렌더링을 늦추기

예: content-visibility는 iOS Safari 지원이 완전하지 않을 수 있으니, 리스트가 길면 IntersectionObserver로 “보이는 것만” 렌더링하는 방식이 더 안정적입니다.

const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    if (e.isIntersecting) {
      e.target.dataset.visible = '1';
      io.unobserve(e.target);
    }
  }
}, { rootMargin: '200px' });

document.querySelectorAll('[data-lazy]').forEach((el) => io.observe(el));
[data-lazy]:not([data-visible="1"]) {
  min-height: 180px;
  background: #f2f3f5;
}

체크리스트: iOS Safari 스크롤 최적화 우선순위

  1. 스크롤 중 메인 스레드 작업 제거: 스크롤 이벤트는 passive + requestAnimationFrame
  2. blur, filter, 큰 그림자, 반투명 겹침 최소화(특히 고정 헤더/오버레이)
  3. position: fixed 남발 금지, 가능하면 sticky
  4. 클리핑(overflow: hidden)과 큰 border-radius 조합 점검
  5. will-change는 최소 요소에만, 과도한 레이어 승격 방지
  6. 내부 스크롤 중첩 최소화
  7. 긴 리스트는 “보이는 것만” 렌더링(가상 스크롤 또는 IO 기반 지연 렌더)

마무리: compositing은 만능이 아니라 “비싼 작업을 피하는 전략”

compositing은 스크롤을 부드럽게 만드는 핵심 도구지만, 레이어를 많이 만들거나(무분별한 will-change), 레이어 자체가 비싼 효과(blur, 큰 그림자, 반투명 겹침)를 포함하면 오히려 역효과가 납니다. iOS Safari 스크롤 끊김은 대개 “합성으로 끝낼 수 없는 작업이 스크롤 중에 발생”하는 것이 본질이므로, 스크롤 중에는 단순하게, 정지 상태에서만 고급 효과를라는 원칙이 가장 실전적입니다.

다음 단계로는, 실제 기기에서 Web Inspector로 스크롤 구간을 캡처하고 Paint/Layout/Scripting 중 무엇이 튀는지 확인한 뒤, 이 글의 패턴을 하나씩 적용해 원인을 고립시키는 방식으로 접근해 보세요.