전체 검색

글, 태그와 카테고리를 검색하고 바로 이동해요

재빌드 없이 고객사 브랜딩 — 토큰 3계층 디자인시스템

고객사가 브랜딩을 바꿔도 빌드는 없어요. 칩 → 시멘틱 → 통합 클래스로 수렴하는 3계층 토큰 체계와, 고객사 추가가 곧 패키지 추가인 소유권 구조 이야기입니다.

2026. 07. 14. 18:00:296분 읽기프로젝트/데이터 플랫폼
#디자인 토큰#디자인시스템#Tailwind
조회 36
색 카드를 갈아끼우자 브랜드가 바뀌는 화면 일러스트

고객사가 브랜딩을 바꾸고 싶다고 해요. 예전 같으면 색상 파일 고치고, 빌드 돌리고, 배포하고… 지금은? 데이터만 바꿉니다. 빌드는 없어요.

연재 3편은 디자인시스템이에요. B2B 멀티테넌트 제품이라 요구사항이 하나 더 붙거든요 — 고객사마다 브랜딩이 다르고, 브랜딩이 바뀔 때 재빌드가 없어야 한다. 이 요구가 토큰 체계 전체의 모양을 결정했습니다.

토큰 3계층 — palette/semantic 이중 입력이 통합 클래스 하나로 수렴하는 구조
palette와 semantic, 두 입력이 통합 클래스 하나로 수렴합니다.

층을 왜 셋이나 쌓았냐면

디자인 토큰은 보통 두 층으로 끝내요. 원색 팔레트 하나, 의미를 붙인 시멘틱 하나. 저희도 그렇게 시작했습니다.

그런데 고객사가 늘면서 두 층으로는 안 되는 순간이 왔어요. 정리하면 이렇습니다.

층 구성되는 것깨지는 지점
1층 (원색만)빠르게 시작 가능색을 바꾸면 모든 컴포넌트를 다 고쳐야 함
2층 (원색 + 시멘틱)역할 단위로 색을 바꿀 수 있음컴포넌트가 시멘틱 이름을 직접 알아서, 고객사별 재바인딩이 안 됨
3층 (+ 통합 유틸)컴포넌트는 통합 이름만 앎이름이 하나 더 늘어남

두 번째 줄이 저희가 부딪힌 벽이에요. 컴포넌트가 시멘틱 변수를 직접 참조하면, 고객사가 그 변수의 의미 자체를 다르게 쓰고 싶을 때 방법이 없습니다. 중간에 한 겹이 더 필요했어요.

그래서 컴포넌트는 맨 위 이름만 압니다. 그 이름이 실제로 어느 색에 닿는지는 아래 두 층이 정해요. 이름이 하나 늘어난 대가로, 고객사마다 다른 연결을 만들 자리가 생겼습니다.

3계층 — 칩, 시멘틱, 통합

토큰은 세 층으로 쌓여요. 맨 아래는 칩 팔레트 — 고정된 원색 집합. 그 위에 시멘틱 토큰(CSS 변수)이 "이 역할에는 이 색"이라는 의미를 붙이고, 맨 위의 통합(unified) 클래스 — bg-ui-*, text-content-*, border-line-* — 가 컴포넌트가 아는 유일한 어휘예요.

Tailwind 4의 @theme 디렉티브로 토큰을 CSS-first로 정의하고, 설정 파일의 색상 유틸은 전부 var(--...) 참조로만 매핑돼요(참조 300여 곳). 컴포넌트에 raw hex를 쓰는 건 규칙으로 금지입니다.

(raw hex를 쓰면 리뷰어가 아니라 규칙이 막아요.)

색 하나가 컴포넌트에 도달하는 경로를 코드로 보면 이렇습니다.

/* 1층 — 고정된 원색 팔레트. 여기는 절대 안 바뀝니다 */
@theme {
  --chip-blue-500: #2f6bff;
  --chip-gray-100: #f4f5f7;
}

/* 2층 — 역할에 의미를 붙입니다 */
:root {
  --semantic-primary: var(--chip-blue-500);
  --semantic-surface: var(--chip-gray-100);
}

