LogoSEO Jing
  • All Posts
  • SEO Jing
  • KD Team
  • CLab CoreTeam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

CSSdvhdvwvhsvhlvhvisualViewportzoomTailwind모바일반응형

100vh가 100%가 아닌 이유 — 모바일 뷰포트 단위 완전 정리

2026년 3월 24일·22분 읽기

height: 100vh인데 스크롤이 생긴다

전체화면 UI를 만들 때 대부분 height: 100vh부터 시작한다. "뷰포트 높이의 100%니까 화면 전체를 채우겠지"라고 생각하지만, 모바일 브라우저에서 열어보면 하단이 잘리거나 스크롤이 생긴다. 특히 iOS Safari에서 이 현상이 두드러진다.

이유는 100vh가 실제로 보이는 화면 높이가 아니기 때문이다. 모바일 브라우저에는 주소창, 탭 바, 하단 네비게이션 같은 UI 요소가 있다. 이 요소들은 스크롤에 따라 나타났다 사라진다. vh 단위는 이 브라우저 UI가 숨겨진 상태의 최대 높이를 기준으로 계산된다. 그래서 주소창이 보이는 상태에서 100vh는 실제 화면보다 크다.

이 문제는 2015년부터 보고되어 왔고, CSS Working Group이 2022년에 새로운 뷰포트 단위를 표준화하면서 공식적인 해결책이 생겼다. 나는 이 블로그에 프레젠테이션 모드를 만들면서 이 문제를 직접 겪었다. 블로그 글을 PPT처럼 슬라이드로 보여주는 전체화면 기능인데, 모바일 Safari에서 열면 하단 바가 화면 밖으로 밀려나서 페이지 인디케이터가 보이지 않았다.


뷰포트 단위 4종 비교 — vh, svh, lvh, dvh

새로운 뷰포트 단위를 이해하려면 모바일 브라우저의 UI 상태 두 가지를 알아야 한다. 주소창이 완전히 펼쳐진 상태(Small Viewport)와, 스크롤로 주소창이 숨겨진 상태(Large Viewport)이다.

단위이름기준값 변화
vhViewport HeightUA 결정 (보통 Large 기준)고정
svhSmall Viewport Height브라우저 UI가 모두 보이는 상태고정

svh와 lvh는 고정값이다. 브라우저 UI 상태와 무관하게 항상 같은 값을 반환한다. 차이는 어느 쪽을 기준으로 잡느냐일 뿐이다. 반면 dvh는 브라우저 UI가 나타나고 사라질 때마다 값이 바뀐다.

어떤 단위를 써야 하는가

상황에 따라 다르다. 핵심은 "이 요소가 브라우저 UI 변화에 반응해야 하는가"이다.

  • 전체화면 오버레이, 모달: dvh가 적합하다. 주소창이 나타나도 레이아웃이 자동으로 줄어들어서 콘텐츠가 잘리지 않는다.
  • 히어로 섹션, 랜딩 페이지: svh가 안전하다. 가장 작은 뷰포트 기준이므로 콘텐츠가 절대 넘치지 않는다.
  • 배경 이미지 영역: lvh가 낫다. 주소창이 사라졌을 때 빈 공간이 보이지 않도록 최대 크기로 잡아두는 것이다.

dvh에는 주의점이 있다. 값이 동적으로 변하기 때문에 스크롤할 때마다 레이아웃 재계산이 발생한다. 성능에 민감한 애니메이션이나 빈번하게 리사이즈되는 요소에는 svh나 lvh로 고정하는 게 나을 수 있다. 전체화면 UI처럼 스크롤이 없는 상황에서 dvh가 가장 빛난다.

프레젠테이션 모드에 적용하기

프레젠테이션 모드는 전체화면 오버레이이므로 dvh로 바꿨다. 기존 height: 100vh를 height: 100dvh로 교체하는 것만으로 모바일 Safari에서 하단이 잘리는 문제가 해결됐다.

tsx
// 외곽 컨테이너: 100vh → 100dvh
<div
  className="fixed inset-0 z-50 bg-white dark:bg-gray-950"
  style={{ height: "100dvh" }}
>

이 프레젠테이션 모드에는 모바일에서 화면을 CSS rotate(90deg)로 강제 가로 회전하는 기능이 있다. 이 경우 물리적 width와 height가 뒤바뀌므로, 회전된 컨테이너의 크기도 dvh/dvw를 교차해서 지정해야 한다.

