유동 레이아웃과 CSS 단위: %, rem, vw, clamp()로 브레이크포인트 사이 잇기

Frontend

이 글은 React로 반응형 웹 구성하기 시리즈 2편입니다.

1편에서 미디어 쿼리로 화면 폭에 따라 레이아웃을 바꾸는 법을 봤습니다. 그런데 미디어 쿼리만으로 반응형을 만들면 한 가지 아쉬운 점이 남습니다. 브레이크포인트는 "계단"이라, 767px에서 768px로 넘어가는 순간 레이아웃이 툭 바뀌고, 그 사이 구간(예: 800px)에서는 아무 변화 없이 고정되어 있습니다.

그래서 데스크톱 브레이크포인트(1024px)로 넘어가기 직전인 1000px 화면에서 여백이 어색하게 벌어지거나, 글자가 너무 크거나 작아 보이는 일이 생깁니다. 미디어 쿼리가 "계단"이라면, 그 계단 사이를 부드럽게 잇는 "경사로"가 바로 유동 단위입니다.

이 글에서는 반응형에서 쓰는 CSS 단위들을 성격별로 정리하고, 각각의 트레이드오프와 함께 clamp()로 계단 사이를 촘촘하게 잇는 방법까지 다룹니다.

한눈에 보면

  • 단위는 절대 단위(px)상대 단위(%, em, rem, vw 등) 로 나뉘고, 반응형은 상대 단위를 얼마나 잘 쓰느냐의 문제입니다.
  • rem은 루트 폰트 크기 기준이라 예측 가능하고 접근성(사용자 폰트 확대)에 강합니다. em은 부모 기준이라 중첩 시 값이 누적됩니다.
  • vw/vh는 뷰포트에 완전히 비례하지만, 그대로 쓰면 너무 커지거나 작아지고 사용자 폰트 확대를 무시하는 접근성 문제가 있습니다.
  • 모바일에서 100vh가 주소창까지 포함해 커지는 문제는 dvh/svh/lvh로 해결합니다.
  • clamp(최소, 선호, 최대)는 "유동적으로 변하되 상한·하한은 지킨다"를 한 줄로 표현합니다. 반응형 단위의 핵심 도구입니다.
  • 미디어 쿼리(계단)와 유동 단위(경사로)는 경쟁 관계가 아니라 함께 쓰는 관계입니다.

계단과 경사로

먼저 두 방식이 폭 변화에 어떻게 반응하는지 감을 잡아 보겠습니다. 아래는 폰트 크기를 예로, 화면 폭이 커질 때 값이 어떻게 변하는지를 나타낸 것입니다.

● 미디어 쿼리만 사용 (계단)

 |          ___________
 |         |
 |     ____|
 |    |
 |____|
 +-------------------------- 화면 폭
   (브레이크포인트에서만 값이 툭툭 바뀜)
 
● clamp() 유동 단위 (경사로)

 |            __________  <- 최대값에서 멈춤
 |          /
 |        /
 |      /
 |____/                   <- 최소값에서 시작
 +-------------------------- 화면 폭
   (구간 전체에서 부드럽게 변하고, 상·하한은 지킴)

계단은 특정 지점에서만 변하고, 경사로는 구간 전체에서 부드럽게 변합니다. 좋은 반응형은 보통 이 둘을 섞습니다. 큰 구조 전환은 미디어 쿼리(계단)로, 그 사이의 폰트·여백·간격 미세 조정은 유동 단위(경사로)로 처리합니다.

단위를 성격으로 나눠 보기

반응형에서 마주치는 단위를 성격으로 묶으면 이렇게 정리됩니다.

단위 기준 성격 주 용도
px 절대(고정) 변하지 않음 테두리, 얇은 선
% 부모 요소 부모에 비례 너비, 유동 컬럼
em 현재 요소의 폰트 크기 부모 폰트에 누적 컴포넌트 내부 간격
rem 루트(html) 폰트 크기 전역 기준, 예측 가능 폰트, 간격 대부분
vw/vh 뷰포트 폭/높이 화면에 완전 비례 히어로, 풀블리드
dvh 동적 뷰포트 브라우저 UI 반영 모바일 전체 높이
ch 문자 0의 폭 글자 수 기준 본문 줄 길이 제한

이 중 반응형에서 특히 자주 헷갈리고, 트레이드오프가 분명한 세 가지를 짚겠습니다.

em vs rem: 누적되느냐, 예측되느냐

em현재 요소의 폰트 크기를 기준으로 합니다. 그래서 중첩되면 값이 곱해지며 누적됩니다.

