🎨 Frontend CSS8. 아키텍처 (Tokens·Tailwind·CSS-in-JS)05 — CSS-in-JS (Runtime vs Zero-runtime)

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의 동작

매 렌더마다:

  1. 템플릿 리터럴 평가 (props 보간).
  2. 결과 CSS 해시.
  3. <style> 태그가 없으면 DOM에 삽입.
  4. 컴포넌트에 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 APIpadding: '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-components2016~12 KB + 동적별도 pluginXtagged template
Emotion2017~7 KB + 동적별도Xtagged + css prop
Linaria20170별도Otagged (빌드 추출)
vanilla-extract20210네이티브 TSOobject literal
Stitches2020 (방치)~5 KB네이티브object + variants
StyleX20230네이티브Oobject + atomic
Panda CSS20230네이티브 TSOobject + recipes

What-if — RSC가 런타임 CSS-in-JS를 끝낸 이유

React Server Components(2023~)는 서버에서 컴포넌트를 렌더해 HTML을 보낸다. 컴포넌트 코드가 클라이언트로 가지 않는다.

styled-components 같은 런타임 CSS-in-JS는:

  1. 컴포넌트가 렌더링되면서 styled.button 호출 → CSS 해시 + <style> 삽입.
  2. 이 작업이 클라이언트 JS에서 일어나야 함.
  3. RSC는 컴포넌트 JS를 클라이언트로 안 보냄 → 작동 불가.

해결책:

  • "use client" 경계로 격리해서 클라이언트 컴포넌트에서만 사용.
  • zero-runtime 도구로 이전 (Panda, vanilla-extract).

Next.js App Router 공식 문서는 “styled-components·Emotion은 클라이언트 컴포넌트에서만 사용” 으로 명시. 사실상 점진적 deprecation.


What-if — Runtime CSS-in-JS의 진짜 비용

스타일 생성/주입의 매 렌더 비용보다 더 큰 문제는 hydration 비용 :

  1. SSR 시 서버가 스타일 수집 → HTML에 <style> 인라인.
  2. 클라이언트가 hydrate 하면서 같은 스타일을 다시 계산.
  3. 일치 검증 → 일치하면 그대로, 불일치면 재삽입.

대용량 페이지에서 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의 클래스 없는 길.