Published on

Safari만 스크롤 잔상? Compositing 버그 디버깅

Authors

Safari에서만 스크롤할 때 콘텐츠가 잔상처럼 남거나, 특정 영역이 늦게 갱신되거나, 스크롤 중 텍스트가 순간적으로 겹쳐 보이는 이슈를 종종 만납니다. Chrome이나 Firefox에서는 멀쩡한데 Safari에서만 발생하면 팀 내에서 재현 환경을 맞추는 것부터 난관입니다.

이 글은 원인을 단정하기보다, compositing(GPU 합성)과 painting(픽셀 다시 그리기) 경계에서 생기는 Safari 특유의 버그를 어떻게 좁혀가며 최종적으로 현실적인 해결책(우회 포함)을 선택할지에 초점을 맞춥니다.

증상 패턴: “잔상”은 대부분 레이어 경계 문제

Safari 스크롤 잔상은 대개 아래 중 하나로 나타납니다.

  • 스크롤 후 이전 프레임이 일부 남아 “덜 지워진” 것처럼 보임
  • position: sticky 헤더 아래 내용이 순간적으로 겹치거나 깜빡임
  • overflow: auto 컨테이너 내부에서만 잔상이 발생
  • transform 또는 filter가 적용된 조상 요소가 있을 때만 발생
  • iOS Safari에서만 발생하고 macOS Safari에서는 덜하거나 반대로 더 심함

이런 유형은 “레이어가 분리되어 합성되는데, 스크롤에 따른 repaint invalidation(다시 그려야 하는 영역 계산)이 어긋나는” 상황에서 자주 생깁니다.

1단계: 재현 최소화와 원인 후보 분리

Safari 전용 버그는 코드가 복잡할수록 접근이 어려우니, 먼저 문제 영역을 레이어 단위로 분리합니다.

체크리스트: 가장 흔한 트리거

  • position: sticky + backdrop-filter 또는 반투명 배경
  • transform이 걸린 조상(특히 transform: translateZ(0) 같은 강제 합성)
  • filter, mix-blend-mode, mask, clip-path
  • will-change 남용
  • overflow: hidden/auto가 겹겹이 중첩
  • box-shadow 또는 blur 계열
  • 비디오, 캔버스, WebGL, Lottie 등 지속 렌더링 요소

재현 최소화의 목표는 “어떤 속성 조합에서만 깨지는지”를 찾는 겁니다.

빠른 축소 방법

  1. 문제 페이지에서 의심 요소를 하나씩 display: none 처리
  2. 잔상이 사라지는 지점을 찾으면, 그 요소의 조상까지 거슬러 올라가며 CSS를 제거
  3. 최종적으로 “이 CSS가 들어가면 깨진다” 수준의 최소 케이스를 만듭니다

이 과정은 Chrome의 Layout Shift 원인 추적처럼, 원인을 좁혀가는 방식 자체가 핵심입니다. 디버깅 접근법은 아래 글도 함께 보면 도움이 됩니다.

2단계: Safari에서 합성 레이어를 의도적으로 “정리”하기

Safari는 합성 레이어 전략이 Chrome과 다르고, 특정 속성 조합에서 레이어가 쪼개졌다가 다시 합쳐지며 페인팅 경계가 꼬이는 경우가 있습니다.

여기서 중요한 전략은 둘 중 하나입니다.

  • 레이어를 더 확실히 분리해서 서로 간섭하지 않게 만들기
  • 반대로 레이어 분리를 줄여서 Safari가 덜 복잡하게 합성하게 만들기

패턴 A: 문제 요소를 별도 레이어로 격리

대표적으로 transform: translateZ(0) 또는 will-change: transform을 써서 레이어를 분리하는 우회가 있습니다. 다만 남용하면 메모리와 배터리를 갉아먹습니다.

/* 잔상이 발생하는 영역을 별도 레이어로 격리 */
.ghosting-zone {
  will-change: transform;
  transform: translateZ(0);
}

