Skip to content

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 문법을 그대로 두고, 빌드 타임에 클래스 이름만 파일 단위로 해싱한다.

css
/* ProductCard.module.css */
.title { font-size: 1.25rem; }
.title.active { color: crimson; }
jsx
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 변수다:

jsx
// 임의 값은 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)만으로 스타일을 조합한다. 이름 짓기가 사라지면 이름 충돌도 사라진다.

jsx
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는 설정 파일 없이 자동 소스 탐지). 즉 문자열 조립으로 클래스를 만들면 스캐너가 못 본다:

jsx
// ❌ 스캐너는 "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 값으로 다룬다:

jsx
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 ModulesTailwind런타임 CSS-in-JS제로 런타임 CSS-in-JS
런타임 비용00렌더 경로에 직렬화·주입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 변수)일수록 소비자 제약이 적다" — 격리 비용을 자기 빌드 타임에 지불하고 런타임 요구를 남기지 않는 선택이다.

참고 자료 ​