반응형 웹의 기본: 뷰포트, 미디어 쿼리, 모바일 퍼스트

Frontend

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

반응형 웹을 만든다고 하면 보통 "미디어 쿼리 몇 개 넣고 화면 크기별로 스타일을 바꾸면 되는 것" 정도로 생각하기 쉽습니다. 그런데 실제로 화면을 만들다 보면 생각보다 자주 막힙니다. 모바일에서 글자가 확대되어 레이아웃이 깨지고, 브레이크포인트를 어디에 둘지 매번 감으로 정하고, 데스크톱에서 잘 되던 화면이 태블릿에서 애매하게 무너집니다.

이 문제들의 뿌리를 따라가 보면 결국 세 가지 기본기로 모입니다.

  • 브라우저가 화면 폭을 어떻게 인식하게 만들 것인가 (뷰포트)
  • 어떤 조건에서 스타일을 바꿀 것인가 (미디어 쿼리)
  • 작은 화면과 큰 화면 중 무엇을 기준으로 삼을 것인가 (모바일 퍼스트)

이 세 가지가 흔들리면 그 위에 아무리 화려한 기법을 쌓아도 계속 불안정합니다. 그래서 시리즈의 첫 글에서는 반응형의 토대를 먼저 단단히 잡겠습니다.

시리즈 구성

이 시리즈는 반응형 웹을 구성하는 여러 기법을 트레이드오프와 예시 중심으로 다룹니다.

  1. 반응형 웹의 기본: 뷰포트, 미디어 쿼리, 모바일 퍼스트 (이 글)
  2. 유동 레이아웃과 CSS 단위: %, rem, vw, clamp()
  3. Flexbox와 Grid로 반응형 레이아웃 설계하기
  4. 컨테이너 쿼리: 미디어 쿼리로는 부족한 순간
  5. React 반응형 스타일링 전략: Tailwind vs CSS-in-JS vs CSS Modules
  6. JS로 반응형 다루기: matchMedia, useMediaQuery, ResizeObserver
  7. 반응형 이미지와 타이포그래피
  8. 적응형(Adaptive) vs 반응형(Responsive)과 컴포넌트 패턴

한눈에 보면

  • 뷰포트 meta 태그가 없으면 미디어 쿼리는 제대로 동작하지 않습니다. 반응형의 스위치를 켜는 코드입니다.
  • 미디어 쿼리는 min-width(작은 화면부터) 또는 max-width(큰 화면부터) 중 하나의 방향을 정해서 일관되게 쓰는 게 핵심입니다.
  • 모바일 퍼스트는 기본 스타일을 모바일로 두고 min-width로 키워가는 방식이고, 경계 계산 실수가 적고 프레임워크와 결이 맞아 유지보수에 유리한 편입니다.
  • 브레이크포인트는 특정 기기 크기가 아니라 콘텐츠가 깨지는 지점을 기준으로 잡는 게 더 오래갑니다.
  • Next.js App Router는 뷰포트를 기본 제공하지만, 필요하면 viewport export로 명시적으로 제어할 수 있습니다.
  • 모바일 퍼스트와 데스크톱 퍼스트는 문법의 문제가 아니라 어떤 화면을 기준으로 사고하느냐의 문제입니다.

뷰포트: 반응형의 스위치

반응형을 처음 배울 때 가장 많이 하는 실수가, 미디어 쿼리를 잘 짜 놓고도 모바일에서 화면이 그냥 축소된 데스크톱처럼 보이는 경우입니다. 원인은 대부분 하나, 뷰포트 meta 태그가 없는 것입니다.

모바일 브라우저는 기본적으로 페이지를 980px 같은 넓은 가상 폭으로 렌더링한 뒤 화면에 맞게 축소해서 보여줍니다. 데스크톱용 사이트를 모바일에서 최소한 "보이게" 하려던 시절의 유산입니다. 이 상태에서는 화면 폭이 실제 기기 폭(예: 390px)이 아니라 980px로 인식되기 때문에, max-width: 768px 같은 미디어 쿼리가 아예 발동하지 않습니다.

같은 390px 기기라도 뷰포트 meta 유무에 따라 브라우저가 인식하는 폭이 달라집니다.

flowchart TD
    device["390px 기기에서 페이지 로드"]
    device --> check{"뷰포트 meta 태그가 있는가?"}
 
    check -->|없음| virtual["가상 폭 980px로 렌더링"]
    virtual --> scale["화면에 맞춰 축소해서 표시"]
    scale --> fail["폭 = 980px 로 인식<br/>→ max-width:768px 미발동<br/>→ 그냥 작아진 데스크톱"]
 
    check -->|있음| real["폭 = device-width(390px)로 렌더링"]
    real --> match["폭 = 390px 로 인식"]
    match --> ok["→ 미디어 쿼리 정상 발동<br/>→ 진짜 반응형 레이아웃"]
 
    style fail fill:#fee2e2,stroke:#ef4444,color:#111
    style ok fill:#dcfce7,stroke:#22c55e,color:#111

