Vue 2(EOL) 서비스를 Nuxt 4 + Tailwind CSS v4로 이관하면서 스타일링 구조를 다시 설계했습니다.
기존에도 디자인 시스템과 토큰이 있었지만, 디자인과 개발이 이를 하나의 기준으로 적극적으로 사용하고 있지는 않았습니다.
이번 이관에서는 Figma를 디자인 토큰의 단일 원천(SSOT)으로 두고, 디자이너가 변경한 토큰이 코드까지 자연스럽게 이어지는 구조를 만들어보기로 했습니다.
그 과정에서 Figma Plugin을 직접 만들었고, 최종적으로는
Figma → DTCG → CSS → Tailwind
로 이어지는 토큰 파이프라인과
Figma 수정 → Plugin 실행 → PR → 리뷰 → Merge
로 이어지는 협업 흐름을 구축했습니다.
1. 먼저, 무엇을 해결하려고 했는가
기존 디자인 시스템은 존재했지만 디자인과 개발이 동일한 토큰을 기준으로 작업하는 구조는 아니었습니다. 디자인에서는 Figma의 값을 보고, 개발에서는 별도의 CSS나 코드 규칙을 참고하는 식이었습니다.
그래서 이번 이관을 계기로 스타일링 방식을 변경하기로 했습니다. 변경 기준은 다음 두 가지를 중점적으로 생각했습니다.
- 디자인과 개발이 같은 이름을 사용할 것
- 토큰 변경이 수작업 없이 코드까지 전달될 것
여기에 프로젝트 규모가 커졌을 때 CSS가 계속 커지는 문제도 함께 해결하고 싶었습니다.
2. Tailwind CSS v4를 선택한 이유
이번 프로젝트에서는 Tailwind CSS v4를 사용했습니다.
가장 큰 이유는 디자인 토큰을 코드의 이름으로 그대로 가져올 수 있다는 점이었습니다.
예를 들어 Figma에서 다음과 같은 Semantic Token을 정의했다면,
fill/brand/primary
코드에서도 같은 이름을 기반으로
<div class="bg-fill-brand-primary">
처럼 사용할 수 있습니다.
결국 구조는 다음과 같습니다.
Figma Token
↓
CSS Variable (@theme)
↓
Tailwind Utility
토큰 이름이 코드까지 이어지기 때문에 디자인과 개발 사이의 용어가 자연스럽게 맞춰집니다.
특히 이번 프로젝트에서는 마크업만 봐도 어떤 의미의 스타일인지 알 수 있는 것을 중요하게 봤습니다.
단순히 bg-red-500이라고 쓰는 것보다
bg-fill-brand-primary
처럼 의미가 드러나는 편이 디자인 시스템을 이해하거나 코드를 수정할 때도 유리하다고 판단했습니다.
또 하나는 Tailwind v4의 CSS-first 방식입니다.
기존처럼 별도의 tailwind.config.js에 설정을 쌓기보다 CSS의 @theme을 중심으로 토큰을 관리할 수 있고, 네이티브 CSS의 기능도 적극적으로 사용할 수 있습니다.
이번 프로젝트에서는 이를 활용해 SCSS를 추가하지 않고 Tailwind v4 + CSS로 스타일링 구조를 구성했습니다.
3. Figma를 코드의 시작점으로 만들기
기존 디자인에서는 이미 토큰을 다음과 같은 계층으로 관리하고 있었습니다.
Primitive
↓
Brand
↓
Semantic
이번 작업에서 이 구조를 새롭게 정의한 것은 아닙니다.
제가 한 일은 기존 토큰 구조를 코드에서도 사용할 수 있도록 변환하고, 각 계층의 역할에 맞게 CSS로 소비하는 방법을 정하는 것이었습니다.
- Primitive(Original) — 실제 색상, 단위 등의 원시값
- Brand — Primitive를 참조하는 브랜드 값
- Semantic — 실제 UI에서 사용하는 의미 기반 값
개발에서는 Semantic과 Brand를 주로 사용하고, Primitive는 값의 원천으로만 사용하도록 했습니다.
이를 코드로 가져오기 위해 다음과 같은 파이프라인을 만들었습니다.
Figma Plugin
↓
DTCG(JSON)
↓
build-tokens.mjs
↓
CSS (@theme)
↓
Tailwind Utility
Figma Plugin은 Figma에 정의된 토큰을 읽어 DTCG 형식의 JSON으로 추출합니다.
이후 별도의 Node 스크립트가 JSON을 프로젝트에서 사용할 CSS로 변환합니다.
변환기에서는 토큰 간 alias를 해석하고, 단위를 처리하고, Dark/Light 모드에 맞게 CSS를 생성하는 등의 작업을 담당합니다.
4. 그래서 Figma Plugin을 만들었습니다
토큰을 코드로 가져오는 것까지 만들고 나니 한 가지 문제가 남았습니다. 토큰을 가져오는 과정 자체가 여전히 수작업이라는 점이었습니다.
디자이너가 Figma에서 토큰을 수정한 뒤 개발자가 직접 export하고 코드를 변경한다면, 기존 작업 방식에서 크게 벗어나지 못합니다.
그래서 Plugin을 단순한 토큰 export 도구가 아니라 변경사항을 코드의 PR까지 연결하는 도구로 만들었습니다.
최종적인 흐름은 다음과 같습니다.
Figma 수정
↓
Plugin 실행
↓
DTCG Snapshot 생성
↓
Git Branch 생성
↓
PR 생성
↓
개발 리뷰
↓
Merge
승인 시점의 Figma를 Snapshot으로 고정하기
여기서 중요했던 부분은 언제 Figma를 읽을 것인가였습니다.
Plugin 실행 이후 개발자가 PR을 리뷰하기까지 시간이 걸릴 수 있습니다.
만약 PR을 승인하는 시점에 Figma를 다시 읽는다면, 그 사이 디자인이 변경됐을 때 실제로 어떤 상태를 승인한 것인지 알 수 없습니다.
그래서 Plugin 버튼을 누른 순간의 Figma 상태를 불변 Snapshot으로 만들었습니다.
디자이너는 Figma의 변경 이력을, 개발자는 Git의 PR diff를 기준으로 변경사항을 확인할 수 있습니다.
5. Plugin에서 GitHub PR까지 연결하기
Plugin에서 직접 GitHub API를 호출해 Branch와 PR을 생성하도록 했습니다.
여기서 인증 정보를 어떻게 처리할지도 해결해야 했습니다.
Figma Plugin은 클라이언트에서 실행되는 코드이기 때문에 GitHub PAT를 코드에 넣으면 사용자에게 그대로 노출됩니다.
그래서 기존 사내 시스템에서 사용하고 있던 Vault + OIDC 인증 방식을 재사용했습니다.
Figma Plugin
↓
OIDC Login
↓
Vault Access Token
↓
github-pat 조회
↓
GitHub API
6. Plugin을 만들면서 끝나지 않았던 문제
토큰을 코드로 가져오는 구조를 만든 뒤 생성된 CSS를 살펴봤습니다.
처음 entry.css는 약 55.4KB였는데, 사실 이 정도의 크기가 서비스 성능에 큰 영향을 주는 수준은 아니었습니다.
그보다 마음에 걸렸던 건 브라우저에서 실제로 사용하지 않는 토큰까지 :root에 전부 들어가 있다는 것이었습니다.
예를 들어 프로젝트에서 사용하지 않는 Primitive 토큰도 아래처럼 전부 존재하고 있었습니다.
:root {
--color-s-blue-gray-100: ...;
--color-s-blue-gray-200: ...;
--color-s-blue-gray-300: ...;
/* ... */
}
디자인 시스템의 원본에는 수백 개의 토큰이 존재할 수 있지만, 특정 페이지에서 실제로 필요한 토큰까지 모두 브라우저에 내려줄 필요는 없습니다.
그래서 이번 작업에서는 CSS 용량 자체를 줄이는 것보다, 사용하지 않는 토큰은 애초에 생성된 CSS에 존재하지 않게 하자. 를 기준으로 구조를 다시 봤습니다.
[data-theme]는 트리셰이킹되지 않았다
처음에는 Dark/Light 테마를 다음처럼 구현했습니다.
[data-theme="dark"] {
--color-fill-surface-base: ...;
}
그런데 이런 셀렉터 기반 선언은 Tailwind가 사용 여부를 판단해 제거하는 대상이 아니었습니다.
결과적으로 실제로 사용하지 않는 테마 토큰까지 CSS에 포함됐습니다.
당시 [data-theme] 오버라이드 블록만 약 몇 백줄을 넘겼습니다.
테마별로 변경되는 값이 모두 색상이라는 점에 착안해 CSS의 light-dark()를 사용했습니다.
@theme {
--color-fill-surface-base:
light-dark(var(--color-bg-25), var(--color-bg-900));
}
테마 전환은 data-theme 셀렉터 대신 color-scheme으로 처리했습니다.
이렇게 하면 테마별 값을 별도의 CSS 블록으로 만들지 않고 하나의 토큰 안에서 처리할 수 있습니다.
결과적으로 테마 오버라이드 블록이 사라지고 크기도 55.4KB → 40.3KB로 줄었습니다.
가장 큰 병목 = Primitive
Primitive는 컴포넌트에서 직접 사용하지 않기 때문에 유틸리티를 생성하지 않도록 :root에 CSS 변수로만 출력하고 있었습니다.
그런데 이 방식에서는 Tailwind의 트리셰이킹 대상에서 빠지게 됩니다.
결과적으로 사용하지 않는 Primitive 536개가 그대로 CSS에 포함되어 있어 아예 방향을 틀기로 했습니다.
Primitive를 CSS 변수로 만들지 않고, 빌드 시점에 실제 소비처에 값을 넣어주자!
기존에는
--color-bg-100: var(--color-s-blue-gray-100);
이었다면,
--color-bg-100: #eaeaed;
처럼 Primitive의 실제 값을 인라인합니다.
다만 모든 var()를 없앤 것은 아닙니다.
Primitive → Brand → Semantic
중에서 Primitive처럼 더 이상 참조할 필요가 없는 leaf token만 리터럴 값으로 치환했습니다.
Brand와 Semantic 사이의 의미 있는 참조 관계는 그대로 유지했습니다.
그 결과:
지표변경 전변경 후
| entry.css raw | 40,312 B | 20,410 B |
| entry.css gzip | 8,094 B | 4,404 B |
| Primitive 변수 | 536개 | 0개 |
| 전체 CSS 변수 | 612개 | 107개 |
최종적으로
55,431 B
↓
20,410 B
약 63%를 줄였습니다.
기능이나 디자인을 줄인 것이 아니라, 사용하지 않는 토큰이 번들에 들어가지 않도록 구조를 변경한 결과였습니다.
인라인의 대가
Primitive를 인라인하면서 새로운 문제가 생겼습니다.
생성된 CSS만 보면 이 값이 어디에서 왔는지 알 수 없었습니다.
--color-brand-100: #ecadae;
Figma에서는 어떤 Primitive를 참조하고 있는지 알고 있지만, 생성된 CSS에서는 그 관계가 사라집니다.
그래서 빌드 과정에서 원본 토큰명을 주석으로 남겼습니다.
--color-brand-100: #ecadae; /* ← color-primary-red-dark-100 */
--padding-lg: 16px; /* ← unit-16 */
이 주석은 개발 중 생성된 CSS를 확인할 때만 의미가 있고, production minify 과정에서는 제거됩니다.
따라서 번들 크기에는 영향을 주지 않으면서 추적성을 보완할 수 있었습니다.
8. 결국 필요한 건 기술보다 규칙이었다
이 구조를 만들고 나니 기술적인 구현보다 중요한 것은 팀에서 지킬 규칙을 정하는 것이라는 생각이 들었습니다.
토큰 파이프라인은 이름과 구조가 조금만 달라져도 원하는 결과를 만들 수 없기 때문입니다.
그래서 디자인팀과 다음과 같은 규칙을 맞췄습니다.
Primitive는 컴포넌트에서 사용하지 않는다
Primitive는 값의 원천일 뿐 실제 UI에서 사용하는 토큰이 아닙니다.
컴포넌트에서는 Semantic과 Brand 토큰만 사용합니다.
테마는 색상에만 적용한다
light-dark()를 사용하기 때문에 이번 구조에서는 테마에 따라 변경되는 값을 색상으로 한정했습니다.
네이밍 규칙을 지킨다
Text Style은 일정한 규약을 따르도록 했습니다.
<Breakpoint>/<category>/<size>/<weight>
이를 기반으로
.headline-xl-bold
같은 반응형 클래스를 생성합니다.
규약에 맞지 않는 이름은 파이프라인에서 조용히 무시되기 때문에, 네이밍 자체가 곧 파이프라인의 입력값이 됩니다.
예외를 어디까지 허용할 것인가
모든 값을 토큰으로 만들 수는 없습니다.
그래서 팔레트 밖의 색상이나 4px 그리드에서 벗어난 값 등을
- 엄격하게 금지할지
- 허용하되 리뷰 대상으로 표시할지
- 자유롭게 허용할지
같은 운영 정책도 함께 정해야 했습니다.
결국 디자인 토큰을 도입한다는 것은 Figma에 토큰을 만드는 것에서 끝나지 않고, 그 토큰을 어떻게 사용할지 합의하는 것까지 포함한다고 생각합니다.
'TECH' 카테고리의 다른 글
| Storybook에서 UseState 사용하기 (1) | 2024.05.25 |
|---|---|
| Storybook에 Next local font, Pretendard 적용하기 (1) | 2024.05.11 |
| NestJS, Multer 한글 파일명 깨지는 오류 (0) | 2024.05.08 |
| Angular ngIf 처럼 동작하는 custom directive 만들기 (3) | 2024.05.01 |
| PM2, NGINX 자동 실행하기 (1) | 2024.01.14 |