복사하지 말고 가져오세요: 디자인 토큰과 스크린샷의 import 경계 만들기

앱의 색상을 바꿨습니다. 랜딩 페이지의 색상도 바꿨습니다. 앱 스크린샷도 새로 찍었습니다. 랜딩 페이지에 있는 스크린샷도 갈아 끼웠습니다.

여기까지만 보면 동기화가 끝난 것 같습니다.

그런데 한 달 뒤 누군가 묻습니다.

“이 주황색은 앱이 기준인가요, 웹이 기준인가요?”

대답이 “아마 앱일 텐데요”라면 이미 동기화가 아닙니다. 색상 값과 이미지 파일이 우연히 같은 시점일 뿐입니다.

Flutter 앱과 Next.js 랜딩 페이지를 잇는 이 구성에 필요했던 것은 더 거대한 design system이 아니었습니다. 한쪽에서 만든 결과를 다른 쪽으로 가져오는 작고 명시적인 import 경계였습니다.

두 저장소가 같은 값을 갖는 것과 같은 출처를 갖는 것은 다릅니다

importer는 MIRAE_APP_DIR/tokens-studio.json을 입력으로 요구하고, 랜딩 페이지는 app/globals.css를 갱신 대상으로 둡니다. 스크린샷 importer는 fastlane/screenshots/ios/kofastlane/screenshots/ios/en-US를 입력으로 요구하며, 현재 웹에는 public/screens/kopublic/screens/en이 있습니다.

손으로 복사해도 결과물은 같아질 수 있습니다. 문제는 다음 변경입니다.

  • 앱의 primary 색상만 바뀌었는데 웹의 어느 CSS 변수를 고쳐야 할까요?
  • 앱 locale은 en-US인데 웹 route는 en이면 어디서 이름을 바꿀까요?
  • 웹에서만 쓰는 chart와 sidebar 색상도 앱 token으로 덮어써야 할까요?
  • 새 스크린샷을 넣었을 때 사라진 옛 파일은 누가 지울까요?

이 질문에 사람이 매번 답하면 두 프로젝트 사이의 계약이 사람 머릿속에 생깁니다. 기억력이 좋은 사람이 휴가를 가는 순간 pipeline도 함께 휴가를 갑니다.

제가 잡은 기준은 단순했습니다.

앱은 원본이고, 랜딩 페이지는 명시적인 import를 통해 필요한 부분만 투영합니다.

양방향 sync도 아니고, 두 저장소를 하나로 합치는 것도 아닙니다. 웹이 앱의 산출물을 pull하는 한 방향 경계입니다.

토큰 이름이 다르면 mapping이 계약입니다

Tokens Studio의 경로와 CSS 변수 이름은 같은 언어를 쓰지 않습니다.

앱 쪽 token은 다음처럼 역할과 theme가 경로에 들어갑니다.

color.role.light.scaffoldBackground
color.role.light.onSurface
color.role.dark.primary

웹은 익숙한 CSS 변수로 소비합니다.

:root {
  --background: #fffbfe;
  --foreground: #1c1b1f;
  --primary: #f99c50;
}

.dark {
  --background: #1c1b1f;
  --foreground: #eeecee;
  --primary: #f99c50;
}

이름이 다르다고 fuzzy matching이나 naming convention 추론을 넣으면 편해 보입니다. 하지만 design token에서 “비슷해 보이는 이름”은 계약이 아닙니다.

그래서 tokens.config.ts에 CSS 변수와 token dot-path의 관계를 직접 적었습니다.

export const TOKEN_MAP = {
  ":root": {
    "--background": "color.role.light.scaffoldBackground",
    "--foreground": "color.role.light.onSurface",
    "--primary": "color.role.light.primary",
  },
  ".dark": {
    "--background": "color.role.dark.scaffoldBackground",
    "--foreground": "color.role.dark.onSurface",
    "--primary": "color.role.dark.primary",
  },
};

실제 mapping은 :root.dark에 각각 14개 CSS 변수를 둡니다. 두 selector는 같은 변수 집합을 사용하고, 값의 출처만 light와 dark token 경로로 나뉩니다.

중요한 것은 mapping에 들어간 것보다 들어가지 않은 것입니다.

--accent, --accent-blue, --ring, --popover, --chart-*, --sidebar-*, 그리고 @theme inline 변수는 웹 전용이라 import 대상이 아닙니다. 웹이 앱을 원본으로 삼는다고 웹의 모든 표현까지 앱에 종속시킬 필요는 없습니다.

allowlist 방식의 장점은 경계가 닫혀 있다는 것입니다. 앱에 token이 하나 추가돼도 웹이 자동으로 새로운 책임을 떠안지 않습니다. 웹에서 쓰기로 결정한 변수만 mapping에 들어옵니다.

importer는 mapping 안에서만 움직입니다

