Published on

Safari iOS에서 position - sticky 깨짐 원인·해결

Authors

서브 헤더, 필터 바, 섹션 탭처럼 position: sticky는 “스크롤하다가 특정 지점부터 붙어있는” UI를 가장 간단히 구현하는 도구입니다. 그런데 iOS Safari에서는 같은 코드가 데스크톱 크롬/안드로이드에서는 멀쩡한데, 아예 sticky가 안 붙거나, 중간에 풀리거나, 스크롤 중 덜컥거리며 점프하는 문제가 자주 발생합니다.

이 글은 iOS Safari에서 sticky가 “깨져 보이는” 현상을 원인별로 분류하고, 각 케이스에 맞는 해결책/우회책을 코드와 함께 정리합니다. 프론트엔드 이슈도 결국은 “환경 가정이 깨진 것”이므로, 인프라 트러블슈팅처럼 체크리스트 방식으로 접근하면 빠르게 수습할 수 있습니다. (비슷한 진단형 글 스타일은 Next.js 14 RSC 느림? TTFB 급증 7가지 해결도 참고할 만합니다.)

iOS Safari sticky의 기본 동작 조건

position: sticky는 단순히 top: 0만 주면 끝이 아니라, 아래 조건들이 맞아야 합니다.

  • sticky 요소는 **스크롤 컨테이너(overflow가 scroll/auto인 조상)**를 기준으로 동작합니다.
  • sticky가 “붙을 수 있는 범위”는 부모 블록의 경계에 의해 제한됩니다.
  • 조상 중 특정 CSS 속성(대표적으로 overflow, transform, contain, -webkit-overflow-scrolling)이 있으면 iOS Safari에서 계산이 달라져 깨질 수 있습니다.

가장 먼저, 아래처럼 최소 재현을 만들어 iOS Safari에서 정상 동작하는 기준점을 확보하세요.

<div class="page">
  <header class="sticky">Sticky Header</header>
  <main>
    <p>... lots of content ...</p>
  </main>
</div>
.page {
  /* 기본은 overflow: visible */
}

.sticky {
  position: sticky;
  top: 0;
  z-index: 10;
  background: white;
}

여기서부터 “내 앱의 레이아웃”을 한 겹씩 덧씌우며 어느 순간 깨지는지 확인하면 원인 추적이 매우 빨라집니다.

원인 1) 조상 요소의 overflow가 sticky를 무력화

가장 흔한 케이스입니다. sticky 요소의 조상 중 하나라도 아래처럼 되어 있으면 sticky 기준 컨테이너가 바뀌거나(혹은 iOS Safari에서 버그로) sticky가 풀립니다.

  • overflow: hidden
  • overflow: auto/scroll (의도치 않은 스크롤 컨테이너 생성)
  • overflow-x: hidden + overflow-y: auto 같은 조합

증상

  • 데스크톱에서는 정상인데 iOS에서만 sticky가 안 붙음
  • 특정 구간에서만 sticky가 풀림

해결

  1. sticky의 조상에서 불필요한 overflow를 제거합니다.
/* 나쁜 예: 레이아웃 정리 목적으로 hidden을 줬는데 sticky가 깨짐 */
.wrapper {
  overflow: hidden;
}

/* 가능한 해결: overflow를 visible로 되돌리고 다른 방식으로 클리핑 */
.wrapper {
  overflow: visible;
}
  1. 클리핑이 꼭 필요하면, sticky를 overflow가 없는 상위 레벨로 이동시키거나, 클리핑 영역과 sticky를 DOM 구조상 분리합니다.
<div class="layout">
  <header class="sticky">Sticky</header>
  <div class="clip">
    <div class="content">...</div>
  </div>
</div>
.clip { overflow: hidden; }
.sticky { position: sticky; top: 0; }

점검 팁

Safari 개발자 도구(Remote Debugging)에서 해당 sticky의 조상들을 타고 올라가며 overflowvisible이 아닌 요소가 있는지 먼저 찾으세요.

원인 2) -webkit-overflow-scrolling: touch와의 충돌

iOS에서 스크롤 영역에 관성 스크롤을 주기 위해 -webkit-overflow-scrolling: touch;를 쓰는 경우가 많습니다. 이 속성이 붙은 스크롤 컨테이너 내부에서 sticky가 깨지는 사례가 오래전부터 반복됩니다.

