CSS svh, dvh, svw, dvw 완벽 정리: 100vh가 모바일에서 깨지는 이유

svh, dvh, svw, dvw — 한 줄 정의부터

  • svh (small viewport height): 브라우저 주소창·툴바가 모두 펼쳐져 있어 뷰포트가 가장 작을 때의 높이 1%
  • lvh (large viewport height): 주소창·툴바가 모두 숨겨져 뷰포트가 가장 클 때의 높이 1%
  • dvh (dynamic viewport height): 지금 이 순간 실제로 보이는 뷰포트 높이 1%. 스크롤에 따라 주소창이 접히고 펼쳐지는 걸 실시간으로 반영
  • svw / lvw / dvw: 위와 같은 개념을 세로(height) 대신 가로(width) 기준으로 적용한 것

가로(w) 계열은 모바일 브라우저에서 주소창이 위아래로 접히는 것과는 직접 관련이 없어서 체감 차이가 크지 않지만, 스펙상 동일한 3종 세트(small/large/dynamic)로 정의되어 있고 세로 폭이 변하는 특수한 레이아웃(가로 스크롤 UI, 회전 대응)에서 의미가 있다.

왜 100vh가 모바일에서 문제가 되는가

기존 vh 단위는 "뷰포트 높이의 1%"라는 단 하나의 정의만 갖고 있었다. 데스크톱에서는 이게 문제없이 작동한다. 브라우저 창 크기가 스크롤한다고 바뀌지 않기 때문이다.

그런데 모바일 브라우저(특히 Safari iOS, Chrome Android)는 사용자가 스크롤을 내리면 주소창과 하단 툴바가 슬쩍 사라지면서 실제 화면에 보이는 영역이 늘어난다. 반대로 위로 스크롤하면 다시 나타난다. 문제는 브라우저가 vh 값을 계산할 때 "주소창이 있는 상태 기준"으로 잡을지 "주소창이 없는 상태 기준"으로 잡을지가 브라우저마다 달랐다는 점이다.

대표적인 증상: height: 100vh로 만든 전체 화면 히어로 섹션이 있는데, 페이지를 처음 열었을 때는 주소창 때문에 하단이 살짝 잘려 보이거나, 반대로 스크롤하면 빈 여백이 생기는 현상. 특히 iOS Safari에서 이 문제가 두드러졌는데, 100vh가 "주소창이 사라진 최대 높이" 기준으로 계산되면서 페이지를 처음 열었을 때(주소창이 보이는 상태)는 화면보다 콘텐츠가 더 길게 잡혀 스크롤이 생기거나, 하단 내비게이션 바가 화면 밖으로 밀려나는 문제가 자주 보고됐다.

이 모호함을 해결하려고 CSS Values and Units Module Level 4 스펙에서 뷰포트를 세 가지 상태로 명확히 나눈 게 small/large/dynamic 뷰포트 단위다.

small / large / dynamic 뷰포트를 그림으로 이해하기

모바일 브라우저의 UI를 상상해보자.

  • 페이지를 막 열었을 때: 상단 주소창 + 하단 툴바가 모두 보임 → 이때 실제 콘텐츠가 차지할 수 있는 세로 공간이 small viewport
  • 스크롤을 내려서 주소창과 툴바가 완전히 사라진 상태: 화면 전체를 콘텐츠가 씀 → 이게 large viewport
  • 지금 이 순간의 실제 상태(주소창이 반쯤 접혔거나 완전히 펼쳐졌거나): dynamic viewport, 즉 실시간으로 변하는 값

100svh는 항상 "주소창이 다 펼쳐진 상태"를 기준으로 고정되어 계산되므로, 브라우저 UI가 어떻게 움직이든 절대 화면 밖으로 넘치지 않는다. 대신 주소창이 사라졌을 때는 화면 하단에 여백이 남는다. 100lvh는 반대로 주소창이 사라진 최대치를 기준으로 잡기 때문에, 주소창이 떠 있는 초기 상태에서는 콘텐츠 하단이 화면 밖으로 나가 스크롤이 생길 수 있다. 100dvh는 이 둘 사이를 실시간으로 오가며, 사용자가 스크롤할 때마다 주소창 상태에 맞춰 값이 재계산된다.