토큰 importer는 다음 순서로 동작합니다.

  1. MIRAE_APP_DIR에서 tokens-studio.json을 읽습니다.
  2. 웹의 app/globals.css에서 :root.dark block을 찾습니다.
  3. TOKEN_MAP에 적힌 dot-path의 .value를 해석합니다.
  4. 값이 hex color이고 CSS block 안에 해당 변수가 있을 때만 교체합니다.
  5. 없는 selector는 warning을 남기고 해당 selector를 건너뜁니다. 누락된 path, 잘못된 color, 없는 CSS 변수는 warning과 skipped count에 포함합니다.

여기서 “CSS 전체를 생성”하지 않은 것은 의도적입니다.

globals.css에는 token 외에도 font, radius, animation, Tailwind theme bridge가 있습니다. 앱이 소유하지 않는 웹 코드를 generator가 다시 쓰게 만들면 경계가 아니라 점령이 됩니다.

importer는 두 selector 안의 allowlist 변수만 바꿉니다. 나머지 CSS는 그대로 둡니다.

다만 이 설계에는 분명한 대가도 있습니다. mapping된 변수를 웹에서 손으로 고치면 다음 live import가 그 값을 다시 덮어씁니다. importer가 “사람이 수정했으니 충돌”이라고 거부해 주지는 않습니다.

그래서 소유권 규칙도 함께 필요합니다.

TOKEN_MAP에 있는 CSS 변수는 앱 token이 소유한다. 웹에서 직접 고치지 않는다.

도구가 소유권을 완전히 강제하지 못한다면 문서와 review에서라도 이 문장을 놓치면 안 됩니다.

dry-run은 안전장치이자 diff 설명서입니다

live import 전에 실행하는 명령은 따로 있습니다.

pnpm import:tokens:dry

dry-run은 파일을 쓰지 않고 변수별 이전 값과 새 값을 보여줍니다. 마지막에는 updated 예정, unchanged, skipped 개수를 요약합니다.

[import_tokens] :root  --primary               #f99c50 → #ff9d52   CHANGED
[import_tokens] dry-run: 1 variable(s) would be updated, 27 unchanged, 0 skipped

위 출력은 형식을 설명하기 위한 예시이며, 현재 두 저장소를 실행해 얻은 기록은 아닙니다. 이번 검증 shell에는 MIRAE_APP_DIR가 설정돼 있지 않아 실제 app token과의 parity는 확인하지 못했습니다.

이 구분이 중요합니다. importer source를 읽으면 “무엇을 비교하도록 구현됐는가”를 확인할 수 있습니다. 실제 dry-run을 실행해야 “오늘 두 저장소 사이에 어떤 차이가 있는가”를 확인할 수 있습니다.

구조를 확인한 테스트가 현재 data parity까지 증명해 주지는 않습니다.

스크린샷도 복사가 아니라 locale mapping입니다

이미지는 더 단순해 보입니다. PNG를 복사하면 끝이니까요.

실제로는 locale 이름부터 다릅니다.

fastlane/screenshots/ios/ko    -> public/screens/ko
fastlane/screenshots/ios/en-US -> public/screens/en

이 변환을 사람의 Finder 작업에 맡기면 영어 파일을 한국어 directory에 넣어도 pipeline은 아무 말도 하지 않습니다.

import_screenshots.sh는 source locale과 target locale을 나란히 선언합니다. 각 source directory가 존재하는지 확인하고, PNG가 5개보다 적거나 10개보다 많으면 중단합니다.

이 숫자는 “모든 앱에는 스크린샷이 5~10개여야 한다”는 일반 법칙이 아닙니다. 현재 이 랜딩 페이지가 받아들이는 입력 범위입니다. 다른 프로젝트에 그대로 복사할 값이 아니라 해당 pipeline의 guardrail입니다.

스크린샷 dry-run도 변경을 네 종류로 나눕니다.

  • NEW: source에는 있고 target에는 없는 파일
  • CHANGED: 이름은 같지만 내용이 다른 파일
  • UNCHANGED: source와 target의 byte가 같은 파일
  • STALE: target에는 남았지만 source에서 사라진 파일
pnpm import:screenshots:dry

특히 STALE이 중요합니다. 새 파일만 덮어쓰는 copy는 source에서 삭제된 옛 screenshot을 target에 계속 남깁니다. 그러면 페이지가 파일 목록을 어떻게 참조하느냐에 따라 과거 화면이 다시 노출될 수 있습니다.

live import는 target locale directory의 기존 PNG를 지운 뒤 source PNG를 정렬해 복사합니다. 따라서 이 명령은 단순한 read가 아닙니다.

pnpm import:screenshots

삭제 범위는 public/screens/ko 또는 public/screens/en 바로 아래의 *.png로 제한돼 있지만, 대상이 틀리면 손실이 생길 수 있는 동작입니다. 그래서 dry-run 결과에서 source path, target path, file count, stale 목록을 확인한 뒤 live import를 실행해야 합니다.

“dry-run도 있으니 안전하다”가 아니라, live 동작이 파괴적이기 때문에 dry-run을 작업 순서에 넣는 것입니다.

