본문으로 건너뛰기
Tech Blog

vanilla-extract를 제대로 쓰는 법

글 복사 완료!

VE의 세 가치는 공짜가 아니에요. globalStyle과 var() 문자열이 그 가치를 어떻게 무너뜨리는지 짚어요.

·11분·

토큰 시스템을 vanilla-extract로 옮긴 팀 코드를 열었는데, 색상 하나가 화면에서 빠져 있었어요. 범인은 color: 'var(--color-brnad)', 변수명 오타였죠. 그런데 빌드는 멀쩡히 통과했더라고요. TypeScript로 스타일을 쓰는데 왜 타입 검사가 이걸 못 잡았을까요. vanilla-extract(이하 VE)의 세 가치는 공짜로 따라오는 게 아니라 특정 사용법을 전제로 해요. globalStyle을 일상용으로 쓰고 토큰을 var() 문자열로 참조하는 순간, 도구가 막아줄 실수를 우리가 직접 열어두는 거예요.

VE가 약속하는 세 가지

VE 공식 문서는 자기 정체성을 한 문장으로 정리해요.

"Use TypeScript as your preprocessor. Write type-safe, locally scoped classes, variables and themes, then generate static CSS files at build time." - vanilla-extract

TypeScript를 전처리기로 써서, 타입 안전하고 지역 스코프인 클래스와 변수와 테마를 작성하고, 빌드 타임에 정적 CSS 파일로 뽑아낸다는 거예요.

여기 세 가치가 한 문장에 다 들어 있어요. 빌드 타임에 정적 CSS로 뽑으니 런타임에 스타일을 만들 일이 없는 zero-runtime, 스타일을 TypeScript로 쓰니까 타입 검사를 받는 type-safe, 그리고 style()이 충돌 없는 고유 클래스명을 만들어 주는 locally scoped. 이 셋이 VE를 styled-components 같은 런타임 방식이나 평범한 CSS와 갈라놓는 지점이에요. VE가 zero-runtime CSS-in-JS라는 큰 그림에서 어디쯤 서 있는지는 스타일링 도구를 고르는 기준에서 다뤘으니 배경이 궁금하면 먼저 보고 와도 좋아요.

중요한 건 기본 단위가 style()이라는 점이에요. style()이 만든 클래스는 JS로 직접 import해서 쓰는, CSS와 JavaScript 사이의 단단한 계약이에요. 이 계약을 우회하기 시작하면 세 가치도 같이 새어 나가요. 아래 두 가지가 가장 흔한 누수 지점이에요.

globalStyle은 비상구지 현관이 아니에요

첫 번째 누수는 globalStyle이에요. 이름 그대로 전역 셀렉터에 스타일을 붙이는 함수인데, style()과 결정적으로 달라요. style()은 고유 클래스명을 돌려주지만, globalStyle은 셀렉터 문자열을 직접 받아요. 즉 locally scoped를 스스로 끄는 거예요.

공식 문서가 거는 제약을 보면 이 함수의 성격이 드러나요.

"The global style object cannot use simple pseudo or complex selectors to avoid unexpected results when merging with the global selector." - vanilla-extract

전역 셀렉터랑 합쳐질 때 엉뚱한 결과가 나오는 걸 막으려고, 단순 가상 선택자나 복합 선택자를 아예 못 쓰게 막아둔 거예요.

쓸 수 있는 게 이렇게 좁다는 건 일상용 도구가 아니라는 신호예요. 실제로 공식 예시도 globalStyle('html, body', { margin: 0 }) 같은 reset 한 줄에 머물러요. 저는 이걸 비상구라고 불러요. reset이나 서드파티 스타일 덮어쓰기처럼 지역 스코프로 풀 수 없는 자리에만 잠깐 쓰는 escape hatch라는 거죠.

