안녕하세요. 피처링 프론트엔드 파트의 리키입니다. 올해 저희는 DataEffect라는 신규 프로덕트와 함께 새로운 디자인 시스템을 구축하였습니다.
그 과정에서 CSS툴로서 Vanilla-Extract를 새로이 도입하였고, 그 과정을 공유하고자 합니다.
CSS 툴을 바꾸고자 했던 이유
피처링의 프론트엔드는 기존에 Styled-Components를 Next js의 page router 구조의 프로젝트에서 사용해왔습니다. 하지만 새로운 프로젝트는, Next js 14버전과 app router구조를 도입 하기로 논의가 진행되었습니다. 그런데 문제가 생겼습니다.
App Router에서는 Styled-Component(CSS-in-JS)를 원활히 사용하기 어렵다.
정확히는 React Server Component(이하 RSC)에서 사용할 수가 없습니다. App Router 구조의 Next js 프로젝트에서는 React 18의 RSC를 제공합니다.
RSC의 코드들은 서버에서만 실행되고, 클라이언트에서는 동작하지 않습니다. 클라이언트에서 리렌더링이 발생하지 않기 때문에 기존에 클라이언트에서 실행되던 hooks도 모두 사용할 수 없습니다.
한편 Styled-Component는 런타임에 스타일을 생성하여 개발환경에서는 style 태그로, 배포된 환경에서는 CSSOM 트리로 주입합니다.또 테마 주입에는 Context API도 사용하고 있습니다.
RSC는 클라이언트에서 동작하는 것들을 사용할 수 없다보니 이러한 방식과 잘 맞지 않습니다.
이를 해결하기 위해서는 런타임 이전에 스타일을 생성해야하고, Next js의 공식문서에서도 CSS Modules와 Tailwind CSS를 추천하고 있습니다.
Vanilla-Extract를 사용해보기로 했다.
하지만 기존에 CSS-in-JS의 방식에 익숙해져 있었다보니, 갑자기 tailwind CSS나 CSS Modules로 넘어가는 것에는 거부감이 있었습니다. type-safe하면서도 zero-runtime을 지원하고, design-token까지 보다 편하게 세팅하고 사용할 수 있었으면 좋겠다고 생각했습니다.
Vanilla-Extract는 자바스크립트를 사용하지만, 런타임이 아닌 빌드타임에 CSS를 미리 생성하여 link태그의 형태로 브라우저에 주입합니다. 타입스크립트를 기반으로 하기 때문에 type-safe하게 CSS를 작성할 수 있습니다. 또 테마를 세팅하는 여러가지 방식을 제공함은 물론, sprinkles 모듈을 통해 tailwind와 같이 utility class와 같은 형태로 사용할 수 있는 편의도 제공합니다. Vanilla-Extract의 장단을 정리해보면 다음과 같습니다.
Pros
-
상대적으로 번들 사이즈가 작다.
-
RSC와 함께 사용할 수 있다.
-
type-safe하게 style을 작성할 수 있다.
-
초기 렌더링이나, 인터랙션이 많은 UI에서 성능 상 이점이 있다.
-
spinkles / recipe 등의 추가 모듈을 함께 제공한다.
Cons
-
빌드 타임에 CSS 변환이 필요하기 때문에 별도의 플러그인 설정이 필요하다.
단, Vanilla-Extract는 Rollup / Vite / Webpack 등 각 번들러를 위한 플러그인을 추가로 제공하고 있습니다.
-
동적인 스타일링에 제약이 있다.
Styled-Component나 Emotion과 같이 props를 통해 동적으로 스타일을 자유롭게 수정하는 것에는 다소 제약이 있는 편입니다.
하지만 함께 제공하고 있는 recipe 모듈을 사용하면 미리 동적인 스타일링의 옵션이 정해져 경우를 커버할 수 있습니다.동적인 스타일링 대상이 정해져있지 않은 값을 지원하는 경우에는 dynamic 모듈을 사용하면 런타임에 동적으로 CSS Variable을 생성할 수 있습니다. 이 경우에도 성능 저하도 거의 없고 번들사이즈도 매우 작습니다.
사실 결과적으로 저희는 DataEffect 프로젝트에 Page Router 구조를 선택했습니다. (여러가지 이유로..) 하지만 그럼에도 Vanilla-Extract는 도입하기로 했습니다.
앞으로 있을 프로젝트에서 App Router를 선택할 가능성이 충분히 높았고, 그 외에도 Vanilla-Extract가 제공하는 이점들이 많았기 때문입니다.
또 디자인 팀과 함께 디자인 시스템을 패키지화 하는 것을 염두에 두고 있었기 때문에 Theming에 대한 편의를 제공하고 있는 부분도 고려하여 결정을 내렸습니다.
Design Token
피처링 프로덕트 팀은 DataEffect 기획을 앞두고 디자인 파트와 FE 파트가 함께 디자인시스템을 구축하기로 했습니다. 그리고 이번 디자인 시스템은 디자인 토큰에서부터 모든 것을 정의하고 약속하여, 업무 효율을 올려보기로 했습니다.
디자인 토큰: 디자인 시스템 내 가장 작은 단위로, 사용할 값들이 정의된 상태.
제가 이해한 디자인 토큰은 쉽게 말하면 프로젝트 내에 미리 정의된 CSS Variable의 모음과 같은 것이었습니다. 얼마나 그 용도와 의미를 세세하게 정의하냐에 따라 Global Token → Sementic Token → Component Token의 순서로 구체화되고(spectrum), 각 토큰은 디자인 시스템내의 각종 컴포넌트들과 실제 서비스를 구현하는 프로젝트에서도 디자이너와 개발자 간의 약속된 값으로서 역할을 합니다.
토큰 네이밍과 객체화
피처링의 디자인 토큰의 네이밍은 케밥케이스로 작성되며 spectrum(e.g. global, sementic)과 category(e.g. colors, spacing)와 같이 해당 토큰을 설명하는 값들이 모여 이름을 구성하게 됩니다. 그중에서도, category의 하위의 이름을 간추린 이름으로 사용합니다.
Global, Sementic, Component의 스펙트럼으로 디자인 파트에서 정의한 토큰들을 실제로 세팅하기에 앞서, 이 정보들을 JS 객체 형태로 저장해두기로 했습니다. spectrum과 category에 해당하는 영역을 폴더로 구분하고, 그 하위의 계층은 객체의 형태로 구분했습니다. 디자인 파트에서 전달된 토큰값이 저장되는 과정은 간단히 말해 아래와 같습니다.