.card {
  font-size: 20px;
}
.card .badge {
  font-size: 0.8em; /* 20px * 0.8 = 16px */
}
.card .badge .dot {
  font-size: 0.8em; /* 16px * 0.8 = 12.8px — 또 곱해짐 */
}

rem은 항상 루트(html) 폰트 크기를 기준으로 하므로 중첩과 무관하게 예측 가능합니다.

html {
  font-size: 16px;
}
.badge {
  font-size: 1rem; /* 위치와 무관하게 항상 16px */
}

트레이드오프는 이렇습니다.

  • rem: 전역적으로 일관되고, 사용자가 브라우저 기본 폰트 크기를 키우면 전체가 함께 커져 접근성에 강합니다. 대부분의 폰트·간격은 rem이 기본값으로 좋습니다.
  • em: "이 컴포넌트의 폰트 크기에 상대적으로" 간격을 잡고 싶을 때 유용합니다. 버튼 안 패딩을 em으로 두면 폰트가 커질 때 패딩도 비례해 커집니다. 대신 중첩 누적을 항상 염두에 둬야 합니다.

실무 기준: 폰트와 대부분의 간격은 rem, 컴포넌트 내부에서 폰트에 비례해야 하는 간격만 em.

vw의 함정: 접근성과 극단값

vw는 뷰포트 폭의 1%입니다. width: 50vw처럼 레이아웃 너비에 쓰면 직관적이고 좋습니다. 문제는 폰트 크기에 vw를 단독으로 쓸 때 생깁니다.

/* 위험한 패턴 */
h1 {
  font-size: 5vw;
}

이 코드에는 두 가지 문제가 있습니다.

  • 극단값: 초광각 모니터(2560px)에서 5vw = 128px로 지나치게 커지고, 아주 좁은 화면에서는 반대로 너무 작아집니다. 상·하한이 없습니다.
  • 접근성: vw는 뷰포트에만 반응하고 사용자의 브라우저 폰트 확대 설정을 무시합니다. 저시력 사용자가 글자를 키워도 vw 기반 글자는 그대로입니다.

그래서 폰트에 뷰포트 단위를 쓰고 싶다면 단독으로 쓰지 말고, 뒤에서 볼 clamp()rem과 섞어 상·하한과 접근성을 함께 챙겨야 합니다.

100vh 문제와 동적 뷰포트 단위

1편에서 잠깐 짚었듯, 모바일에서 height: 100vh는 주소창이 보이는 상태의 높이까지 포함해 실제 보이는 영역보다 커질 수 있습니다. 이걸 해결하는 게 동적 뷰포트 단위입니다.

  • svh (small): 브라우저 UI가 최대로 보일 때 기준 높이
  • lvh (large): 브라우저 UI가 숨겨졌을 때 기준 높이
  • dvh (dynamic): UI 표시 상태에 따라 실시간으로 바뀌는 높이
.hero {
  min-height: 100dvh; /* 주소창 표시/숨김에 따라 실제 보이는 높이로 */
}

트레이드오프도 있습니다. dvh는 스크롤로 주소창이 접힐 때 값이 바뀌면서 레이아웃이 살짝 움직일 수 있습니다. "절대 안 잘리는 게 중요하면 svh", "가득 채우는 느낌이 중요하면 dvh"로 상황에 맞게 고릅니다.

clamp(): 반응형 단위의 핵심 도구

이제 이 글의 주인공입니다. clamp()는 세 값을 받습니다.

clamp(최솟값, 선호값, 최댓값)
  • 선호값이 보통 뷰포트에 비례하는 유동값(vw, % 등)이고,
  • 그 값이 최솟값보다 작아지면 최솟값으로,
  • 최댓값보다 커지면 최댓값으로 고정됩니다.

즉 "유동적으로 변하되, 너무 작아지지도 너무 커지지도 않게"를 한 줄로 표현합니다.

유동 타이포그래피

앞서 본 font-size: 5vw의 문제를 clamp()로 고쳐 보겠습니다.

h1 {
  /* 최소 1.5rem, 선호 4vw, 최대 3rem */
  font-size: clamp(1.5rem, 4vw, 3rem);
}

이제 좁은 화면에서도 1.5rem 밑으로 안 내려가고, 넓은 화면에서도 3rem을 넘지 않습니다. 선호값에 rem이 섞인 형태(clamp(1.5rem, 1rem + 2vw, 3rem))로 쓰면 사용자 폰트 확대에도 어느 정도 반응하게 만들 수 있습니다. 타이포그래피는 7편에서 더 깊이 다룹니다.

유동 간격과 여백

clamp()는 폰트뿐 아니라 여백·간격에도 유용합니다. 브레이크포인트 사이에서 섹션 여백이 부드럽게 변하도록 만들 수 있습니다.

