Published on

Safari iOS 스크롤 끊김 해결 - layer·contain 튜닝

Authors

서버나 API가 느린 것도 아닌데, iOS Safari에서만 스크롤이 ‘툭툭’ 끊기는 현상은 프론트엔드 성능 이슈 중에서도 특히 재현/진단이 까다로운 편입니다. 같은 페이지가 Chrome(안드로이드/데스크톱)에서는 부드럽고, iPhone Safari에서만 버벅이면 대개 원인은 네트워크가 아니라 렌더링 파이프라인(레이아웃 → 페인트 → 합성) 쪽에 있습니다.

이 글은 “iOS Safari 스크롤 끊김”을 레이어(layer)와 contain 튜닝 관점에서 정리합니다. 핵심은 다음 두 가지입니다.

  • 스크롤 중에 레이아웃/페인트 작업이 과도하게 발생하지 않게 만들기
  • 합성(compositing)이 필요한 요소를 적절히 레이어로 분리하되, 과도한 레이어 생성으로 메모리/타일링 비용을 키우지 않기

> 참고: 성능 디버깅은 원인 가설을 세우고 체크리스트로 좁히는 방식이 효과적입니다. 비슷한 접근법의 체크리스트 글로는 Assistants API v2 run이 queued나 in_progress에 멈출 때 실전 디버깅 체크리스트도 참고할 만합니다(도메인은 다르지만 ‘증상→가설→검증’ 구조가 유사합니다).

iOS Safari 스크롤 끊김의 대표 원인 6가지

1) 스크롤 중 레이아웃 스래싱(Layout Thrashing)

스크롤 이벤트나 requestAnimationFrame에서 DOM 측정/수정을 섞으면, 브라우저가 레이아웃을 반복 계산합니다.

  • getBoundingClientRect(), offsetHeight 같은 측정(read)
  • style.top = ..., classList.add(...) 같은 변경(write)

read/write가 교차하면 강제 동기 레이아웃이 발생할 수 있습니다.

2) 큰 페인트 영역(특히 blur, box-shadow, filter)

filter: blur()나 큰 box-shadow는 iOS Safari에서 비용이 큽니다. 스크롤로 인해 해당 요소가 계속 다시 그려지면 FPS가 급락합니다.

3) 고정 요소(fixed/sticky) + 복잡한 배경

position: sticky/fixed가 많은 UI(헤더, 탭바, 떠다니는 버튼 등)에서, 배경이 복잡하거나 투명/블러가 있으면 합성 비용이 커집니다.

4) 과도한 레이어 승격(will-change 남발)

will-change: transform을 남발하면 “부드러워질 것 같지만” 실제로는 레이어가 과도하게 생겨 메모리/타일링/합성 비용 증가로 더 느려질 수 있습니다.

5) 스크롤 컨테이너 중첩과 -webkit-overflow-scrolling: touch

예전에는 내부 스크롤에 -webkit-overflow-scrolling: touch를 많이 썼지만, 중첩 스크롤/복잡한 DOM과 만나면 오히려 버벅임이 나타나는 케이스가 있습니다(특히 sticky와 결합).

6) 이미지/비디오 디코딩과 리사이즈

스크롤 중에 큰 이미지가 리사이즈되거나, object-fit/background-size: cover가 많은 경우 디코딩/리샘플링 비용이 커질 수 있습니다.

진단: “레이아웃·페인트·합성” 중 어디가 병목인가

iOS Safari는 데스크톱 Chrome처럼 풍부한 오버레이가 항상 있진 않지만, 여전히 Safari Web Inspector로 접근하면 많은 것을 볼 수 있습니다.

1) iPhone Safari 원격 디버깅 연결

  • macOS Safari → Develop 메뉴 활성화
  • iPhone 설정에서 Web Inspector 활성화
  • 케이블/동일 네트워크로 연결 후 Develop에서 페이지 선택

2) 스크롤 중 어떤 작업이 튀는지 관찰

  • Layout 이벤트가 자주/길게 발생하면: DOM 측정/변경 패턴 의심
  • Paint가 크면: blur/shadow/큰 배경/대형 요소 의심
  • Composite가 크면: 레이어 수 과다, 큰 fixed 요소, 투명/필터 의심

해결 전략 1: 레이어(layer) 승격을 “필요한 곳에만”

핵심은 “스크롤 중 계속 다시 그려지는 요소”를 합성 레이어로 분리해 페인트를 줄이는 것입니다. 하지만 iOS Safari에서 레이어는 공짜가 아니므로, 딱 2~5개 정도의 핵심 요소에만 적용하는 식으로 접근하는 것이 안전합니다.

1) 헤더/툴바 같은 고정 UI를 레이어로 분리

