기술 블로그

iOS에서만 느려지는 sticky 탭바 해결하기 본문

프론트엔드

iOS에서만 느려지는 sticky 탭바 해결하기

jaegwan 2026. 8. 18. 15:33
반응형

반응형 UI를 만들다 보면 데스크톱에서는 멀쩡한데 실기기에서만 어긋나는 문제를 마주하곤 합니다. 최근 메인 페이지의 sticky 탭바가 실기기 아이폰에서만 스크롤을 한 박자 늦게 따라오는 현상을 겪었습니다. 크롬 개발자도구의 모바일 에뮬레이터에서도 재현되지 않아 원인을 좁히기가 쉽지 않았고, 가설을 두 번 세워 두 번 다 기각당한 끝에 전혀 다른 파일의 공통 mixin 한 줄에서 범인을 찾았습니다.

이번 포스트에서는 그 과정을 브라우저 렌더링 엔진의 관점에서 정리합니다.


1. 현상 및 재현 조건

메인 페이지의 '성공 레퍼런스' 섹션에는 모바일에서 상단에 고정되는 탭바가 있습니다. 사이트 헤더(높이 60px) 바로 아래에 붙고, 스크롤로 헤더가 숨으면 그 빈자리로 올라오는 구조입니다.

.tabBarSticky {
  width: 100%;

  @include small {
    position: sticky;
    top: 60px;
    z-index: 10;
    transition: top 300ms ease-in-out;
  }
}

.tabBarStickyUp {
  @include small {
    top: 0; // 헤더 숨김 시 헤더가 비운 자리로
  }
}

문제: 실기기 아이폰에서 탭바가 스크롤에 딱 붙지 않고 한 프레임 이상 끌려다니다가, 스크롤을 멈추면 툭 하고 제자리로 돌아옵니다. 특히 화면에서 손을 뗀 뒤 관성(momentum) 스크롤 구간에서 두드러졌습니다.

재현 조건:

환경 결과
데스크톱 크롬 정상
크롬 DevTools 모바일 에뮬레이터 정상
실기기 iOS Safari (≤480px) 재현

에뮬레이터가 뷰포트 크기만 흉내 낼 뿐 WebKit의 합성·스크롤 처리까지 재현하지는 않기 때문에, 이 문제는 처음부터 실기기에서만 볼 수 있었습니다.

초기 가설: transition: top이 컴포지터 속성이 아니라서 생기는 지연.


2. 가설 1 — transition: top은 컴포지터 속성이 아니다

가장 먼저 눈에 띈 것은 트랜지션의 대상이었습니다. 헤더와 탭바가 서로 다른 방식으로 움직이고 있었습니다.

사이트 헤더 탭바
이동 수단 transform: translateY(-100%) top: 60px → 0
처리 주체 GPU / 컴포지터 메인 스레드 (레이아웃)

transform은 메인 스레드가 바빠도 컴포지터가 혼자 300ms를 그려냅니다. 반면 top은 레이아웃 속성이라 매 프레임 재계산이 필요하고, 대상이 position: sticky이므로 sticky 제약(constraint)까지 매 프레임 다시 계산해야 합니다.

게다가 WebKit은 sticky 요소의 컨테이너 사각형과 오프셋(top 포함)을 스크롤링 트리에 한 번 커밋해두고, 이후에는 스크롤 스레드가 알아서 위치를 잡아주는 구조입니다. 그런데 top이 애니메이션 중이면 그 커밋값이 매 프레임 바뀝니다. 즉 트랜지션이 도는 300ms 동안 sticky가 fast path에서 떨어진다는 추론이었습니다.

그럴듯했습니다. 그래서 transition.tabBarStickyUptop: 0을 통째로 주석 처리하고 다시 테스트했습니다.

결과: 그대로 느림

가설 1 기각. 하지만 이 실패가 중요한 정보를 줬습니다.

top이 아예 바뀌지 않는데도 느리다면, 문제는 "움직일 때"가 아니라 "그냥 스크롤할 때" 입니다. 트랜지션이 sticky를 fast path에서 떨어뜨린 것이 아니라, sticky가 애초에 fast path에 올라간 적이 없었다는 뜻입니다.

