05 — CSS-in-JS
한 줄 답: CSS-in-JS는 *“CSS를 JS의 값으로 다루자”*는 사상이다. 2017년 styled-components가 정점을 찍었고, 2020년 vanilla-extract·Linaria가 zero-runtime으로 비용을 없앴으며, 2023년 React Server Components가 런타임 CSS-in-JS를 사실상 종료시켰다. 살아남는 길은 빌드 타임 추출 — Panda CSS·vanilla-extract·Linaria가 그 길이다.
Why — 왜 CSS-in-JS인가
컴포넌트 사고 vs CSS 파일 분리
React는 컴포넌트 = JS + HTML + 상태를 한 파일에 묶었다. CSS만 별도 파일에 두면:
- 컴포넌트 삭제 시 어느 CSS를 같이 지울지 추적 어려움.
- 동적 스타일(
color: ${props.danger ? 'red' : 'blue'})에 JS와 CSS의 데이터 흐름이 끊김. - 클래스 이름 충돌 우려.
styled-components(2016)의 발상:
const Button = styled.button`
background: ${(p) => (p.primary ? 'blue' : 'gray')};
color: white;
`;
<Button primary>Save</Button>— 컴포넌트가 자기 스타일을 안다. 삭제하면 함께 사라진다. props로 동적 분기가 자연스럽다.
How — Runtime CSS-in-JS의 동작
매 렌더마다:
- 템플릿 리터럴 평가 (props 보간).
- 결과 CSS 해시.
<style>태그가 없으면 DOM에 삽입.- 컴포넌트에
className부여.
비용:
- JS 번들에 CSS 파서·러너 포함 (styled-components v6 ≈ 12 KB gzip).
- 매 렌더마다 해시 계산 + DOM 변경.
- SSR 시 모든 스타일을 수집해 HTML에 인라인 주입해야 → 스트리밍 SSR 복잡.
What — 1세대: styled-components / Emotion
styled-components
import styled, { css } from 'styled-components';
const Button = styled.button`
padding: 8px 16px;
background: ${(p) => p.theme.colors.primary};
${(p) =>
p.danger &&
css`
background: red;
`}
&:hover { opacity: 0.9; }
`;
<Button danger>Delete</Button>Emotion
import { css } from '@emotion/react';
import styled from '@emotion/styled';
const Button = styled.button`...`; // styled-components 호환
<div css={css`color: red;`}>...</div>; // css prop 방식styled-components vs Emotion:
- Emotion이 약간 더 작고, css prop 지원.
- API는 거의 동일.
- 둘 다 런타임 — RSC 호환 안 됨.
What — 2세대: Zero-runtime (빌드 타임 추출)
빌드 시 CSS를 정적 파일로 추출해 런타임 비용 0.
vanilla-extract (2021, Seek — CSS Modules 만든 팀)
// Button.css.ts
import { style } from '@vanilla-extract/css';
export const button = style({
padding: '8px 16px',
background: 'blue',
':hover': { opacity: 0.9 },
});import { button } from './Button.css';
<button className={button}>Save</button>;특징:
- 타입 안전 TS API —
padding: '8'처럼 잘못된 값 컴파일 에러. - 빌드 시 진짜 CSS 파일로 컴파일.
- 런타임 비용 0.
recipes로 variant 시스템:
import { recipe } from '@vanilla-extract/recipes';
export const button = recipe({
base: { padding: '8px 16px' },
variants: {
intent: {
primary: { background: 'blue' },
danger: { background: 'red' },
},
size: {
sm: { fontSize: 12 },
md: { fontSize: 14 },
},
},
defaultVariants: { intent: 'primary', size: 'md' },
});Linaria (2017)
styled-components 문법을 그대로, 빌드 타임 추출.
import { styled } from '@linaria/react';
const Button = styled.button`
padding: 8px 16px;
background: ${(p) => p.theme.primary};
`;— Babel/SWC 플러그인이 빌드 시 CSS로 변환. props에 의존하는 부분은 CSS 변수로.
Panda CSS (2023, Chakra UI 팀)
vanilla-extract의 후계자급 야심작. Tailwind-like API + 타입 안전 + zero-runtime.
import { css } from 'styled-system/css';
<button className={css({ p: 4, bg: 'blue.500', _hover: { opacity: 0.9 } })}>// Recipe (variant)
import { defineRecipe } from '@pandacss/dev';
export const button = defineRecipe({
className: 'button',
base: { px: 4, py: 2 },
variants: {
intent: {
primary: { bg: 'blue.500', color: 'white' },
danger: { bg: 'red.500', color: 'white' },
},
},
});특징:
- Tailwind 토큰 시스템 + TS 자동완성 + 빌드 추출.
cva패턴(class-variance-authority)을 네이티브.- React Server Components 호환.
StyleX (2023, Meta)
Facebook 내부에서 쓰던 atomic CSS-in-JS를 오픈소스화.
import * as stylex from '@stylexjs/stylex';
const styles = stylex.create({
button: {
padding: 8,
background: 'blue',
':hover': { opacity: 0.9 },
},
});
<button {...stylex.props(styles.button)}>Save</button>— 모든 속성을 atomic 클래스로 분해 (Tailwind와 비슷한 결과 CSS).
What — 비교표
| 도구 | 출시 | 런타임 비용 | 타입 안전 | RSC 호환 | API 스타일 |
|---|---|---|---|---|---|
| styled-components | 2016 | ~12 KB + 동적 | 별도 plugin | X | tagged template |
| Emotion | 2017 | ~7 KB + 동적 | 별도 | X | tagged + css prop |
| Linaria | 2017 | 0 | 별도 | O | tagged (빌드 추출) |
| vanilla-extract | 2021 | 0 | 네이티브 TS | O | object literal |
| Stitches | 2020 (방치) | ~5 KB | 네이티브 | △ | object + variants |
| StyleX | 2023 | 0 | 네이티브 | O | object + atomic |
| Panda CSS | 2023 | 0 | 네이티브 TS | O | object + recipes |
What-if — RSC가 런타임 CSS-in-JS를 끝낸 이유
React Server Components(2023~)는 서버에서 컴포넌트를 렌더해 HTML을 보낸다. 컴포넌트 코드가 클라이언트로 가지 않는다.
styled-components 같은 런타임 CSS-in-JS는:
- 컴포넌트가 렌더링되면서
styled.button호출 → CSS 해시 +<style>삽입. - 이 작업이 클라이언트 JS에서 일어나야 함.
- RSC는 컴포넌트 JS를 클라이언트로 안 보냄 → 작동 불가.
해결책:
"use client"경계로 격리해서 클라이언트 컴포넌트에서만 사용.- zero-runtime 도구로 이전 (Panda, vanilla-extract).
Next.js App Router 공식 문서는 “styled-components·Emotion은 클라이언트 컴포넌트에서만 사용” 으로 명시. 사실상 점진적 deprecation.
What-if — Runtime CSS-in-JS의 진짜 비용
스타일 생성/주입의 매 렌더 비용보다 더 큰 문제는 hydration 비용 :
- SSR 시 서버가 스타일 수집 → HTML에
<style>인라인. - 클라이언트가 hydrate 하면서 같은 스타일을 다시 계산.
- 일치 검증 → 일치하면 그대로, 불일치면 재삽입.
대용량 페이지에서 Time-to-Interactive 200~500ms 증가 보고 사례 多.
Airbnb는 2022년 styled-components → Linaria 마이그레이션으로 page load 50ms 감소. Spotify는 2023년 internal CSS-in-JS → vanilla-extract. GitHub은 2024년 styled-components → CSS Modules + Primer Tokens.
런타임 CSS-in-JS는 죽지 않았지만, 대형 프로덕션 권장에서는 사라지고 있다.
What-if — 그래도 Runtime을 쓰는 이유
styled-components가 완전히 죽은 건 아니다. 적합한 곳:
- 작은 SPA — RSC 안 쓰고, 빌드 단순함이 중요할 때.
- 디자인 시스템 라이브러리 — 소비자에게 theme provider로 토큰 주입.
- 런타임 테마 전환이 매우 복잡한 경우 —
props.theme패턴이 깔끔.
단, 새 프로젝트라면 Panda CSS·vanilla-extract를 먼저 검토하는 게 2025년 기본.
Insight — CSS는 JS가 아니다의 재발견
CSS-in-JS의 10년은 “CSS를 JS의 일부로 만들자” 는 시도와, “CSS는 CSS로 두자” 는 후퇴의 사이클이었다.
2014~2016년 React가 컴포넌트 사고를 강제하면서, 개발자들은 CSS도 컴포넌트 안에 두고 싶었다. CSS Modules는 얇은 답이었지만, 동적 값·테마·props 분기에서 갈증이 있었다. styled-components(Max Stoiber, 2016)가 그 갈증을 극한까지 푼 해답이었다.
2018~2020년 정점에서:
- 컴포넌트가 자기 스타일을 갖는다 → 개발 경험 ↑
- 런타임 비용 → 성능 ↓
- 서버 사이드 추출 복잡도 → SSR 어려움
- 번들 크기 ~12 KB → 모바일 로딩 ↓
2021년 vanilla-extract의 등장이 전환점이었다 — CSS-in-JS의 개발 경험과 CSS Modules의 정적 추출을 합쳤다.
2023년 React Server Components의 결정타. 컴포넌트가 클라이언트로 가지 않는다는 새 패러다임은 런타임 CSS-in-JS의 전제를 깨버렸다. 같은 해 Panda CSS·StyleX가 등장한 건 우연이 아니다 — 모두 같은 결론에 도달했다.
흥미로운 점은 2025년 zero-runtime CSS-in-JS의 최종 형태가 Tailwind와 거의 같아 보인다 는 것이다:
- Tailwind: 클래스 합성 + 빌드 추출.
- Panda CSS: 함수 호출 + 빌드 추출 (→ 결과는 클래스 합성).
- StyleX: object literal + 빌드 추출 (→ atomic 클래스로 분해).
동일한 종착점에 다른 길로 도달했다. CSS는 CSS로 두되, 작성 인터페이스만 다르게 한다. TS 자동완성을 원하면 Panda·vanilla-extract, 순수 CSS의 친숙함을 원하면 Tailwind·CSS Modules.
“우리는 CSS를 JS로 만들 수 없다. CSS의 도메인이 너무 강하다. 우리가 할 수 있는 건 작성 환경만 JS답게 하는 것이다.” — Mark Dalgleish (vanilla-extract 저자)
CSS-in-JS의 10년은 그 사실을 비싸게 배운 시간이었다.
요약 + Mermaid
- Runtime CSS-in-JS (styled-components, Emotion): 런타임 비용 + RSC 미호환.
- Zero-runtime (vanilla-extract, Linaria, Panda CSS, StyleX): 빌드 추출 + RSC 호환.
- 2023년 RSC가 런타임 CSS-in-JS를 사실상 deprecated.
- vanilla-extract: 타입 안전 TS API, recipes로 variant.
- Panda CSS: Tailwind-like + 타입 안전 + zero-runtime.
- StyleX: Meta의 atomic 빌드 추출.
- 결국 모든 길이 클래스 + 빌드 추출로 수렴.
다음: 06-modern-patterns — Open Props, Pico, Web Components의 클래스 없는 길.