Published on

Safari iOS 스크롤 잔상·jank 7분 해결법

Authors

iOS Safari에서 스크롤할 때 화면이 순간적으로 깨져 보이거나(잔상), 프레임이 뚝뚝 끊기며(jank) 터치 반응이 늦는 현상은 생각보다 흔합니다. 특히 position: fixed 헤더, overflow: auto로 만든 내부 스크롤 영역, 스크롤 중 DOM 업데이트, 무거운 그림자/블러 효과가 겹치면 재현이 쉬워집니다.

이 글은 “정확한 원인 추적”보다 “7분 안에 체감 개선”을 목표로 합니다. 아래 체크리스트를 위에서부터 적용하면 대다수 케이스에서 즉시 좋아집니다.

참고: 이 글은 프론트 이슈지만, 장애 대응처럼 체크리스트 기반으로 접근하면 시간을 크게 줄일 수 있습니다. 캐시/렌더링 이슈를 비슷한 방식으로 다룬 글로는 Next.js 14 ISR 캐시가 안 갱신될 때 원인·해결, Next.js App Router RSC 캐시 꼬임 해결 가이드도 함께 참고할 만합니다.

0분 준비: 증상 유형을 먼저 분류하기

아래 중 어디에 가까운지 고르면 원인 범위가 급격히 좁아집니다.

  • 잔상/깜빡임(ghosting): 스크롤 중 텍스트나 카드가 잠깐 이전 위치에 남아 보이거나, fixed 요소가 흔들리는 느낌
  • jank(끊김): 스크롤이 계단식으로 움직이고 프레임 드랍이 체감됨
  • 터치 지연: 스크롤은 되는데 터치/클릭이 늦게 반응하거나, 스크롤 시작이 무거움

이 글의 처방은 위 3가지를 모두 커버하지만, 특히 iOS Safari에서 자주 터지는 조합은 다음입니다.

  • position: fixed + 큰 영역 backdrop-filter/filter/box-shadow
  • -webkit-overflow-scrolling: touch가 걸린 내부 스크롤 컨테이너
  • 스크롤 중 setState/DOM 측정(getBoundingClientRect) 반복
  • 큰 이미지/웹폰트가 스크롤 중 늦게 로드되며 레이아웃 변동

1분: 스크롤 중 JS 작업을 0에 가깝게 만들기

iOS Safari의 스크롤은 메인 스레드 작업에 민감합니다. 스크롤 이벤트에서 레이아웃 측정과 상태 업데이트를 하면 jank가 바로 발생합니다.

스크롤 이벤트는 passive로, 업데이트는 requestAnimationFrame으로

let ticking = false;

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

  requestAnimationFrame(() => {
    // DOM 읽기/쓰기 최소화
    // 예: 헤더 그림자 토글, 현재 섹션 계산 등
    ticking = false;
  });
}

window.addEventListener('scroll', onScroll, { passive: true });
  • passive: true는 브라우저가 “이 핸들러가 스크롤을 막지 않는다”고 확신하게 만들어 스크롤 경로를 가볍게 합니다.
  • requestAnimationFrame으로 프레임 단위로 묶으면 과도한 이벤트 폭주를 줄입니다.

스크롤 중 레이아웃 측정 금지

아래 패턴이 있다면 우선 제거하거나, 측정을 캐싱하고 resize 시에만 갱신하세요.

  • getBoundingClientRect()
  • offsetHeight, offsetTop
  • scrollTop을 읽고 곧바로 스타일을 바꾸는 로직(읽기/쓰기 교차)

2분: position: fixed 흔들림과 잔상 줄이기

iOS Safari는 fixed 요소가 합성 레이어를 잘못 타거나, 스크롤 중 리페인트 비용이 커지면 잔상이 생기기 쉽습니다.

fixed 헤더/바텀바를 합성 레이어로 분리