같은 interface를 써도 두 importer의 위험은 다릅니다

두 명령은 모두 MIRAE_APP_DIR를 요구하고 :dry script를 제공합니다. 겉모습은 같습니다.

하지만 review할 대상은 다릅니다.

토큰 import에서는 다음을 봅니다.

  • mapping에 예상하지 않은 CSS 변수가 들어왔는가?
  • light와 dark selector의 변수 집합이 같은가?
  • skipped warning이 있는가?
  • 색상 diff가 앱의 의도와 맞는가?

스크린샷 import에서는 다음을 봅니다.

  • koen-US source가 올바른 target locale로 가는가?
  • file count가 예상 범위인가?
  • STALE로 지워질 파일이 의도된 것인가?
  • live 실행 뒤 Git diff가 새 screenshot 세트만 포함하는가?

공통 interface가 위험까지 같게 만들지는 않습니다. dry-run은 버튼 하나가 아니라, 각 importer가 무엇을 바꿀지 사람이 읽을 수 있게 번역하는 단계입니다.

검증도 실제로 읽는 속성을 구분해야 합니다

현재 token test suite는 environment variable 누락, token file 누락, dry-run 출력, missing path warning, @theme inline 제외, live mapped-variable 교체를 검사합니다.

다만 일부 테스트는 실제 app/globals.css를 fixture로 잠시 바꾼 뒤 복원합니다. 공유 작업 tree에서 다른 process가 같은 파일을 편집하고 있다면 “테스트니까 안전하다”고 말할 수 없습니다.

이번 글을 확인하면서 전체 suite를 실행하지 않은 이유도 이것입니다. 대신 repository를 깨끗한 상태로 확인한 뒤, TOKEN_MAP을 직접 읽는 ad-hoc assertion을 실행했습니다.

먼저 일부러 잘못된 기대를 넣었습니다.

assert.ok("--accent" in TOKEN_MAP[":root"]);

이 검사는 예상대로 실패했습니다. 그다음 실제 계약을 검사했습니다.

for (const selector of [":root", ".dark"] as const) {
  assert.equal(Object.keys(TOKEN_MAP[selector]).length, 14);
  assert.ok(!("--accent" in TOKEN_MAP[selector]));
  assert.ok(!("--chart-1" in TOKEN_MAP[selector]));
}

assert.deepEqual(Object.keys(TOKEN_MAP[":root"]), Object.keys(TOKEN_MAP[".dark"]));

이 검사는 통과했습니다.

여기서 확인한 것은 현재 mapping의 shape와 제외 경계입니다. 실제 tokens-studio.json의 모든 path가 오늘도 존재하는지는 확인하지 않았습니다. 그 속성은 real MIRAE_APP_DIR를 주고 실행하는 path-resolution test나 token dry-run이 읽어야 합니다.

검증 명령이 초록색이라는 말보다 먼저 “그 명령이 무엇을 읽었는가”를 말해야 하는 이유입니다.

import 경계는 복사를 없애는 것이 아니라 책임을 드러냅니다

이 pipeline도 자동 sync는 아닙니다. 앱이 바뀌었다고 랜딩 페이지가 저절로 갱신되지는 않습니다.

운영 순서는 여전히 사람이 시작합니다.

  1. 앱에서 token이나 iOS screenshot을 갱신합니다.
  2. 랜딩 페이지에서 해당 dry-run을 실행합니다.
  3. 변경 대상과 warning을 읽습니다.
  4. live import를 실행합니다.
  5. 생성된 Git diff와 실제 페이지 rendering을 확인합니다.

자동화가 덜 됐다고 볼 수도 있습니다. 저는 오히려 이 정도가 작은 제품 두 개를 잇는 shippable baseline이라고 봅니다.

파일 watcher, webhook, cross-repository workflow를 먼저 붙이면 trigger, credential, partial failure, retry 정책까지 새로 소유해야 합니다. 지금 필요한 것은 “언제나 자동”이 아니라 “누가 원본이고 무엇이 바뀔지 명확”한 상태였습니다.

좋은 import 경계는 복사를 마법처럼 없애지 않습니다.

  • 원본을 한쪽으로 정합니다.
  • 이름이 다른 지점을 mapping으로 드러냅니다.
  • 가져오지 않을 항목을 allowlist 밖에 둡니다.
  • 변경 전에는 dry-run으로 diff를 설명합니다.
  • live 단계의 파괴 범위를 좁힙니다.
  • 마지막에는 실제 consumer가 읽는 결과를 확인합니다.

디자인 시스템을 공유한다는 말은 두 저장소에 같은 hex code가 있다는 뜻이 아닙니다. 어느 값이 원본인지, 어떤 변환을 거쳤는지, 무엇은 의도적으로 공유하지 않는지를 다시 실행 가능한 형태로 남기는 일입니다.

그 출발점은 거대한 platform이 아닐 수 있습니다. 작은 mapping object 하나와, 실행하기 전에 먼저 보여주는 --dry-run 하나면 충분할 때가 많습니다.