React 반응형 스타일링 전략: Tailwind vs CSS-in-JS vs CSS Modules

Frontend

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

지금까지 반응형을 만드는 CSS 기법들(미디어 쿼리, 유동 단위, Flexbox/Grid, 컨테이너 쿼리)을 봤습니다. 그런데 실제 React 프로젝트에서는 이 규칙들을 어떤 스타일링 도구로 표현하느냐를 또 정해야 합니다. Tailwind CSS의 md: 접두사, CSS-in-JS의 테마 브레이크포인트, CSS Modules의 미디어 쿼리 — 결과는 같아도 관리하는 감각이 꽤 다릅니다.

이 글은 스타일링 도구의 일반 비교가 아니라 반응형을 표현할 때 각 도구가 어떻게 다른지에 초점을 둡니다. 도구 자체의 SSR·런타임·협업 관점 종합 비교는 아래 글에서 이미 다뤘으니 함께 보면 좋습니다.

한눈에 보면

  • 반응형의 실제 동작(미디어 쿼리·유동 단위)은 결국 CSS로 내려갑니다. 그래서 어느 도구를 써도 반응형 자체는 가능하고, 차이는 표현 방식과 관리 비용입니다.
  • Tailwind: md:, lg: 접두사로 마크업에서 바로 브레이크포인트가 읽힙니다. 빠르지만 클래스가 길어질 수 있습니다.
  • CSS Modules: 표준 미디어 쿼리를 그대로 씁니다. CSS와 마크업이 분리되고, 컨테이너 쿼리 같은 최신 기능도 자연스럽습니다.
  • CSS-in-JS: 테마의 브레이크포인트를 코드로 다뤄 동적 반응에 강하지만, SSR·런타임 비용을 함께 봐야 합니다.
  • JS로 화면 폭을 읽어 분기하는 방식(useMediaQuery 등)은 세 도구 모두와 무관하게 SSR 문제를 안고 있어, 되도록 CSS 기반으로 표현하는 게 낫습니다. (6편에서 상세히)
  • 결론은 "무엇이 옳다"가 아니라 팀의 브레이크포인트를 어디서 한 곳으로 관리하느냐입니다.

같은 반응형 카드, 세 가지 표현

4편의 "좁으면 세로, 넓으면 좌우" 카드를 세 도구로 각각 만들어 비교하겠습니다. 브레이크포인트는 768px 하나입니다.

Tailwind CSS

export function MediaCard({ image, title, desc }: CardProps) {
  return (
    <article className="grid grid-cols-1 gap-4 md:grid-cols-[160px_1fr] md:items-center">
      <img className="aspect-video w-full object-cover md:aspect-square" src={image} alt="" />
      <div>
        <h3 className="text-lg font-semibold">{title}</h3>
        <p className="text-sm text-gray-600">{desc}</p>
      </div>
    </article>
  );
}

브레이크포인트가 md: 접두사로 마크업에 그대로 드러납니다. 파일을 오갈 필요 없이 "이 요소가 md에서 어떻게 바뀌는지"가 한눈에 읽힙니다. 대신 반응형 변형이 많아지면 class가 길어지고, 브레이크포인트 값 자체는 tailwind.config에서 관리합니다.

CSS Modules

/* MediaCard.module.css */
.card {
  display: grid;
  gap: 16px;
  grid-template-columns: 1fr;
}
.thumb {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}
 
@media (min-width: 768px) {
  .card {
    grid-template-columns: 160px 1fr;
    align-items: center;
  }
  .thumb {
    aspect-ratio: 1 / 1;
  }
}
import styles from './MediaCard.module.css';
 
export function MediaCard({ image, title, desc }: CardProps) {
  return (
    <article className={styles.card}>
      <img className={styles.thumb} src={image} alt="" />
      <div>
        <h3>{title}</h3>
        <p>{desc}</p>
      </div>
    </article>
  );
}

표준 CSS 그대로라, 미디어 쿼리든 컨테이너 쿼리든 clamp()든 문법을 배운 그대로 씁니다. 마크업은 깔끔하지만 스타일을 보려면 CSS 파일을 함께 열어야 합니다.

CSS-in-JS

import styled from 'styled-components';
 
const Card = styled.article`
  display: grid;
  gap: 16px;
  grid-template-columns: 1fr;
 
  @media (min-width: ${({ theme }) => theme.breakpoints.md}) {
    grid-template-columns: 160px 1fr;
    align-items: center;
  }
`;
 
export function MediaCard({ image, title, desc }: CardProps) {
  return (
    <Card>
      <img src={image} alt="" />
      <div>
        <h3>{title}</h3>
        <p>{desc}</p>
      </div>
    </Card>
  );
}

브레이크포인트를 theme.breakpoints.md처럼 코드 값으로 참조합니다. props나 상태에 따라 브레이크포인트나 스타일을 동적으로 바꾸기 좋다는 게 강점입니다. 대신 App Router/RSC 환경에서는 'use client'와 스타일 레지스트리 설정이 필요하고, 런타임 스타일 생성 비용을 함께 고려해야 합니다.

브레이크포인트를 한 곳에서 관리하기

반응형 스타일링에서 도구 선택보다 더 중요한 게 있습니다. 브레이크포인트 값이 프로젝트 곳곳에 흩어지지 않게 하는 것입니다. 768px이 여기저기 하드코딩되면 나중에 하나만 바꿔도 전부 찾아 고쳐야 합니다.