증상

  • 스크롤이 “부드럽게” 되는 대신 sticky가 붙지 않거나, 붙어도 흔들림

해결 옵션

  • 가능하면 해당 속성을 제거합니다.
.scroll-area {
  overflow: auto;
  /* -webkit-overflow-scrolling: touch; */
}
  • 관성 스크롤이 꼭 필요하면, sticky를 그 스크롤 컨테이너 밖으로 빼고 상단 고정 레이어로 구현(또는 JS 기반)합니다.
<div class="screen">
  <div class="sticky">Sticky</div>
  <div class="scroll-area">...</div>
</div>
.screen { position: relative; }
.sticky { position: sticky; top: 0; z-index: 10; }
.scroll-area { overflow: auto; -webkit-overflow-scrolling: touch; }

> 핵심은 “sticky가 동작해야 하는 기준 스크롤”과 “touch 관성 스크롤 컨테이너”를 분리하는 것입니다.

원인 3) transform/filters/perspective가 있는 조상에서 깨짐

iOS Safari는 조상에 transform: translateZ(0), transform: translate3d(...), filter, perspective 등이 있을 때 sticky의 고정 좌표 계산이 꼬이거나, 스크롤 중 레이어 합성(compositing) 때문에 점프가 발생하는 경우가 있습니다.

흔한 실수

  • 성능 최적화 목적으로 부모에 transform: translateZ(0)를 넣음
  • 애니메이션/트랜지션을 위해 wrapper에 transform을 걸어둠
/* 이런 최적화가 iOS sticky를 망가뜨릴 수 있음 */
.container {
  transform: translateZ(0);
}

해결

  • sticky 조상 체인에서 transform 계열을 제거하거나, transform이 필요한 요소를 sticky와 형제 레벨로 분리합니다.
<div class="container">
  <header class="sticky">Sticky</header>
  <div class="animated">...</div>
</div>
.animated { transform: translateZ(0); }
.sticky { position: sticky; top: 0; }
  • 꼭 wrapper 전체에 transform이 필요하다면, sticky는 wrapper 밖으로 빼서 별도 레이어로 두는 것이 안전합니다.

원인 4) height/min-height/scroll 레이아웃이 불안정

sticky는 스크롤 중에 “붙는 순간”이 존재하므로, 레이아웃이 스크롤 도중 변하면(특히 iOS에서 주소창/툴바가 접혔다 펴지는 과정) sticky 위치가 흔들릴 수 있습니다.

대표 패턴

  • height: 100vh를 모바일에서 그대로 사용
  • iOS Safari에서 100vh가 주소창 포함/제외로 바뀌며 레이아웃 점프

해결

  • 최신 브라우저에서는 100dvh(dynamic viewport height) 사용을 고려합니다.
.page {
  min-height: 100dvh; /* 지원 브라우저에서 주소창 변화에 더 안정적 */
}
  • 폴백을 포함한 안전한 구성(프로젝트 상황에 맞게 선택):
.page {
  min-height: 100vh;
  min-height: 100dvh;
}
  • sticky 요소의 높이가 동적으로 바뀌는 경우(이미지 로딩, 폰트 로딩)에는 초기 레이아웃을 고정하거나, 로딩 전 높이 공간을 확보하세요.

원인 5) 부모의 경계(overflow/height)가 sticky 범위를 잘라먹음

sticky는 “viewport에 붙는다”가 아니라 부모 박스 안에서만 붙을 수 있습니다. iOS Safari에서 특히 부모의 높이가 예상과 달리 계산되면, sticky가 너무 빨리 풀린 것처럼 보입니다.

해결

  • sticky의 부모가 의도한 만큼의 높이를 갖는지 확인합니다.
  • display: flex 컨테이너에서 자식 높이 계산이 꼬이면, 부모/조상에 min-height: 0 또는 align-items 설정이 필요할 수 있습니다.
.flex-parent {
  display: flex;
  flex-direction: column;
  min-height: 0; /* 내부 스크롤/높이 계산 안정화에 도움되는 경우가 있음 */
}

원인 6) table/thead, overflow 영역 내부의 sticky (제한적 지원)

table 관련 요소(thead, th)에 sticky를 걸거나, 복잡한 스크롤 영역(가로/세로 동시 스크롤)에서 sticky를 기대하면 iOS Safari에서 제약이 많습니다.