GlobalTheme
Vanilla-Extract는 토큰을 세팅하는 것, 즉 Theming하는 기능을 제공하고 있습니다. 그 중에서도 프로젝트 전반에 걸쳐 사용할 토큰들을 세팅할 때 사용하는 것이 createGlobalTheme입니다.
// theme.css.ts
import { createGlobalTheme, createGlobalThemeContract } from '@vanilla-extract/css';
import { global, semantic, getVarName } from '@featuring-corp/design-tokens';
export const vars = createGlobalThemeContract({ global, semantic }, getVarName);
createGlobalTheme(':root', vars, { global, semantic });
createGlobalThemeContract
글로벌 theme을 위한 theme contract를 만드는 역할을 합니다. theme contract는 createGlobalTheme을 사용하는 것 만으로도 생성이 되지만, 같은 타입구조의 다른 테마를 선언할 때 스위칭이 되는 것이 아니라 두 가지 테마의 변수들이 모두 선언되는 문제가 있습니다. createGlobalThemeContract는 css를 실제로 생성하지 않기 때문에 테마 스위칭 시의 변수 선언 문제를 해결할 수 있습니다. 또한 createGloabalTheme의 정적타입을 체크하는 역할을 하며, 이 함수가 반환하는 vars는 선언한 테마의 CSS Variable을 참조하는 데에도 사용할 수 있습니다.
// style 작성 시
export const box = style({
backgroundColor: vars.global.colors.primary[60],
})
// 컴파일 후
.style_box__17sngl78h {
background-color: var(--global-colors-primary-60);
}
createGlobalTheme
앞서 만든 theme contract를 기반으로 CSS를 실제로 생성합니다. 하나의 theme contract를 두고 여러번 createGlobalTheme을 실행할 수도 있습니다.

