(2/2) 레거시 개발 환경에서 디자인 시스템 개편하기
1부에서는 Express + EJS + SCSS라는 레거시 환경을 유지한 채로, 디자인 기준이 코드에 안전하게 전달되도록 토큰 기반 스타일 파이프라인을 구축하는 과정에 집중했습니다.
디자인 토큰은 Figma에서 정의되어 JSON을 거쳐 SCSS/CSS로 변환되었고, 컴포넌트 코드는 더 이상 픽셀이나 색상 값이 아닌 의미 있는 이름(토큰) 만을 사용하게 되었습니다.
이어서 이 토큰을 실제 UI 구성 요소에 적용하고, 지속적으로 사용할 수 있는 형태로 만든 과정을 설명드리려 합니다.
2부에서는 다음 내용을 다룹니다.
- 토큰을 기반으로 한 아이콘 시스템 설계 및 관리
- 레거시 환경에 맞춘 UI 컴포넌트 구현 방식
- 스토리북 없이도 기준을 공유할 수 있는 가이드 페이지(
/guide) 구성
2부에서 진행된 작업 파이프라인 구성도 (아이콘 · 컴포넌트)
2부에서는 1부에서 구축한 토큰 기반 스타일 파이프라인을 그대로 활용하면서, 아이콘과 UI 컴포넌트를 실제로 운영 가능한 형태로 관리하기 위한 흐름을 추가로 구성했습니다.
아이콘과 컴포넌트 각각에 대해 **“단일 소스(Source of Truth)를 정하고, 자동 생성된 결과물을 가이드 페이지에서 확인한다”**는 공통된 운영 패턴을 적용하는 것입니다.
아래 구성도는 2부에서 다룰 작업을 이 공통 패턴 기준으로 정리한 것입니다.
1) SCSS SVG 아이콘 시스템: 등록과 사용
이 프로젝트에서 아이콘은 “에셋 파일”이 아니라 UI 규칙의 일부로 취급했습니다. 그래서 파일 시스템이 아니라, SCSS 레지스트리를 단일 소스로 삼는 방식을 선택했습니다.
아이콘이 파일로만 흩어져 있으면,
- 어떤 아이콘이 “정답”인지 찾기 어렵고
- 화면마다 서로 다른 방식으로 붙게 되고(파일 경로/인라인/배경 이미지 등)
- 결국 이름과 스타일이 서서히 파편화됩니다.
그래서 2부에서는 아이콘도 토큰/컴포넌트와 동일하게 단일 소스(Source of Truth)를 정하고, 그 소스에서 파생 결과물을 자동 생성해 확인하는 루프를 만들었습니다.
구성
- 등록(단일 소스):
src/client/scss/icons/_icons.scss - API(함수/믹스인):
src/client/scss/icons/_registry.scss - 가이드 생성기:
src/client/scripts/icons-to-json.cjs - 가이드 데이터(생성물):
src/server/generated/icons.json - 가이드 페이지:
/guide/icons(src/client/views/pages/guide/icons.ejs)
아이콘은 “SCSS에 등록된 목록”이 단일 소스가 되며, 가이드 페이지는 이 단일 소스를 기준으로 자동 생성됩니다.
등록 예시
@use "./registry" as icon;
@include icon.ds-register-icon(
"sun",
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='black' d='...'/></svg>"
);여기서 “SCSS에 SVG 문자열을 넣는 방식”이 낯설 수 있는데, 이 샘플에서는 아래 목적을 위해 선택했습니다.
- 등록 목록을 한 곳에서 관리하고(단일 소스)
- SCSS에서 바로 사용할 API(
ds-icon,ds-icon-url)를 제공하고 - 가이드 페이지가 목록을 자동으로 읽어(스크립트가 SCSS를 파싱) 검색/복사를 제공하게 만들기
등록 규칙(운영을 위해 반드시 필요했던 제약)
이 구조는 “등록 형식이 일정하다”는 전제 위에 돌아갑니다. 특히 icons-to-json.cjs는 SCSS 파일을 정규식으로 파싱하기 때문에, 운영 규칙을 최소한으로 정해두는 편이 안전했습니다.
- 이름과 SVG는 “쌍따옴표” 문자열로 등록합니다.
- SVG 안에서는 홑따옴표를 사용합니다. (SVG 문자열 내부에 쌍따옴표가 들어가면 파서가 깨질 수 있어요)
- mask 방식(단색)을 기본으로 쓰기 위해, SVG path는 **단색 채우기(
fill='black')**를 전제로 둡니다.
이 제약들은 “깔끔한 구현”을 위한 것이 아니라, 운영 중에 예외가 생기지 않게 하기 위한 최소한의 안전장치였습니다.
사용 예시(단색 아이콘: mask 방식)
@use "../icons/registry" as icon;
.theme-btn::before {
content: "";
@include icon.ds-icon("sun", 14px, currentColor);
}mask 방식은 SVG를 “색이 들어간 이미지”로 쓰는 대신, SVG를 “형태(마스크)”로 사용하고 실제 색은 background-color로 채우는 방식입니다.
- 아이콘 형태:
mask-image(SVG data-uri) - 아이콘 색상:
background-color(예: currentColor)
테마와 조합했을 때 단색 아이콘을 일관되게 유지하기 쉬워, 기본 아이콘 방식으로 선택했습니다.
SCSS API(실제로 제공되는 것)
아이콘 레지스트리의 핵심 API는 src/client/scss/icons/_registry.scss에 있습니다.
icon.ds-icon-url("name"):url("data:image/svg+xml,...")형태를 반환(마스크/백그라운드 이미지 둘 다에 사용 가능)@mixin icon.ds-icon("name", 16px, currentColor): 단색 아이콘(마스크)@mixin icon.ds-icon-bg("name", 16px): 멀티컬러 아이콘(배경 이미지)
가이드 데이터는 어떻게 생성되는가(왜 JSON이 필요한가)
가이드 페이지에서 “현재 등록된 아이콘 목록”을 보여주려면, 결국 어디선가 목록을 관리해야 합니다.
이 샘플에서는 그 목록을 사람이 따로 쓰지 않고, 단일 소스(_icons.scss)에서 자동으로 뽑아 icons.json을 생성합니다.
pnpm icons:json이 스크립트(src/client/scripts/icons-to-json.cjs)는 _icons.scss에서 ds-register-icon("name", "<svg...>") 형태를 찾아,
namesvg(원문)dataUri(가이드 프리뷰를 위한 data URI)
를 JSON으로 저장합니다. 결과적으로 /guide/icons는 “사람이 수동으로 만든 문서”가 아닌, 등록된 실체를 기반으로 한 자동 생성물이 됩니다.
운영 팁(레거시에서 특히 중요한 것)
- mask 방식은 SVG가 “실루엣”으로 잘려야 해서
fill='black'같은 단색 채우기를 전제로 두는 편이 안전했습니다. - 아이콘 레지스트리는 SCSS 엔트리에 포함되어야 실제로 등록이 동작합니다.
- 예:
src/client/scss/app.scss에서@use "./icons/icons";
- 예:
- 가이드 페이지 프리뷰는 SCSS 믹스인을 “EJS에서 직접 호출”하는 방식이 아니라, 아이콘 카드 DOM에
--icon-url을 주입하고(icons.ejs), 스타일에서mask-image: var(--icon-url)로 렌더링합니다(src/client/scss/pages/_guide-icons.scss).
다음 섹션에서는 컴포넌트도 같은 방식으로(단일 소스 + 규칙 + 가이드 페이지) 운영 루프를 만들고 조립하는 방법을 정리합니다.
2) EJS + SCSS로 컴포넌트 만들기(토큰 기반)
이 환경에서 컴포넌트는 React 컴포넌트가 아니라 EJS partial이 사실상 UI 단위가 됩니다. 그래서 컴포넌트도 “모아두는 장소”와 “사용 규칙”을 먼저 만들었습니다.
- 컴포넌트 템플릿:
src/client/views/components/*.ejs - 컴포넌트 스타일:
src/client/scss/components/_*.scss(entry인src/client/scss/app.scss에서@use로 로드)
핵심은 1부와 동일합니다.
값(픽셀/색상)을 직접 쓰지 않고,
var(--ds-...)(테마/색) +ds.sem-number(...)(스케일/반응형)만으로 UI를 만든다.
2-1. EJS 컴포넌트 작성
컴포넌트는 src/client/views/components 폴더에 두고 EJS 템플릿으로 작성합니다.
예: src/client/views/components/button.ejs (요약)
<%
const variant = typeof locals.variant === 'string' ? locals.variant : '';
const size = typeof locals.size === 'string' ? locals.size : '';
const label = typeof locals.label === 'string' ? locals.label : '';
const classes = ['ds-btn', variant ? `ds-btn--${variant}` : '', size ? `ds-btn--${size}` : ''].filter(Boolean).join(' ');
%>
<% if (typeof locals.href === 'string' && locals.href) { %>
<a class="<%= classes %>" href="<%= locals.href %>"><%= label %></a>
<% } else { %>
<button class="<%= classes %>"><%= label %></button>
<% } %>이 예시에서 중요한 건 “기능이 많아서”가 아니라, /guide 같은 가이드 페이지에서는 props를 하나도 안 넘겨도 컴포넌트가 깨지지 않고 떠야 한다는 점입니다. 그래서 템플릿에서는 locals가 비어 있을 수도 있다는 전제로 값의 존재/타입을 확인하고, 안전한 기본값을 두는 식으로 방어적으로 작성했습니다다.
2-2. SCSS 컴포넌트 스타일 작성
스타일은 src/client/scss/components 폴더에 작성합니다. 값 하드코딩 대신 CSS 변수와 스케일 map을 사용하도록 구성합니다.
예: src/client/scss/components/_button.scss (요약)
@use "../abstracts/ds" as ds;
.ds-btn {
border: 1px solid var(--ds-line);
color: var(--ds-text);
background: color-mix(in oklab, var(--ds-surface-2) 82%, transparent);
@include ds.ds-props((
padding: ds.sem-number(padding, 12),
border-radius: ds.sem-number(radius, 12)
));
}여기서 ds.ds-props()는 “반응형 map을 받아서 base/md/... 값을 자동으로 나누어 출력”하는 믹스인입니다. 즉, 개발자는 숫자를 계산하지 않고 토큰 스케일 키만 선택하면 됩니다.
2-3. 페이지에서 조립
페이지에서는 include()로 컴포넌트를 조립합니다.
<%- include('../../components/button.ejs', { label: 'Save', variant: 'primary' }) %>여기까지가 “컴포넌트를 만들고 페이지에 적용”하는 과정입니다. 다음으로는 이 컴포넌트와 아이콘을 브라우저에서 빠르게 확인하기 위한 가이드 페이지 구축 방법을 설명합니다.
3) 가이드 페이지 구축(스토리북 대체): 자동 생성 + 서버 렌더
가이드 페이지의 핵심은 “사람이 목록을 관리하지 않는다”는 점입니다. 단일 소스에서 목록을 자동 추출해 JSON을 만들고, 페이지는 그 JSON을 렌더링합니다.
왜 스토리북을 쓰지 않았나
처음에는 스토리북을 도입하는 방향도 검토했습니다. 컴포넌트를 문서화하고, 팀 내에서 “정답 UI”를 공유하기에 좋은 도구이기 때문입니다.
다만 이 프로젝트에서는 개발 시간이 타이트했고, 스토리북 자체를 “도입”하는 것뿐 아니라 스토리북 방식으로 컴포넌트를 개발/유지하는 흐름을 학습할 시간이 부족했습니다.
그래서 2부에서는 스토리북의 모든 기능을 목표로 하기보다는, 운영에 필요한 최소 기능(목록/검색/프리뷰/복사/테마 확인)만 갖춘 서버 렌더 기반 가이드 페이지를 먼저 만들어 “기준을 확인하는 루프”를 확보하는 쪽을 선택했습니다.
참고: 이 샘플에서는 가이드 페이지 타이틀을 영문(
Icons Guide,Components Guide)으로 두었습니다. UI 문구는 팀 컨벤션에 맞춰 한글화/영문화해도 무방하며, 핵심은 “단일 소스 + 자동 생성 + 확인 루프”입니다.
공통 패턴(쉽게 설명)
- 단일 소스(Source of truth)를 정합니다.
- 생성기(Generator)가 소스를 읽어 “목록/메타데이터”를 JSON으로 생성합니다.
- Express 라우트가 JSON을 읽어 EJS 페이지를 렌더링합니다.
- 브라우저에서 검색/복사/프리뷰로 확인합니다.
Source of truth (SCSS/EJS)
-> Generator (Node script)
-> src/server/generated/*.json
-> Express route + EJS page라우트 연결(Express)
가이드 페이지는 일반 페이지와 동일하게 Express 라우트로 연결됩니다.
// src/server/routes/index.ts (요약)
app.get("/guide/icons", guideController.icons);
app.get("/guide/components", componentsGuideController.index);아이콘 가이드
- 단일 소스:
src/client/scss/icons/_icons.scss - 생성기:
src/client/scripts/icons-to-json.cjs - 생성물:
src/server/generated/icons.json - 페이지:
src/client/views/pages/guide/icons.ejs - URL:
/guide/icons
컴포넌트 가이드
- 단일 소스:
src/client/views/components/*.ejs - 생성기:
src/client/scripts/components-to-json.cjs - 생성물:
src/server/generated/components.json - 예시 케이스(옵션):
src/client/guide/components.cases.json - 페이지:
src/client/views/pages/guide/components.ejs - URL:
/guide/components
컴포넌트 가이드는 “기본 예시”를 자동으로 만들되, 화면 맥락이 필요한 컴포넌트는 components.cases.json으로 예시를 오버라이드할 수 있게 두었습니다. (예: props 조합이 여러 개인 컴포넌트)
- 기본 예시:
src/client/scripts/components-to-json.cjs의defaultExamplesFor() - 오버라이드:
src/client/guide/components.cases.json
이 방식은 “추가/삭제/변경”이 단일 소스에만 발생하고, 가이드 페이지는 자동으로 따라오게 만드는 데 목적이 있습니다.
(중요) 가이드 페이지의 UX는 어디에서 구현되나
이 샘플의 가이드는 스토리북 수준의 복잡한 문서화는 하지 않지만, 운영에 필요한 최소 기능은 제공합니다.
- 검색/필터: 아이콘/컴포넌트 이름 기준 필터
- 복사: 아이콘 이름 / SCSS 사용 스니펫 / EJS include 스니펫 복사
- 테마 토글:
data-theme전환(토큰 기반 테마 즉시 확인)
이 동작은 src/client/main.ts에 구현되어 있고, 빌드 후 src/public/js/client/main.js로 제공됩니다.
4) 운영 루프
2부에서 강조하고 싶은 운영 루프는 다음과 같습니다.
- 변경은 한 곳에서
- 토큰:
src/client/tokens/*→pnpm tokens:scss - 아이콘:
src/client/scss/icons/_icons.scss - 컴포넌트:
src/client/views/components/*.ejs+src/client/scss/components/*
- 토큰:
- 확인은 한 곳에서
/guide/icons/guide/components
운영 중에는 “소스를 바꾼 뒤 가이드가 따라오나”만 확인하면 됩니다.
pnpm gendev 환경에서의 주의점(한 번만 겪어도 체감되는 포인트)
이 샘플의 pnpm dev는 dev:scss에서 시작할 때 pnpm gen을 한 번 실행한 뒤 sass --watch로 들어갑니다.
즉, 개발 중에 _icons.scss나 views/components/*.ejs를 수정하면:
- SCSS 변경은
sass --watch가 즉시 반영하지만 icons.json/components.json은 자동으로 다시 생성되지 않습니다.
그래서 가이드 페이지 목록이 최신이 아니라고 느껴질 때는 아래 중 하나를 명시적으로 실행하면 됩니다.
pnpm icons:json
pnpm components:json
# 또는
pnpm gen이 운영 루프가 굴러가기 시작하면, 서버 렌더링 환경에서도 디자인 시스템을 “운영” 가능한 형태로 유지할 수 있게 됩니다.
2부 요약
2부에서는 1부에서 만든 토큰 기반 스타일 파이프라인 위에, 아이콘과 컴포넌트를 “운영 가능한 방식”으로 올리는 과정을 정리했습니다.
- 아이콘은
src/client/scss/icons/_icons.scss를 단일 소스로 두고, SCSS 레지스트리 API(ds-icon,ds-icon-url)로 사용 방식을 고정했습니다. - 컴포넌트는
src/client/views/components/*.ejs를 단일 소스로 두고, 값 하드코딩 없이var(--ds-...)와ds.sem-number(...)만으로 스타일을 구성하도록 규칙을 잡았습니다. - 스토리북과 유사한
/guide/icons,/guide/components가이드 페이지를 만들어 “등록된 실체를 자동 생성물(JSON)로 확인”하는 루프를 만들었습니다.
샘플 코드
이번 1~2부에서 다룬 내용은 샘플 코드로도 확인할 수 있습니다.