참고로 이 시점에 놓친 것이 하나 있습니다. CSS만 껐을 뿐 JS는 그대로였습니다. 스크롤 방향을 감지하는 useHideOnScroll() 훅이 여전히 방향이 바뀔 때마다 setState를 호출해 섹션 전체(차트 + motion 노드 4개)를 리렌더하고 있었습니다. 그 결과로 붙는 클래스가 아무 일도 하지 않을 뿐, 비용은 그대로 내면서 효과만 없앤 상태였습니다. 별도로 정리할 부채입니다.


3. 가설 2 — 합성 레이어 폭증

position: sticky가 fast path에 올라가지 못하는 이유로 다음에 의심한 것은 레이어 예산 초과였습니다.

프로젝트에는 GSAP SplitText 기반 헤딩 컴포넌트가 있습니다. 스크롤 진입 시 글자 단위로 등장하는 효과인데, 내부를 열어보니 다음과 같았습니다.

// 트윈 쪽
willChange: "transform, opacity",
force3D: true,               // → translate3d()

// 그리고 컴포넌트 루트 인라인 스타일
const style: React.CSSProperties = {
  willChange: "transform, opacity",   // 영구. 애니메이션이 끝나도 지워지지 않음
};

force3D: truewill-change가 함께 걸리면 쪼개진 글자 하나하나가 각각 GPU 합성 레이어로 승격됩니다. 그리고 애니메이션이 끝난 뒤에도 will-change가 요소에 그대로 남습니다.

홈 페이지는 섹션이 12개이고 전부 같은 헤딩 컴포넌트를 쓰며, 하나가 23줄을 렌더합니다. 한국어 제목이 1525자이므로 대략 이런 계산이 나옵니다.

12 섹션 × 23줄 × 1525자 ≈ 수백 개의 영구 합성 레이어

데스크톱 크롬은 이 정도를 그냥 삼킵니다. 하지만 iOS WebKit은 타일/레이어 메모리 예산이 훨씬 빡빡해서, 한도를 넘으면 레이어를 퇴출하고 메인 스레드 페인팅으로 되돌립니다. 그 과정에서 sticky의 fast path가 날아갈 수 있다고 봤습니다.

결과: 이것도 아니었습니다

루트의 willChange 한 줄을 주석 처리하고 테스트했지만 변화가 없었습니다.

가설 2도 기각. 다만 엄밀히 말하면 이 테스트는 불완전했습니다. 지운 것은 부모 span의 will-change뿐이고, 글자 하나하나를 승격시키는 트윈 쪽 willChange + force3D는 그대로 남아 있었기 때문입니다. 이후 진범이 잡히면서 이 줄기는 접었지만, 레이어 폭증 자체는 여전히 정리해야 할 성능 부채로 남아 있습니다. 원인은 아니었어도 좋은 상태는 아니니까요.


4. 전환점 — "어디서"가 아니라 "언제만"

두 가설이 연달아 깨지고 나서 질문을 바꿔봤습니다.

지금까지는 "왜 아이폰에서만 느린가"를 물었고, 그래서 계속 iOS/WebKit 쪽만 팠습니다. 그런데 다시 보니 조건이 하나 더 있었습니다.

이 sticky는 @include small(≤480px)에서만 켜집니다. 즉 증상은 "아이폰에서만"이 아니라 정확히는 "small 브레이크포인트에서만" 이었습니다.

그렇다면 @include small 안에만 존재하는 CSS를 전부 다시 읽어야 했습니다. 그리고 sticky의 조상 체인을 따라 올라가자마자 답이 나왔습니다.

// _section.scss — 홈 섹션 공통 mixin
@mixin home-section {
  @include flex(column, center, center);
  width: 100%;

  @include medium {
    max-width: 648px;
  }
  @include small {
    overflow-x: clip;   // ← 여기
  }
}

.tabBarSticky의 부모인 .section이, 정확히 문제가 발생하는 그 브레이크포인트에서만 클리핑 컨테이너가 되고 있었습니다.

한 줄을 지우고 테스트했더니 해결되었습니다.


5. 원인 분석 — 왜 overflow: clip이 sticky를 망가뜨렸나

WebKit은 sticky 요소를 스크롤 스레드에서 처리할지 판단할 때, 해당 요소부터 스크롤 컨테이너(여기서는 뷰포트)까지 레이어 체인을 거슬러 올라갑니다. 이때 "클리핑은 하지만 스크롤은 하지 않는 조상" 을 만나면 비동기 처리를 포기합니다. 컴포지터 입장에서 "따로 움직이는 클립"을 표현할 방법이 없기 때문입니다.