Sprinkles
sprinkles는 Vanilla-Extract가 제공하는 모듈로서 design token과 같이 약속된 값을 보다 축약된 형태로 간단하게 사용할 수 있게 도와줍니다. 특정 속성에서 사용할 값들을 미리 지정하거나, 속성의 명을 더 축약된 형태로 작성하거나, 또는 의사 클래스(pseudo-class)나 미디어 쿼리(media-query)를 미리 조건으로서 정의해둘 수도 있습니다. sprinkles를 잘 세팅한다면 tailwind CSS와 같은 형태로 styling을 하는 것도 일부 가능합니다. 따라서 sprinkles 세팅까지 마쳐야 vanilla extract에서 design token을 제대로 사용할 준비가 되었다고 할 수 있습니다.
defineProperties
이름 그대로 sprinkles에 들어갈 모든 프로퍼티를 정의합니다. 해당 함수는 conditions, properties, shorthands 필드의 값을 받고, 각각 아래와 같은 역할을 합니다.
-
conditions: 예약된 조건 값을 입력합니다. mobile, tablet, desktop과 같이 일정 breakPoint를 기준으로 적용할 미디어쿼리를 작성할 수도 있고, 저희는 conditions에 의사클래스를 입력해둠으로써 hover, active 등과 같은 상태들에 대응할 수 있도록 했습니다.
conditions: {
default: {},
hover: { selector: '&:hover' },
active: { selector: '&:active' },
{...}
},
defaultCondition: 'default',
-
shorthands: 축약어를 입력합니다. 특정 CSS 속성명을 축약할 수도 있고, 새로운 속성명에 여러가지 속성을 할당해서 한번에 여러 값이 입력되도록 할 수도 있습니다.
shorthands: {
margin: ['marginTop', 'marginBottom', 'marginLeft', 'marginRight'],
marginX: ['marginLeft', 'marginRight'],
marginY: ['marginTop', 'marginBottom'],
bgColor: ['backgroundColor'],
{...}
}
-
properties: 속성별로 사용할 값들을 리스트업 합니다. 앞에서 생성했던 theme contract를 다시 이용해서 속성별 값을 예약해주었습니다.
properties: {
// X - properties의 정의된 타입에 맞지 않음.
backgroundColor: {...vars.global.colors, ...vars.sementic.colors},
하지만 문제가 있었는데 properties는 1차원 객체만 값으로 할당할 수 있었고, 1차원 객체로 할당할 경우 각 토큰을 구분하기가 어려워지는 문제가 있었습니다. (저희가 세팅한 디자인 토큰의 객체는 마지막 key가 비슷한 숫자인 경우가 많았습니다.) 2-3개의 키를 카멜케이스로 연결한 값을 사용하기로 약속한 저희 디자인 시스템 정책과는 맞지 않았습니다. 그래서 이 과정에 값을 정제하는 함수와 커스텀 유틸리티 타입을 추가로 포함하였습니다.
// colors.ts
const primary = {
10: '#ecefff',
20: '#dce2ff',
{...}
} as const
export const colors = {
primary,
white: '#ffffff',
{...}
}
export type Colors = FlattenObjectKeys<typeof colors>; // 타입을 정제해서 export
// 원래 colors의 타입
{
white: "#ffffff";
primary: {
readonly 10: "#ecefff";
readonly 20: "#dce2ff';
{...}
}
{...}
}
// 커스텀 유틸리티 타입을 통해 수정된 타입
{
white: string,
"primary-10": string,
"primary-20": string,
{...}
}
// sprinkles.ts
const colors = {
...transformObject<SemanticColor>(vars.semantic.color),
...transformObject<Colors>(vars.global.colors),
};
properties: {
// O - properties의 정의된 타입에 맞으며, 원활한 타입추론도 가능.
backgroundColor: colors,
createSprinkles
defineProperties를 통해 생성한 SprinklesProperties 타입의 객체들을 받아 실제 사용할 sprinkles 함수를 반환합니다. 저희는 하나의 프로퍼티 객체만 사용했지만 여러개를 포함해 생성할 수도 있습니다.
export const sprinkles = createSprinkles(baseProperties);
이렇게 생성한 sprinkles는 런타임과 컴파일타임을 구분하지 않고 사용이 가능합니다. css.ts에서 사용할 경우, 컴파일 타임에 빌드되며, 그 외의 경우에는 런타임에 빌드됩니다. 또 앞서 설정한 축약어와 토큰들로 보다 간편한 스타일링을 가능하게하고, 완벽한 타입추론까지 제공합니다.
// component.css.ts
const container = sprinkles({
bgColor: { // 축약어 사용 가능
default: 'background-1', // 예약된 conditions
hover: 'background-3'
}
})


component 토큰 세팅하기
global 토큰이나 sementic 토큰의 경우 프로젝트 전체에서 사용되는 토큰으로, createGlobalTheme을 사용했다면 , 컴포넌트 토큰은 해당 컴포넌트에서만 사용되는 값이기 때문에 보다 지역적으로 테마를 적용할 수 있는 createThemeContract와 createTheme을 사용했습니다.
createGlobalTheme과 대부분 비슷하지만 createTheme은 클래스를 반환하여, 해당 클래스를 가진 곳에서만 테마가 적용된다는 차이가 있습니다.
// button.css.ts
export const themeVars = createThemeContract(themeObj);
export const buttonThemeClass = createTheme(themeVars, themeObj);
export const buttonContainer = style([
buttonThemeClass,
{...}
])
1부에서는 Vanilla-Extract를 도입한 이유와 DesignToken 세팅 과정을 공유해봤습니다. 이어질 2부에서는 Vanilla-Extract를 실제로 사용해서 스타일링하는 방식과 관련 api들에 대해서 얘기해보겠습니다.
참고