/* 고정 헤더가 스크롤과 함께 재페인트되는 경우 */
.app-header {
  position: sticky;
  top: 0;
  z-index: 100;

  /* 레이어 승격 후보 */
  will-change: transform;
  transform: translateZ(0);
}
  • translateZ(0)은 레이어 승격을 유도하는 전통적인 트릭입니다.
  • will-change는 “곧 변할 것”을 힌트로 주지만 남발 금지입니다.

언제 효과가 큰가?

  • sticky/fixed 요소가 투명 배경이거나 반투명 오버레이가 있을 때
  • 스크롤 중 헤더 내부 애니메이션(예: 축소)이 있을 때

언제 역효과인가?

  • 헤더가 매우 크거나(높이 큼), 내부에 이미지/블러/필터가 많아 레이어 자체가 무거울 때

2) blur/filter를 레이어로 “격리”하거나 제거

backdrop-filter는 특히 iOS에서 비쌉니다. 가능하면 스크롤 중에는 끄거나, 단순한 반투명 색으로 대체합니다.

/* 스크롤 중 성능이 떨어지는 backdrop-filter 대체 */
.glass {
  background: rgba(255, 255, 255, 0.85);
  /* backdrop-filter: blur(12px); */
}

/* 꼭 필요하다면, 적용 범위를 최소화 */
.glass--small {
  width: fit-content;
  padding: 8px 12px;
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
}

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

스크롤과 동시에 동작하는 UI(예: FAB 숨김/등장)는 top/left/height가 아니라 transform/opacity로 처리해야 레이아웃/페인트를 최소화합니다.

.fab {
  position: fixed;
  right: 16px;
  bottom: 16px;
  transition: transform 180ms ease, opacity 180ms ease;
  will-change: transform, opacity;
}

.fab.is-hidden {
  transform: translateY(24px);
  opacity: 0;
  pointer-events: none;
}

해결 전략 2: contain으로 “레이아웃/페인트 전파” 끊기

contain은 브라우저에게 “이 요소 내부 변화가 바깥 레이아웃/페인트에 영향을 덜 준다”는 힌트를 줍니다. 스크롤 성능 최적화에서 가장 실전적인 도구 중 하나입니다.

1) 카드 리스트/피드 아이템에 contain 적용

피드/리스트가 길고 각 아이템이 복잡하다면, 아이템 단위로 렌더링 비용을 격리하는 것이 효과적입니다.

.feed-item {
  /* 레이아웃/페인트를 아이템 내부로 가둠 */
  contain: layout paint;

  /* iOS에서 텍스트/이미지 조합이 많을 때 도움이 되는 경우가 있음 */
  content-visibility: auto;
  contain-intrinsic-size: 200px; /* 대략적인 높이 힌트 */
}
  • content-visibility: auto는 화면 밖 요소의 렌더링을 미루는 데 유용합니다.
  • 다만 iOS Safari 버전에 따라 지원/효과가 다를 수 있으니, 점진적 적용(지원 여부 체크)로 접근하세요.
@supports (content-visibility: auto) {
  .feed-item {
    content-visibility: auto;
    contain-intrinsic-size: 200px;
  }
}

2) 모달/드로어 같은 큰 컴포넌트는 contain으로 격리

모달이 열릴 때 배경 전체가 리페인트되는 느낌이라면, 모달 컨테이너에 contain을 주고 내부를 transform으로 띄우는 식이 좋습니다.

.modal-root {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: center;

  /* 모달 내부 변화가 페이지 전체 레이아웃에 영향을 주지 않도록 */
  contain: layout paint;
}

.modal {
  width: min(92vw, 520px);
  border-radius: 16px;
  background: #fff;
  transform: translateZ(0);
}

해결 전략 3: 스크롤 이벤트 처리 최소화(특히 passive)

iOS Safari에서 스크롤 중 JS가 개입하면 체감이 급격히 나빠질 수 있습니다. 스크롤 이벤트를 꼭 써야 한다면:

  • passive: true로 메인 스레드 블로킹을 줄이고
  • 측정/변경을 분리하고
  • 가능하면 IntersectionObserver로 대체합니다.

1) passive 스크롤 리스너

window.addEventListener(
  'scroll',
  () => {
    // 가벼운 상태 업데이트만
  },
  { passive: true }
);

2) 레이아웃 스래싱 방지: read/write 분리

let ticking = false;

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

  requestAnimationFrame(() => {
    // read
    const y = window.scrollY;

    // write (class 토글 정도로 제한)
    document.documentElement.classList.toggle('scrolled', y > 8);

    ticking = false;
  });
}

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

3) 스크롤 기반 로딩/노출은 IntersectionObserver로

