이 글은 STOCAT 프로젝트에서 디자인 시스템 토큰을 정리하면서 발견한 코드 개선 경험을 기록한 글이다.
들어가며
처음 프론트엔드 스타일링을 접한 게 Tailwind CSS였다. 유틸리티 클래스 기반의 빠른 개발 속도에 만족하면서 쭉 써왔는데, 어느 순간 한 가지 불안이 생겼다.
"나는 CSS-in-JS 방식을 제대로 모른다."
실무에서는 styled-components, Emotion 같은 CSS-in-JS 라이브러리가 여전히 많이 쓰인다. 채용 공고에서도 이 기술 스택을 요구하는 곳이 적지 않다. Tailwind만 써본 입장에서는 styled.div 같은 문법도, ThemeProvider 패턴도 낯설었다. 이 갭을 메우고 싶었다.
CSS-in-JS를 학습하기로 마음먹은 뒤, 여러 선택지를 조사하다가 vanilla-extract 를 알게 됐다. 런타임 오버헤드 없이 TypeScript로 스타일을 작성할 수 있다는 점이 끌렸고, 마침 진행 중인 STOCAT 프로젝트에 직접 적용해보기로 했다. 이 글은 그 과정을 정리한 개발일지다.
CSS-in-JS란
CSS-in-JS를 이야기하기 전에, 먼저 전통적인 CSS 작성 방식의 문제를 짚어보자.
일반 CSS는 전역 스코프 다. .button 클래스를 정의하면, 프로젝트 어디서든 같은 이름의 클래스와 충돌할 수 있다.
BEM 같은 네이밍 컨벤션으로 이를 완화하지만, 사람이 규칙을 지켜야 한다는 점에서 근본적인 해결은 아니다.
CSS-in-JS는 이 문제를 JavaScript 안에서 스타일을 정의 하는 방식으로 풀어냈다. 각 스타일이 고유한 클래스명을 자동 생성하니 스코프 충돌이 원천적으로 사라지고, 컴포넌트와 스타일을 한곳에서 관리할 수 있게 된다.
대표적인 CSS-in-JS 라이브러리의 작성 방식을 간단히 비교해보면 이렇다.
styled-components / Emotion (런타임 CSS-in-JS)
const Button = styled.button`
background-color: blue;
color: white;
padding: 8px 16px;
`;
vanilla-extract (제로 런타임 CSS-in-JS)
// Button.css.ts
export const button = style({
backgroundColor: "blue",
color: "white",
padding: "8px 16px",
});
문법은 다르지만, "JavaScript/TypeScript로 스타일을 정의한다"는 핵심 아이디어는 같다. 다만 결정적인 차이가 하나 있다.
스타일이 언제 CSS로 변환되느냐 다.
제로 런타임 CSS-in-JS, 왜 중요한가
CSS-in-JS 라이브러리는 스타일 처리 시점에 따라 크게 두 가지로 나뉜다.
런타임 CSS-in-JS (styled-components, Emotion)
사용자가 브라우저에서 페이지를 열면, JavaScript가 실행되면서 스타일 코드를 해석하고, <style> 태그를 동적으로 생성해 DOM에 삽입한다. 즉, 사용자의 브라우저가 스타일을 생성하는 일을 한다.
[사용자 브라우저]
1. JS 번들 다운로드 (스타일 코드 포함)
2. JS 실행 → 스타일 문자열 파싱
3. 고유 클래스명 생성 (예: .sc-bdVTJa)
4. <style> 태그 생성 후 DOM에 삽입
5. 컴포넌트 렌더링
이 과정에서 몇 가지 비용이 발생한다.
- 번들 크기 증가 : 스타일 코드가 JS 번들에 포함된다. 라이브러리 런타임 코드(styled-components 기준 약 12KB gzip)도 함께 번들에 들어간다.
- 렌더링 지연 : 컴포넌트가 마운트될 때마다 스타일을 파싱하고 주입하는 연산이 필요하다. 컴포넌트 수가 많아질수록 이 비용이 누적된다.
- SSR 복잡성 : 서버에서 렌더링한 HTML에 스타일이 없으면 FOUC(Flash of Unstyled Content)가 발생한다. 이를 방지하려면 서버 측 스타일 추출 설정이 별도로 필요하다.
제로 런타임 CSS-in-JS (vanilla-extract, Linaria)
빌드 시점(개발자의 컴퓨터)에서 모든 스타일 처리가 끝난다. .css.ts 파일에 작성한 스타일은 빌드할 때 일반 .css 파일로 추출되고, 브라우저에는 순수한 CSS만 전달된다.
[빌드 타임 - 개발자 컴퓨터]
1. .css.ts 파일 분석
2. style() 호출 → 고유 클래스명 생성
3. 정적 .css 파일 추출
[사용자 브라우저]
1. .css 파일 다운로드 (일반 CSS와 동일)
2. 컴포넌트 렌더링
"제로 런타임"이란 결국, 스타일과 관련된 JavaScript가 사용자 브라우저에서 단 한 줄도 실행되지 않는다는 뜻이다.
빌드 결과물만 놓고 보면 CSS Modules와 다를 바 없다.
이 방식의 장점을 정리하면 다음과 같다.
| 런타임 CSS-in-JS | 제로 런타임 CSS-in-JS | |
|---|---|---|
| JS 번들 크기 | 스타일 코드 + 라이브러리 런타임 포함 | 스타일 코드 미포함 |
| 브라우저 연산 | 스타일 파싱, 클래스 생성, DOM 주입 | 없음 |
| FOUC 위험 | SSR 설정 필요 | 없음 (정적 CSS) |
| 캐싱 | JS와 함께 캐싱 | CSS 별도 캐싱 가능 |
| 타입 안전성 | 부분적 | 완전 지원 |
왜 vanilla-extract를 선택했나
CSS-in-JS를 학습하겠다고 마음먹었을 때, 선택지는 여러 개였다. styled-components, Emotion, vanilla-extract, Linaria 등.
각각의 특성을 비교하고 나서 vanilla-extract를 골랐다.
라이브러리 비교
| Tailwind CSS | vanilla-extract | styled-components | Emotion | CSS Modules | |
|---|---|---|---|---|---|
| 타입 | 유틸리티 CSS | 제로 런타임 CSS-in-JS | 런타임 CSS-in-JS | 런타임 CSS-in-JS | CSS 모듈 |
| 런타임 비용 | 없음 | 없음 | 있음 | 있음 | 없음 |
| 타입 안전성 | 없음 | 완전 지원 | 부분적 | 부분적 | 없음 |
| 번들 영향 | CSS만 | CSS만 | JS에 포함 | JS에 포함 | CSS만 |
| 동적 스타일 | 조건부 클래스 | CSS 변수 + variants | props 기반 동적 | props 기반 동적 | 조건부 클래스 |
| 테마 시스템 | config 기반 | createTheme() | ThemeProvider | ThemeProvider | 수동 관리 |
| DX | 빠른 프로토타이핑 | 구조적, 타입 안전 | 유연함 | 유연함 | 단순함 |
선택 이유
1. CSS-in-JS의 패턴을 익히면서도 성능 부담이 없다
styled-components나 Emotion으로 CSS-in-JS 패턴을 배울 수도 있었지만, 런타임 오버헤드라는 본질적인 단점을 안고 가야 한다. vanilla-extract는 CSS-in-JS의 핵심 개념(스코프 격리, 테마 시스템, 컴포넌트 코로케이션)을 그대로 제공하면서도 빌드 타임에 CSS를 추출한다. "CSS-in-JS의 장점은 취하되 단점은 빼겠다"는 접근이 합리적으로 느껴졌다.
2. TypeScript 네이티브
vanilla-extract는 .css.ts 파일에서 스타일을 정의한다. style() 함수의 인자가 CSSProperties 타입이라, 잘못된 CSS 속성명을 쓰면 컴파일 에러가 난다. IDE 자동완성도 완벽하게 작동한다. Tailwind 클래스명은 결국 문자열이라 오타를 잡을 수 없었는데, 이 부분이 해소된다.
3. 테마 시스템이 깔끔하다
createTheme() API로 디자인 토큰을 CSS 변수로 관리할 수 있다. 나중에 다크 모드를 추가하거나 테마를 교체할 때, 변수 값만 바꾸면 된다. styled-components의 ThemeProvider 패턴과 비슷한 목적이지만, CSS 변수 기반이라 런타임 비용이 없다.
초기 설정
STOCAT 프로젝트는 Vite + React + TypeScript 환경이다. vanilla-extract 설정은 의외로 간단했다.
패키지 설치
pnpm add -D @vanilla-extract/css @vanilla-extract/vite-plugin
딱 두 개만 설치하면 된다.
@vanilla-extract/css:style(),globalStyle(),createTheme()등 스타일 API@vanilla-extract/vite-plugin:.css.ts파일을 빌드 타임에 처리하는 Vite 플러그인
Vite 플러그인 등록
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
import path from "path";
export default defineConfig({
plugins: [react(), vanillaExtractPlugin()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
});
vanillaExtractPlugin() 을 plugins 배열에 추가하는 게 전부다. 별도의 config 파일이나 복잡한 옵션이 필요 없다. Next.js, Webpack 환경에서도 각각 전용 플러그인이 제공된다.
파일 컨벤션
vanilla-extract는 .css.ts 확장자를 사용한다. 이 확장자를 가진 파일만 빌드 타임에 처리되며, 일반 .ts 파일에서는 style() 같은 API를 사용할 수 없다. 이 제약 덕분에 "어떤 파일이 스타일 파일인지"가 파일명만으로 명확하게 드러난다.
Header/
├── Header.tsx ← 컴포넌트
├── Header.css.ts ← 스타일 (빌드 타임에 CSS로 추출)
└── index.ts ← re-export
STOCAT 프로젝트에 적용한 개념
여기서부터는 실제로 STOCAT 프로젝트에서 작성한 코드를 기반으로 설명한다.
이 프로젝트는 주식 포트폴리오 관리 앱으로, 모바일 뷰를 기준으로 개발 중이다.
1. 3단계 색상 토큰 시스템
프로젝트의 색상을 체계적으로 관리하기 위해 Scale - Semantic - Theme 3단계 구조를 설계했다.
color.scale.css.ts 색상 원시 값 (green[500], grey[900] ...)
↓
color.semantic.css.ts 의미 부여 (green.normal, green.hover ...)
↓
vars.css.ts CSS 변수 생성 (vars.color.green.normal)
↓
컴포넌트.css.ts 스타일에서 사용
이 계층 구조의 핵심은 변경의 영향 범위를 제어하는 것 이다. 브랜드 컬러가 바뀌면 Scale만 수정하면 되고, "hover 상태에 쓰이는 색"을 바꾸고 싶으면 Semantic만 수정하면 된다.
1단계: Scale - 원시 색상 값
// src/shared/styles/color.scale.css.ts
export const green = {
50: "#e6faf7",
100: "#d9f7f3",
200: "#b0efe6",
500: "#00ccad",
600: "#00b89c",
700: "#00a38a",
800: "#009982",
900: "#007a68",
950: "#005c4e",
1000: "#00473d",
} as const;
export const grey = {
50: "#f8f8f8",
100: "#e8e8e8",
200: "#dddddd",
// ... 300~800
900: "#1A1A1A",
} as const;
Tailwind의 색상 스케일과 비슷한 구조다. as const 로 리터럴 타입을 보장해서, 이후 단계에서 타입 추론이 정확하게 동작한다.
2단계: Semantic - 의미 부여
// src/shared/styles/color.semantic.css.ts
import { green, blue, grey, blueGreen } from "./color.scale.css";
export const color = {
green: {
light: green[50],
lightHover: green[100],
lightActive: green[200],
normal: green[500],
hover: green[600],
active: green[700],
dark: green[800],
darkHover: green[900],
darkActive: green[950],
darker: green[1000],
},
blue: {
light: blue[50],
normal: blue[500],
dark: blue[800],
darkActive: blue[950],
// ...
},
grey,
} as const;
숫자 인덱스 대신 normal , hover , dark 같은 역할 기반 이름을 부여한다. 컴포넌트에서 green[500] 보다 green.normal 이 의도가 명확하다.
3단계: Theme - CSS 변수 생성
// src/shared/styles/vars.css.ts
import { createTheme } from "@vanilla-extract/css";
import { color } from "./color.semantic.css";
export const [themeClass, vars] = createTheme({
color: {
green: color.green,
blue: color.blue,
blueGreen: color.blueGreen,
grey: color.grey,
},
});
createTheme() 은 전달받은 객체의 모든 리프 값을 CSS 변수 로 변환한다. vars.color.green.normal 이라고 쓰면, 실제로는 var(--color-green-normal__xxxx) 같은 CSS 변수로 치환된다.
그리고 루트 컴포넌트에서 themeClass 를 적용하면, 하위 모든 컴포넌트가 이 변수에 접근할 수 있다.
// src/app/App.tsx
import { themeClass } from "@/shared/styles/vars.css";
import * as styles from "./app.css";
import { Outlet } from "react-router-dom";
export default function App() {
return (
<div className={`${themeClass} ${styles.viewport}`}>
<div className={styles.mobileFrame}>
<Header />
<div className={styles.content}>
<Suspense>
<Outlet />
</Suspense>
</div>
<BottomAppBar />
</div>
</div>
);
}
2. 글로벌 스타일 리셋
// src/shared/styles/global.css.ts
import { globalStyle } from "@vanilla-extract/css";
globalStyle("*", {
boxSizing: "border-box",
});
globalStyle("html, body", {
margin: 0,
padding: 0,
});
globalStyle("button", {
cursor: "pointer",
border: "none",
background: "none",
padding: 0,
margin: 0,
font: "inherit",
color: "inherit",
});
최소한의 리셋만 정의했다. 엔트리 파일에서 import하면 바로 적용된다.
// src/main.tsx
import "./shared/styles/global.css.ts";
3. 기본 스타일 패턴 - style()과 네임스페이스 import
STOCAT 프로젝트의 모든 컴포넌트는 동일한 패턴으로 스타일을 작성한다. .css.ts 파일에서 style() 로 스타일을 정의하고, 컴포넌트에서 * as styles 로 가져온다.
종목 상세 페이지의 PriceInfo 컴포넌트를 예로 들어보자.
// PriceInfo.css.ts
import { style } from "@vanilla-extract/css";
import { vars } from "@/shared/styles/vars.css";
export const container = style({
padding: "16px 20px",
});
export const title = style({
fontSize: "16px",
fontWeight: 600,
color: vars.color.grey[900],
margin: 0,
});
export const rangeBar = style({
position: "relative",
height: "4px",
backgroundColor: vars.color.grey[200],
borderRadius: "2px",
marginBottom: "8px",
});
export const rangeIndicator = style({
position: "absolute",
width: "12px",
height: "12px",
backgroundColor: vars.color.grey[700],
borderRadius: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
});
style() 안에 들어가는 건 일반 CSS 속성을 camelCase로 바꾼 것이다. background-color → backgroundColor , font-size → fontSize . TypeScript 타입 체크가 되니 backgroundColr 같은 오타를 치면 IDE에서 바로 알려준다.
4. 조건부 스타일 - 상태에 따라 다른 클래스 적용
보유 종목 리스트에서 등락률에 따라 색상을 다르게 표시하는 예시다.
// MyStockContent.css.ts
import { vars } from "@/shared/styles/vars.css";
import { style } from "@vanilla-extract/css";
export const increase = style({
fontSize: 14,
fontWeight: 500,
color: "#FB2C36",
});
export const decrease = style({
fontSize: 14,
fontWeight: 500,
color: "#0088FF",
});
// MyStockContent.tsx
import * as styles from "./MyStockContent.css";
export default function MyStockContent({
name, averagePrice, currentPrice, changeRate,
}: MyStockContentProps) {
const isPositive = changeRate >= 0;
return (
<div className={styles.container}>
<div className={styles.corpInfoWrapper}>
<div className={styles.corpInfoLogo}></div>
<div className={styles.corpInfoTitleWrapper}>
<span className={styles.corpInfoTitle}>{name}</span>
<span className={styles.averagePrice}>{averagePrice}</span>
</div>
</div>
<div className={styles.priceWrapper}>
<span className={styles.currentPrice}>{currentPrice}</span>
<span className={isPositive ? styles.increase : styles.decrease}>
{isPositive ? "+" : ""}{changeRate}%
</span>
</div>
</div>
);
}
조건에 따라 미리 정의된 스타일 클래스를 선택하는 방식이다. 런타임에 새로운 CSS를 생성하는 게 아니라, 이미 빌드된 클래스 중 하나를 고르는 것이므로 성능 부담이 없다.
5. styleVariants - 레이아웃 변형 관리
MarketCard 컴포넌트는 가로형(horizontal)과 세로형(vertical) 두 가지 레이아웃을 지원한다.
// MarketCard.css.ts
import { style, styleVariants } from "@vanilla-extract/css";
import { vars } from "@/shared/styles/vars.css";
const base = style({
flexShrink: 0,
borderRadius: 16,
overflow: "hidden",
backgroundColor: vars.color.grey[50],
});
export const layout = styleVariants({
horizontal: [
base,
{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 12,
padding: "12px 16px",
width: 260,
},
],
vertical: [
base,
{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
gap: 8,
padding: 16,
width: 160,
height: 160,
},
],
});
export const info = styleVariants({
horizontal: {
display: "flex",
flexDirection: "column",
gap: 2,
minWidth: 0,
},
vertical: {
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
},
});
// MarketCard.tsx
import * as styles from "./MarketCard.css";
export default function MarketCard({ variant, name, sector, changeRate, price }: MarketCardProps) {
return (
<div className={styles.layout[variant]}>
<img className={styles.logo} src={logoUrl} alt={name} />
<div className={styles.info[variant]}>
<div className={styles.nameRow[variant]}>
<span className={styles.name}>{name}</span>
<span className={styles.badge}>{sector}</span>
</div>
<div className={styles.priceRow}>
<span className={styles.changeRate} style={{ color: changeColor }}>
{changeText}
</span>
<span className={styles.price}>{price}</span>
</div>
</div>
</div>
);
}
styles.layout[variant] 처럼 객체 키로 접근하면, variant prop의 타입이 "horizontal" | "vertical" 로 좁혀진다. 존재하지 않는 variant를 전달하면 타입 에러가 발생한다. 이런 식으로 스타일 변형도 타입 시스템의 보호를 받는다.
이 컴포넌트는 홈 화면에서 이렇게 사용된다.
// HomeMarketSection.tsx
<MarketCategory variant="horizontal" stocks={MOCK_KR} /> {/* 한국 종목 */}
<MarketCategory variant="horizontal" stocks={MOCK_US} /> {/* 미국 종목 */}
<MarketCategory variant="vertical" stocks={MOCK_CRYPTO} /> {/* 암호화폐 */}
6. 스타일 합성 - 배열로 기존 스타일 확장
기간 선택 버튼에서 기본 스타일과 활성 상태 스타일을 합성하는 예시다.
// PeriodSelector.css.ts
export const periodButton = style({
padding: "6px 12px",
fontSize: "13px",
fontWeight: 500,
color: vars.color.grey[600],
backgroundColor: "transparent",
border: "none",
borderRadius: "4px",
cursor: "pointer",
transition: "all 0.2s ease",
":hover": {
backgroundColor: vars.color.grey[100],
},
});
export const periodButtonActive = style([
periodButton,
{
color: vars.color.grey[900],
fontWeight: 600,
},
]);
// PeriodSelector.tsx
{periods.map((period) => (
<button
key={period.key}
className={
activePeriod === period.key
? styles.periodButtonActive
: styles.periodButton
}
onClick={() => onPeriodChange(period.key)}
>
{period.label}
</button>
))}
style() 의 인자로 배열 을 전달하면 스타일이 합성된다. periodButtonActive 는 periodButton 의 모든 스타일을 상속받으면서 color 과 fontWeight 만 오버라이드한다. CSS의 상속 개념을 TypeScript 레벨에서 구현한 셈이다.
7. globalStyle - 자식 요소 스타일링
vanilla-extract의 style() 은 기본적으로 단일 클래스에 대한 스타일만 정의한다. 자식 요소를 선택하는 셀렉터( .parent > .child )를 사용할 수 없다. 이때 globalStyle() 과 조합한다.
종목 리스트의 필터 버튼 스타일링 예시다.
// StockList.css.ts
import { globalStyle, style } from "@vanilla-extract/css";
import { vars } from "@/shared/styles/vars.css";
export const badgeList = style({
width: "100%",
display: "flex",
alignItems: "flex-start",
gap: 8,
});
globalStyle(`${badgeList} > button`, {
paddingTop: 6,
paddingBottom: 6,
paddingLeft: 12,
paddingRight: 12,
fontSize: 12,
fontWeight: 700,
color: vars.color.grey[800],
borderRadius: 100,
borderWidth: 1,
borderStyle: "solid",
borderColor: vars.color.grey[100],
});
globalStyle(`${badgeList} > button[aria-selected="true"]`, {
backgroundColor: "#F6F6F6",
border: "none",
});
// StockList.tsx
<div className={styles.badgeList}>
{filters.map((filter) => (
<button
key={filter}
aria-selected={filter === selectedFilter}
onClick={() => onFilterChange(filter)}
>
{filter}
</button>
))}
</div>
globalStyle() 의 첫 번째 인자에서 ${badgeList} 변수를 직접 사용할 수 있다. 빌드 타임에 해시된 클래스명(예: _1mfk2a0 )으로 치환되기 때문에 스코프 충돌 걱정 없이 자식 요소를 스타일링할 수 있다. 이 점이 일반 CSS Modules와 비교했을 때 vanilla-extract의 큰 이점이다.
프로젝트 파일 구조
src/
├── shared/styles/
│ ├── color.scale.css.ts ← 색상 원시 값
│ ├── color.semantic.css.ts ← 의미론적 색상
│ ├── vars.css.ts ← createTheme (CSS 변수)
│ └── global.css.ts ← 리셋 스타일
│
├── layout/
│ ├── Header/
│ │ ├── Header.tsx
│ │ └── Header.css.ts
│ └── BottomAppBar/
│ ├── BottomAppBar.tsx
│ └── BottomAppBar.css.ts
│
└── pages/
├── home/
│ ├── Home.tsx
│ ├── Home.css.ts
│ └── components/
│ ├── HomeGreetingSection/
│ │ ├── HomeGreetingSection.css.ts
│ │ ├── HomeBanner/HomeBanner.css.ts
│ │ └── HomeWallet/HomeWallet.css.ts
│ ├── HomeStockSection/
│ │ ├── HomeStockSection.css.ts
│ │ ├── StockList/StockList.css.ts
│ │ └── StockAnalysis/StockAnalysis.css.ts
│ └── HomeMarketSection/
│ ├── HomeMarketSection.css.ts
│ └── MarketCard/MarketCard.css.ts
├── market/
└── stockDetail/
이 브랜치에서 작업한
.css.ts파일이 총 35개다. 모든 컴포넌트가 같은 디렉토리에 스타일 파일을 두는 코로케이션 패턴을 따르고 있어서, 관련 코드를 찾으러 프로젝트를 돌아다닐 필요가 없다.
도입 후 느낀 점
좋았던 점
타입 안전성이 실질적으로 생산성을 높인다.
bakgroundColor 같은 오타를 치면 IDE에서 바로 빨간 줄이 뜬다. vars.color. 까지 입력하면 사용 가능한 색상 목록이 자동완성으로 뜬다. MDN에서 CSS 속성을 검색하는 빈도가 확실히 줄었다.
CSS-in-JS의 핵심 개념을 자연스럽게 익혔다.
style() , globalStyle() , createTheme() , styleVariants() 같은 API를 사용하면서 스코프 격리, 테마 시스템, 변형 관리 같은 CSS-in-JS의 핵심 패턴을 경험했다. styled-components나 Emotion의 코드를 읽을 때도 패턴이 눈에 들어오기 시작했다.
빌드 결과물에 대한 확신이 있다.
개발자 도구에서 확인해보면 일반 CSS 파일이 로드된 것을 볼 수 있다. 런타임에 <style> 태그가 동적으로 추가되거나 하는 일이 없다.
아쉬웠던 점
globalStyle() 을 써야 하는 케이스를 처음 만났을 때 헤맸다.
style() 안에서 자식 셀렉터를 쓸 수 없다는 걸 처음에 몰랐다. 공식 문서를 찾아보고 globalStyle() 과 조합하는 패턴을 알게 됐지만, 이 제약이 처음에는 불편하게 느껴졌다.
조건부 클래스 조합이 약간 번거롭다.
여러 스타일을 조건부로 조합할 때 문자열 템플릿 리터럴을 써야 한다. clsx 를 도입하면 개선되겠지만, 아직은 적용하지 않았다.
Tailwind 대비 코드량이 늘어난다.
Tailwind에서 flex items-center gap-4 면 끝나는 걸, vanilla-extract에서는 style({ display: "flex", alignItems: "center", gap: 4 }) 로 써야 한다. 다만 이건 구조적 명확성과의 트레이드오프라고 생각한다.
앞으로 개선하면 좋을 것
1. Sprinkles 도입
현재 모든 스타일을 style() 로 정의하고 있다. 반복적으로 사용되는 유틸리티 스타일(display, padding, margin, gap 등)은 Sprinkles 를 도입하면 개발 속도를 높일 수 있다.
pnpm add @vanilla-extract/sprinkles
Sprinkles는 vanilla-extract 위에 구축된 유틸리티 클래스 생성 도구다. Tailwind처럼 미리 정의된 속성을 조합해서 사용할 수 있으면서도, TypeScript 타입 안전성은 그대로 유지된다.
// sprinkles.css.ts (도입 시 예상 코드)
import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles";
const responsiveProperties = defineProperties({
properties: {
display: ["flex", "grid", "block", "none"],
flexDirection: ["row", "column"],
alignItems: ["center", "flex-start", "flex-end"],
gap: { 0: 0, 4: 4, 8: 8, 12: 12, 16: 16, 24: 24 },
padding: { 0: 0, 4: 4, 8: 8, 12: 12, 16: 16, 20: 20, 24: 24 },
},
});
export const sprinkles = createSprinkles(responsiveProperties);
2. Recipe로 다중 Variant 관리
현재 styleVariants 로 간단한 변형을 처리하고 있다. 하지만 버튼처럼 size + color + variant를 조합해야 하는 컴포넌트에는 Recipe 가 더 적합하다.
pnpm add @vanilla-extract/recipes
// recipe 도입 시 예상 코드
import { recipe } from "@vanilla-extract/recipes";
export const button = recipe({
base: { borderRadius: 8, fontWeight: 600, cursor: "pointer" },
variants: {
size: {
sm: { padding: "6px 12px", fontSize: 13 },
md: { padding: "8px 16px", fontSize: 15 },
},
color: {
primary: { backgroundColor: vars.color.green.normal, color: "white" },
secondary: { backgroundColor: vars.color.grey[100], color: vars.color.grey[900] },
},
},
defaultVariants: { size: "md", color: "primary" },
});
3. 다크 모드
createTheme() 의 진정한 힘은 다중 테마 지원에 있다. 현재는 라이트 테마 하나만 정의되어 있지만, 동일한 vars 인터페이스에 다크 테마 값을 매핑하는 것만으로 다크 모드를 구현할 수 있다.
export const [darkThemeClass] = createTheme(vars, {
color: {
grey: {
50: "#1A1A1A",
100: "#2A2A2A",
// ... 밝은 값과 반전
900: "#f8f8f8",
},
// ...
},
});
4. Tailwind 잔여 설정 정리
현재 tailwind.config.ts 와 @tailwindcss/vite 플러그인이 아직 남아있다. 실제로 Tailwind 클래스를 사용하는 코드는 없으므로, 이 잔여 설정을 정리하면 빌드 파이프라인이 깔끔해진다.
5. clsx 도입
조건부 클래스 조합을 개선하기 위해 clsx 유틸리티를 도입할 수 있다. 현재 문자열 템플릿으로 처리하는 부분을 더 선언적으로 바꿀 수 있다.
// before
className={`${styles.navItem} ${isActive ? styles.navItemActive : ""}`}
// after (clsx 도입 시)
className={clsx(styles.navItem, isActive && styles.navItemActive)}
마무리
이번 작업은 단순한 라이브러리 교체가 아니었다. Tailwind CSS의 유틸리티 클래스를 조합하는 방식에서, TypeScript 객체로 스타일을 설계하는 방식으로 전환하면서 CSS-in-JS라는 패러다임 자체를 새로 배우는 경험 이었다.
CSS-in-JS를 실무에서 많이 쓰는데 그 방식을 모른다는 불안감에서 시작한 학습이었지만, 실제로 프로젝트에 적용해보니 생각보다 많은 것을 얻었다. 타입 안전한 스타일링, 체계적인 테마 시스템, 컴포넌트 코로케이션. 이런 개념들을 코드로 체험하면서 이해한 것이 가장 큰 수확이다.
물론 모든 프로젝트에 vanilla-extract가 정답은 아니다. 빠른 프로토타이핑이 중요한 상황에서는 Tailwind가 여전히 강력하다. 하지만 디자인 시스템을 체계적으로 구축하고, 타입 안전성까지 챙기고 싶다면 vanilla-extract는 충분히 매력적인 선택이다.
다음에는 Sprinkles와 Recipe를 도입하면서 컴포넌트 라이브러리를 더 발전시켜볼 예정이다!
참고 자료
'개발 일지 > FrontEnd_프론트엔드' 카테고리의 다른 글
| [STOCAT] vanilla-extract에서 CVA 패턴 쓰기 : @vanilla-extract/recipes 도입기 (0) | 2026.03.03 |
|---|---|
| [STOCAT] vanilla-extract 타이포그래피 토큰, 3줄 -> 1줄로 줄이기 (0) | 2026.02.28 |