/* 3층 — 컴포넌트가 아는 유일한 이름 */
:root {
  --ui-accent:  var(--semantic-primary);
  --ui-surface: var(--semantic-surface);
}
// 컴포넌트가 쓰는 건 3층 이름뿐입니다
<button className="bg-ui-accent text-content-inverse" />

// 금지 — raw hex
<button style={{ background: "#2f6bff" }} />
// 리뷰어가 아니라 검사가 막습니다

규칙은 딱 하나예요. 위층은 아래층만 참조하고, 건너뛰지 않는다. 컴포넌트가 1층 칩을 직접 쓰는 순간 이 구조 전체가 무의미해집니다.

이중 입력, 단일 출력

테마 입력은 두 모드예요. palette 모드는 사전 정의 테마 중 하나를 data-theme 속성으로 스위칭하고, semantic 모드는 고객사가 role 키에 색값을 직접 주입하면 런타임이 :root 인라인 스타일로 시멘틱 변수를 오버라이드합니다. 핵심은 두 모드가 최종적으로 같은 통합 클래스로 수렴한다는 점이에요. 셀렉터 하나로 표현하면 이렇게 됩니다.

/* semantic 모드일 때만 통합 변수를 고객사 시멘틱 변수로 재바인딩 */:root[data-theme-enabled][data-theme-mode='semantic'] {
  --ui-surface: var(--color-semantic-surface);
  --content-primary: var(--color-semantic-content);
  /* ... */
}

컴포넌트는 자기가 palette 테마 아래 있는지 고객사 커스텀 테마 아래 있는지 몰라요. 모드 분기가 컴포넌트 코드에 존재하지 않는 것 — 이게 이 설계의 전부입니다.

두 모드가 왜 둘 다 필요했나

처음엔 한 모드로 충분하다고 생각했어요. 고객사가 색을 직접 주면 되니까요. 그런데 실제 요구가 두 종류였습니다.

모드누가 쓰나입력바뀌는 시점
palette대부분의 고객사사전 정의 테마 중 선택설정 한 번
semantic브랜딩 가이드가 확고한 고객사역할별 색을 직접 주입언제든 · 실시간

앞쪽이 압도적으로 많아요. 그런데 뒤쪽 고객사가 매출 비중이 크죠. 흔한 이야기입니다.

여기서 흔한 실수가 컴포넌트에 모드 분기를 넣는 거예요. "커스텀 테마면 이쪽, 아니면 저쪽." 그러면 모든 컴포넌트가 테마 시스템을 알아야 하고, 모드가 셋이 되는 날 전부 다시 고쳐야 합니다.

그래서 분기를 CSS 한 곳으로 몰았어요. semantic 모드일 때만 3층 변수를 고객사 시멘틱 변수로 다시 묶습니다. 컴포넌트는 자기가 어느 모드 아래 있는지 끝까지 모르고요.

고객사 테마 = 코드 소유권

고객사별 색 정의는 중앙 테마 파일의 분기가 아니라 고객사 워크스페이스 패키지가 소유해요. 각 고객사 패키지는 자기 시멘틱 토큰 상수(role→색 맵, 루트/사이드바/메인 3개 스코프)를 갖고, 지난 글에서 다룬 contribution 모델로 런타임에 기여합니다. 중앙 어디에도 if (client === '...')가 없어요. 고객사 추가는 패키지 추가입니다.

여기에 런타임 Theme Designer가 얹혀요. 시멘틱 토큰을 실시간으로 :root에 오버라이드하는 플러그인이라, 고객사 담당자가 브랜딩을 조정하고 결과를 바로 확인할 수 있어요. 빌드 파이프라인은 관여하지 않습니다.

중앙에 분기가 없다는 걸 어떻게 지키나

"중앙 테마 파일에 고객사 분기가 한 줄도 없다"는 말은 선언만으로는 안 지켜져요. 급한 날이 오면 if 하나가 들어갑니다.

// 고객사 패키지가 자기 토큰을 소유합니다
// packages/client-x/theme/tokens.ts
export const clientXTokens = {
  root:    { primary: "#0d47a1", surface: "#ffffff" },
  sidebar: { primary: "#08306b", surface: "#0d1b2a" },
  main:    { primary: "#0d47a1", surface: "#f7f9fc" },
};