각 도구는 이 "단일 출처(single source of truth)"를 두는 방식이 다릅니다.

flowchart LR
    src["브레이크포인트<br/>단일 출처"]
    src --> tw["Tailwind<br/>tailwind.config.ts<br/>theme.screens"]
    src --> cm["CSS Modules<br/>@custom-media<br/>또는 CSS 변수"]
    src --> js["CSS-in-JS<br/>theme.breakpoints<br/>(ThemeProvider)"]
 
    style src fill:#dbeafe,stroke:#3b82f6,color:#111
  • Tailwind: tailwind.configtheme.screens가 유일한 출처입니다. 접두사(md:)는 전부 여기를 참조하므로 자연스럽게 한 곳으로 모입니다.
  • CSS Modules: PostCSS의 @custom-media나 CSS 변수로 브레이크포인트를 정의해 공유하면, 매 파일에 768px을 반복하지 않을 수 있습니다.
  • CSS-in-JS: ThemeProvidertheme.breakpoints가 출처가 됩니다. 헬퍼 함수(media.up('md') 등)를 만들어 두면 표현이 더 깔끔해집니다.

실제로 이렇게 모읍니다

CSS Modules — PostCSS의 postcss-custom-media@custom-media를 전역 주입하면, 각 컴포넌트는 768px 대신 이름으로 참조합니다.

/* breakpoints.css — 한 곳에서 정의 */
@custom-media --md (min-width: 768px);
@custom-media --lg (min-width: 1024px);
/* 각 컴포넌트: 값이 아니라 이름으로 */
@media (--md) {
  .card {
    grid-template-columns: 160px 1fr;
  }
}

CSS-in-JS — 브레이크포인트 객체와 media.up 헬퍼를 한 파일에 두고 템플릿에서 호출합니다.

// media.ts — 단일 출처 + 헬퍼
const breakpoints = { md: '768px', lg: '1024px' } as const;
 
export const media = {
  up: (key: keyof typeof breakpoints) => `@media (min-width: ${breakpoints[key]})`,
};
const Card = styled.article`
  grid-template-columns: 1fr;
  ${media.up('md')} {
    grid-template-columns: 160px 1fr;
  }
`;

이렇게 해 두면 나중에 768px을 바꿀 때 정의 한 곳만 고치면 되고, 값이 여러 파일에 흩어지지 않습니다.

도구가 무엇이든, 이 "한 곳"을 만들어 두는 것이 흩어진 미디어 쿼리를 방지하는 핵심입니다.

트레이드오프 정리

관점 Tailwind CSS CSS Modules CSS-in-JS
반응형 표현 md: 접두사(마크업 내) 표준 @media(CSS 파일) 템플릿 안 @media + theme 값
브레이크포인트 theme.screens @custom-media/CSS 변수 theme.breakpoints
가독성 마크업에서 바로 읽힘 마크업·스타일 분리 컴포넌트 안에 응집
동적 반응 상태 조합은 길어질 수 있음 보통 props 기반 동적 분기에 강함
최신 CSS(컨테이너) 플러그인/버전 확인 필요 표준 그대로 자연스러움 템플릿에 그대로 작성 가능
SSR 난이도 낮음(정적 추출) 낮음(정적 CSS) 설정·런타임 고려 필요

실무에서는 어떻게 고르면 좋을까

반응형만 놓고 보면 대략 이런 기준이 잡힙니다.

  • 빠른 화면 생산과 일관된 브레이크포인트 운영이 중요하면 → Tailwind
  • 표준 CSS 기반으로 컨테이너 쿼리·유동 단위를 마음껏 쓰고 싶고 SSR을 단순하게 가져가려면 → CSS Modules
  • props·상태에 따라 브레이크포인트나 반응 규칙을 동적으로 바꿔야 하는 컴포넌트 라이브러리라면 → CSS-in-JS

다만 세 도구 모두 공통으로 지켜야 할 원칙이 하나 있습니다. 반응형은 되도록 CSS로 표현하고, JS로 화면 폭을 읽어 분기하는 방식은 최후의 수단으로 두는 것입니다. 그 이유와 불가피할 때의 안전한 처리법을 다음 글에서 다룹니다.

정리하면

반응형의 실제 동작은 결국 CSS로 내려가므로, 스타일링 도구 선택은 "가능/불가능"이 아니라 표현 방식과 관리 비용의 문제입니다.

  • Tailwind: 접두사로 마크업에서 브레이크포인트가 바로 읽히고, 값은 config 한 곳에서 관리됩니다.
  • CSS Modules: 표준 CSS라 최신 기능과 SSR에 자연스럽고, 스타일과 마크업이 분리됩니다.
  • CSS-in-JS: 테마 브레이크포인트로 동적 반응에 강하지만 SSR·런타임을 함께 고려해야 합니다.
  • 무엇을 쓰든 브레이크포인트의 단일 출처를 만들어 값이 흩어지지 않게 하세요.

다음 글에서는 CSS만으로 해결되지 않아 결국 JavaScript로 화면을 읽어야 할 때 쓰는 matchMedia, useMediaQuery, ResizeObserver와, 그때 반드시 마주치는 SSR 하이드레이션 함정을 다룹니다.

같이 보면 좋은 글