tsx
// 모바일 강제 회전: 물리적 width/height가 뒤바뀜
const containerStyle = needsRotation
  ? {
      width: "100dvh", // 물리적 세로 길이 → 회전 후 가로
      height: "100dvw", // 물리적 가로 길이 → 회전 후 세로
      top: "50%",
      left: "50%",
      transform: "translate(-50%, -50%) rotate(90deg)",
    }
  : { width: "100%", height: "100%" };

JavaScript에서 실제 뷰포트 구하기 — visualViewport API

CSS 단위로 레이아웃을 잡는 것과 별개로, JavaScript에서 뷰포트 크기를 계산해야 할 때가 있다. 슬라이드 분할처럼 콘텐츠 높이를 측정해서 페이지를 나누는 로직이 대표적이다. 그런데 window.innerHeight는 vh와 같은 문제를 가지고 있다. 브라우저 UI를 무시한 고정 높이를 반환한다.

window.visualViewport은 실제로 사용자에게 보이는 영역의 크기를 알려준다. 주소창, 소프트 키보드, 핀치 줌 등 모든 브라우저 UI 요소를 고려한 값이다.

ts
// window.innerHeight: 브라우저 UI 무시한 고정값 (≈ 100vh)
// visualViewport.height: 실제 가시 영역 (≈ 100dvh를 px로 환산)
const vv = window.visualViewport;
const realHeight = vv?.height ?? window.innerHeight;

visualViewport의 프로퍼티

프로퍼티설명
width / height가시 영역의 크기 (CSS 픽셀)
offsetLeft / offsetTop핀치 줌으로 이동한 오프셋
pageLeft / pageTop페이지 기준 절대 좌표
scale

resize 이벤트도 지원한다. 소프트 키보드가 올라오거나 주소창이 변할 때 visualViewport.addEventListener('resize', handler)로 반응할 수 있다. 다만 전체화면 UI에서는 초기 한 번만 측정하면 충분한 경우가 많으므로, 이벤트 리스너가 반드시 필요한 것은 아니다.

슬라이드 분할에 적용한 사례

프레젠테이션 모드에서는 블로그 글의 DOM을 복제해서, 화면 높이에 맞게 자동으로 슬라이드를 분할한다. 이때 "화면 높이"를 window.innerHeight로 잡으면 모바일 에서 마지막 줄이 하단 바 뒤에 숨는 문제가 생겼다. visualViewport으로 교체하니 브라우저 UI를 뺀 실제 가용 높이로 분할되어 문제가 사라졌다.

