본문으로 건너뛰기
Tech Blog

단위마다 다른 기준이 있다

글 복사 완료!

100vh가 잘리고 1px이 안 커지는 이유, 단위마다 기준이 달라서예요.

·12분·

데스크톱에서 잘 보이던 레이아웃이 모바일에서 100vh를 줬는데도 아래로 잘려요. 폰트만 1px 키웠더니 한 곳에서만 안 커져요. 저도 한참은 "px이면 px이지" 싶었거든요. 알고 보니 CSS의 길이 단위는 다들 자기 기준점을 들고 살아가더라고요. px, em, rem, vh, dvh가 어떤 기준 위에 서 있는지 한 번 그려두면 그 다음부터는 선택이 직관이 돼요.

px는 절대 단위가 아니다

CSS 사양은 px를 절대 단위로 분류해요. 1px = 1/96 inch로 못 박아 둔 것까지도 절대 단위 같죠. 그런데 정의를 한 줄 더 따라가면 이상해집니다. W3C는 픽셀의 기준을 "참조 픽셀(reference pixel)"이라고 부르는데, 이건 96dpi 디바이스에서 팔 길이(약 28인치) 거리에서 픽셀 한 개가 만드는 시각 각도를 뜻하거든요. 길이의 기준 자체가 사람의 시야각에 묶여 있는 셈이에요.

이 추상화 덕분에 고해상도 화면에서도 1px 글자가 너무 작게 보이지 않아요. 같은 공간에 더 많은 하드웨어 픽셀이 박혀 있는 Retina 디스플레이에서도 1 CSS 픽셀은 여러 하드웨어 픽셀에 매핑되거든요. 이 비율이 window.devicePixelRatio로 노출돼 있어요. 직접 확인해 보세요.

vw/vh도 같은 흐름 위에 있어요. vw는 뷰포트 너비의 1%인데, 여기서 말하는 "뷰포트 너비"가 곧 viewport meta 태그의 width=device-width로 잡혀요. meta 태그가 빠지면 모바일 브라우저가 980px 가상 뷰포트를 만들어 화면에 욱여넣어 축소 렌더해요. 그러면 1vw의 기준이 화면이 아니라 그 가상 980px이 돼버려요. <meta name="viewport" content="width=device-width, initial-scale=1">은 디자이너 취향 문제가 아니라 단위의 기준선을 맞추는 선언인 셈이에요.

em은 누적되고, rem은 누적되지 않는다

em은 자기 자신이 계산한 font-size를 1로 보는 단위에요. font-size가 따로 없으면 부모로부터 상속받은 값이 곧 자신의 font-size가 되니까, 결과적으로 부모 폰트 크기가 기준이 돼요. 그래서 같은 단위가 깊은 중첩에서 곱해지는 누적 문제가 생기는데, 이걸 풀려고 사양이 나중에 추가한 게 rem이에요. 루트 요소(<html>)의 font-size를 기준으로 박아두니까 어느 깊이에서도 같은 값이 나오죠.

문제는 em이 깊게 쌓이는 순간이에요. 자식이 font-size: 1.2em이고 그 자식도 1.2em, 또 자식도 1.2em이면 결과는 1.2배가 아니라 1.2 × 1.2 × 1.2 = 1.728배가 돼요. 직접 슬라이더를 움직여 보세요.

그래서 컴포넌트 내부 패딩처럼 "현재 폰트 크기에 비례하는 여백"을 원할 때는 em이 어울리고, 글로벌 타이포그래피 시스템처럼 "어디에 박혀도 같은 크기여야 하는 값"은 rem이 어울려요.

rem을 권하는 또 다른 이유는 접근성이에요. MDN은 절대 단위에 대해 이렇게 짚어요.

"Absolute lengths can cause accessibility problems because they are fixed and do not scale according to user settings." - MDN

브라우저 설정에서 기본 폰트 크기를 키워둔 사용자가 적지 않거든요. 본문 폰트를 16px로 못 박으면 그 설정이 무시돼요. 같은 자리를 1rem으로 두면 사용자가 키운 만큼 따라 커지고요.

100vh가 모바일에서 잘리는 이유