그래서 반응형의 시작은 브라우저에게 "이 페이지는 실제 기기 폭을 기준으로 그려라"라고 알려주는 것입니다.

<meta name="viewport" content="width=device-width, initial-scale=1" />
  • width=device-width: 뷰포트 폭을 기기의 실제 폭에 맞춥니다.
  • initial-scale=1: 처음 로드 시 확대/축소 없이 1:1 배율로 시작합니다.

이 한 줄이 있어야 미디어 쿼리가 우리가 기대하는 폭 기준으로 동작합니다.

Next.js App Router에서의 뷰포트

Next.js App Router는 이 meta 태그를 기본으로 넣어 줍니다. 그래서 아무것도 안 해도 위 설정이 들어갑니다. 다만 명시적으로 제어하고 싶을 때는 viewport export를 씁니다.

// app/layout.tsx
import type { Viewport } from 'next';
 
export const viewport: Viewport = {
  width: 'device-width',
  initialScale: 1,
};
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>{children}</body>
    </html>
  );
}

여기서 흔히 유혹을 느끼는 옵션이 maximum-scale=1이나 user-scalable=no입니다. 화면 확대를 막으면 레이아웃이 안정적으로 보이기 때문입니다. 하지만 이건 접근성 관점에서 피해야 하는 설정입니다. 저시력 사용자가 화면을 확대하지 못하게 막는 것이기 때문입니다. 레이아웃은 확대에도 견디도록 만드는 게 맞고, 확대 자체를 막는 방향은 지양하는 게 좋습니다.

미디어 쿼리: 언제 스타일을 바꿀 것인가

뷰포트로 스위치를 켰다면, 다음은 "어떤 조건에서 스타일을 바꿀지"를 정하는 미디어 쿼리입니다.

/* 화면 폭이 768px 이상일 때 적용 */
@media (min-width: 768px) {
  .container {
    display: grid;
    grid-template-columns: 240px 1fr;
  }
}

여기서 방향이 두 가지입니다.

  • min-width: 작은 화면을 기본으로 두고, 폭이 커질 때 스타일을 더한다 → 모바일 퍼스트
  • max-width: 큰 화면을 기본으로 두고, 폭이 작아질 때 스타일을 덮어쓴다 → 데스크톱 퍼스트

둘을 한 프로젝트에서 섞으면, 나중에 "이 화면은 어느 폭에서 무슨 스타일이 이기는가"를 추적하기가 급격히 어려워집니다. 한 방향을 정해서 일관되게 가는 것이 미디어 쿼리 전략의 절반입니다.

브레이크포인트는 기기가 아니라 콘텐츠 기준으로

브레이크포인트를 "아이폰은 390, 아이패드는 768, 데스크톱은 1440"처럼 특정 기기 폭에 맞춰 잡고 싶은 유혹이 큽니다. 하지만 기기 종류는 계속 늘어나고, 폴더블·초광각 모니터·분할 화면까지 생각하면 특정 기기를 쫓는 방식은 금방 무너집니다.

더 오래가는 기준은 콘텐츠가 보기 불편해지는 지점입니다. 예를 들어 카드 목록을 만들 때, "화면이 좁아 카드가 한 줄에 하나만 들어가면 답답한 순간"이 브레이크포인트입니다. 그 폭이 720px이면 720px이 브레이크포인트인 것이고, 이는 아이패드냐 아니냐와 무관합니다.

Tailwind CSS를 쓴다면 이미 콘텐츠 친화적인 브레이크포인트 스케일이 준비되어 있고, 이 값들은 전부 min-width 기준(모바일 퍼스트)입니다.

접두사 min-width 대략적인 대상
(없음) 0 모바일 기본
sm 640px 큰 모바일
md 768px 태블릿
lg 1024px 작은 데스크톱
xl 1280px 데스크톱
2xl 1536px 큰 데스크톱

모바일 퍼스트 vs 데스크톱 퍼스트

이제 시리즈 전체에서 계속 마주칠 첫 번째 트레이드오프입니다. 두 방식은 스타일이 쌓이는 방향이 정반대입니다. 모바일 퍼스트는 가장 단순한 화면에서 시작해 커질수록 스타일을 더하고, 데스크톱 퍼스트는 가장 복잡한 화면에서 시작해 작아질수록 스타일을 덮어씁니다.