문제는 이 비상구를 현관처럼 쓰는 코드예요. 셀렉터를 통째로 문자열로 박아 넣으면 클래스명이 전역에 노출되고, 다른 스타일과 충돌할 여지가 생겨요. 꼭 전역 셀렉터에 스코프 클래스를 끼워야 한다면, 문자열 대신 템플릿 리터럴로 스코프 클래스를 참조하는 정식 통로가 있어요.

// 비상구를 현관처럼 쓰는 코드. 전역 클래스명이 그대로 노출돼요
import { globalStyle } from '@vanilla-extract/css';
 
globalStyle('.card > a', {
  color: 'pink',
});
// 스코프 클래스를 셀렉터에 끼우는 정식 통로
import { style, globalStyle } from '@vanilla-extract/css';
 
export const card = style({});
 
globalStyle(`${card} > a`, {
  color: 'pink',
});

아래 버전은 card가 여전히 고유 해시 클래스라서, 전역으로 새어 나가지 않으면서도 자식 셀렉터를 걸 수 있어요. 같은 globalStyle인데 스코프를 지키느냐 버리느냐가 갈리는 거죠.

var() 문자열은 타입 검사를 조용히 통과해요

두 번째 누수가 진짜 핵심이에요. 그리고 globalStyle 문제랑은 성격이 완전히 달라요. 이건 스코프가 아니라 타입과 참조 무결성의 문제고, style()을 쓰든 globalStyle을 쓰든 독립적으로 생겨요.

VE에서 테마 토큰을 만드는 createTheme, createThemeContract, createGlobalTheme은 전부 vars 객체를 돌려줘요. 그리고 스타일에서는 이 객체를 점 표기로 참조해요.

// theme.css.ts
import { createGlobalTheme } from '@vanilla-extract/css';
 
export const vars = createGlobalTheme(':root', {
  color: {
    brand: 'blue',
  },
});
// button.css.ts
import { style } from '@vanilla-extract/css';
import { vars } from './theme.css';
 
export const button = style({
  color: vars.color.brand, // 타입이 있는 객체 참조
});

vars.color.brand는 타입이 있는 객체 프로퍼티예요. brnad로 오타를 내면 TypeScript가 그 자리에서 빨간 줄을 그어요. 이게 type-safe의 실체예요. 그런데 같은 값을 생 문자열로 쓰면 이야기가 달라져요.

// button.css.ts
import { style } from '@vanilla-extract/css';
 
export const button = style({
  color: 'var(--color-brnad)', // 오타. 그래도 컴파일은 통과해요
});

'var(--color-brnad)'는 그냥 문자열이라 TypeScript가 들여다볼 내용이 없어요. 오타가 났는데 빌드가 통과하고, 브라우저에서도 조용히 실패해요. CSS 커스텀 프로퍼티의 동작 때문이에요.

"However, when the values of custom properties are parsed, the browser doesn't yet know where they will be used, so it must consider nearly all values as valid." - MDN

커스텀 프로퍼티는 파싱 시점에 어디 쓰일지 모르니까, 브라우저가 사실상 모든 값을 일단 유효한 걸로 받아들여요.

게다가 커스텀 프로퍼티 이름은 대소문자까지 구분해요. --color-brand--color-Brand는 다른 변수죠. 그러니 오타 난 var()는 컴파일러도, 브라우저도 잡아주지 않아요. 잘못된 참조를 만나면 그냥 초깃값이나 상속값으로 떨어질 뿐이에요. VE를 쓰는데 토큰을 문자열로 참조하면, 정작 VE가 메워주는 그 빈틈을 다시 열어두는 셈이에요. 동작은 하니까 더 위험하죠. 진짜 쟁점은 "지금 돌아가느냐"가 아니라 "토큰 이름을 바꾸거나 지웠을 때 조용히 깨지느냐"거든요.

동적이어도 zero-runtime은 안 깨져요