const io = new IntersectionObserver(
  (entries) => {
    for (const e of entries) {
      if (e.isIntersecting) {
        e.target.classList.add('in-view');
        io.unobserve(e.target);
      }
    }
  },
  { rootMargin: '200px 0px' }
);

document.querySelectorAll('.feed-item').forEach((el) => io.observe(el));

해결 전략 4: 스크롤 컨테이너 구조 단순화

iOS Safari에서 중첩 스크롤 컨테이너(예: body도 스크롤, 내부 div도 스크롤)가 많으면 스크롤 체인이 복잡해지고, sticky/fixed와 충돌이 늘어납니다.

권장 패턴

  • 가능하면 페이지 스크롤은 body 하나로 통일
  • 내부 스크롤은 정말 필요한 영역(예: 채팅 메시지)만

-webkit-overflow-scrolling: touch는 신중히

.chat-scroll {
  overflow: auto;
  -webkit-overflow-scrolling: touch;
}
  • iOS에서 관성 스크롤을 위해 쓰지만, sticky/transform과 결합하면 문제가 생길 수 있습니다.
  • 버벅임이 심하면 이 옵션을 제거하거나, 내부 요소의 레이어/contain을 재조정해보세요.

실전 튜닝 레시피: “헤더 + 피드” 페이지 예시

아래는 iOS Safari에서 흔한 “sticky 헤더 + 카드 피드”의 기본 튜닝 세트입니다.

/* 1) 헤더는 합성 레이어 후보 (남발 금지) */
.header {
  position: sticky;
  top: 0;
  z-index: 10;
  background: rgba(255, 255, 255, 0.98);
  will-change: transform;
  transform: translateZ(0);
}

/* 2) 피드 아이템은 contain으로 격리 */
.card {
  contain: layout paint;
}

/* 3) 무거운 효과 최소화 */
.card {
  /* box-shadow는 작고 단순하게 */
  box-shadow: 0 2px 10px rgba(0,0,0,0.08);
}

/* 4) 이미지 리사이즈 비용 줄이기: 고정 비율 박스 */
.thumb {
  aspect-ratio: 16 / 9;
  overflow: hidden;
}

.thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

그리고 스크롤 이벤트는 되도록 UI 클래스 토글 정도로 제한합니다.

let rafId = 0;

window.addEventListener(
  'scroll',
  () => {
    if (rafId) return;
    rafId = requestAnimationFrame(() => {
      const y = window.scrollY;
      document.body.classList.toggle('is-top', y < 8);
      rafId = 0;
    });
  },
  { passive: true }
);

자주 하는 실수: will-change를 “보험”처럼 걸어두기

will-change는 성능을 올려주는 마법 주문이 아니라, 브라우저에 메모리/리소스 예약을 요구하는 힌트입니다.

  • 상시 적용: 레이어 과다 → 메모리 증가 → 오히려 스크롤 드랍
  • 필요한 순간에만 적용: 애니메이션 시작 직전에 추가하고 끝나면 제거
function promote(el) {
  el.style.willChange = 'transform';
}

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

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

// 예: 특정 인터랙션 동안만 승격
header.addEventListener('pointerdown', () => promote(header));
header.addEventListener('pointerup', () => demote(header));
header.addEventListener('pointercancel', () => demote(header));

체크리스트: iOS Safari 스크롤 끊김을 30분 안에 줄이는 순서

  1. 무거운 시각 효과 제거/축소: blur/backdrop-filter/큰 shadow부터 의심
  2. sticky/fixed 요소에 대해 레이어 분리(선별 적용): transform: translateZ(0) + 최소한의 will-change
  3. 긴 리스트는 contain 적용: contain: layout paint부터 시작
  4. 스크롤 이벤트는 passive + rAF + 최소 변경
  5. 중첩 스크롤을 줄이고, -webkit-overflow-scrolling: touch는 케이스별로 재검토

맺음말

iOS Safari 스크롤 끊김은 대부분 “JS가 느려서”가 아니라, 레이아웃/페인트/합성의 경계에서 비용이 터지는 지점을 건드렸을 때 발생합니다. 이때 가장 효과적인 접근은:

  • 레이어 승격을 핵심 UI에만 제한하고
  • contain으로 렌더링 비용의 전파를 끊고
  • 스크롤 중 JS 개입을 최소화하는 것

입니다.

성능 이슈는 한 번에 완벽히 해결하기보다, 가설을 세우고 변경을 작게 가져가며 비교하는 방식이 정답에 가깝습니다. 특히 iOS Safari는 버전/기기/페이지 구조에 따라 결과가 달라지므로, 위 튜닝을 “세트로 적용”하기보다 병목을 확인하면서 하나씩 적용해 보세요.