flowchart LR
    subgraph MF["모바일 퍼스트 (min-width) · 더해가기"]
        direction LR
        m1["기본<br/>1열"] -->|"min-width:768px"| m2["+2열"]
        m2 -->|"min-width:1024px"| m3["+3열"]
    end
 
    subgraph DF["데스크톱 퍼스트 (max-width) · 덮어쓰기"]
        direction RL
        d1["기본<br/>3열"] -->|"max-width:1023px"| d2["2열로 덮기"]
        d2 -->|"max-width:767px"| d3["1열로 덮기"]
    end
 
    style m1 fill:#dbeafe,stroke:#3b82f6,color:#111
    style d1 fill:#e0e7ff,stroke:#6366f1,color:#111

같은 카드 그리드를 두 방식으로 만들어 비교해 보겠습니다.

모바일 퍼스트 (min-width)

기본 스타일을 모바일로 두고, 화면이 커질 때 열을 늘립니다.

/* 기본: 모바일 — 1열 */
.grid {
  display: grid;
  gap: 16px;
  grid-template-columns: 1fr;
}
 
/* 태블릿 이상 — 2열 */
@media (min-width: 768px) {
  .grid {
    grid-template-columns: repeat(2, 1fr);
  }
}
 
/* 데스크톱 이상 — 3열 */
@media (min-width: 1024px) {
  .grid {
    grid-template-columns: repeat(3, 1fr);
  }
}

데스크톱 퍼스트 (max-width)

기본 스타일을 데스크톱으로 두고, 화면이 작아질 때 열을 줄입니다.

/* 기본: 데스크톱 — 3열 */
.grid {
  display: grid;
  gap: 16px;
  grid-template-columns: repeat(3, 1fr);
}
 
/* 태블릿 이하 — 2열 */
@media (max-width: 1023px) {
  .grid {
    grid-template-columns: repeat(2, 1fr);
  }
}
 
/* 모바일 이하 — 1열 */
@media (max-width: 767px) {
  .grid {
    grid-template-columns: 1fr;
  }
}

두 방식 모두, 폭이 바뀌면 같은 카드 6개가 아래처럼 열 수를 바꿔 재배치됩니다. (박스 안 숫자는 카드 순서)

● 모바일  ( < 768px )  ·  1열
+-------------+
|      1      |
+-------------+
|      2      |
+-------------+
|      3      |
+-------------+
|      4      |
+-------------+
|      5      |
+-------------+
|      6      |
+-------------+
 
● 태블릿  ( 768 ~ 1023px )  ·  2열
+------+ +------+
|  1   | |  2   |
+------+ +------+
|  3   | |  4   |
+------+ +------+
|  5   | |  6   |
+------+ +------+
 
● 데스크톱  ( >= 1024px )  ·  3열
+-----+ +-----+ +-----+
|  1  | |  2  | |  3  |
+-----+ +-----+ +-----+
|  4  | |  5  | |  6  |
+-----+ +-----+ +-----+

결과 화면은 같습니다. 하지만 읽고 유지보수하는 감각성능에서 차이가 납니다.

관점 모바일 퍼스트 (min-width) 데스크톱 퍼스트 (max-width)
기본 스타일 모바일 (가장 단순한 레이아웃) 데스크톱 (가장 복잡한 레이아웃)
스타일 흐름 작은 화면 → 큰 화면으로 더해감 큰 화면 → 작은 화면으로 덮어씀
사고 방식 "여기서 뭘 더 보여줄까" "여기서 뭘 숨기고 줄일까"
재정의(override) 모바일에서 적음 (기본이 모바일) 모바일에서 override 누적
경계 계산 직관적 (768px 이상) 헷갈리기 쉬움 (767px 이하처럼 1px 조정)
큰 화면 우선 UI 열을 더하는 흐름이 다소 번거로움 데스크톱 중심 관리자 화면 등에는 자연스러움

어느 쪽을 택할까

일반적인 웹 서비스, 특히 모바일 트래픽 비중이 큰 서비스라면 모바일 퍼스트를 기본값으로 두는 편이 낫습니다. 흔히 "성능에 유리하다"고 하지만, 실제 렌더링 성능 차이는 대부분 미미합니다. 진짜 이점은 성능보다 유지보수와 사고방식에 있습니다.

  • 기본을 가장 단순한 모바일에 두고 화면이 커질 때 하나씩 얹는 점진적 향상(progressive enhancement) 흐름이라, 각 단계에서 "무엇을 더할지"가 명확합니다.
  • max-width767px 같은 "1px 뺀 경계"를 계산할 필요가 없어 실수가 줄어듭니다.
  • Tailwind, 대부분의 CSS 프레임워크가 모바일 퍼스트를 전제로 설계되어 있어 결이 맞습니다.

반대로 사내 관리자 도구나 대시보드처럼 데스크톱이 절대적인 주 사용 환경이고 모바일은 예외적으로만 지원한다면, 데스크톱을 기본으로 두는 편이 오히려 자연스러울 수 있습니다. 핵심은 "무엇이 표준이고 무엇이 예외인가"를 정하고 그에 맞는 방향을 고르는 것입니다.