여기서 흔한 반론이 나와요. "런타임에 값이 정해지는 색은 어떡하냐, 결국 var() 문자열 쓸 수밖에 없지 않냐"는 거죠. VE는 이것도 객체 참조로 풀 수 있게 길을 따로 내줬어요. createThemeContract로 값 없는 계약만 만들고, @vanilla-extract/dynamicassignInlineVars로 런타임에 값을 채우는 방식이에요.

// theme.css.ts
import { createThemeContract } from '@vanilla-extract/css';
 
export const themeVars = createThemeContract({
  color: {
    brand: null,
  },
});
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { themeVars } from './theme.css';
 
function Box({ brandColor }) {
  return (
    <div
      style={assignInlineVars(themeVars, {
        color: { brand: brandColor },
      })}
    />
  );
}

assignInlineVars는 계약에 정의된 변수를 다 채우라고 타입으로 강제해요. 여기서도 오타나 누락은 컴파일 단계에서 걸려요. 그러면서도 런타임에 CSS를 새로 만들어 주입하지 않고, 인라인 스타일로 변수 값만 바꿔요. 공식 문서가 이 점을 콕 집어요.

"This approach facilitates type-safe runtime theming without requiring the creation and injection of CSS at runtime." - vanilla-extract

런타임에 CSS를 만들어 주입하지 않고도 타입 안전한 동적 테마를 쓸 수 있게 해주는 방식이에요.

그래서 "동적이니까 어쩔 수 없이 문자열"이라는 핑계가 안 통해요. 동적이어도 객체 참조를 유지하는 정식 경로가 있고, 그 경로가 zero-runtime도 안 깨거든요.

그래서 어떻게 쓰면 되나

정리하면 누수는 두 갈래고, 둘을 섞으면 안 돼요. globalStyle에 문자열 셀렉터를 박는 건 locally scoped를 버리는 스코프 문제예요. 토큰을 var() 문자열로 참조하는 건 type-safe를 버리는 참조 무결성 문제고요. 원인도 해법도 달라요. 전자는 style()을 기본으로 두고 globalStyle은 reset 같은 비상구로만 좁히면 되고, 후자는 vars 객체 참조로 바꾸면 돼요.

한 가지 더 짚을 게 있어요. 이런 코드가 사람 손이 아니라 토큰 생성기에서 나왔다면, 고쳐야 할 건 글쓴이가 아니라 생성기예요. 디자인 토큰을 VE 코드로 뱉는 파이프라인이 var() 문자열을 출력하고 있다면, 그 출력을 vars 객체 참조로 바꾸는 게 근본 해법이죠. 한 파일씩 손으로 고쳐봐야 다음 빌드에 또 같은 문자열이 쏟아져요.

반대로 전역 유틸리티 클래스명을 DOM에 문자열로 붙이는 게 의도된 설계라면, 그땐 globalStyle 자체는 정당한 선택일 수 있어요. 그 경우엔 비판의 타깃을 var() 참조 하나로 좁히면 돼요. 어느 쪽이든 출발점은 같아요. VE의 세 가치는 특정 사용법을 전제로 하니까, 그 전제를 지키는 사용법을 기본값으로 삼는 거예요.

자주 묻는 질문

답을 펼치기 전에 스스로 답해보세요

아니에요. reset이나 서드파티 스타일 덮어쓰기처럼 지역 스코프로 풀 수 없는 자리에는 정당한 도구예요. 일상적인 컴포넌트 스타일까지 globalStyle로 처리하는 게 문제일 뿐이에요. 기본은 style()이고 globalStyle은 비상구라고 기억하면 돼요.
동작 여부가 쟁점이 아니라 변경에 대한 안전성이 쟁점이에요. 토큰 이름을 바꾸거나 지웠을 때, vars 객체 참조는 컴파일이 깨져서 바로 알려주지만 var() 문자열은 조용히 초깃값으로 떨어져요. 오타도 똑같이 조용히 통과하고요.

참고 자료

관련 글