.header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  transform: translateZ(0);
  will-change: transform;
  backface-visibility: hidden;
}
  • translateZ(0)는 GPU 합성 레이어로 올리는 “강제 힌트”로 자주 쓰입니다.
  • 단, will-change는 남용하면 메모리를 잡아먹습니다. fixed처럼 항상 떠 있는 요소에만 제한적으로 적용하세요.

fixed 위에 무거운 효과를 얹지 말기

특히 아래는 iOS에서 비용이 큽니다.

  • backdrop-filter: blur(...)
  • box-shadow 여러 겹
  • filter: blur(...) 같은 픽셀 기반 필터

가능하면 스크롤 중에는 효과를 끄고, 멈췄을 때만 켜는 방식이 체감이 큽니다.

let timer;

window.addEventListener(
  'scroll',
  () => {
    document.documentElement.classList.add('is-scrolling');
    clearTimeout(timer);
    timer = setTimeout(() => {
      document.documentElement.classList.remove('is-scrolling');
    }, 120);
  },
  { passive: true }
);
/* 스크롤 중에는 무거운 효과 제거 */
.is-scrolling .header {
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: none;
}

/* 평상시 효과 */
.header {
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}

3분: 내부 스크롤(overflow: auto)은 가능하면 없애기

iOS Safari에서 “페이지 스크롤”과 “내부 스크롤 컨테이너”가 섞이면 잔상과 터치 이상이 늘어납니다. 대표적으로 모달, 바텀시트, 사이드 패널에서 overflow: auto를 쓰는 경우입니다.

우선순위: 내부 스크롤을 제거하고 body 스크롤만 쓰기

모달을 띄울 때는 배경 스크롤을 잠그고, 모달 내용은 가능한 한 문서 흐름 안에서 스크롤되게 구성하는 편이 안정적입니다.

바디 스크롤 잠금 예시

function lockBodyScroll() {
  const scrollY = window.scrollY;
  document.body.style.position = 'fixed';
  document.body.style.top = `-${scrollY}px`;
  document.body.style.left = '0';
  document.body.style.right = '0';
  document.body.dataset.scrollY = String(scrollY);
}

function unlockBodyScroll() {
  const scrollY = Number(document.body.dataset.scrollY || '0');
  document.body.style.position = '';
  document.body.style.top = '';
  document.body.style.left = '';
  document.body.style.right = '';
  delete document.body.dataset.scrollY;
  window.scrollTo(0, scrollY);
}
  • iOS에서 흔한 overflow: hidden만으로는 스크롤 잠금이 완벽하지 않은 경우가 많습니다.
  • 위 패턴은 “현재 스크롤 위치를 고정”하는 방식이라 재현성이 좋습니다.

내부 스크롤이 꼭 필요하면

-webkit-overflow-scrolling: touch는 관성 스크롤을 주지만, 조합에 따라 잔상/깜빡임을 유발하기도 합니다. 문제가 심하면 오히려 제거해 보세요.

.sheetBody {
  overflow: auto;
  /* 문제가 있으면 아래를 빼서 비교 */
  -webkit-overflow-scrolling: touch;
}

4분: 터치 이벤트가 스크롤을 막고 있는지 확인

touchstart/touchmove에서 preventDefault()를 호출하거나, 이벤트 리스너가 passive가 아니면 스크롤 경로가 무거워질 수 있습니다.

touchmove를 써야 한다면 passive 여부를 의식하기

// 스크롤을 막을 필요가 없다면 passive로
window.addEventListener('touchmove', () => {
  // do nothing
}, { passive: true });

반대로, 정말로 preventDefault()가 필요하면(예: 커스텀 드래그) 해당 영역을 최소화하고, 페이지 전체에 걸지 마세요.

CSS touch-action은 iOS에서 제한적이지만 시도 가치가 있음

.draggable {
  touch-action: pan-y;
}

브라우저/버전에 따라 효과가 다르므로, “적용 후 체감 개선이 있는지” 기준으로 판단하세요.