React에서의 예시

지금까지의 개념을 실제 컴포넌트로 옮겨 보겠습니다. 같은 반응형 카드 그리드를 CSS Modules와 Tailwind CSS로 각각 만들어 봅니다. 둘 다 모바일 퍼스트입니다.

CSS Modules 방식

/* CardGrid.module.css */
.grid {
  display: grid;
  gap: 16px;
  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);
  }
}
import styles from './CardGrid.module.css';
 
type Card = { id: string; title: string };
 
export function CardGrid({ cards }: { cards: Card[] }) {
  return (
    <ul className={styles.grid}>
      {cards.map((card) => (
        <li key={card.id}>{card.title}</li>
      ))}
    </ul>
  );
}

Tailwind CSS 방식

type Card = { id: string; title: string };
 
export function CardGrid({ cards }: { cards: Card[] }) {
  return (
    <ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
      {cards.map((card) => (
        <li key={card.id}>{card.title}</li>
      ))}
    </ul>
  );
}

여기서 눈여겨볼 점은, Tailwind의 grid-cols-1이 접두사 없이 모든 화면의 기본값이고, md:lg:가 각각 min-width: 768px, min-width: 1024px에서 덮어쓴다는 것입니다. 즉 접두사 없는 클래스 = 모바일 기본이라는 규칙이 곧 모바일 퍼스트 그 자체입니다. CSS Modules 예시와 완전히 같은 사고 흐름입니다.

두 방식의 트레이드오프(어떤 상황에서 무엇이 유리한지)는 시리즈 5편에서 본격적으로 다룹니다. 여기서는 어느 도구를 쓰든 모바일 퍼스트라는 뼈대는 동일하다는 점만 확인하면 됩니다.

자주 부딪히는 함정들

기본기를 다질 때 특히 자주 만나는 실수 두 가지만 짚겠습니다.

1. min-widthmax-width를 섞어 쓰다 경계가 겹친다

한 프로젝트에서 방향을 섞으면 이런 코드가 나옵니다.

@media (min-width: 768px) {
  /* A */
}
@media (max-width: 768px) {
  /* B */
}

정확히 768px에서는 두 조건이 동시에 참이라 A와 B가 함께 적용됩니다. 경계에서 예측하기 어려운 결과가 나오는 전형적인 원인입니다. 방향을 하나로 통일하면 애초에 이런 겹침이 생기지 않습니다.

2. 모바일에서 100vh가 화면보다 크다

전체 화면 높이를 잡으려고 height: 100vh를 쓰면, 모바일 브라우저에서는 주소창 영역까지 포함해 계산되어 실제 보이는 영역보다 커지는 경우가 있습니다. 스크롤이 애매하게 생기거나 하단이 잘리는 원인입니다.

이럴 때는 동적 뷰포트 단위인 dvh를 쓰면 브라우저 UI 변화까지 반영된 높이를 얻을 수 있습니다.

.hero {
  min-height: 100dvh; /* 주소창 표시/숨김에 따라 실제 보이는 높이로 계산 */
}

뷰포트 단위(vh, dvh, vw 등)의 종류와 트레이드오프는 2편에서 더 자세히 다룹니다.

정리하면

반응형의 화려한 기법들은 전부 이 세 가지 토대 위에 올라갑니다.

  • 뷰포트 meta: 없으면 미디어 쿼리 자체가 동작하지 않습니다. 반응형의 스위치입니다. 대신 확대를 막는 설정(user-scalable=no)은 접근성 때문에 피합니다.
  • 미디어 쿼리: min-widthmax-width 중 한 방향을 정해 일관되게 씁니다. 브레이크포인트는 기기가 아니라 콘텐츠가 깨지는 지점 기준으로 잡습니다.
  • 모바일 퍼스트: 기본을 모바일로 두고 키워가는 방식이며, 성능·경계 계산·프레임워크 궁합에서 유리한 편입니다. 데스크톱이 절대적 주 환경일 때만 예외적으로 데스크톱 퍼스트를 고려합니다.

제 기준으로 요약하면 이렇습니다.

  • 일반 웹 서비스라면 모바일 퍼스트를 기본값으로 두세요.
  • 브레이크포인트는 감이 아니라 "콘텐츠가 불편해지는 폭"으로 정하세요.
  • 화려한 기법을 얹기 전에, 이 세 가지가 흔들리지 않는지부터 확인하세요.

다음 글에서는 이 토대 위에서 브레이크포인트 사이의 빈 구간을 부드럽게 이어주는 유동 레이아웃과 CSS 단위를 다룹니다. 미디어 쿼리가 "계단"이라면, 유동 단위는 그 계단 사이를 잇는 "경사로"입니다.

같이 보면 좋은 글