반응형 웹 디자인 | 미디어 쿼리와 모바일 최적화
이 글의 핵심
viewport 메타 태그 하나를 빠뜨려서 모바일에서 데스크톱 화면이 축소돼 보이는 문제부터 짚고 시작합니다. 모바일 퍼스트와 데스크톱 퍼스트 중 무엇을 기본으로 둘지, 브레이크포인트를 어떻게 나눌지, clamp()·min()·max()를 언제 미디어 쿼리 대신 쓸지를 네비게이션·그리드 예제로 판단할 수 있게 합니다.
들어가며
반응형 웹 디자인은 뷰포트 너비·해상도에 따라 레이아웃과 글자 크기를 바꾸는 기법입니다. Flexbox·Grid·미디어 쿼리를 함께 쓰는 경우가 많습니다.
viewport 메타 태그 설정
Meta 태그
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
설명:
width=device-width: 화면 너비를 기기 너비로 설정initial-scale=1.0: 초기 확대 비율
이 태그가 필요한 이유는 모바일 브라우저의 역사에 있습니다. 스마트폰이 처음 나왔을 때 대부분의 웹사이트는 데스크톱 전용이었기 때문에, 모바일 브라우저는 페이지를 약 980px 너비의 가상 화면(layout viewport)에 그린 뒤 축소해서 보여주는 방식을 택했습니다. 이 기본 동작 덕분에 옛날 사이트도 “작게나마” 보였지만, 반응형 CSS 입장에서는 뷰포트가 항상 980px로 보이므로 max-width: 767px 같은 조건이 영원히 맞지 않습니다. width=device-width는 “이 페이지는 모바일을 고려해 만들었으니 가상 너비를 쓰지 말라”는 선언입니다.
인터넷에서 자주 보이는 maximum-scale=1, user-scalable=no는 넣지 않는 것이 좋습니다. 시력이 약한 사용자가 확대하지 못하게 막는 접근성 문제(WCAG 1.4.4 텍스트 크기 조정)가 있고, iOS Safari는 10 버전부터 이 설정을 무시합니다. 입력칸에 포커스할 때 iOS가 화면을 자동 확대하는 것이 싫어서 넣는 경우가 많은데, 그 현상은 입력칸 글자 크기를 16px 이상으로 두면 사라집니다.
미디어 쿼리 문법과 브레이크포인트
기본 문법
/* 모바일 (기본) */
.container {
width: 100%;
padding: 10px;
}
/* 태블릿 이상 */
@media (min-width: 768px) {
.container {
width: 750px;
margin: 0 auto;
}
}
/* 데스크톱 이상 */
@media (min-width: 1024px) {
.container {
width: 1000px;
}
}
브레이크포인트
/* 모바일 */
@media (max-width: 767px) {
body {
font-size: 14px;
}
}
/* 태블릿 */
@media (min-width: 768px) and (max-width: 1023px) {
body {
font-size: 16px;
}
}
/* 데스크톱 */
@media (min-width: 1024px) {
body {
font-size: 18px;
}
}
이렇게 max-width: 767px와 min-width: 768px로 구간을 나누면 겉보기에는 빈틈이 없지만, 고해상도 화면이나 브라우저 확대 상태에서는 뷰포트 너비가 767.5px 같은 소수가 될 수 있고, 이때 어느 쿼리에도 해당하지 않는 틈이 생깁니다. 최신 브라우저가 지원하는 범위 문법(@media (width < 768px), @media (768px <= width < 1024px))을 쓰면 경계가 정확히 맞물립니다. 더 근본적으로는 위 예시처럼 구간마다 겹치지 않게 모든 값을 다시 쓰는 방식보다, 다음 절의 모바일 퍼스트처럼 min-width만 써서 덧씌우는 방식이 겹침·누락 문제를 덜 만듭니다.
본문 글자 크기를 모바일에서 14px로 줄이는 것도 다시 생각해 볼 만합니다. 모바일은 화면이 작을 뿐 눈과의 거리도 가깝기 때문에, 본문은 16px 안팎을 유지하고 제목 크기와 여백을 줄이는 편이 가독성이 좋습니다.
미디어 타입
/* 화면 */
@media screen and (min-width: 768px) {
/* 스타일 */
}
/* 인쇄 */
@media print {
.no-print {
display: none;
}
}
/* 가로/세로 */
@media (orientation: landscape) {
/* 가로 모드 */
}
@media (orientation: portrait) {
/* 세로 모드 */
}
srcset·picture로 반응형 이미지
기본 반응형
img {
max-width: 100%;
height: auto;
}
Picture 태그
<picture>
<source media="(min-width: 1024px)" srcset="large.jpg">
<source media="(min-width: 768px)" srcset="medium.jpg">
<img src="small.jpg" alt="반응형 이미지">
</picture>
srcset 속성
<img
src="small.jpg"
srcset="small.jpg 480w, medium.jpg 768w, large.jpg 1024w"
sizes="(max-width: 768px) 100vw, 50vw"
alt="반응형 이미지"
>
<picture>와 srcset은 목적이 다릅니다. <picture> + media는 아트 디렉션, 즉 화면에 따라 아예 다른 구도의 이미지(모바일은 세로로 자른 사진, 데스크톱은 가로 파노라마)를 보여줄 때 쓰고, 브라우저는 조건에 맞는 <source>를 반드시 따릅니다. srcset + sizes는 같은 이미지의 해상도 선택을 브라우저에게 맡기는 방식입니다. 480w는 “이 파일의 실제 가로 픽셀 수”이고, sizes는 “이 이미지가 화면에서 차지할 너비”입니다. 브라우저는 두 정보와 기기 픽셀 비율(DPR)을 곱해 가장 적당한 파일을 고르므로, 폭 375px에 DPR 3인 휴대폰에서는 100vw × 3 = 1125px에 가까운 large.jpg를 받을 수 있습니다.
흔한 실수는 sizes를 빠뜨리는 것입니다. 없으면 브라우저는 이미지가 100vw를 차지한다고 가정하므로, 실제로는 사이드바 안의 작은 썸네일인데도 가장 큰 파일을 내려받습니다. 반대로 sizes를 실제 레이아웃보다 작게 적으면 흐릿한 이미지가 나옵니다. 또 <img>에 width와 height 속성을 넣어 두면 브라우저가 이미지 로드 전에 비율만큼 공간을 확보해 레이아웃이 밀리는 현상(CLS)을 막을 수 있습니다. CSS의 height: auto와 함께 쓰면 반응형 크기 조정에도 문제가 없습니다.
vw·clamp()로 반응형 폰트
vw 단위
h1 {
font-size: 5vw;
}
vw만으로 글자 크기를 정하면 두 가지 문제가 생깁니다. 320px 화면에서는 16px, 2560px 모니터에서는 128px처럼 극단적인 크기가 나오고, 더 심각하게는 브라우저 확대(Ctrl +)가 글자에 반영되지 않습니다. 확대는 뷰포트 기준 단위가 아니라 CSS 픽셀 크기를 바꾸는 것이라 5vw는 확대해도 거의 그대로입니다. 그래서 아래처럼 clamp()로 범위를 막고, 선호값에 rem을 섞어(4vw + 1rem) 확대에도 반응하게 만드는 것이 일반적입니다.
clamp() 함수
h1 {
font-size: clamp(1.5rem, 5vw, 3rem);
}
p {
font-size: clamp(1rem, 2vw, 1.2rem);
}
미디어 쿼리
h1 {
font-size: 24px;
}
@media (min-width: 768px) {
h1 {
font-size: 32px;
}
}
@media (min-width: 1024px) {
h1 {
font-size: 40px;
}
}
모바일 퍼스트로 CSS 쌓기
모바일 우선 접근
/* 모바일 기본 */
.nav {
flex-direction: column;
padding: 10px;
}
.nav-item {
width: 100%;
}
/* 태블릿 이상 */
@media (min-width: 768px) {
.nav {
flex-direction: row;
padding: 20px;
}
.nav-item {
width: auto;
}
}
모바일 퍼스트가 권장되는 이유는 “모바일 사용자가 많아서”만은 아닙니다. CSS 관점에서 좁은 화면의 레이아웃은 대개 한 열로 쌓는 단순한 형태이고, 넓은 화면으로 갈수록 열·사이드바·가로 배치가 추가됩니다. 단순한 것을 기본으로 두고 복잡한 것을 덧붙이면 미디어 쿼리 안에는 “추가되는 속성”만 들어가지만, 반대로 하면 데스크톱의 float, 고정 너비, 절대 위치를 모바일 쿼리에서 하나하나 되돌리는 코드가 쌓입니다. 저도 데스크톱 퍼스트로 만든 사이트에 모바일 대응을 붙이다가 width: auto !important, position: static이 미디어 쿼리마다 반복되는 상황을 겪었는데, 이런 “되돌리기” 코드가 늘어날수록 한쪽을 고치면 다른 쪽이 깨지는 일이 잦아집니다.
반응형 네비게이션·그리드·레이아웃 만들기
반응형 네비게이션
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>반응형 네비게이션</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.navbar {
background: #333;
padding: 1rem;
}
.nav-menu {
list-style: none;
display: flex;
flex-direction: column;
gap: 1rem;
}
.nav-menu a {
color: white;
text-decoration: none;
padding: 0.5rem;
display: block;
}
.nav-menu a:hover {
background: #555;
}
@media (min-width: 768px) {
.nav-menu {
flex-direction: row;
justify-content: center;
}
}
</style>
</head>
<body>
<nav class="navbar">
<ul class="nav-menu">
<li><a href="#">홈</a></li>
<li><a href="#">소개</a></li>
<li><a href="#">서비스</a></li>
<li><a href="#">연락처</a></li>
</ul>
</nav>
</body>
</html>
반응형 그리드
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>반응형 그리드</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #f5f5f5;
}
.grid {
display: grid;
gap: 20px;
padding: 20px;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(3, 1fr);
}
}
.card {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.card h3 {
margin-bottom: 10px;
color: #333;
}
.card p {
color: #666;
line-height: 1.6;
}
</style>
</head>
<body>
<div class="grid">
<div class="card">
<h3>카드 1</h3>
<p>반응형 그리드 예제입니다.</p>
</div>
<div class="card">
<h3>카드 2</h3>
<p>화면 크기에 따라 열 개수가 변합니다.</p>
</div>
<div class="card">
<h3>카드 3</h3>
<p>모바일: 1열, 태블릿: 2열, 데스크톱: 3열</p>
</div>
<div class="card">
<h3>카드 4</h3>
<p>CSS Grid를 활용한 레이아웃입니다.</p>
</div>
<div class="card">
<h3>카드 5</h3>
<p>gap 속성으로 간격을 조정합니다.</p>
</div>
<div class="card">
<h3>카드 6</h3>
<p>미디어 쿼리로 반응형을 구현합니다.</p>
</div>
</div>
</body>
</html>
반응형 레이아웃
<style>
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.hero {
display: flex;
flex-direction: column;
gap: 20px;
}
.hero-image {
width: 100%;
}
.hero-content {
flex: 1;
}
@media (min-width: 768px) {
.hero {
flex-direction: row;
align-items: center;
}
.hero-image {
width: 50%;
}
}
@media (min-width: 1024px) {
.hero {
gap: 40px;
}
}
</style>
<div class="container">
<div class="hero">
<img class="hero-image" src="hero.jpg" alt="히어로 이미지">
<div class="hero-content">
<h1>반응형 웹 디자인</h1>
<p>모든 기기에서 완벽하게 보이는 웹사이트를 만드세요.</p>
</div>
</div>
</div>
Container Queries와 Fluid Typography
Container Queries
.card-container {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
display: flex;
}
}
미디어 쿼리는 뷰포트 너비를 보지만, 컴포넌트가 실제로 놓이는 공간은 뷰포트와 무관한 경우가 많습니다. 같은 카드가 넓은 본문에도, 좁은 사이드바에도 들어간다면 뷰포트 기준으로는 두 경우를 구분할 수 없습니다. Container Queries는 부모 컨테이너의 너비를 기준으로 스타일을 바꾸므로, 카드가 어디에 놓이든 자기 공간에 맞게 배치됩니다. 2023년 이후 주요 브라우저가 모두 지원합니다.
주의할 점이 두 가지 있습니다. 첫째, 요소는 자기 자신을 쿼리할 수 없습니다. 위 예시처럼 container-type은 .card의 부모(.card-container)에 두고, 쿼리 안에서는 자식 스타일을 바꿔야 합니다. 둘째, container-type: inline-size를 준 요소는 내용물로 자신의 가로 크기를 정하지 않게 됩니다(크기 격리). 그래서 너비가 내용에 따라 정해지던 요소(flex 아이템의 flex: none, inline-block, float)에 붙이면 너비가 0으로 줄어드는 현상이 생깁니다. 컨테이너에는 블록 요소처럼 너비가 바깥에서 정해지는 요소를 쓰는 것이 안전합니다.
Fluid Typography
:root {
--fluid-min-width: 320;
--fluid-max-width: 1140;
--fluid-min-size: 16;
--fluid-max-size: 20;
}
body {
font-size: clamp(
1rem,
calc(1rem + (20 - 16) * ((100vw - 320px) / (1140 - 320))),
1.25rem
);
}
미디어 쿼리 브레이크포인트 정리
고정 숫자에 매달리기보다, 콘텐츠가 깨지는 지점에서 끊는 것이 이상적입니다. 그래도 팀·디자인 시스템과 맞출 출발점은 아래처럼 잡는 경우가 많습니다.
| 구간 | 대략적인 너비 | 용도 |
|---|---|---|
| 모바일 | ~ 575px | 1열, 햄버거 메뉴, 세로 스택 |
| 큰 모바일~소형 태블릿 | 576px ~ 767px | 2열 시도, 여백 조정 |
| 태블릿 | 768px ~ 1023px | 2열 그리드, 일부 가로 배치 |
| 데스크톱 | 1024px ~ 1279px | 3열, 사이드바 노출 |
| 와이드 | 1280px 이상 | 최대 너비(max-width) + 여백 |
min-width vs max-width: 모바일 퍼스트라면 기본 스타일을 좁은 화면에 두고 min-width로 단계적으로 덧씌웁니다. 데스크톱 퍼스트는 반대로 max-width로 줄여 나갑니다.
/* 모바일 퍼스트 예시 */
.layout { padding: 1rem; }
@media (min-width: 768px) {
.layout { padding: 1.5rem; max-width: 720px; margin-inline: auto; }
}
@media (min-width: 1024px) {
.layout { max-width: 960px; }
}
모바일 퍼스트 vs 데스크톱 퍼스트
| 모바일 퍼스트 | 데스크톱 퍼스트 | |
|---|---|---|
| 기본 CSS | 좁은 화면(한 열, 단순 타이포) | 넓은 화면(다열, 큰 여백) |
| 미디어 쿼리 | min-width로 확장 | max-width로 축소 |
| 장점 | 작은 화면에 필요한 CSS만 먼저 로드하는 방식으로 작성하기 쉬움, 성능·접근성 논의와 잘 맞음 | 기존 데스크톱 사이트를 줄이는 리팩터에 적합할 수 있음 |
| 주의 | “데스크톱 디자인”을 나중에 추가하다 보더라도 기본이 단순해야 유지보수가 쉬움 | 모바일에서 불필요한 스타일을 덮어쓰는 비용이 커질 수 있음 |
신규 프로젝트는 모바일 퍼스트 + min-width 단계 확장이 일반적인 권장 패턴입니다.
clamp(), min(), max() — 레이아웃·타이포에 쓰기
이 함수들은 미디어 쿼리 없이 유동적인 값을 만들 때 유용합니다.
clamp(최소, 선호, 최대)
선호값이 계산되지만, 항상 최소~최대 안에 가둡니다.
/* 제목: 너무 작아지지도, 너무 커지지도 않게 */
h1 {
font-size: clamp(1.5rem, 4vw + 1rem, 3rem);
}
/* 카드 패딩 */
.card {
padding: clamp(1rem, 3vw, 2rem);
}
min() / max()
- min(A, B): 둘 중 더 작은 값 — “상한”을 걸 때 (
width: min(100%, 40rem)등). - max(A, B): 둘 중 더 큰 값 — “하한”을 걸 때.
/* 뷰포트에 맞추되 1200px를 넘지 않는 컨테이너 */
.container {
width: min(100% - 2rem, 1200px);
margin-inline: auto;
}
/* 스크롤 여백까지 고려한 여백 (자주 쓰는 패턴) */
.section {
padding-inline: max(1rem, (100vw - 1200px) / 2);
}
100vw는 한 가지 함정이 있습니다. Windows처럼 스크롤바가 공간을 차지하는 환경에서 100vw는 스크롤바 너비까지 포함한 값이라, width: 100vw인 요소는 실제 보이는 영역보다 넓어져 가로 스크롤이 생깁니다. macOS의 오버레이 스크롤바에서는 드러나지 않아 개발 중에 놓치기 쉽습니다. 너비에는 가능하면 100%를 쓰고, 위처럼 여백 계산에 100vw를 쓸 때는 수 픽셀 차이가 레이아웃을 넘치게 하지 않는지 확인하세요.
세로 방향에도 비슷한 문제가 있습니다. 모바일 브라우저에서 height: 100vh는 주소창이 숨겨졌을 때의 높이 기준이라, 주소창이 보이는 첫 화면에서는 하단 버튼이 가려집니다. 100dvh(동적), 100svh(주소창이 보일 때의 작은 높이)를 쓰면 이 문제를 피할 수 있습니다.
clamp는 폰트·패딩·갭에, min/max는 너비·여백 한계를 동시에 잡을 때 자주 짝을 이룹니다.
유동 컨테이너·터치 타깃·hover 의존 금지 같은 패턴
- 유동 컨테이너 + 상한:
width: min(100%, 72rem); margin-inline: auto;— 큰 화면에서만 읽기 폭 제한. - 그리드 열 개수:
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));— 카드가 알아서 줄바꿈( Grid 편과 연계). - 터치 타깃: 모바일에서 클릭 영역은 약 44×44px 이상을 목표로
padding또는min-height로 확보. - 이미지:
max-width: 100%; height: auto;+ 필요 시object-fit: cover로 비율 유지. - 숨김 처리:
display: none으로 네비 항목을 없앨 때, 스크린 리더 전용 링크가 필요한지 함께 검토합니다. - hover 의존 금지: 터치 기기에는 hover가 없으므로 드롭다운을
:hover로만 열면 모바일에서 열리지 않거나 첫 탭에 붙어 버립니다. hover 효과는@media (hover: hover) and (pointer: fine)안에 두고, 메뉴 열기는 클릭/탭으로 처리합니다.
/* 예: 작은 화면에서만 세로 스택 */
.stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 768px) {
.stack {
flex-direction: row;
align-items: center;
}
}
반응형 디자인 요약
핵심 요약
- Viewport:
<meta name="viewport"> - 미디어 쿼리:
@media (min-width: 768px) - 모바일 퍼스트: 작은 화면부터 시작
- 반응형 이미지:
max-width: 100%,<picture> - 브레이크포인트: 768px(태블릿), 1024px(데스크톱) — 필요 시 576 / 1280 등을 보강
clamp/min/max: 미디어 쿼리 없이 유동 값 제한
반응형 체크리스트
- Viewport 메타 태그 설정
- 모바일 퍼스트 접근
- 적절한 브레이크포인트 사용
- 반응형 이미지 최적화
- 터치 친화적 UI (44px 이상)
다음 단계
같이 보면 좋은 글
- HTML/CSS 시작하기 | 웹 개발 첫걸음
- CSS 기초 | 선택자, 속성, 색상, 폰트
- CSS 박스 모델 | Margin, Padding, Border 정리
- CSS Flexbox | 플렉스박스 레이아웃
- CSS Grid | 그리드 레이아웃
자주 묻는 질문 (FAQ)
Q. 미디어 쿼리를 작성했는데 모바일에서 데스크톱 화면이 그대로 축소되어 보이는 이유는 무엇인가요?
A. 대부분 <meta name="viewport" content="width=device-width, initial-scale=1.0">가 빠진 경우입니다. 이 태그가 없으면 모바일 브라우저는 페이지를 980px 안팎의 가상 너비로 렌더링한 뒤 화면에 맞게 축소하기 때문에, max-width: 768px 같은 미디어 쿼리 조건이 아예 맞지 않습니다. 반응형 작업을 시작할 때 가장 먼저 <head>에 이 태그가 있는지 확인하는 것이 좋습니다.