// 그리고 기여 모델로 런타임에 올립니다
export const themeEntry = createThemeEntry({
  source: { layer: "client", id: "client-x" },
  tokens: clientXTokens,
});
// 중앙에는 이런 코드가 존재할 수 없습니다
if (client === "client-x") {
  return clientXTheme;      // 검사가 막습니다
}

// 대신 중앙은 이것만 합니다
const theme = themeRegistry.resolve(activeClientId);

스코프를 셋으로 나눈 것도 이유가 있어요. 사이드바만 어둡게 쓰는 고객사가 많았거든요. 스코프가 하나면 그런 요구가 들어올 때마다 예외가 생깁니다.

여기에 런타임 테마 디자이너가 얹혀요. 고객사 담당자가 색을 조정하면 바로 화면에 반영됩니다. 빌드 파이프라인은 아무것도 안 해요. 이 글의 제목이 "재빌드 없는"인 이유입니다.

타이포그래피도 같은 원리

색만이 아니에요. 디자인 툴의 text style을 1:1로 CSS 변수화하고(스타일별 size/line-height/weight/letter-spacing 4개 변수), typo-content-* 유틸 클래스로만 소비해요. 버튼 사이즈 맵에는 "디자인 가이드 정합(날짜)" 주석을 남겨서 디자인-코드 싱크 이력을 추적하고요. 디자이너와의 계약을 코드에 박아두는 겁니다.

경계 지키기

UI 패키지는 package.json exports 맵으로 컴포넌트 단위 public entry(40여 개)만 노출하고 deep import를 막아요. ThemeProvider는 토큰 문자열을 런타임 검증해서 미등록 토큰이면 throw합니다 — 조용히 기본값으로 넘어가지 않아요(2편의 "자동 fallback 금지"와 같은 원칙이에요).

조용히 넘어가지 않게 하기

토큰 체계에서 제일 위험한 건 오타예요. 없는 토큰 이름을 쓰면 CSS는 아무 말 없이 기본값으로 넘어가거든요. 화면이 조금 이상해지는데 아무도 원인을 못 찾습니다.

// 등록되지 않은 토큰이면 즉시 실패
function resolveToken(name) {
  const value = registry.get(name);
  if (value === undefined) {
    throw new Error(
      `등록되지 않은 토큰: ${name}\n` +
      `사용 가능한 이름은 3층 통합 토큰뿐입니다.`
    );
  }
  return value;
}

// 기본값으로 조용히 넘어가지 않습니다.
// 조용한 실패는 못 고치는 실패예요.

그리고 UI 패키지는 깊은 경로 import를 막았어요. 공개 입구만 열어 두면, 내부 구조를 바꿀 때 바깥이 안 깨집니다. 반대로 말하면 — 열어 둔 만큼만 바꿀 수 없게 되는 거고요.

치른 값

치른 것왜 받아들였나
이름이 한 겹 늘어남새로 온 사람이 "이 색은 어디서 오나"를 두 번 따라가야 함 — 대신 고객사별 재바인딩이 가능해짐
CSS 변수 참조 수백 곳치환 작업이 지루함 — 한 번 하면 이후 색 변경은 데이터 변경
모드가 둘테스트할 조합이 두 배 — 컴포넌트에는 분기가 없어서 조합 폭발은 없음
raw hex 금지의 불편함급할 때 색 하나 박고 싶어짐 — 그 한 번이 구조를 무너뜨려서 예외를 안 뒀음

결국 디자인시스템도 아키텍처예요. 색상 하나가 컴포넌트에 도달하는 경로를 하나로 강제하면, 브랜딩 변경은 데이터 변경이 되고, 재빌드는 필요 없어집니다.

(중앙 테마 파일에 고객사 분기가 없다는 말, 진짜예요. 한 줄도요.)

시리즈 이어읽기

재빌드 없는 플랫폼

2 / 5

이야기 나누기

반응을 남기고 새 글 소식을 받아보세요.

댓글0
서로를 존중하는 대화를 남겨주세요.

첫 댓글을 남겨 대화를 시작해보세요.

새 글 구독
새 글이 공개되면 이메일로 알려드려요.