모바일 스크롤 아키텍처 마이그레이션 - 내부 컨테이너 스크롤에서 페이지 전체 스크롤로
모바일 스크롤 아키텍처 마이그레이션: 내부 컨테이너 스크롤 → 페이지 전체 스크롤
Fixed 플로팅 버튼 스크롤 버그 해결을 위해 “내부 컨테이너 스크롤”에서 “페이지 전체 스크롤(브라우저 기본 스크롤)” 구조로 전환하면서 발생한 일련의 이슈와 해결 과정
📌 발단: 플로팅 버튼에서 스크롤 시 html 전체가 스크롤되는 현상
원인
기존 구조는 내부 컨테이너 스크롤 패턴(페이지 내부의 특정 div가 스크롤을 담당):
<div class="h-dvh">
<BottomSheetProvider class="h-full overflow-hidden">
<div class="relative h-full">
<Page class="h-full overflow-y-scroll"> ← 실제 스크롤 컨테이너
- globals.css의
@media (hover: hover)로 데스크톱에서만 html/bodyoverflow: hiddenlock - 모바일에서는 html/body가 자유 스크롤 가능한 상태
추론
position: fixed 플로팅 버튼에 터치 후 스크롤 시:
- 브라우저가 touch 이벤트의 스크롤 타깃을 찾음 → “가장 가까운 스크롤 가능한 조상 요소”
— scrollable ancestor: 이 요소가 스크롤될 때 누가 움직이는가를 결정하는 조상 (DOM 트리 위로 탐색하며overflow: auto | scroll가진 첫 요소) - fixed 버튼은 내부 컨테이너의 자손이 아님 (position: fixed로 뷰포트 기준 배치)
- 타깃 탐색이 html/body까지 fallthrough
— fallthrough: 적절한 타깃을 찾지 못해 상위로 계속 밀려 올라가는 현상 (switch case의 break 없는 동작, 이벤트 버블링과 유사한 개념) - 모바일에선 html/body가 unlock 상태 → html이 스크롤됨
- 결과적으로 플로팅 버튼에서 스크롤 시 전체 페이지가 위로 밀리며 잘림
결과
페이지 전체 스크롤 구조로 마이그레이션이 근본 해결책. 단 플로팅 버튼 없는 페이지는 내부 컨테이너 스크롤 유지 가능 (리스크/효용 균형).
1️⃣ Phase 1~2: 내부 컨테이너 스크롤 → 페이지 전체 스크롤 마이그레이션
원인
레이아웃 전반에 내부 컨테이너 스크롤 전제가 박혀있었음:
- 루트 layout:
<div class="h-dvh">모바일 wrapper - BottomSheetProvider: 내부
h-full overflow-hidden - 각 Page wrapper:
h-full overflow-y-scroll - 각 View:
h-dvh또는calc(100dvh)SCSS
추론
- 데스크톱은 사이드바 레이아웃이라 내부 컨테이너 스크롤 유지 필요 (사이드바 + 메인 영역)
- 모바일만 페이지 전체 스크롤로 전환하면 데스크톱 영향 없음
- device detection context의
isDesktop으로 조건 분기
결과
플로팅 버튼 있는 페이지만 선별 마이그레이션 (플로팅 버튼 없는 페이지는 마이그레이션 불필요 → 롤백)
핵심 변경 패턴:
// 루트 layout (모바일)
// Before: <div className="h-dvh"><BottomSheetProvider className="h-full overflow-hidden">
// After: <BottomSheetProvider> // plain relative div
// BottomSheetProvider 내부 (모바일)
// Before: <div className="relative h-full">
// After: <div className="relative">
// Page wrapper (모바일)
// Before: h-full overflow-y-scroll
// After: isDesktop ? 'h-full overflow-y-scroll' : ''
2️⃣ Phase 3: iOS Safari scroll lock 강화
원인
기존 scroll lock 훅은 document.body.style.overflow = 'hidden' 한 줄만 적용.
추론
iOS Safari에서 overflow: hidden만으로는 터치 스크롤을 완전히 막지 못함. 실무 표준은:
-
position: fixed; top: -${scrollY}px: body를 뷰포트에 고정 + 스크롤 위치 보존 -
width: 100%: shrink-to-fit 방지 -
overscroll-behavior: none: 바운스/pull-to-refresh 차단 -
touch-action: none: 터치 제스처 차단 - 언락 시
window.scrollTo(0, savedY)로 원래 위치 복원
결과
전역 CSS + data attribute 패턴으로 통일:
/* globals.css */
body[data-scroll-lock] {
position: fixed;
width: 100%;
overflow: hidden;
overscroll-behavior: none;
touch-action: none;
-webkit-overflow-scrolling: none;
}
// Lock
document.body.dataset.scrollLock = 'true';
document.body.style.top = `-${scrollY}px`;
document.body.style.paddingRight = `${scrollbarWidth}px`;
// Unlock
delete document.body.dataset.scrollLock;
document.body.style.top = '';
document.body.style.paddingRight = '';
window.scrollTo(0, savedY);
주의: 공용 UI 패키지의 lock 훅은 원본 유지. 다른 앱에 영향 주지 않기 위함.
3️⃣ Phase 4: 바텀시트 dim 사라짐 + 배경 스크롤
원인
Phase 1에서 BottomSheetProvider의 h-full을 제거했는데, 해당 div를 container 옵션으로 전달받은 Dim이:
-
position: absolute(scoped)로 배치 - 부모(BottomSheetProvider div)의 높이에 스코핑됨
- 부모가 content 높이로 줄어들어 dim이 뷰포트 전체를 덮지 못함
추론
모바일에선:
-
container주입을 생략 → Dim이document.bodyportal +position: fixed로 렌더 - 공용 Dim 훅이
disableDocumentScroll=true경로로 동작 → 하지만 기존 lock 방식은 iOS 안전하지 않음 - 바텀시트 렌더러에 자체 scroll lock 추가 (iOS Safari 안전한 방식)
결과
- 바텀시트 열기 훅에서 모바일 시
container주입 skip - 바텀시트 렌더러에
data-scroll-lock패턴 scroll lock 추가 - 공용 Dim 훅은 원본 유지 (타 앱 영향 차단)
// 바텀시트 open hook
const defaultContainer = isDesktop ? (containerRef?.current ?? null) : null;
// 바텀시트 렌더러 자체 lock
useEffect(() => {
if (!shouldRender || isDesktop) return;
// apply data-scroll-lock lock
return () => {
// release + window.scrollTo(0, savedY)
options?.onAfterClose?.();
};
}, [isDesktop, shouldRender, options]);
4️⃣ Phase 5: Scroll 훅 device-aware 전환
원인
- scroll progress 훅:
containerRef.current.addEventListener('scroll') - tab state 훅:
container.scrollTop,anchor.offsetTop기반 계산 - meta sticky 훅: IntersectionObserver
root: scrollContainerRef.current - expandable 플로팅 버튼:
document.querySelector('.flex-1.overflow-y-auto')로 스크롤 컨테이너 탐색
→ 모바일이 페이지 전체 스크롤로 전환된 상태에서 모두 scroll 이벤트를 못 잡음
추론
모든 scroll 관련 훅/컴포넌트가 device-aware로 동작해야 함:
- 모바일:
window+window.scrollY+getBoundingClientRect().top + scrollY(document 절대 Y) - 데스크톱: 기존
containerRef+scrollTop+offsetTop
결과
공통 헬퍼 3개 유틸로 일원화:
// shared/model/utils/scrollTarget.ts
export const getScrollMetrics = (useWindow, container) => ({ scrollTop, clientHeight, scrollHeight });
export const getAnchorY = (el, useWindow) => useWindow
? el.getBoundingClientRect().top + window.scrollY
: el.offsetTop;
export const getScrollTarget = (useWindow, container) => useWindow ? window : container;
각 훅에서 isDesktop으로 내부 분기:
- scroll observer/progress: target 자동 전환
- tab state: computeTabKey 입력 metric 통일
- meta sticky: IntersectionObserver
root: isDesktop ? ref.current : null - filter scrollTo: scroll target/offset 자동 전환
5️⃣ Sticky 요소 계층: 마이그레이션 시 고려사항
페이지 전체 스크롤로 전환하면 모든 sticky 요소의 기준이 body 하나로 통합됨. 기존에 내부 스크롤 컨텍스트 안에서 자연스럽게 쌓여있던 AppBar/Tab/FilterBar 등이 모두 같은 top: 0에서 만나 겹치므로 계층을 명시적으로 재설계해야 한다.
✅ 체크리스트
1. sticky 요소 전수 조사
-
sticky top-0로 검색 → AppBar, Tab, FilterBar, 목록 헤더 등 - 데스크톱에선 각자 다른 scroll container 안에 있어 자연스레 분리되었던 요소들이, 모바일 body 스크롤에선 하나의 scroll context(body) 로 합쳐져 top:0에서 충돌
2. 위→아래 순서로 누적 오프셋 계산
- 최상단 요소(보통 AppBar):
top: 0 - 그 아래 요소:
top: APP_BAR_HEIGHT - 또 그 아래 요소:
top: APP_BAR_HEIGHT + TAB_HEIGHT(또는 해당 페이지 구조에 맞게) - 페이지별로 Tab이 있는 곳과 없는 곳의 오프셋이 다르므로 구조 확인 필수
3. device-aware 오프셋 분기
- 데스크톱은 기존 내부 스크롤 컨텍스트라
top: 0유지해도 자연스레 AppBar 아래 위치 - 모바일만 누적 오프셋 적용
// Tab 요소 예시 style=// filter state 훅 예시 (기존 공식을 device 분기) const stickyTop = isDesktop ? headerOffset - APP_BAR_HEIGHT // 내부 스크롤 기준 : headerOffset; // body 스크롤 기준 (절대 Y)
4. 누락된 상위 sticky 확인
- 일부 AppBar는
sticky가 없고 일반 flow에 있을 수 있음 (내부 스크롤 구조에선 어차피 상단에 붙어있어 문제 없었음) - 페이지 전체 스크롤로 바뀌면 AppBar가 스크롤 따라 사라짐 → 명시적으로
sticky top-0추가 필요
5. z-index 재점검
- 여러 sticky가 동시에 top 근처에 있을 때 z-index 순서가 중요
- 상단 요소일수록 높은 z-index: AppBar(200) > Tab(150) > FilterBar(140) 등
- z-index 생략된 곳은 브라우저 기본값(auto)라 예측 불가 → 명시 권장
6. 내부 스크롤 유지 페이지와의 경계
- 플로팅 버튼 없는 페이지(예: 등록 플로우)는 내부 컨테이너 스크롤 유지
- 이런 페이지의 sticky 오프셋은 기존 그대로 (device 분기 불필요)
- 마이그레이션 대상 페이지만 선별 적용
🎯 핵심 원칙
- 페이지 전체 스크롤 → sticky 기준이 body 하나로 통합됨, 계층 재설계 필수
- 모바일 오프셋은 위 요소들의 높이 누적합으로 계산
- device-aware
top값으로 데스크톱 내부 스크롤과 모바일 body 스크롤 양쪽 모두 커버 - z-index는 명시적으로 작성해 예측 가능한 스태킹 확보
6️⃣ 플로팅 버튼 positioning: 마이그레이션 시 고려사항
페이지 전체 스크롤 구조로 전환할 때 플로팅 버튼은 특히 예외적으로 확인해야 하는 영역. position: absolute vs fixed의 차이와 데스크톱/모바일 레이아웃 차이가 맞물려 쉽게 깨진다.
✅ 체크리스트
1. 모든 플로팅 버튼의 position 속성 체크
-
position: absolute로 되어 있으면 → positioned ancestor
—position: absolute의 좌표 원점이 어디인가를 결정하는 조상 (DOM 트리 위로 탐색하며position이static이 아닌 첫 요소, 없으면 뷰포트)
— 기준으로 배치됨 - 기존 내부 컨테이너 스크롤 구조에선 ancestor가 고정 높이(
h-full/h-dvh)라 하단 고정 정상 작동 - 페이지 전체 스크롤로 바꾸면 ancestor 높이가 content 따라 늘어남 → 버튼이 content 끝으로 밀려남
2. 사이드바 레이아웃 있는 데스크톱 대응
- 데스크톱에 사이드바(예: 390px 고정 폭)가 있다면
fixed로 바꾸면 뷰포트 전체 기준이라 사이드바 밖 영역까지 번짐 - 데스크톱은
absolute유지(사이드바 내부 기준), 모바일만fixed로 분기해야 함
className={`${isDesktop ? 'absolute' : 'fixed'} bottom-7 right-5`}
3. 이미 fixed인 버튼의 중앙 정렬 재확인
-
fixed left-1/2 -translate-x-1/2는 뷰포트 가로 중앙 - 데스크톱 사이드바 레이아웃에선 뷰포트 중앙이 사이드바 내부가 아닌 메인 영역(지도 등)과 겹칠 수 있음
- 데스크톱에서만
absolute로 바꿔 positioned ancestor(사이드바) 중앙에 오도록 조정
4. IntersectionObserver·scroll listener 동반 로직 확인
- 플로팅 버튼에 “스크롤 시 펼침/접힘” 같은 애니메이션이 걸린 경우
document.querySelector('id 혹은 class')로 스크롤 컨테이너를 찾는 코드가 있을 수 있음 - 모바일이 페이지 전체 스크롤로 바뀌면 해당 셀렉터가 null → 리스너 미등록 → 애니메이션 미작동
- device 분기로
window이벤트로 전환 필요
🎯 핵심 원칙
- 단순
absolute는 ancestor 높이에 종속 → 페이지 전체 스크롤에선 불안정 - 플로팅 버튼의 의미상 “뷰포트 고정”이 맞다면 기본적으로
fixed - 사이드바 레이아웃이 있는 데스크톱이면 device-aware
absolute/fixed분기
7️⃣ 스크롤 위치 기반 UI 상태(탭 활성화·진행도 표시 등)의 false positive
배경: 어떤 UI가 영향을 받는가
페이지 스크롤 위치를 읽어 현재 보고 있는 섹션을 판별하거나 UI 상태를 바꾸는 로직이 있을 때 발생하는 문제. 예를 들어:
- 긴 페이지에 여러 섹션(개요·리뷰·상세 등)이 있고 상단 탭바가 현재 섹션에 따라 하이라이트되는 경우 (scrollspy 패턴)
- 스크롤 내려가면 헤더 색·배경이 변하는 효과
- “페이지 끝 도달했나”를 감지해 무한 스크롤 또는 마지막 섹션 탭 강조 처리
이런 로직은 보통 window.scrollY·document.documentElement.scrollHeight·innerHeight를 조합한 공식으로 판단한다. 예: scrollY + innerHeight >= scrollHeight → “스크롤 끝 도달”.
문제: 바텀시트/팝업 열림이 UI 상태를 오작동시킴
페이지 전체 scroll lock이 발동되면 position: fixed로 body가 flow 밖으로 나가면서 scroll metrics가 왜곡됨:
-
window.scrollY→ 0 -
scrollHeight→ 뷰포트 높이 수준으로 축소 - 결과적으로 “스크롤 끝 도달” 공식이 항상 true로 평가됨
사용자가 단지 필터 버튼을 눌러 바텀시트를 여는 것만으로도:
- 탭이 갑자기 “마지막 섹션”으로 튐
- 무한 스크롤이 불필요하게 다음 페이지를 fetch
- 헤더 색이 스크롤 끝 상태로 고정됨
해결 방법
scroll metrics 기반 판단 로직에 lock 상태 가드를 추가:
- 오버레이(바텀시트·팝업·다이얼로그) 열림 시 body에 공통 속성(
data-scroll-lock등)을 부여 - scroll 기반 계산 훅은 해당 속성이 있을 때 “스크롤 끝” 같은 파생 상태를 false로 고정하거나 이전 값 유지
- 이렇게 하면 오버레이 열림 중에는 UI 상태가 얼어붙고, 오버레이 닫힌 뒤 정상 scroll metrics 복구 시점부터 다시 계산이 유효해짐
핵심: 계산 자체는 그대로 두되, “계산 입력이 왜곡되는 구간”은 판단에서 배제.
📚 핵심 인사이트
1. 내부 컨테이너 스크롤 구조는 “fixed 버튼 터치 버그”의 근본 원인
- 브라우저의 터치 스크롤 타깃 탐색이 fixed 요소를 건너뛰고 상위로 fallthrough
- html/body가 unlock인 모바일에선 이 fallthrough가 html scroll로 이어져 페이지 깨짐
2. @media (hover: hover) 가드는 데스크톱/모바일 이분법에 부적절
- 태블릿 가로모드(호버 없지만 1024px+), 하이브리드 디바이스 등 엣지 케이스
- 실제 디바이스 타입은 SSR UA 기반으로 판별하고 CSS는 무조건 적용 권장
3. iOS Safari scroll lock 표준
-
overflow: hidden만으로는 부족 -
position: fixed; top: -scrollY+overscroll-behavior: none+touch-action: none세트 - 언락 시
window.scrollTo(0, savedY)복원 필수
4. 페이지 전체 scroll lock은 scroll metrics를 왜곡
-
position: fixed로 body를 잠그면window.scrollY가 0으로 초기화되고document.documentElement.scrollHeight도 뷰포트 수준으로 축소됨 (body가 flow 밖이라 스크롤 개념 자체가 사라지기 때문) - 이 수치들을 읽어 판단하는 로직(탭 활성화, 스크롤 진행도, “스크롤 끝” 감지 등)이 lock 발동 순간 false positive/negative를 일으킴
- 예:
scrollY + innerHeight >= scrollHeight공식이 lock 상태에선 항상 true → 바텀시트 오픈만으로 “스크롤 끝에 도달했다”고 오판 - scroll 기반 상태 계산 훅은 lock 상태를 명시적으로 배제해야 안전 (
data-scroll-lock같은 공통 속성으로 가드)
6. device-aware scroll 유틸 일원화
-
getScrollMetrics,getAnchorY,getScrollTarget3개 헬퍼로 device 분기 중앙집중화 - 향후 scroll 기반 훅 추가 시 재사용
7. floating button은 fixed 또는 device-aware absolute/fixed
- 단순
absolute는 ancestor 높이에 종속 → 페이지 전체 스크롤에선 content 따라 밀림 - 사이드바 레이아웃 있는 경우 데스크톱
absolute(사이드바 내부) vs 모바일fixed(뷰포트) 분기