Flutter 앱을 개발하다 보면 항상 부딪히는 문제가 있다. 디자이너는 Figma나 웹 기반 도구로 작업하는데, 개발자는 Dart 코드에 색상을 하드코딩한다. Color(0xFF10B981) 같은 값이 app_colors.dart에만 있고, 웹 쪽 CSS에는 #10B981로 따로 있다. 두 곳을 따로 관리하다 보면 어느 순간 서로 달라져 있다.
이번에 Svelte+Storybook 기반 웹 디자인 키트와 Flutter 앱의 토큰을 하나의 기준으로 맞추는 작업을 했다. 삽질한 내용 위주로 정리한다.
문제: 두 곳에 사는 디자인 토큰
기존 상태는 이랬다.
웹 (CSS)
:root {
--color-primary: #0000FF; /* 기본값 그대로 */
--radius: 0px;
}
Flutter (Dart)
class AppColors {
static const primary = Color(0xFF10B981); /* 실제 색상 */
}
CSS 쪽은 스타터 키트 기본값이 그대로 남아 있고, Flutter는 실제 브랜드 색상이 들어가 있다. Storybook으로 컴포넌트를 띄우면 파란색 버튼이 나오는데 앱에서는 초록색이다.
해결 방향: CSS가 Single Source of Truth
토큰 체계를 잡는 방향을 이렇게 정했다.
- CSS 변수 → 웹 Storybook의 기준
- Dart 상수 → Flutter의 기준
- 두 곳을 수동으로 동기화 (자동화는 나중에)
자동화(Style Dictionary 같은 툴)를 쓰면 이상적이지만, 프로젝트 규모가 크지 않으면 오버엔지니어링이다. 지금은 토큰 파일을 잘 정리해두고 한 곳에서 같이 관리하는 걸로 충분하다.
tokens.css 정리
기존 파일에서 브랜드에 맞게 전면 교체했다.
:root {
/* Primary */
--color-primary: #10B981;
--color-primary-dark: #059669;
--color-primary-light: rgba(16, 185, 129, 0.08);
/* Border Radius */
--radius: 12px; /* 기본값 */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-2xl: 24px;
--radius-pill: 9999px;
/* Shadow (Showcase 앱 참조, 미니멀) */
--shadow-1: 0 1px 3px rgba(0, 0, 0, 0.06);
--shadow-2: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-3: 0 4px 20px rgba(0, 0, 0, 0.10);
--shadow-4: 0 8px 40px rgba(0, 0, 0, 0.14);
/* Motion */
--duration-fast: 150ms;
--duration-normal: 300ms;
--ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
}
포인트는 --radius: 0px 기본값을 12px로 바꾼 것이다. 스타터 키트가 라디우스를 0으로 두고 있었는데, 모바일 앱 느낌을 내려면 기본 12px가 맞다.
Flutter 토큰 파일 분리
기존에는 app_colors.dart 하나에 모든 게 들어가 있었다. 이번에 역할별로 파일을 나눴다.
app_radius.dart (신규)
class AppRadius {
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
static const double xl = 20;
static const double xxl = 24;
static const double pill = 9999;
static final BorderRadius borderMd = BorderRadius.circular(md);
static final BorderRadius borderXl = BorderRadius.circular(xl);
static final BorderRadius borderXxl = BorderRadius.circular(xxl);
// ...
}
BorderRadius.circular(20) 같은 매직 넘버를 코드 곳곳에 쓰는 대신, AppRadius.borderXl 하나로 통일된다. 나중에 라디우스를 바꾸고 싶으면 한 곳만 수정하면 된다.
app_shadows.dart (신규)
class AppShadows {
static const List<BoxShadow> xs = [
BoxShadow(
offset: Offset(0, 1),
blurRadius: 3,
color: Color.fromRGBO(0, 0, 0, 0.06),
),
];
static const List<BoxShadow> md = [
BoxShadow(
offset: Offset(0, 4),
blurRadius: 20,
color: Color.fromRGBO(0, 0, 0, 0.10),
),
];
// ...
}
BoxDecoration(boxShadow: AppShadows.sm) 형태로 쓴다. CSS의 --shadow-2와 숫자를 맞춰뒀다.
app_colors.dart 확장
카테고리별 색상을 추가했다. 기능 카테고리마다 색상이 다른 앱이라 필요했다.
// 카테고리별 색상
static const catParking = Color(0xFF3B82F6); // 파랑
static const catNoise = Color(0xFF8B5CF6); // 보라
static const catTrash = Color(0xFFF59E0B); // 노랑
static const catRoad = Color(0xFFEF4444); // 빨강
static const catStreetlight = Color(0xFF06B6D4); // 하늘
static const catOther = Color(0xFF6B7280); // 회색
app_theme.dart 매직 넘버 제거
기존에 하드코딩된 숫자를 전부 토큰으로 교체했다.
Before
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
After
shape: RoundedRectangleBorder(
borderRadius: AppRadius.borderXxl,
),
버튼은 AppRadius.borderXl(20), 카드는 AppRadius.borderXxl(24), 인풋은 AppRadius.borderMd(12). Tailwind에서 rounded-xl 쓰듯이 Flutter에서도 같은 감각으로 쓸 수 있게 됐다.
Storybook tailwind.config.js 확장
CSS 변수를 Tailwind 클래스로 쓰려면 config에 바인딩이 필요하다.
module.exports = {
theme: {
extend: {
colors: {
primary: {
DEFAULT: 'var(--color-primary)',
dark: 'var(--color-primary-dark)',
light: 'var(--color-primary-light)',
},
cat: {
parking: 'var(--color-cat-parking)',
noise: 'var(--color-cat-noise)',
trash: 'var(--color-cat-trash)',
road: 'var(--color-cat-road)',
streetlight: 'var(--color-cat-streetlight)',
other: 'var(--color-cat-other)',
},
},
borderRadius: {
md: 'var(--radius-md)',
xl: 'var(--radius-xl)',
pill: 'var(--radius-pill)',
},
},
},
};
이렇게 하면 Svelte 컴포넌트에서 class="bg-primary rounded-xl" 형태로 쓸 수 있고, 색상을 바꾸고 싶을 때 tokens.css의 변수 하나만 수정하면 Storybook 전체에 반영된다.
삽질: ReportCategoryCard overflow
시뮬레이터에 올리자마자 노란 줄무늬 경고가 떴다.
A RenderFlex overflowed by 3.6 pixels on the bottom.
Column Column:file:///…/report_category_card.dart:38
constraints: BoxConstraints(w=75.7, h=70.4)
카드 높이 70.4px에 내용이 3.6px 넘친 것이다. 구조를 보면 이유가 명확하다.
padding(16) + 아이콘(44) + spacing(10) + 텍스트(약 20) + padding(16) = 106px
컨테이너 높이가 70px인데 내용이 106px. 당연히 넘친다.
// Before
padding: const EdgeInsets.all(16),
const SizedBox(height: 10),
// After
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
const SizedBox(height: 6),
padding을 줄이고 간격을 조금 좁혔더니 해결됐다. 카드 크기를 키우는 방법도 있지만, 그리드 레이아웃 전체를 건드려야 해서 이 쪽이 더 빠르다.
디렉토리 구조
최종적으로 정리된 구조다.
project/
├── design_guide/ # 디자인 가이드 문서 (6개)
│ ├── 00-overview.md
│ ├── 01-reference-analysis.md
│ ├── 02-design-tokens.md
│ ├── 03-component-plan.md
│ ├── 04-page-plan.md
│ └── 05-implementation-roadmap.md
│
├── design_kit/ # Svelte + Storybook
│ ├── styles/tokens.css # 브랜드 토큰 (Single Source of Truth)
│ ├── tailwind.config.js # 토큰 바인딩
│ └── components/ # 53개 컴포넌트
│
└── mobile/lib/theme/ # Flutter 토큰 동기화
├── app_colors.dart
├── app_radius.dart # 신규
├── app_shadows.dart # 신규
└── app_theme.dart
정리
디자인 토큰 동기화의 핵심은 구조화다. 색상 하나만 있어도 primary, primary-dark, primary-light로 나눠두면 나중에 쓰는 사람이 의도를 이해할 수 있다. 매직 넘버 BorderRadius.circular(20) 하나를 없애는 게 사소해 보여도, 프로젝트 전체에 통일성을 만든다.
Flutter와 Web 토큰을 완전히 자동화하려면 Style Dictionary 같은 툴이 필요하다. 하지만 소규모 프로젝트에서는 파일 두 개(tokens.css, app_*.dart)를 잘 관리하는 것만으로도 충분하다. 오버엔지니어링보다 지금 작동하는 단순한 구조가 낫다.
💬 댓글
비밀번호를 기억해두면 나중에 내 댓글을 삭제할 수 있어요.