tsx
useEffect(() => {
  if (!articleRef.current) return;
  const vv = window.visualViewport;
  // 회전 시 물리적 width가 높이가 됨
  const viewH = needsRotation
    ? (vv?.width ?? window.innerWidth)
    : (vv?.height ?? window.innerHeight);
  const padding = isMobile ? SLIDE_PADDING_Y_MOBILE : SLIDE_PADDING_Y;
  const available = (viewH - padding - bottomBarHeight) * getFillRatio(viewH);

회전 상태에서는 축이 뒤집히기 때문에 vv?.width가 슬라이드 높이가 되고, vv?.height가 슬라이드 너비가 된다는 점에 주의해야 한다. 물리적 기기의 가로세로와 CSS 레이아웃의 가로세로가 일치하지 않는 것이다.


CSS zoom vs transform: scale — 무엇이 다른가

전체화면 UI에서 콘텐츠를 확대해야 할 때, transform: scale()과 CSS zoom 중 하나를 선택해야 한다. 시각적으로는 비슷해 보이지만 레이아웃에 미치는 영향이 전혀 다르다.

transform: scale()

transform은 시각적 변환이다. 요소의 렌더링된 모습만 바꾸고, 레이아웃 공간은 원래 크기 그대로 유지된다. scale(2)로 2배 확대해도 주변 요소는 원래 위치에 그대로 있다. 요소 자체는 커졌는데 차지하는 공간은 같으니, 확대된 부분이 다른 요소 위에 겹쳐 그려진다.

css
.scaled {
  transform: scale(2);
  /* 레이아웃 공간: 원래 크기 유지 */
  /* 시각적 크기: 2배 확대 (주변 요소 위에 겹침) */
}

CSS zoom

zoom은 레이아웃 변환이다. 요소의 실제 레이아웃 크기 자체를 바꾼다. zoom: 2로 2배 확대하면 요소가 차지하는 공간도 2배가 된다. 주변 요소가 밀려나고, 부모 컨테이너의 스크롤도 정상적으로 동작한다.

css
.zoomed {
  zoom: 2;
  /* 레이아웃 공간: 2배 확대 */
  /* 시각적 크기: 2배 확대 (주변 요소가 밀려남) */
}
속성레이아웃 영향주변 요소스크롤브라우저 지원
transform: scale()없음겹침원래 크기 기준모든 브라우저
zoom있음밀려남

프레젠테이션에서 zoom을 선택한 이유

프레젠테이션 모드는 발표용이다. 빔프로젝터에 띄우거나 큰 모니터에서 멀리서 보는 상황을 고려하면 글자를 키울 수 있어야 했다. 처음에는 transform: scale()을 시도했는데, 슬라이드 콘텐츠가 확대되면서 하단 바 위에 겹쳐 그려지는 문제가 생겼다. 레이아웃 공간이 원래 크기 그대로이기 때문이다. zoom으로 바꾸니 콘텐츠가 확대되면서 자연스럽게 overflow 처리가 되었다.

tsx
// PC에서만 zoom 적용, 모바일은 화면이 작으므로 원본 크기 사용
<div
  ref={slideContentRef}
  className="mx-auto w-full max-w-5xl overflow-hidden"
  style={isMobile ? undefined : { zoom: pcScale }}
/>

다만 zoom을 쓰면 한 가지 함정이 있다. 슬라이드 분할 로직에서 콘텐츠 높이를 getBoundingClientRect()로 측정하는데, 이 값은 zoom 전의 원래 크기를 반환한다. zoom: 2에서 화면 높이가 800px이면 실제로 콘텐츠가 들어갈 공간은 400px인데, 측정 함수에 800px을 전달하면 콘텐츠가 넘친다. 가용 공간을 zoom 배율로 나눠서 전달해야 한다. 이것을 역스케일링이라고 부른다.

tsx
const scale = isMobile ? 1 : pcScale;
// zoom: 2 → 가용 공간을 절반으로 나눠서 전달
extractSlides(article, availableHeight / scale, slideWidth / scale);

하단 바에는 확대/축소 버튼을 추가했다. 스케일을 변경하면 슬라이드 분할이 달라지므로 currentSlide를 0으로 리셋한다. 갑자기 중간 슬라이드로 점프하는 것보다 처음부터 다시 보는 게 자연스럽기 때문이다.


모바일 판정 — width만 보면 안 되는 이유

반응형 웹에서 모바일 판정은 보통 max-width: 768px 미디어 쿼리로 한다. CSS에서는 이게 충분하지만, JavaScript에서 모바일/PC를 구분해서 동작(behavior)을 바꿔야 할 때는 부족하다.

가로 모드(landscape) 태블릿이 대표적인 반례이다. iPad를 가로로 들면 innerWidth가 1024px이므로 max-width: 768px에 해당하지 않는다. PC로 판정되면서 마우스 기반 인터랙션이 적용된다. 터치 기기인데 호버 효과를 기대하게 되는 것이다.

반대로 이미 가로 모드인 스마트폰에서 max-width: 768px에 걸리면, 세로 모드 전용 회전 로직이 적용되어 화면이 뒤집힐 수 있다. 문제는

"모바일이다"와 "회전이 필요하다"를 하나의 조건으로 묶었기 때문

이다.

짧은 변 기준 판정

해결 방법은 간단하다. 현재 width가 아니라 짧은 변(short side) 을 기준으로 판정한다.

ts
// orientation에 무관하게 일관된 모바일 판정
const short = Math.min(window.innerWidth, window.innerHeight);
const isMobile = short <= 768;

가로든 세로든, 짧은 변이 768px 이하면 모바일이다. iPad(짧은 변 768px)는 모바일로, 일반 노트북(짧은 변 900px+)은 PC로 판정된다.

판정을 분리하기

그리고 모바일 여부와 회전 필요 여부를 별도 상태로 분리한다. 이미 가로 모드라면 모바일이어도 회전할 필요가 없기 때문이다. 프레젠테이션 모드에서는 이 두 판정이 각각 다른 곳에 영향을 준다. isMobile은 터치 인터랙션(롱프레스 종료, 탭 네비게이션)과 패딩, 하단 바 높이를 결정하고, needsRotation은 CSS 회전과 축 뒤바꿈 로직을 제어한다.

tsx
const [isMobile] = useState(() => {
  const short = Math.min(window.innerWidth, window.innerHeight);
  return short <= 768;
});

// 세로 상태(portrait)일 때만 회전
const [needsRotation] = useState(() => {
  return isMobile && window.innerHeight > window.innerWidth;
});
기기 상태isMobileneedsRotation결과
스마트폰 세로truetrue모바일 UI + 90도 회전
스마트폰 가로truefalse모바일 UI, 회전 안 함

이전에는 isMobile이 true이면 무조건 회전했기 때문에, 이미 가로로 들고 있는 스마트폰에서 프레젠테이션을 열면 화면이 세로로 뒤집혀 버렸다. 분리 후에는 가로 스마트폰은 모바일 UI(터치 인터랙션)만 적용되고 회전은 하지 않는다.


Tailwind에서 자식 요소 스타일링 — 임의 변형(Arbitrary Variant)

Tailwind CSS는 유틸리티 클래스로 현재 요소의 스타일을 지정하는 게 기본이다. 그런데 가끔 자식 요소의 스타일을 부모에서 제어해야 할 때가 있다. 대표적인 예가 pre > code 패턴이다. pre에서 flexbox를 설정하고, 자식 code에 margin: auto를 줘서 중앙 정렬하고 싶은데, code 태그에 직접 className을 줄 수 없는 경우(외부 라이브러리가 렌더링하는 경우 등)가 있다.

Tailwind의 임의 변형(arbitrary variant) 문법은 대괄호 안에 CSS 선택자를 쓸 수 있게 해준다. &는 현재 요소를 가리킨다.

tsx
<pre className="flex flex-col [&>code]:my-auto [&>code]:mx-auto">
  <code>{children}</code>
</pre>

[&>code]:my-auto는 다음 CSS로 컴파일된다.

css
.\\[\\&\\>code\\]\\:my-auto > code {
  margin-top: auto;
  margin-bottom: auto;
}

코드 전체화면 뷰어에 적용한 사례

이 블로그의 코드 블록에는 전체화면 뷰어가 있다. 코드를 크게 보여주는 기능인데, 짧은 코드일 때 화면 상단에 붙어서 보기 어색했다. pre 안의 code를 세로·가로 중앙에 놓고 싶었지만, code 태그는 dangerouslySetInnerHTML로 렌더링되어 직접 className을 줄 수 없었다. [&>code] 패턴으로 부모인 pre에서 자식 스타일을 제어해 해결했다.

tsx
// FullscreenView 내부의 pre 태그
<pre
  className="flex-1 flex flex-col overflow-auto bg-gray-900 p-6
  text-base leading-7 text-gray-100
  md:py-32 md:px-[25dvw] md:text-2xl md:leading-8
  [&>code]:my-auto [&>code]:mx-auto"
>
  {children}
</pre>

자주 쓰는 패턴들

Tailwind 클래스생성되는 CSS용도
[&>code]:my-auto> code { margin-block: auto }직접 자식 code 수직 중앙
[&_img]:rounded-lgimg { border-radius: ... }하위 모든 img에 rounded
[&>*:first-child]:mt-0

&>는 직접 자식, &_(공백)는 하위 모든 자손, &+는 인접 형제 선택자이다. 별도의 CSS 파일 없이 JSX 안에서 복잡한 선택자를 표현할 수 있다는 점이 핵심이다.


컴포넌트 재사용 — 같은 UI가 두 곳에 있다면

프레젠테이션 모드 안에서 긴 코드 블록이 슬라이드에 다 들어가지 않으면, "코드 전체 보기" 버튼으로 교체된다. 이 버튼을 누르면 전체화면 코드 뷰어가 열리는데, 처음에는 이 뷰어를 프레젠테이션 컴포넌트 내부에 직접 구현했다. 회전 로직, 롱프레스 종료, 하단 바를 그대로 복사해서 넣었다.

그런데 이 코드는 이미 CodeBlock 컴포넌트 안에 FullscreenView로 존재한다. 코드 블록의 전체화면 버튼과 프레젠테이션의 코드 전체 보기가 사실상 같은 UI였다. 하나를 수정하면 다른 쪽도 수정해야 하는 상황이 된 것이다.

사실 초기에 이런 식으로 구현한 이유가, 회전을 해야한다에 너무 매몰돼서, 비효율적으로 구현한 듯하다. 그래서 FullscreenView를 독립적으로 export하고, rotate prop을 추가해서 호출하는 쪽에서 회전 여부를 제어할 수 있게 했다. 프레젠테이션에서는 이미 컨테이너가 회전된 상태이므로 rotate={false}를 넘기고, 코드 블록에서 직접 열 때는 생략하면 자동으로 모바일 세로 모드일 때만 회전한다.

tsx
// 프레젠테이션에서 FullscreenView 재사용
import { FullscreenView } from "@app/ui";

{
  fullscreenCode &&
    createPortal(
      <FullscreenView
        language={fullscreenCode.language}
        onClose={() => setFullscreenCode(null)}
        rotate={false} // 이미 회전된 컨테이너 안이므로
      >
        <code
          classNamefont-mono

이렇게 분리하면서 프레젠테이션 내부의 코드 뷰어 구현부(약 30줄)를 통째로 제거할 수 있었다. 앞으로 전체화면 코드 뷰어의 디자인이나 인터랙션을 수정할 때 한 곳만 고치면 된다.


정리

전체화면 UI를 다양한 기기에서 안정적으로 동작시키려면, 뷰포트의 실체를 이해해야 한다. 100vh가 100%가 아닌 것은 버그가 아니라 모바일 브라우저의 설계이고, 이를 보완하는 도구(dvh, visualViewport)가 이미 표준에 있다.

  • 전체화면 레이아웃에는 100dvh를 쓰고, JS에서는 visualViewport으로 실제 크기를 구한다.
  • 콘텐츠 확대에는 zoom이 transform: scale()보다 레이아웃 친화적이지만, 측정 시 역스케일링을 잊지 않는다.
  • 모바일 판정은 짧은 변 기준으로 하고, "모바일이다"와 "회전이 필요하다"를 분리한다.
  • Tailwind 자식 선택자는 [&>selector] 패턴으로 별도 CSS 없이 처리한다.
  • 같은 UI가 두 곳에 있으면 prop으로 차이를 제어하는 공통 컴포넌트로 뽑아낸다.

PC, 노트북, 태블릿 가로/세로, 스마트폰 가로/세로 — 뷰포트 크기와 브라우저 UI가 모두 다르다. 하나의 코드로 이 모든 환경을 커버하려면, 생각보다 많은 개념이 필요했었던 경험이다.

포스트 목록

/SEOJing
파일 11개, 폴더 1개
Cloudflare Workers에서 fs 모듈이 안 되는 이유와 해결법모바일 웹에서 가로 모드를 강제하는 5가지 방법 — iOS Safari에서도 동작하는 코드 뷰어 만들기블로그 글을 PPT로 만들기 — DOM 클로닝 기반 프레젠테이션 모드100vh가 100%가 아닌 이유 — 모바일 뷰포트 단위 완전 정리Context로 퀴즈 컴포넌트를 만들다 막혀서 React.Children을 공부하게 된 이야기localStorage 읽기에서 하이드레이션 에러가 터지는 이유 useSyncExternalStore로 해결useEffect cleanup과 의존성 배열 — 실전 버그 사례로 이해하는 생애주기vinext + GitHub Actions로 Cloudflare Workers 배포하기vinext 오픈소스 기여기: 한국어 slug가 RSC에서 이슈를 일으킨 이유RSC 환경에서 WebAssembly가 차단되는 이유 — Shiki에서 rehype-prism-plus로vinext는 왜 빠를까? — SSR, Vite, Edge, 그리고 Web Vitals까지
lvhLarge Viewport Height브라우저 UI가 모두 숨겨진 상태고정
dvhDynamic Viewport Height현재 실제 가시 영역동적
핀치 줌 배율 (기본 1.0)
const scale = isMobile ? 1 : pcScale;
const slideW = needsRotation
? (vv?.height ?? window.innerHeight) - 64
: Math.min(window.innerWidth - 128, 1024);
setSlides(
extractSlides(articleRef.current, available / scale, slideW / scale),
);
}, [articleRef, isMobile, needsRotation, bottomBarHeight, pcScale]);
확대된 크기 기준
Chrome, Safari, Edge (Firefox 126+)
태블릿 가로
true
false
모바일 UI, 회전 안 함
노트북/데스크탑falsefalsePC UI
> *:first-child { margin-top: 0 }
첫 자식 상단 마진 제거
[&+p]:mt-4+ p { margin-top: ... }인접 형제 p에 상단 마진
=
"
"
dangerouslySetInnerHTML={{ __html: fullscreenCode.html }}
/>
</FullscreenView>,
document.body,
);
}