React 반응형 스타일링 전략: Tailwind vs CSS-in-JS vs CSS Modules
이 글은 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.config의theme.screens가 유일한 출처입니다. 접두사(md:)는 전부 여기를 참조하므로 자연스럽게 한 곳으로 모입니다. - CSS Modules: PostCSS의
@custom-media나 CSS 변수로 브레이크포인트를 정의해 공유하면, 매 파일에768px을 반복하지 않을 수 있습니다. - CSS-in-JS:
ThemeProvider의theme.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 하이드레이션 함정을 다룹니다.