.section {
  /* 모바일에선 좁게, 데스크톱에선 넓게, 그 사이는 부드럽게 */
  padding-block: clamp(2rem, 6vw, 6rem);
}
 
.stack {
  display: grid;
  gap: clamp(1rem, 3vw, 2.5rem);
}

이렇게 하면 여백을 위해 미디어 쿼리를 여러 개 두던 것을 한 줄로 대체할 수 있습니다.

min()과 max()

clamp()의 형제인 min()max()도 자주 쓰입니다.

.container {
  /* "화면의 90%" 와 "1200px" 중 작은 값 — 큰 화면에선 1200px로 고정 */
  width: min(90%, 1200px);
}
 
.thumb {
  /* 최소 200px는 보장 */
  width: max(200px, 20vw);
}

특히 width: min(90%, 1200px)는 "최대 폭 1200px, 단 화면이 좁으면 좌우 여백 5%씩"을 미디어 쿼리 없이 표현하는 아주 흔한 관용구입니다.

React 예시: 유동 히어로 카드

지금까지의 단위를 하나의 컴포넌트로 모아 보겠습니다. 미디어 쿼리 없이도 폭에 따라 폰트·여백·너비가 부드럽게 변합니다.

/* HeroCard.module.css */
.card {
  width: min(92%, 900px); /* 최대 900px, 좁으면 좌우 4% 여백 */
  margin-inline: auto;
  padding: clamp(1.25rem, 4vw, 3rem); /* 유동 패딩 */
  border-radius: 16px;
}
 
.title {
  font-size: clamp(1.5rem, 1rem + 3vw, 3rem); /* 유동 + 접근성 고려 */
  line-height: 1.2;
}
 
.desc {
  max-width: 60ch; /* 본문 줄 길이 제한 */
  font-size: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
}
import styles from './HeroCard.module.css';
 
export function HeroCard({ title, description }: { title: string; description: string }) {
  return (
    <section className={styles.card}>
      <h1 className={styles.title}>{title}</h1>
      <p className={styles.desc}>{description}</p>
    </section>
  );
}

Tailwind CSS를 쓴다면 임의 값 문법으로 같은 표현을 할 수 있습니다.

export function HeroCard({ title, description }: { title: string; description: string }) {
  return (
    <section className="mx-auto w-[min(92%,900px)] rounded-2xl p-[clamp(1.25rem,4vw,3rem)]">
      <h1 className="text-[clamp(1.5rem,1rem+3vw,3rem)] leading-tight">{title}</h1>
      <p className="max-w-[60ch] text-[clamp(1rem,0.9rem+0.5vw,1.25rem)]">{description}</p>
    </section>
  );
}

트레이드오프 정리

단위/기법 강점 주의점
px 정확·예측 가능 사용자 폰트 확대에 반응 안 함, 반응형에 약함
rem 전역 일관·접근성 강함 루트 폰트 크기 설계가 선행되어야 함
em 컴포넌트 내부 비례에 유용 중첩 시 값이 누적됨
vw/vh 단독 뷰포트에 완전 비례 극단값·접근성 무시 (특히 폰트에 단독 금지)
dvh/svh 모바일 100vh 문제 해결 dvh는 스크롤 중 값이 변할 수 있음
clamp() 유동 + 상·하한을 한 줄로 세 값 설계에 감각이 필요
min()/max() 미디어 쿼리 없이 상·하한 표현 남용하면 의도 파악이 어려워질 수 있음

정리하면

미디어 쿼리가 큰 구조를 바꾸는 계단이라면, 유동 단위는 그 사이를 부드럽게 잇는 경사로입니다.

  • 폰트와 간격의 기본은 rem — 예측 가능하고 접근성에 강합니다.
  • vw는 폰트에 단독으로 쓰지 말 것 — 반드시 clamp()로 상·하한과 접근성을 챙깁니다.
  • 모바일 전체 높이는 dvh/svh — 상황에 따라 잘리지 않는 쪽/가득 채우는 쪽을 고릅니다.
  • clamp()min()은 미디어 쿼리를 줄여 주는 도구 — 여백·너비·폰트의 미세 조정을 한 줄로 처리합니다.

핵심은 미디어 쿼리와 유동 단위 중 하나를 고르는 게 아니라, 큰 전환은 계단으로, 미세 조정은 경사로로 나눠 함께 쓰는 것입니다.

다음 글에서는 이 단위들을 실제 레이아웃 엔진에 얹어, Flexbox와 Grid로 미디어 쿼리 자체를 줄이는 방법을 다룹니다.

같이 보면 좋은 글