fast path에서 떨어지면 이렇게 됩니다.

정상 (fast path) 떨어졌을 때
스크롤 처리 스크롤 스레드 스크롤 스레드
sticky 위치 갱신 스크롤 스레드가 함께 메인 스레드가 뒤늦게
관성 스크롤 중 딱 붙어 있음 끌려다니다 멈추면 툭

iOS에서 스크롤은 컴포지터가 먼저 그리고 JS/메인 스레드가 뒤따르는 구조이므로, sticky 갱신이 메인 스레드로 내려가면 구조적으로 한 프레임 이상 늦습니다. 데스크톱 크롬에서 보이지 않았던 것은 Blink의 sticky 구현이 다르고 메인 스레드가 한가했기 때문입니다.


6. 그런데 clip을 고른 판단 자체는 옳았습니다

해당 줄에는 주석으로 이유가 적혀 있었습니다.

// clip: overflow-x:hidden 이면 브라우저가 overflow-y 를 auto 로 계산해
// 세로 스크롤바가 생김 → clip 으로 회피.

그리고 이건 맞는 얘기입니다.

overflow-x: hidden overflow-x: clip
자르는가 O O
스크롤 컨테이너 생성 O X
반대축(overflow-y: visible) auto로 강제 계산 → 세로 스크롤바 visible 유지

CSS 스펙상 한 축이 visible이고 다른 축이 visible/clip이 아니면, visible인 쪽이 auto로 계산됩니다. hidden은 스크롤 컨테이너가 되므로 이 규칙에 걸리고, clip은 걸리지 않습니다.

"세로 스크롤바를 피하려고 hidden 대신 clip을 썼다"는 결정은 정확했습니다. 다만 clip은 스크롤 컨테이너를 만들지 않을 뿐, 클리핑 레이어는 여전히 만듭니다. WebKit이 걸린 지점은 그쪽이었습니다.

스펙 레벨에서 옳은 선택이 합성(compositing) 레이어에서 대가를 치른 케이스입니다. 두 층위는 별개로 판단해야 합니다.


7. 삭제해도 안전한가 — 검증

"원인이니까 지운다"로 끝내면 안 됩니다. 저 줄이 원래 무엇을 막고 있었는지 확인해야 했습니다.

7-1. 왜 있었나

git 이력을 확인했습니다.

aec4b26  refactor: main 컴포넌트 section스타일 공통화

이 커밋은 mixin으로 승격만 했을 뿐, 그 이전부터 Agency·SuccessReference 각 파일에 있던 줄이었습니다. 커밋 메시지에도 주석에도 "어떤 오버플로를 잡았다"는 기록이 없었습니다. 해당 섹션들에 white-space: nowrap + flex-shrink: 0 조합(칩·배지·라벨)이 많은 것으로 보아, 특정 버그를 잡은 것이 아니라 "모바일에서 뭐라도 삐져나가면 잘라라"는 예방적 안전망으로 깔아둔 것으로 보였습니다.

7-2. 이 clip은 뷰포트 가장자리에서만 자르고 있었습니다

레이아웃 체인에 좌우 padding도, max-width도 없습니다.

body            max-width:100vw · overflow-x:clip
└ .shell        stretch
  └ .content    flex:1, 블록 → 100%
    └ .page     100%, 좌우 padding 없음
      └ .section width:100%          ← 여기

그리고 결정적으로, 같은 mixin 안에서 두 선언의 적용 구간이 겹치지 않습니다.

  • max-width: 648px@include medium
  • overflow-x: clip@include small

즉 이 clip은 "섹션이 뷰포트보다 좁을 때 자르는" 역할을 한 번도 한 적이 없고, 항상 뷰포트 가장자리에서 자르고 있었습니다.

7-3. 그 가장자리는 이미 body가 자르고 있습니다

html { overflow-x: hidden; }  // 루트의 overflow는 뷰포트로 전파
body { overflow-x: clip; }

자르는 x좌표가 .section의 것과 동일합니다. 결론적으로 중복이었습니다. 지워도 가로 스크롤은 생기지 않고, 잘리는 위치도 그대로입니다.

반응형
Comments