폰트 기준이 정리됐으면 이번엔 화면 기준 쪽이에요. 100vh인 히어로 섹션이 데스크톱에서는 멀쩡한데 모바일 사파리에서는 마지막 줄이 주소창 뒤로 숨어버린 적이 있을 거예요. 이건 사양과 호환성이 부딪혀서 생긴 흔적이에요.

vh가 처음 표준화될 때는 모바일 브라우저가 주소창을 동적으로 접고 펴는 시대가 아니었어요. 그래서 그냥 "뷰포트의 1%"로 단순하게 정의됐고, 100vh = 화면 전체 높이로 동작했죠. 근데 사파리가 스크롤할 때 주소창을 접는 UI를 도입하면서 문제가 생겼어요. 매 스크롤마다 100vh 값이 바뀌면 레이아웃이 흔들리잖아요. 사파리는 "그러면 100vh는 주소창이 접혔을 때(가장 큰 상태)의 값으로 고정해두자"라고 정했고, 다른 브라우저들도 호환성 때문에 그 결정을 따라갔어요.

이게 사양에도 그대로 반영돼서, 지금도 vh는 "large viewport size의 1%"로 정의돼 있어요. 결과적으로 모바일에서 height: 100vh인 요소는 주소창이 보이는 상황에서 화면 아래로 그만큼 잘려나가요. 디자인 의도가 "화면을 꽉 채우자"였다면 정반대로 동작하는 거죠.

해결책으로 사양은 뷰포트 단위를 세 세트로 분리했어요. lv*(large), sv*(small), dv*(dynamic). 기존 vhlv* 쪽에 매핑돼 있고요.

svh, lvh, dvh 셋 중 언제 무엇을

세 세트의 차이는 "UA UI(주소창, 탭바)가 펼쳐진 상태를 어떻게 다루느냐"에 있어요. svh는 UI가 다 펼쳐져서 가장 작아진 상태의 뷰포트, lvh는 UI가 다 숨어서 가장 커진 상태, dvh는 그 사이에서 실시간으로 변하는 값이에요. dv* 값은 언제나 sv*lv* 사이에 clamp 돼요.

선택은 "잘리면 안 되는 영역이냐, 가려져도 되는 영역이냐"로 갈리는 편이에요. 첫 화면에 보일 히어로 섹션은 주소창이 보이는 순간에도 콘텐츠가 안 잘려야 하니까 svh가 안전하고요. 풀스크린 모달처럼 UI가 사라진 상태에서 영역을 최대로 쓰고 싶으면 lvh가 맞아요. 일반적인 경우엔 dvh로 두면 주소창의 펼침/접힘에 자동으로 따라가고요.

다만 dvh도 만능은 아니에요. 사양에 흥미로운 경고가 있거든요.

"The sizes of the dynamic viewport-percentage units are not stable even while the viewport itself is unchanged." - W3C CSS Values 4

뷰포트가 바뀌지 않아도 dvh 값은 안정적이지 않다는 뜻이에요. 스크롤 위치에 따라 UA가 UI를 접거나 펴면 그 순간 dvh가 다시 계산되거든요. 거기에 이 값은 60fps로 갱신되지도 않아요. 큰 요소를 height: 100dvh로 두고 트랜지션을 걸면 미세한 떨림이 보일 수 있어요.

브라우저 지원은 2022년 안에 메이저 브라우저(Safari 15.4, Firefox 101, Chrome/Edge 108)가 모두 도입을 마쳤어요. 글로벌 지원율이 94%를 넘어서, 폴리필 없이 써도 되는 단계에 들어왔거든요.

다섯 단위가 결국 같은 질문에 다른 답을 내고 있었어요. "이 길이는 무엇을 기준으로 하나?" px는 참조 픽셀, em은 부모 폰트, rem은 루트 폰트, vh는 large viewport, dvh는 그 순간의 뷰포트. 단위마다 기준이 다르고, 상황마다 잘 맞는 답이 따로 있어요. 비슷한 질문을 비율 쪽으로 던져두면 aspect-ratio 한 줄로 끝나는 영상 비율 이야기가 이어져요.

참고 자료

관련 글