해결

  • 테이블 헤더 고정이 목적이면, 테이블을 div 기반 그리드로 재구성하거나,
  • 헤더를 테이블 밖으로 분리해 “가짜 헤더”로 동기화하는 방식을 고려합니다.
<div class="table-wrap">
  <div class="table-head sticky"> ...columns... </div>
  <div class="table-body"> ...rows... </div>
</div>
.table-wrap { position: relative; }
.table-head { position: sticky; top: 0; background: #fff; z-index: 2; }
.table-body { overflow: auto; }

실전 디버깅 체크리스트 (10분 컷)

아래 순서로 보면 대부분 케이스는 빠르게 잡힙니다.

  1. sticky 요소의 조상을 위로 타고 올라가며 overflow != visible 확인
  2. 스크롤 컨테이너에 -webkit-overflow-scrolling: touch가 있는지 확인
  3. 조상에 transform/filter/perspective/contain이 있는지 확인
  4. vh 기반 레이아웃(특히 height: 100vh) 사용 여부 확인 → dvh 전환 검토
  5. sticky의 부모 높이/경계가 의도대로인지 확인(특히 flex/grid)

이런 식의 체계적인 원인 분해는 백엔드에서도 동일하게 유효합니다. 예를 들어 400 에러를 “입력/스키마/툴 출력”으로 쪼개서 보는 접근은 OpenAI Responses API 400 invalid_tool_output 해결법과 같은 문제에도 그대로 적용됩니다.

우회책: JS로 sticky를 에뮬레이션(최후의 수단)

정말로 iOS Safari 특정 버전에서만 깨지고(또는 레이아웃 제약 때문에 CSS로 해결이 불가능하고), 제품 요구사항상 반드시 고정되어야 한다면 JS로 “sticky처럼” 동작하게 만들 수 있습니다.

핵심은 IntersectionObserver로 “붙는 임계점”을 감지하고, 그 순간부터 position: fixed로 전환하는 방식입니다.

<div id="sentinel"></div>
<header id="bar" class="bar">Filter</header>
.bar {
  background: #fff;
  z-index: 1000;
}

.bar.is-fixed {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
}
const sentinel = document.querySelector('#sentinel');
const bar = document.querySelector('#bar');

const io = new IntersectionObserver(
  ([entry]) => {
    // sentinel이 화면에서 사라지면(위로 스크롤해 지나가면) 고정
    bar.classList.toggle('is-fixed', !entry.isIntersecting);
  },
  { threshold: [1] }
);

io.observe(sentinel);

주의할 점:

  • fixed 전환 시 레이아웃이 위로 당겨지지 않도록 placeholder(높이 보정용)를 둘 수 있습니다.
  • 스크롤 컨테이너가 window가 아니라 특정 div라면 observer root를 지정해야 합니다.

권장 패턴: “sticky는 단순한 DOM/레이아웃에서만”

iOS Safari에서 sticky를 안정적으로 쓰는 가장 좋은 방법은, sticky를 다음 조건에 가깝게 유지하는 것입니다.

  • 가능한 한 body/window 스크롤 기준으로 동작하게 만들기
  • sticky 조상에 overflow, transform을 두지 않기
  • 100vh 의존을 줄이고 dvh/레이아웃 안정화 적용

레이아웃을 조금만 분리해도(클리핑/애니메이션/스크롤을 sticky와 분리) 재현 불가능했던 버그들이 사라지는 경우가 많습니다.

마무리

iOS Safari에서 position: sticky가 깨지는 원인은 대체로 (1) overflow로 스크롤 컨테이너가 바뀌거나, (2) touch 관성 스크롤, (3) transform 계열 속성, (4) 모바일 viewport(vh) 변화로 요약됩니다.

문제 페이지에서 sticky가 “왜 안 붙지?”를 감으로 때려맞히기보다, 조상 체인과 스크롤 컨테이너를 기준으로 체크리스트를 돌리면 빠르게 해결됩니다. 성능/네트워크 문제를 구조적으로 진단하는 방식은 프론트 UI 버그에도 통합니다. 예컨대 “원인 후보를 줄이고 재현을 최소화”하는 접근은 EKS ALB Ingress에서 504 Idle timeout만 반복될 때 같은 글에서의 트러블슈팅 흐름과도 닮아 있습니다.