주의점

  • will-change는 “곧 변화할 것”에만 제한적으로 사용해야 합니다
  • 장시간 유지하면 GPU 메모리 압박으로 오히려 프레임 드랍이 날 수 있습니다

패턴 B: 불필요한 합성 트리거 제거

반대로 다음 속성들이 조상에 걸려 있으면 자식의 스크롤 영역이 이상해질 수 있습니다.

/* 조상에 transform이 걸리면 sticky/overflow와 충돌하는 사례가 많음 */
.page-wrapper {
  /* transform: translateZ(0); */
  /* filter: blur(0); */
}

특히 position: sticky가 있는 섹션의 조상에 transform이 걸려 있으면 Safari에서 스크롤 중 잔상, 깜빡임, 오프셋 오류가 동반되는 경우가 많습니다.

3단계: contain으로 페인트 경계를 명확히 하기

Safari에서 스크롤 관련 잔상은 “어디까지 다시 그려야 하는지”를 잘못 계산하는 느낌으로 나타나는 경우가 있습니다. 이때 contain으로 레이아웃과 페인트 경계를 명확히 해주면 해결되는 케이스가 있습니다.

/* 스크롤 컨테이너 또는 잔상 발생 블록에 적용 */
.section {
  contain: layout paint;
}

/* 또는 페인트만 격리 */
.card-list {
  contain: paint;
}

이미 iOS Safari에서 스크롤이 끊기거나 이상하게 보이는 문제를 contain으로 완화한 사례가 있습니다.

contain 적용 시 체크할 것

  • position: sticky가 기대대로 동작하는지 확인
  • 내부 요소의 position: fixed 또는 포털 UI가 잘리는지 확인
  • 접근성 포커스 링이 잘리는지 확인

4단계: 스크롤 컨테이너 구조를 단순화하기

Safari는 중첩 스크롤(overflow: auto inside overflow: auto)에서 합성 문제가 더 자주 발생합니다. 가능하면 다음을 지향합니다.

  • 스크롤은 가능한 한 “페이지 전체 한 번”으로 끝내기
  • 내부 스크롤 컨테이너가 필요하면, 그 안에서 position: sticky 조합을 최소화
  • 스크롤 컨테이너 내부에 backdrop-filter 같은 고비용 효과를 두지 않기

예시: 중첩 스크롤 제거

/* 나쁜 예: 중첩 스크롤이 많고, 각 레이어가 합성됨 */
.modal {
  overflow: auto;
}
.modal .body {
  overflow: auto;
}

/* 개선 예: 스크롤 책임을 한 곳으로 */
.modal {
  overflow: hidden;
}
.modal .body {
  overflow: auto;
  -webkit-overflow-scrolling: touch;
}

-webkit-overflow-scrolling: touch는 iOS에서 관성 스크롤을 켜지만, 특정 버그 조합에서는 오히려 잔상을 악화시키는 경우도 있어 A/B로 확인해야 합니다.

5단계: “문제 조합”을 의심하라: sticky + blur + 투명

실무에서 가장 자주 본 조합은 다음입니다.

  • 상단 고정 헤더: position: sticky; top: 0;
  • 반투명 배경: background: rgba(...)
  • 블러: backdrop-filter: blur(...)

Safari는 이 조합에서 스크롤 중 아래 콘텐츠가 헤더에 “묻어” 보이거나, 헤더 영역이 이전 프레임을 잡아두는 것처럼 보일 때가 있습니다.

우회 1: blur를 포기하고 유사 효과로 대체

.header {
  position: sticky;
  top: 0;
  background: rgba(20, 20, 20, 0.92);
  /* backdrop-filter: blur(12px); */
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

우회 2: blur는 유지하되 레이어 격리

.header {
  position: sticky;
  top: 0;
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);

  /* Safari에서 합성 경계를 안정화하려는 목적 */
  transform: translateZ(0);
  will-change: transform;
}