언제 어떤 단위를 써야 하나

dvh — 화면을 딱 채우고 싶을 때

전체 화면 히어로 섹션, 모바일 앱처럼 보이는 랜딩 페이지의 첫 화면 등 "지금 보이는 화면에 딱 맞추고 싶다"면 dvh가 가장 직관적이다.

.hero {
  height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
}

다만 dvh는 스크롤에 따라 값이 계속 재계산되기 때문에, 레이아웃이 미세하게 움직이는 게 거슬릴 수 있는 요소(예: 애니메이션이 걸린 배경)에는 오히려 부적합할 수 있다. 이런 경우엔 아래 svh를 고려한다.

svh — 절대 넘치지 않게, 안정적으로 고정하고 싶을 때

화면 하단에 고정되는 CTA 버튼 바, 모달 오버레이처럼 "브라우저 UI가 어떻게 움직이든 절대 화면 밖으로 넘어가면 안 되는" 요소에는 svh가 안전하다.

.bottom-bar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  /* 주소창이 떠 있는 가장 작은 화면 기준으로 계산되므로
     항상 화면 안에 들어온다 */
  min-height: 10svh;
}

lvh — 콘텐츠가 최대로 펼쳐진 상태를 기준 삼고 싶을 때

실사용에서는 상대적으로 덜 쓰이지만, "주소창이 사라진 상태를 기준으로 배경 이미지를 꽉 채우고 싶다" 같은 경우에 쓸 수 있다. 다만 이 경우 초기 로드 시 콘텐츠 하단이 살짝 가려질 수 있다는 점을 감안해야 한다.

모달 예시 — dvh로 실시간 대응

.modal {
  position: fixed;
  inset: 0;
  height: 100dvh;
  overflow-y: auto;
  padding: 1rem;
}

모달처럼 내부 스크롤이 있는 요소는 dvh를 쓰면 브라우저 UI 상태와 무관하게 항상 현재 화면에 맞춰 열려서, 스크롤 위치가 어색하게 남는 문제가 줄어든다.

svw, dvw는 언제 쓰나

가로 방향은 모바일 주소창 이슈와는 관계가 적지만, 다음과 같은 상황에서 의미가 있다.

  • 가로 스크롤 기반 갤러리나 캐러셀에서 뷰포트 폭 기준으로 카드 크기를 잡을 때
  • 화면 회전(가로/세로 전환) 시 레이아웃이 즉시 반응해야 하는 경우
  • PWA나 풀스크린 앱처럼 브라우저 크롬이 사라지는 환경에서 가로 폭 계산이 브라우저 UI에 영향을 받는 특수 케이스
.carousel-card {
  width: 80dvw;
  max-width: 320px;
}

일반적인 반응형 레이아웃에서는 기존 vw%, 혹은 100vw가 스크롤바 폭 때문에 생기는 미세한 가로 스크롤 버그를 신경 쓰는 정도로 충분한 경우가 많고, svw/dvw까지 필요한 상황은 상대적으로 드물다. 다만 브라우저 UI(예: 세로 모드 툴바)가 가로 폭에도 영향을 주는 특정 모바일 브라우저 환경을 정밀하게 대응해야 한다면 이 단위들을 쓸 근거가 생긴다.

브라우저 지원 현황

small/large/dynamic 뷰포트 단위(svh, lvh, dvh, svw, lvw, dvw 및 svmin/dvmin 등 파생 단위 포함)는 이제 주요 브라우저에서 폭넓게 지원된다.

  • Chrome, Edge: 108버전 이상
  • Firefox: 101버전 이상
  • Safari(데스크톱·iOS 공통): 15.4버전 이상
  • Samsung Internet: 21버전 이상

즉 2022년 초중반 이후 배포된 브라우저 버전이면 대부분 문제없이 동작한다. 다만 오래된 기업용 브라우저나 특정 임베디드 웹뷰 환경까지 지원해야 한다면, 폴백을 함께 준비하는 게 안전하다.

