5-9. 스타일링 전략
한 줄 요약: 이 문서를 읽고 나면 CSS Modules / Tailwind / CSS-in-JS를 "격리를 어느 시점(빌드/런타임)에 사는가"라는 하나의 축으로 비교하고, 팀·프로젝트 조건에 따라 무너지는 지점까지 계산해 선택할 수 있다.
이 문서는 React 19, Tailwind CSS 4 기준이다.
학습 목표
- CSS의 전역 네임스페이스가 컴포넌트 모델과 충돌하는 지점을 명시하고, 각 스타일링 접근이 격리를 얻는 시점(빌드 타임 이름 변환 / 빌드 타임 원자화 / 런타임 생성)을 구분할 수 있다.
- CSS Modules의 해싱, Tailwind의 원자화와 소스 스캔, 런타임 CSS-in-JS의 렌더 경로 비용을 메커니즘 수준에서 설명할 수 있다.
- "동적 스타일"의 두 층위(상태에 따른 전환 vs 임의 값 계산)를 구분하고 각 접근에서의 올바른 처리법을 적용할 수 있다.
- 팀 규모·디자인 시스템·동적 스타일 비중·SSR 여부로 접근을 선택하고, 각 선택이 무너지는 경계 조건을 제시할 수 있다.
배경: 왜 이것이 존재하는가
CSS는 문서 전체가 하나의 스타일 공간이라는 전제로 설계되었다 — 모든 선택자는 전역이고, 충돌은 캐스케이드와 특이성이 중재한다. 이 설계는 "문서를 꾸미는 스타일시트"에는 맞았지만, React의 단위인 컴포넌트 — 자기 완결적이고, 어디에나 놓일 수 있고, 수백 개가 조합되는 — 와 정면충돌한다: .title이라는 클래스는 어느 컴포넌트의 것인가? 컴포넌트 A의 스타일 수정이 화면 반대편의 B를 깨뜨리지 않는다고 누가 보장하는가?
BEM 같은 명명 규약은 이 문제를 인간의 규율로 풀었다(.product-card__title--active — 이름에 스코프를 인코딩). 동작은 하지만 규율은 어긴 사람이 아니라 다른 사람이 다치는 방식으로 무너진다. 이 문서에서 다루는 세 접근은 규율을 도구의 보장으로 바꾸되, 그 보장을 사는 시점이 다르다:
- CSS Modules: 빌드 타임에 클래스 이름을 변환해 격리 — "네가 쓴 이름은 파일 밖에서 안 보인다"
- Tailwind: 격리할 이름 자체를 없앤다 — 스타일이 전역 원자 클래스의 조합이면 충돌할 "내 이름"이 없다
- CSS-in-JS: 런타임에 JS가 스타일을 생성·주입 — 격리는 생성된 해시 이름이, 동적 능력은 JS가 보장
빌드 타임 vs 런타임이라는 축은 낯익은 트레이드오프다 — 정적 타이핑 vs 동적 타이핑, AOT vs JIT과 같은 축이다. 미리 계산할수록 런타임이 싸고 예측 가능해지며, 런타임으로 미룰수록 유연해지고 비싸진다. 이 축 위에서 세 접근의 위치와 비용을 계산하는 것이 이 문서의 일이다.
핵심 개념
CSS Modules — 이름 변환으로 사는 격리
CSS Modules는 CSS 문법을 그대로 두고, 빌드 타임에 클래스 이름만 파일 단위로 해싱한다.
/* ProductCard.module.css */
.title { font-size: 1.25rem; }
.title.active { color: crimson; }import styles from './ProductCard.module.css';
// styles는 빌드가 만든 매핑 객체: { title: "_title_x7k2b_1", active: "_active_x7k2b_2" }
function ProductCard({ isActive }) {
return <h3 className={`${styles.title} ${isActive ? styles.active : ''}`}>…</h3>;
}메커니즘: 번들러(Vite는 기본 내장)가 .module.css를 만나면 각 클래스를 [이름]_[파일 해시] 형태로 재작성한 CSS를 산출하고, JS에는 원래 이름 → 해시 이름 매핑 객체를 준다. 격리는 "다른 파일의 .title과 물리적으로 다른 이름"이라는 사실에서 나온다 — 특이성 전쟁도, 명명 규약도 필요 없다. 존재하지 않는 키 접근(styles.titel)이 undefined가 되는 것으로 오타가 (TypeScript 연동 시 컴파일 타임에) 드러나는 부수 이득도 있다.
비용 구조: 빌드 타임에 모든 것이 끝나므로 런타임 비용이 0이다 — 브라우저는 평범한 정적 CSS 파일을 받는다. 경계는 동적 스타일이다: 클래스는 빌드 시점에 존재해야 하므로, "상태에 따라 미리 정의된 스타일 사이를 전환"(위의 active)은 되지만 "사용자 입력으로 계산된 임의 값"(진행률 바의 width: 37%)은 클래스로 만들 수 없다. 이 경우의 정석은 인라인 style 또는 CSS 변수다:
// 임의 값은 CSS 변수로 주입 — 정적 CSS와 동적 값의 표준 분업
<div className={styles.bar} style={{ '--progress': `${percent}%` }} />
/* .bar { width: var(--progress); transition: width 0.3s; } */Tailwind CSS — 이름을 없애는 원자화
Tailwind는 문제를 뒤집는다: 컴포넌트마다 이름을 짓고 격리하는 대신, 단일 프로퍼티 단위의 전역 유틸리티 클래스(flex, p-4, text-lg)만으로 스타일을 조합한다. 이름 짓기가 사라지면 이름 충돌도 사라진다.
function ProductCard({ isActive }) {
return (
<h3 className={`text-xl font-semibold ${isActive ? 'text-red-600' : 'text-gray-900'}`}>
…
</h3>
);
}비용 구조가 원자화의 요점이다. 컴포넌트별 CSS는 컴포넌트 수에 비례해 자란다(선택자와 선언이 매번 새로). 원자 클래스는 어휘가 유한하므로 — p-4는 앱 어디서 천 번 쓰여도 CSS에는 한 줄 — CSS 총량이 앱 규모와 무관하게 수렴한다. 반복은 HTML 쪽으로 옮겨 가는데(같은 클래스 나열이 곳곳에), 이는 gzip이 잘 먹는 종류의 반복이고, 재사용 단위는 CSS 클래스가 아니라 React 컴포넌트가 담당한다는 것이 Tailwind + React 조합의 분업이다 — btn-primary 같은 CSS 추상화를 만드는 대신 <Button variant="primary">를 만든다.
동작 원리에서 나오는 제약 하나가 중요하다. Tailwind는 소스 파일을 텍스트로 스캔해 등장한 클래스만 CSS로 생성한다(Tailwind 4는 설정 파일 없이 자동 소스 탐지). 즉 문자열 조립으로 클래스를 만들면 스캐너가 못 본다:
// ❌ 스캐너는 "text-red-600"이라는 완성된 문자열을 소스에서 못 찾는다 → CSS 미생성
<h3 className={`text-${color}-600`}>
// ✅ 완성된 클래스 이름이 소스에 문자 그대로 존재해야 한다
<h3 className={isActive ? 'text-red-600' : 'text-gray-900'}>임의 계산값은 CSS Modules와 같은 답(인라인 style/CSS 변수)이거나 대괄호 임의 값(w-[37%] — 단 이것도 소스에 리터럴로 있을 때만)이다.
부수 효과로서의 디자인 토큰 강제도 선택 기준에 든다: 클래스 어휘가 스케일(spacing 4px 단위, 색상 팔레트)로 제한되므로, 디자이너 없는 팀에도 일관성이 기본값이 된다 — 역으로 정교한 커스텀 디자인 시스템이 이미 있는 팀은 토큰 매핑(Tailwind 4의 @theme CSS 변수)부터 해야 어휘가 맞는다.
CSS-in-JS — 런타임으로 미룬 유연성
styled-components/Emotion 계열은 스타일을 JS 값으로 다룬다:
const Title = styled.h3`
font-size: 1.25rem;
color: ${props => props.$active ? 'crimson' : 'inherit'}; /* JS 표현식이 스타일에 */
`;능력의 원천이자 비용의 원천이 같다: 스타일 생성이 렌더 경로 위에 있다. 렌더마다 ① props로 스타일 문자열을 계산(직렬화)하고, ② 해시해서 클래스 이름을 만들고, ③ 처음 보는 조합이면 <style> 시트에 규칙을 주입한다. ①은 매 렌더, ②③은 새 조합마다 발생하며, 전부 5-2의 렌더 단계에 얹히는 JS 비용이다 — 넓은 리렌더 × 많은 styled 컴포넌트의 곱으로 커지고, React DevTools Profiler에서 컴포넌트 렌더 시간으로, Performance 패널에서는 스타일 재계산(Recalculate Style)의 빈발로 관찰된다. 여기에 라이브러리 런타임 자체의 번들 비용이 더해진다.
SSR(7-5)에서는 추가 문제가 있다: 서버에는 브라우저 스타일 시트가 없으므로 렌더 중 생성된 CSS를 수집해 HTML에 인라인으로 실어 보내는 별도 배관이 필요하고, 이 "렌더 중 주입" 모델은 React 18+의 스트리밍 SSR·RSC와 구조적으로 어긋난다(서버 컴포넌트에는 렌더 중 부수 효과의 자리가 없다). 생태계가 제로 런타임 컴파일 방식 — vanilla-extract(TS로 쓰고 빌드 타임에 정적 CSS 추출), Linaria, StyleX — 으로 이동한 배경이 이것이다: CSS-in-JS의 DX(동일 파일, 타입, 토큰 공유)는 유지하되 산출물은 정적 CSS로, 즉 비용 지불 시점을 런타임에서 빌드 타임으로 되돌린 것이다.
남는 정당 영역은 스타일이 정말로 런타임 데이터인 경우다 — 사용자가 정의하는 테마 색, 데이터 시각화의 계산된 색상 스케일처럼 빌드 타임에 열거 불가능한 스타일. 단 이 좁은 요구는 대개 CSS 변수 주입으로도 풀린다는 점이 런타임 CSS-in-JS의 입지를 더 좁힌다.
동적 스타일의 두 층위 — 공통 원리
세 접근을 관통하는 구분을 명시하면:
| 층위 | 예 | 올바른 도구 |
|---|---|---|
| 변형(variant) 전환 — 유한한 상태 집합 | active/disabled, 크기 s/m/l | 미리 정의된 클래스 간 전환 (셋 다 빌드 타임에 가능) |
| 임의 값 — 연속적/열거 불가 | 진행률 %, 계산된 색, 드래그 좌표 | 인라인 style 또는 CSS 변수 주입 |
"동적 스타일 때문에 CSS-in-JS가 필요하다"는 주장의 대부분은 1층위(변형)를 말하는 것이고, 변형은 어느 접근이든 정적으로 처리된다. 2층위조차 CSS 변수로 정적 CSS와 분업이 되므로, 런타임 스타일 생성이 필요한 경우는 생각보다 드물다.
실무 관점
선택 기준표
| 조건 | CSS Modules | Tailwind | 런타임 CSS-in-JS | 제로 런타임 CSS-in-JS |
|---|---|---|---|---|
| 런타임 비용 | 0 | 0 | 렌더 경로에 직렬화·주입 | 0 |
| CSS 총량 | 컴포넌트 수 비례 | 수렴 (원자 어휘) | 사용된 조합 비례 | 컴포넌트 수 비례 |
| 동적 스타일 | 변형 O / 임의 값은 CSS 변수 | 변형 O (리터럴 제약) / 임의 값은 CSS 변수 | 전부 JS로 자연스럽게 | 변형 O / 임의 값은 CSS 변수 |
| 타입·토큰 공유 | 약함 (별도 도구) | 어휘 자체가 토큰 | 강함 | 강함 |
| SSR/RSC 적합성 | 좋음 | 좋음 | 배관 필요, RSC와 마찰 | 좋음 |
| 무너지는 지점 | 명명·구조 규율이 다시 사람 몫, 파일 왕복 | 긴 클래스 목록의 가독성, 토큰 밖 디자인, 문자열 조립 함정 | 규모·SSR에서 성능, 생태계 이탈 | 빌드 도구 결합, 상대적으로 작은 생태계 |
| 잘 맞는 곳 | CSS 자산·역량이 있는 팀, 점진 도입 | 제품 빠른 반복, 일관성 자동 강제, 컴포넌트 재사용 문화 | (신규 채택은 근거 필요) 런타임 테마가 본질인 앱 | 디자인 시스템 라이브러리, 타입 안전 토큰 요구 |
2026년 현재 신규 React 프로젝트의 무난한 기본값은 Tailwind(제품 코드) 또는 CSS Modules(CSS 역량 중심 팀)이고, 런타임 CSS-in-JS의 신규 채택은 "왜 런타임이어야 하는가"에 답이 있을 때로 좁혀졌다 — 기존 코드베이스 유지와 신규 선택은 다른 문제다.
성능 주장은 계측으로
- "CSS-in-JS가 느리다"를 확인하려면: React DevTools Profiler로 같은 상호작용에서 styled 컴포넌트 밀집 트리의 렌더 시간을 보고, Performance 패널에서 스타일 재계산·시트 갱신이 상호작용마다 반복되는지 본다. 문제의 형태는 대개 "리렌더 넓음(5-5) × 렌더당 스타일 비용"의 곱이므로, 스타일링 교체 전에 리렌더 구조부터 진단하는 것이 순서다.
- CSS 총량 주장(원자화 수렴)은 번들 분석으로 확인한다: 빌드 산출물의 CSS 파일 크기를 페이지/컴포넌트가 늘어난 두 시점에서 비교하면 성장 곡선의 차이가 보인다.
어느 접근이든 지켜지는 것
- 특이성을 평평하게: 세 접근 모두 단일 클래스 선택자(특이성 0,1,0)를 산출하는 것이 정상 상태다.
!important나 중첩 선택자 곡예가 나타나면 접근이 아니라 구조(스타일 소유권이 두 곳)를 의심한다. - 전역 스타일의 자리: reset, 폰트, CSS 변수 테마는 어느 접근에서든 소수의 전역 시트로 — 격리 도구 안에 우겨 넣지 않는다.
- co-location: 스타일은 컴포넌트 옆에(같은 파일이든 옆 파일이든). 컴포넌트를 지우면 스타일도 같이 죽는 구조가 유지보수의 핵심이고, 세 접근 모두 이것을 다른 방식으로 달성한 것이다.
더 깊이
스타일 주입의 브라우저 비용 — CSSOM과 무효화
런타임 주입이 정확히 무엇을 지불하는지 한 층 아래로 내려가면: <style> 시트에 규칙을 추가하면 브라우저는 CSSOM을 갱신하고 영향 범위의 스타일 재계산을 수행한다(7-1의 파이프라인 첫 단계). 시트가 커진 상태에서의 빈번한 규칙 추가는 선택자 매칭 대상의 증가 + 재계산 트리거의 반복이라, "규칙 수천 개 시트에 상호작용마다 주입"이 최악 조합이다. 반면 정적 CSS는 로드 시 한 번 파싱되고, 클래스 전환은 이미 매칭된 규칙 집합 안의 전환이라 훨씬 싸다. CSS 변수 방식이 임의 값에 유리한 이유도 여기 있다 — 변수 값 변경은 규칙 추가가 아니라 상속된 커스텀 프로퍼티의 재해석이라 무효화 범위가 좁다.
Tailwind 4의 구현 이동 — 설정에서 CSS로
Tailwind 4는 엔진을 Rust로 재작성(Oxide)하면서 모델도 CSS 쪽으로 옮겼다: JS 설정 파일(tailwind.config.js) 대신 CSS의 @theme 블록에서 디자인 토큰을 CSS 변수로 선언하고, 생성되는 유틸리티가 그 변수를 참조한다. 토큰이 런타임에 CSS 변수로 실존하므로 JS에서 var(--color-primary)로 읽는 상호운용이 공짜가 되었고, 다크 모드·테마 전환이 "변수 재정의"라는 CSS 표준 메커니즘으로 수렴한다. "프레임워크 고유 개념이 플랫폼 기능으로 이동"하는 전형적 궤적이다.
대안 축 — 플랫폼의 스코핑
세 접근 모두 "전역 CSS 위의 우회"인데, 플랫폼 자체의 격리 수단도 자라고 있다: Shadow DOM(진짜 스타일 경계 — 단 React SSR·전역 테마와의 마찰로 앱 개발보다 웹 컴포넌트 배포용), @scope 규칙(선택자 적용 범위를 서브트리로 제한 — Baseline 진입 중), CSS Nesting. 이들이 성숙하면 "빌드 도구 없는 격리"의 지형이 달라질 수 있다 — 다만 "이름이 전역"이라는 문제와 "규칙이 어느 서브트리에 적용되는가"는 별개 문제라, 모듈 시스템(이름 격리)의 역할이 완전히 사라지지는 않는다.
정리
- 문제는 하나다: CSS의 전역 네임스페이스 vs 컴포넌트 격리. 접근들의 차이는 격리를 사는 시점 — CSS Modules는 빌드 타임 이름 해싱, Tailwind는 이름 자체의 제거(원자 어휘), CSS-in-JS는 런타임 생성이다.
- 미리 계산할수록 런타임이 싸고, 미룰수록 유연하다 — AOT/JIT과 같은 축이며, 생태계는 제로 런타임(빌드 타임) 쪽으로 이동했다.
- Tailwind의 CSS는 어휘가 유한해 총량이 수렴하고, 소스 텍스트 스캔이므로 클래스 이름은 소스에 리터럴로 존재해야 한다(문자열 조립 금지).
- 런타임 CSS-in-JS는 스타일 직렬화·주입을 렌더 경로에 얹는다 — 리렌더가 넓은 앱과 SSR/RSC에서 비용이 구조화되며, 그 DX는 제로 런타임 방식이 승계했다.
- 동적 스타일은 변형 전환(유한 — 어느 접근이든 정적 처리)과 임의 값(연속 — 인라인 style/CSS 변수)으로 구분한다. 런타임 스타일 생성이 정말 필요한 경우는 드물다.
확인 문제
Q1. 대시보드의 게이지 컴포넌트가 실시간 수치(0~100%)에 따라 채움 폭과 색상(수치 구간별 green/amber/red)을 바꿔야 한다. 팀은 Tailwind를 쓴다. 동료가 className={`w-[${value}%] text-${level}-600`}로 구현했더니 스타일이 적용되지 않는다. 원인과 올바른 구현을 두 층위 구분으로 설명하라.
정답과 해설
원인: Tailwind는 소스를 텍스트 스캔해 리터럴로 존재하는 클래스만 생성한다. w-[${value}%]와 text-${level}-600은 완성된 클래스 문자열이 소스에 없으므로 CSS가 아예 생성되지 않는다.
올바른 구현은 층위를 나눈다. 색상은 변형 전환(유한 집합 — green/amber/red 3개)이므로 완성된 리터럴 간 선택으로: const levelClass = { ok: 'bg-green-600', warn: 'bg-amber-600', danger: 'bg-red-600' }[level] — 세 문자열이 소스에 그대로 있으므로 스캐너가 본다. 채움 폭은 임의 값(연속 — 0~100% 열거 불가)이므로 클래스가 아니라 CSS 변수/인라인 style로: <div className="h-2 rounded bg-green-600 transition-[width]" style={{ width: . "동적이니까 문자열 조립"이 아니라 "유한하면 클래스 전환, 연속이면 값 주입"이 모든 접근 공통의 규칙이다.${value}% }} />
Q2. styled-components 기반 대형 앱에서 "타이핑이 버벅인다"는 리포트가 왔다. 스타일링을 통째로 교체하자는 의견에 대해, 이 문서와 5-5의 진단 순서로 "교체가 답인지"를 판정하는 절차를 설계하라.
정답과 해설
절차: ① React DevTools Profiler로 타이핑 상호작용을 녹화 — 커밋 시간과 렌더된 컴포넌트 범위를 본다. 전파가 비정상적으로 넓으면(입력 상태가 트리 위쪽에 있어 페이지 전체가 리렌더) 원인은 상태 구조이고, 상태 내리기/children 패턴(5-5)이 먼저다 — 이 경우 스타일링을 바꿔도 넓은 리렌더는 남는다. ② 전파가 정상인데 개별 컴포넌트 렌더가 느리면, styled 컴포넌트의 스타일 직렬화 비용인지 확인 — Performance 패널에서 렌더 구간의 JS 프로파일에 라이브러리의 직렬화/주입 함수가 잡히는지, Recalculate Style이 입력마다 반복되는지 본다. ③ ②가 사실로 확인되면 국소 완화(입력 근처 컴포넌트에서 props 기반 동적 스타일을 CSS 변수 방식으로 전환해 조합 폭발을 제거)를 먼저 시도하고 재계측한다. ④ 교체 판단은 그 후다 — 비용이 구조적(앱 전반 × SSR 요구)임이 수치로 보일 때, DX를 유지하는 제로 런타임 계열로의 이행을 검토한다. 요지: "스타일링 교체"는 가장 비싼 조치이므로, 리렌더 구조 → 국소 완화 → 교체의 순서로 계측이 각 단계를 정당화해야 한다.
Q3. 사내 디자인 시스템 컴포넌트 라이브러리(여러 제품이 npm으로 소비, 일부 제품은 SSR/RSC 사용)를 새로 만든다. 스타일링 접근을 선택하고, 소비자 관점의 제약 조건들로 근거를 구성하라.
정답과 해설
제약 조건 정리: 라이브러리는 소비자의 빌드·런타임 환경을 통제할 수 없다 — ① 런타임 CSS-in-JS는 소비자에게 런타임 의존성과 SSR 배관을 강제하고 RSC(서버 컴포넌트에서 스타일 주입 불가)와 마찰하므로 배제된다. ② Tailwind는 소비자도 Tailwind 파이프라인(라이브러리 소스 스캔 설정 포함)을 돌려야 하므로, "Tailwind 안 쓰는 제품"이 소비자에 있으면 강제가 된다 — 사내 전 제품이 Tailwind로 표준화돼 있다면 유효하고, 아니면 부적합. ③ 무난한 답은 정적 CSS를 산출하는 쪽이다: CSS Modules(빌드 후 정적 CSS 동봉) 또는 제로 런타임 CSS-in-JS(vanilla-extract 등 — 타입 안전한 토큰 API를 소비자에게 노출할 수 있어 디자인 시스템 요구와 정합). 토큰은 CSS 변수로 노출해 소비자가 테마를 변수 재정의로 커스터마이즈하게 한다. 핵심 근거: 라이브러리 경계에서는 "산출물이 플랫폼 표준(정적 CSS + CSS 변수)일수록 소비자 제약이 적다" — 격리 비용을 자기 빌드 타임에 지불하고 런타임 요구를 남기지 않는 선택이다.
참고 자료
- CSS Modules 명세(비공식 스펙) — 이름 변환 규칙과 composes. 1차 자료.
- Tailwind CSS 공식 문서 — Detecting classes in source files — 텍스트 스캔 모델과 문자열 조립 금지의 공식 근거. Theme variables(@theme) — Tailwind 4의 토큰 모델.
- Why We're Breaking Up with CSS-in-JS (Emotion 메인테이너) — 런타임 비용의 내부자 계측과 제로 런타임 이동의 배경. 검증용 참고.
- vanilla-extract 문서 — 제로 런타임 CSS-in-JS의 대표 구현.
- MDN — Using CSS custom properties — 임의 값 주입의 표준 메커니즘.