overflow-x: hidden으로 덮어둔 문제가 터지기까지. 테이블 오버플로부터 Radix Sheet 스크롤 잠금까지, 모바일 가로 스크롤 문제를 근본적으로 해결한 과정을 정리합니다.
모바일에서 블로그 포스트를 열었는데 페이지 전체가 좌우로 밀린다. 오른쪽에 알 수 없는 빈 공간이 생기고, 스크롤하면 콘텐츠가 덜컹거린다. 이 글은 그 원인을 추적하고 해결하면서 겪은 두 가지 삽질을 정리한 기록이다.


어느 날 모바일에서 특정 포스트를 열었더니 뷰포트 오른쪽에 빈 공간이 보였다. 페이지 전체가 가로 스크롤되고 있었다.
원인은 클로드가 빠르게 찾아줬다. 클로드는 내 두번째 뇌임
테이블이 문제였다. 열이 많은 테이블이 뷰포트 너비를 초과하면서 페이지 전체를 밀어내고 있었다.
[viewport: 375px]
┌─────────────────────────────┐
│ .prose (max-width: 800px) │
│ ┌───────────────────────────┼──── table (실제 너비: 900px)
│ │ col1 │ col2 │ col3 │ ...│...│
│ └───────────────────────────┼────
└─────────────────────────────┘
↑ 오버플로 → 페이지 전체 가로 스크롤
HTML <table>은 기본적으로 내용에 맞게 확장된다. 부모 컨테이너에 overflow 제어가 없으면 오버플로가 상위로 계속 전파되어 결국 body까지 밀어낸다.
그냥 body에 overflow-x: hidden을 때렸다.
body {
overflow-x: hidden; /* "해결!" */
}
이젠 테이블이 아예 짤려서 안보인다.
overflow-x: hidden은 오버플로를 잘라내는 것이지 해결하는 것이 아니다. 테이블 오른쪽 데이터는 그냥 잘려서 보이지 않게 된다.
올바른 접근은 테이블 자체에 스크롤 컨테이너를 만들어주는 것이다. 이 블로그는 MDX를 사용하므로 커스텀 컴포넌트로 <table>을 래핑할 수 있다.
그냥 md 로만 하려고 했는데, 블로그 만들때 mdx를 추천해준 클로드 나이스
// components/mdx-components.tsx
table: ({ children, ...props }: ComponentProps<'table'>) => (
<div className="overflow-x-auto -mx-4 px-4 md:mx-0 md:px-0">
<table {...props}>{children}</table>
</div>
),
이렇게 하면 테이블이 넓어도 래퍼 <div> 안에서만 가로 스크롤되고, 페이지 전체는 영향을 받지 않는다.
div.overflow-x-auto ← 여기서 스크롤 컨텍스트가 끊김
└─ table (넓어도 래퍼 안에서만 스크롤)
-mx-4 px-4는 모바일에서 부모의 패딩 바깥까지 래퍼를 확장하는 네거티브 마진 패턴이다. 좁은 화면에서 스크롤 영역을 최대한 넓혀준다. 데스크톱에서는 md:mx-0 md:px-0으로 해제한다.
이제 body의 overflow-x: hidden을 제거할 수 있다. 그리고 제거하자마자, 그동안 숨겨져 있던 두 번째 문제가 드러났다.
이 블로그는 모바일에서 목차(TOC)를 Radix UI의 Sheet 컴포넌트(슬라이드 패널)로 보여준다. Sheet를 열고 목차 항목을 탭하면 해당 섹션으로 스크롤되어야 했으나..
데스크톱에서는 정상 동작한다. 모바일에서만 문제.
| 환경 | TOC 위치 | 오버레이 | 스크롤 잠금 |
|---|---|---|---|
| 데스크톱 | aside (sticky) | 없음 | 없음 |
| 모바일 | Radix Sheet (Dialog 기반) | fixed inset-0 z-50 | overflow: hidden on body |
Radix Dialog(Sheet의 기반)가 열리면 다음이 자동 적용된다:
document.body.style.overflow = 'hidden'
document.body.style.pointerEvents = 'none' // Overlay 아래 영역
이 상태에서 window.scrollTo()를 호출하면? 아무 일도 안 일어난다. body에 overflow: hidden이 걸려 있어 스크롤 자체가 차단되기 때문이다.
// 실패한 코드
onItemClick?.() // setTocOpen(false) → Sheet 닫기
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset
requestAnimationFrame(() => {
window.scrollTo({ top: y }) // 이 시점에 아직 Sheet가 닫히지 않음
})
React의 setState는 비동기다. setTocOpen(false)를 호출해도 바로 Sheet가 닫히지 않는다.
실제 타이밍:
setTocOpen(false)
→ React re-render 예약 (비동기)
→ Sheet exit 애니메이션 시작 (~200-300ms)
→ 애니메이션 완료
→ Radix가 body overflow: hidden 제거
→ 비로소 window.scrollTo() 가능
requestAnimationFrame은 다음 화면 리페인트 전에 1회 실행된다 (60Hz 디스플레이 기준 ~16ms). Sheet 닫힘 애니메이션은 300ms. 스크롤 명령이 너무 일찍 실행되어 무시당한다.
const scrollToHeading = () => {
const element = document.getElementById(id)
if (element) {
const yOffset = -100
const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset
window.scrollTo({ top: y, behavior: 'smooth' })
}
}
if (onItemClick) {
onItemClick() // Sheet 닫기 시작
setTimeout(scrollToHeading, 350) // 닫힘 완료 후 스크롤
} else {
requestAnimationFrame(scrollToHeading) // 데스크톱: 즉시 스크롤
}
핵심:
setTimeout 콜백 안에서 getBoundingClientRect() 호출하여 Sheet 닫힌 후의 정확한 좌표 획득onItemClick 유무로 모바일/데스크톱 경로를 분리하여 데스크톱 동작에 영향 없음| 문제 | 수정 파일 | 변경 내용 |
|---|---|---|
| 테이블 가로 오버플로 | components/mdx-components.tsx | table 커스텀 컴포넌트 추가 (래퍼 div) |
| 임시 조치 제거 | app/globals.css | body의 overflow-x: hidden 제거 |
| TOC 스크롤 미동작 | components/table-of-contents.tsx | handleClick에 타이밍 분기 로직 |
| TOC Sheet 연동 | components/blog-layout.tsx | onItemClick prop으로 Sheet 닫기 전달 |
가로 오버플로가 발생하면 원인 요소를 찾아 해당 요소에서 overflow-x: auto로 처리해야 한다. body에 overflow-x: hidden을 걸면 콘텐츠가 잘리고, 다른 오버플로 문제를 가린다.
Sheet, Dialog, AlertDialog 등 Radix의 오버레이 컴포넌트는 열릴 때 body에 overflow: hidden을 건다. 오버레이가 열린 상태에서 window.scrollTo()는 동작하지 않는다. 오버레이를 닫은 후 충분한 지연을 두고 스크롤해야 한다.
setState 직후의 코드는 이전 상태의 DOM에서 실행된다. 상태 변경 → 리렌더 → DOM 업데이트 → 애니메이션 완료까지 타이밍을 확보해야 한다. requestAnimationFrame은 1프레임(~16ms)만 기다리므로, 애니메이션이 있는 전환에는 부족하다.
<table>, <img>, <iframe> 등 기본 HTML 요소가 반응형 레이아웃을 깨뜨릴 때, MDX 커스텀 컴포넌트로 래퍼를 감싸면 모든 콘텐츠에 일괄 적용된다. 개별 MDX 파일을 수정할 필요가 없다.
GitHub Pages에 Next.js와 shadcn/ui를 사용하여 정적 블로그를 구축하는 과정을 공유합니다.
TypeScript를 사용하면서 개발 생산성을 향상시킬 수 있는 실용적인 팁들을 공유합니다.
Spring의 @Controller와 @RestController 어노테이션의 차이점과 사용 시나리오를 실제 예제와 함께 알아봅니다
백준 2526번 싸이클 문제를 풀며 나머지 연산의 순환 특성을 이해하고 최적화하는 과정