.hero {
  height: 100vh; /* 구형 브라우저 폴백 */
  height: 100dvh; /* 지원 브라우저에서 덮어씀 */
}

CSS는 인식하지 못하는 속성값을 무시하고 마지막에 유효한 선언을 적용하는 특성이 있어서, 이렇게 두 줄을 순서대로 써두면 신형 브라우저는 dvh를, 구형 브라우저는 vh를 자연스럽게 적용받는다. @supports 규칙으로 더 명시적으로 분기할 수도 있다.

@supports (height: 100dvh) {
  .hero {
    height: 100dvh;
  }
}

실전 팁 — vh 대신 언제 바꿔야 하나

기존에 100vh로 작성된 코드를 전부 바꿀 필요는 없다. 문제가 실제로 발생하는 지점, 즉 모바일에서 주소창이 접히고 펼쳐질 때 레이아웃이 흔들리는 요소만 골라서 dvhsvh로 바꾸는 게 효율적이다.

  • 풀스크린 히어로, 랜딩 첫 화면 → 100dvh로 교체 권장
  • 화면 하단 고정 바, 항상 보여야 하는 CTA → svh 기반으로 최소 높이를 잡아 넘침 방지
  • 일반적인 카드, 섹션 높이 → 대부분 굳이 바꿀 필요 없음. vh 그대로 두거나 %, auto로 충분한 경우가 많음
  • min-height: 100vh로 "최소한 화면은 채워라" 정도의 느슨한 용도라면 100lvh100dvh 어느 쪽을 써도 큰 문제는 없다

svmin, svmax, dvmin, dvmax도 알아두면 좋다

스펙에는 svh/lvh/dvh, svw/lvw/dvw 외에도 vmin, vmax의 확장판인 svmin, lvmin, dvmin, svmax, lvmax, dvmax도 정의되어 있다. vmin이 가로/세로 중 더 작은 값을 기준으로 삼고 vmax가 더 큰 값을 기준으로 삼듯이, 이 확장판들도 small/large/dynamic 세 가지 뷰포트 상태 안에서 동일한 규칙을 따른다. 정사각형에 가까운 요소를 화면 크기에 비례해서 만들고 싶을 때, 화면 회전에도 안정적으로 대응해야 한다면 dvmin을 고려할 수 있다. 다만 실무에서 svh/dvh만큼 자주 쓰이는 단위는 아니라서, 필요할 때 스펙을 다시 찾아보는 정도로도 충분하다.

실전 버그 사례 — 100vh 모달이 iOS에서 잘리는 문제

풀스크린 모달을 height: 100vh로 만들었는데, iOS Safari에서 하단 버튼이 화면 밖으로 밀려나 안 보인다는 문의가 흔하다. 원인은 앞서 설명한 대로 100vh가 주소창이 사라진 최대 높이를 기준으로 계산되기 때문에, 모달을 열었을 때 실제로 주소창이 아직 떠 있는 상태라면 콘텐츠가 화면보다 길게 잡히기 때문이다. 이럴 때는 다음처럼 수정하면 대부분 해결된다.

.modal-fullscreen {
  height: 100vh; /* 폴백 */
  height: 100svh; /* 안전하게 넘치지 않는 값 */
  height: 100dvh; /* 지원 브라우저에서 실시간 반영 */
  overflow-y: auto;
}

100svh를 중간에 넣어두면, 100dvh를 지원하지 않는 일부 구형 브라우저에서도 최소한 화면 밖으로 넘치지 않는 안전한 값으로 동작한다. 세 줄을 순서대로 선언해두는 것만으로 폴백 우선순위가 자연스럽게 정리된다.

JavaScript로 뷰포트 값을 다뤄야 할 때

CSS 단위만으로 해결이 안 되는 복잡한 레이아웃(예: 특정 픽셀 값을 JS 계산에 써야 하는 경우)에는 window.visualViewport API를 함께 쓰기도 한다.

const vh = window.visualViewport
  ? window.visualViewport.height
  : window.innerHeight;
document.documentElement.style.setProperty('--real-vh', `${vh}px`);

