개발 일지/FrontEnd_프론트엔드

[STOCAT] Tailwind CSS만 쓰던 나의 vanilla-extract 첫 도전기

the.Dev.Cat 2026. 2. 26. 13:10

 

이 글은 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-colorbackgroundColor , font-sizefontSize . 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() 의 인자로 배열 을 전달하면 스타일이 합성된다. periodButtonActiveperiodButton 의 모든 스타일을 상속받으면서 colorfontWeight 만 오버라이드한다. 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를 도입하면서 컴포넌트 라이브러리를 더 발전시켜볼 예정이다!

 


 

참고 자료