이 우회는 효과가 있는 대신, 헤더가 많거나 화면이 복잡하면 GPU 비용이 늘 수 있습니다.

6단계: iOS Safari 주소창 리사이즈와 vh 함정

iOS Safari는 스크롤 중 주소창이 접히고 펼쳐지며 viewport 높이가 바뀝니다. 이때 100vh 기반 레이아웃이 스크롤 중 재계산되면서, 페인트가 어긋나 “잔상처럼” 느껴지는 현상이 동반될 수 있습니다.

대안: dvh 또는 JS로 CSS 변수 고정

/* 지원 브라우저에서는 동적 뷰포트 단위를 사용 */
.full {
  height: 100dvh;
}

호환성 때문에 JS로 --vh를 고정하는 패턴도 여전히 씁니다.

function setVh() {
  const vh = window.innerHeight * 0.01;
  document.documentElement.style.setProperty('--vh', `${vh}px`);
}

setVh();
window.addEventListener('resize', setVh);
.full {
  height: calc(var(--vh) * 100);
}

7단계: 디버깅용 “강제 리페인트”는 최후의 수단

정말 막혔을 때, 스크롤 이벤트에서 특정 요소에 클래스를 토글해 리페인트를 유도하면 잔상이 사라지는 경우가 있습니다. 하지만 이는 성능과 배터리에 악영향을 주므로 최후의 수단으로만 봐야 합니다.

const el = document.querySelector('.ghosting-zone');
let raf = 0;

window.addEventListener('scroll', () => {
  if (raf) return;
  raf = requestAnimationFrame(() => {
    el.classList.add('force-repaint');
    // 다음 프레임에 제거
    requestAnimationFrame(() => {
      el.classList.remove('force-repaint');
      raf = 0;
    });
  });
}, { passive: true });
/* 레이아웃을 건드리지 않는 선에서 페인트를 흔드는 트릭 */
.force-repaint {
  outline: 1px solid transparent;
}

이 방식이 먹힌다면, 근본 원인은 “Safari가 특정 상황에서 repaint를 누락”하는 쪽일 가능성이 큽니다. 이후에는 contain 적용, 레이어 격리, 문제 속성 제거 같은 정석 우회로 되돌아가야 합니다.

8단계: 실제 해결 전략 선택 기준

Safari-only 잔상은 “버그를 완전히 없애는 정답”이 아니라, 제품 요구사항과 비용 사이에서 타협하는 문제가 되는 경우가 많습니다.

  • 디자인 효과(blur, shadow, blend)가 핵심 가치인가
  • 잔상이 발생하는 페이지가 트래픽 상위인가
  • iOS만 문제인가, macOS도 문제인가
  • 저사양 기기에서만 재현되는가

권장 우선순위는 보통 다음과 같습니다.

  1. 문제 CSS 조합 제거 또는 단순화
  2. contain으로 경계 명확화
  3. 레이어 격리(translateZ(0), 제한적 will-change)
  4. 최후의 수단으로 강제 리페인트 트릭

결론: “합성 경계”를 설계하고, Safari를 단순하게 만들기

Safari에서만 보이는 스크롤 잔상은 대개 합성 레이어와 페인팅 경계가 꼬이면서 생깁니다. 디버깅의 핵심은 (1) 재현을 최소화해 트리거를 특정하고, (2) 레이어를 더 명확히 격리하거나 불필요한 합성 트리거를 제거해 Safari가 덜 헷갈리게 만드는 것입니다.

특히 position: stickybackdrop-filter, 중첩 overflow, 조상의 transform 조합은 1순위 의심 대상으로 두고, contain을 통한 경계 설정을 적극적으로 검토해 보세요. 동일한 UI를 유지해야 한다면 제한적으로 레이어 격리를 적용하고, 성능 측정까지 포함해 최종안을 결정하는 것이 안전합니다.