이렇게 CSS 변수로 실제 뷰포트 높이를 저장해두고 height: var(--real-vh)처럼 쓰는 방식은 dvh 등장 이전에 널리 쓰이던 우회법이다. 지금은 dvh 지원 범위가 넓어졌기 때문에 대부분의 경우 이런 JS 우회 없이 CSS만으로 해결할 수 있지만, resizescroll 이벤트에 맞춰 정교하게 값을 제어해야 하는 특수한 인터랙션(예: 키보드가 올라올 때 레이아웃을 다르게 반응시키는 경우)에는 여전히 JS 조합이 유용하다.

프레임워크와 CSS 라이브러리에서의 지원

Tailwind CSS는 최신 버전에서 h-dvh, h-svh, h-lvh, w-dvw, w-svw, w-lvw 같은 유틸리티 클래스를 기본 제공한다. 별도 플러그인 없이 클래스명만 바꿔주면 되기 때문에 마이그레이션 부담이 크지 않다. 예를 들어 기존에 h-screen(내부적으로 100vh를 사용)으로 만든 히어로 섹션을 h-dvh로 바꾸는 것만으로 모바일 대응이 개선된다. 다만 Tailwind 버전이 낮다면 이 유틸리티가 없을 수 있으니, 버전을 먼저 확인하고 필요하면 커스텀 유틸리티를 theme.extend에 직접 추가하면 된다.

// tailwind.config.js 예시 (구버전 대응)
module.exports = {
  theme: {
    extend: {
      height: {
        dvh: '100dvh',
        svh: '100svh',
      },
    },
  },
};

CSS-in-JS 라이브러리(styled-components, Emotion 등)에서도 특별한 설정 없이 문자열 값 그대로 100dvh를 넣으면 동작한다. 브라우저가 해당 문법을 지원하지 않으면 해당 선언만 무시되므로, 폴백 값을 먼저 선언하고 그 다음 줄에 새 단위를 선언하는 원칙만 지키면 라이브러리 종류와 무관하게 안전하게 적용할 수 있다.

자주 헷갈리는 포인트 정리

Q. dvh를 쓰면 항상 svh보다 나은가? 아니다. 레이아웃이 스크롤 중에 계속 흔들리는 걸 원치 않는다면 오히려 svh나 고정된 값이 낫다. dvh는 "실시간으로 정확히 맞추고 싶다"는 요구가 있을 때 쓰는 것이지 무조건 상위 호환 개념이 아니다.

Q. 100%와 100dvh는 뭐가 다른가? height: 100%는 부모 요소의 높이를 기준으로 계산되기 때문에, 부모에게 명시적인 높이가 없으면 아예 작동하지 않는다. 반면 dvh, svh, lvh는 부모와 무관하게 항상 브라우저 뷰포트를 기준으로 계산되므로, 중첩된 레이아웃에서도 안정적으로 작동한다.

Q. body에 100vh 대신 100dvh를 넣으면 스크롤이 이상해지지 않나? bodyhtml처럼 페이지 최상위 요소에 뷰포트 단위를 적용할 때는 min-height를 쓰는 게 일반적으로 더 안전하다. height로 고정하면 내용이 뷰포트보다 길어졌을 때 오히려 스크롤이 깨질 수 있으므로, min-height: 100dvh 형태로 "최소한 화면은 채우되 내용이 길면 자연스럽게 늘어나도록" 처리하는 패턴을 권장한다.

정리

vh가 데스크톱 기준으로 설계된 단일 값이었다면, svh·lvh·dvh(그리고 가로 방향의 svw·lvw·dvw)는 모바일 브라우저의 주소창이 사라졌다 나타났다 하는 현실을 반영해 뷰포트를 세 가지 상태로 쪼갠 것이다. 화면 밖으로 절대 넘치면 안 되는 요소는 svh, 지금 보이는 화면에 실시간으로 딱 맞추고 싶은 요소는 dvh를 쓰면 된다. 지원 브라우저 범위도 2022년 이후 버전 기준으로 넓게 확보되어 있어서, 모바일에서 100vh가 어긋나는 문제로 골치를 앓고 있다면 지금 바로 적용해볼 만하다.

koenjaesfr