5분: 이미지/폰트 레이아웃 변동(=스크롤 중 흔들림) 제거

잔상처럼 보이지만 실제로는 레이아웃이 계속 재배치되어 발생하는 경우가 많습니다.

이미지에 width/height 또는 aspect-ratio를 지정

<img src="/banner.jpg" alt="banner" width="1200" height="600" loading="lazy" />

또는 CSS로:

.cardThumb {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

웹폰트는 font-display: swap으로 지연 교체

@font-face {
  font-family: 'MyFont';
  src: url('/fonts/myfont.woff2') format('woff2');
  font-display: swap;
}

폰트가 늦게 뜨면서 글자 폭이 바뀌면 스크롤 중 “미세한 점프”가 생기고, 이게 jank로 체감될 수 있습니다.

6분: 페인트 비용 큰 CSS를 줄여서 합성 중심으로 바꾸기

iOS Safari에서 특히 비싼 스타일:

  • 큰 영역 box-shadow
  • filter, backdrop-filter
  • mix-blend-mode
  • 반투명 레이어가 여러 겹 겹치는 구조

애니메이션은 transform/opacity

.toast {
  transform: translateY(12px);
  opacity: 0;
  transition: transform 180ms ease, opacity 180ms ease;
}

.toast.isOpen {
  transform: translateY(0);
  opacity: 1;
}

레이아웃을 바꾸는 top, height 애니메이션은 스크롤 성능을 크게 해칩니다.

contain으로 페인트 범위를 제한

.cardList {
  contain: content;
}
  • contain은 브라우저 최적화 힌트입니다.
  • 모든 레이아웃에 무작정 쓰면 예상치 못한 부작용이 있을 수 있으니, 독립적인 컴포넌트 단위(카드 리스트, 사이드 패널)로 제한하세요.

7분: iOS Safari 전용 핫픽스(최후의 수단)

위 조치로도 특정 iOS 버전에서 잔상이 남는 경우가 있습니다. 그때는 “문제 요소만” 타겟으로 한정해 우회하세요.

스크롤 잔상이 심한 요소에 translate3d 적용

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

텍스트 깜빡임이 의심되면

.text {
  -webkit-font-smoothing: antialiased;
}

효과는 케이스 바이 케이스지만, 특정 조합에서 잔상을 완화하는 경우가 있습니다.

빠른 진단 체크리스트(복붙용)

  • 스크롤 이벤트에 passive: true 적용
  • 스크롤 핸들러에서 레이아웃 측정/상태 업데이트 제거 또는 requestAnimationFrame로 묶기
  • fixed 요소에 transform: translateZ(0)will-change: transform 제한 적용
  • 스크롤 중 backdrop-filter/큰 box-shadow 비활성화
  • 내부 스크롤 컨테이너 최소화, 필요 시 -webkit-overflow-scrolling: touch 제거 테스트
  • 이미지 width/height 또는 aspect-ratio로 CLS 제거
  • 웹폰트 font-display: swap

마무리: “스크롤 중엔 아무것도 하지 않는다”가 정답에 가깝다

iOS Safari 스크롤 잔상·jank는 단일 원인이라기보다 “스크롤 중 메인 스레드 점유 + 페인트 비용 폭증 + 레이아웃 변동”의 합성 결과인 경우가 대부분입니다. 그래서 해결도 한 방짜리 옵션보다, 위 체크리스트처럼 비용을 줄이는 방향으로 빠르게 정리하는 게 효과적입니다.

만약 Next.js 기반 서비스에서 이 문제가 특정 페이지에서만 심하다면, 해당 페이지의 클라이언트 컴포넌트 비중과 스크롤 중 상태 업데이트를 우선 의심해 보세요. 캐시/렌더링 경계가 꼬여서 불필요한 리렌더가 발생하는 경우도 있어, 필요하면 Next.js App Router RSC 캐시 꼬임 해결 가이드에서 소개한 “경계 정리” 접근을 함께 적용하면 좋습니다.