2026년 8월 15일 · 8분 읽기
SwiftUI 디자인 토큰 런타임 교체: 호출부 367곳을 건드리지 않는 파사드
static let 색 토큰 44개가 91개 파일에서 367번 불리는 상태에서 런타임 테마 교체를 만들었습니다. 호출부를 그대로 두고 값만 갈아타는 파사드 구조를 씁니다.
2026년 8월 23일 업데이트
색 토큰이 44개였습니다. static let으로 선언돼 있었고, 91개 파일에서 367번 불렸습니다.
유료 테마를 팔려면 이 값들이 실행 중에 바뀌어야 합니다. static let은 바뀌지 않습니다.
367곳을 고치는 방법도 있습니다. 하지만 고치는 김에 다른 것도 발견했습니다. 그라디언트 일곱 곳을 전수 조사하니 셋이 아무도 참조하지 않는 죽은 코드였고, 그 뒤에 죽은 토큰 25개가 딸려 있었습니다.
호출부를 건드리지 않고 상수를 런타임 값으로 바꾼 방법이 앞부분이고, 그 김에 토큰을 44개에서 41개로 줄인 이야기가 뒷부분입니다.
한눈에 보기
| 문제 | 색 토큰이 44개였습니다. |
| 결정 | 단색 faint로 바꿨습니다. |
| 결과 | 이 작업 중에 파생 그라디언트를 캐시하는 코드를 만들었습니다. |
| 제약 | 값을 여러 벌 만들 수 있게 되면 그 벌마다 검사가 필요합니다. |
값 타입 하나와 파사드로 갈랐습니다
static let을 값 타입으로 옮기고, 원래 이름은 계산 프로퍼티로 남겼습니다.
// 전: 컴파일타임 상수
enum StreamTokens {
static let paper = Color(hex: 0xFFFFFF)
static let ink = Color(hex: 0x121212)
}
// 후: 값 타입 + 파사드
struct StreamPalette {
let paper: Color
let ink: Color
}
enum StreamTokens {
private(set) static var active = StreamPalette.default
static var paper: Color { active.paper } // 계산 프로퍼티
static var ink: Color { active.ink }
static func activate(_ palette: StreamPalette) { active = palette }
}
호출부는 그대로입니다. StreamTokens.paper라고 쓰던 코드가 문법도, 의미도 바뀌지 않습니다.
접근 비용도 같습니다. 구조체 프로퍼티를 한 번 읽는 것이고, 딕셔너리 조회나 문자열 키 탐색이 아닙니다.
지킨 것은 하나입니다. 367곳을 고치지 않는 것. 대규모 치환은 놓치는 자리를 만들고, 리뷰가 불가능해집니다.
파사드가 두 가지를 동시에 해결합니다
| 문제 | 파사드가 하는 일 |
|---|---|
| 호출부 367곳 | 이름을 그대로 유지합니다 |
| 런타임 교체 | activate(_:)로 active를 바꿉니다 |
값 타입을 따로 둔 이유는 팔레트를 값으로 다루기 위해서입니다. 팔레트를 만들고, 검사하고, 비교하는 코드를 쓸 수 있습니다. static let은 그럴 수 없습니다.
딕셔너리로 두지 않았습니다
역할을 딕셔너리에 담을 수도 있었습니다. palette["paper"]처럼 씁니다. 그러지 않은 이유가 선언 위 주석에 있습니다.
역할이 저장 프로퍼티로 전부 나열되는 것이 이 타입의 계약이다. 딕셔너리로 두면 새 팔레트가 역할 하나를 빠뜨려도 런타임에야 드러나지만, 지금은 memberwise init이 컴파일 시점에 막는다. 39개를 다 적어야 팔레트 하나가 성립한다.
—
StreamPalette.swift:11-13
딕셔너리는 키를 빠뜨려도 컴파일됩니다. 그 색을 쓰는 화면에 들어가야 드러납니다.
저장 프로퍼티로 전부 나열하면 memberwise 초기화가 강제합니다. 팔레트를 하나 더 만들 때 빠뜨린 역할이 컴파일 오류가 됩니다.
팔레트 식별자를 바꾸는 일은 마이그레이션입니다
// JustSendKit/Sources/Design/StreamPalette.swift:21-22
/// 팔레트 신원. `AppSettings`에 rawValue로 저장되므로 **문자열을 바꾸면 마이그레이션**이다.
public enum ID: String, Codable, CaseIterable, Sendable {
사용자가 고른 팔레트를 문자열로 저장합니다. case craftLight의 이름을 바꾸면 그 팔레트를 쓰던 사용자의 설정이 아무것도 가리키지 않습니다.
주석이 그 사실을 선언 바로 위에 적어 뒀습니다. 이름을 바꾸려는 사람이 반드시 지나는 자리입니다.
파생 값은 팔레트에 넣지 않았습니다
토큰 중에는 다른 토큰에서 나오는 것이 있습니다. 카드 안쪽의 눌린 자리는 지면 색을 한 단 어둡게 한 값입니다.
이 값을 팔레트에 저장하지 않았습니다. 계산합니다.
// JustSendKit/Sources/Design/ColorMixing.swift:51-54
/// 방향을 팔레트가 정하지 않아도 위계가 늘 같은 방향으로 쌓인다.
public func steppedSurface(_ amount: Double) -> Color {
mixed(with: prefersDarkInk ? .black : .white, by: amount)
}
호출부는 이렇습니다.
// JustSendKit/Sources/Design/StreamPalette.swift:631, 676
sunken: paper.steppedSurface(0.05),
selectionFill: paper.steppedSurface(0.22))
밝은 팔레트에서는 검정을 섞고 어두운 팔레트에서는 흰색을 섞습니다. 방향을 팔레트가 정하지 않아도 위계가 늘 같은 쪽으로 쌓입니다.
파생을 팔레트에 저장하면 무엇이 문제인지도 주석에 있습니다.
파생은 여기 없다.
controlFill·swipeTrackSweep·highlightPlate처럼 다른 역할에서 나오는 값은StreamTokens가 계산한다. 파생을 팔레트에 넣으면 두 값이 어긋난 팔레트를 만들 수 있게 되고, 그 순간 "형광펜으로 그은 색과 홈 카드의 색이 같다" 같은 약속이 팔레트마다 다시 검사해야 하는 것이 된다.—
StreamPalette.swift:15-18
약속을 값으로 저장하면 팔레트마다 그 약속이 지켜졌는지 검사해야 합니다. 계산하면 약속이 코드에 한 번만 있습니다.
앞서 죽은 토큰으로 나온 swipeTrackSweep이 이 주석에 예시로 남아 있는 것도 눈에 걸립니다. 파생 목록에는 있고 참조는 0건이었습니다.
그라디언트를 전수 조사하니 절반이 죽어 있었습니다
토큰을 옮기면서 그라디언트도 정리하려 했습니다. 인상을 정돈하는 작업이었습니다.
앱 전체에 그라디언트가 일곱 곳 있었습니다. 하나씩 참조를 찾아봤습니다.
| 그라디언트 | 참조 |
|---|---|
swipeTrackSweep |
선언 외 0건 |
swipeMarkHalo |
선언 외 0건 |
heroScrim |
선언 외 0건 |
| 나머지 넷 | 사용 중 |
셋이 죽어 있었습니다.
원인은 기능 제거였습니다. 스와이프 트랙 기능이 통째로 제거됐는데 그 기능이 쓰던 토큰만 남아 있었습니다.
죽은 토큰이 25개 딸려 있었습니다
죽은 그라디언트 셋을 지우려고 보니 그것들이 참조하는 색 토큰도 아무도 쓰지 않았습니다.
swipe*로 시작하는 토큰 23개, heroShape, radiusHero. 합쳐서 25개입니다.
삭제 직후의 수는 다음과 같습니다.
| 전 | 후 | |
|---|---|---|
StreamTokens 줄 수 |
1,102 | 979 |
그 작업 직후의 수입니다. 123줄이 사라졌습니다. 팔레트를 열두 벌까지 늘린 지금은 1,128줄입니다.
기능을 지울 때 그 기능이 쓰던 토큰을 함께 지우기 어렵습니다. 토큰은 여러 곳에서 쓰일 수 있으니 지우기 전에 확인해야 하고, 그 확인이 귀찮으면 남겨 둡니다. 남겨 두면 다음 사람은 그것이 쓰이는 줄 압니다.
죽은 코드가 새 작업을 방해합니다
토큰이 죽어 있어도 컴파일은 됩니다. 문제는 팔레트를 새로 만들 때 나타납니다.
팔레트 하나를 만들려면 44개 색을 정해야 합니다. 그중 25개가 죽은 토큰이면, 존재하지 않는 기능을 위한 색을 25번 고민하는 셈입니다.
장식 하나를 지우니 팔레트가 41개로 줄었습니다
홈의 목적지 카드에 입체 글리프가 있었습니다. 좌상단에서 광원이 오고 우하단에 그늘이 지고 그림자가 깔린 표식입니다.
세 겹이었습니다. 그리고 그 카드에서 가장 물러나야 하는 자리였습니다.
같은 카드에서 제목은 15pt에서 13pt로 내렸습니다. 주인공은 아래의 숫자입니다. 가장 뒤로 물러날 표식에 세 겹을 쌓고 있었습니다.
단색 faint로 바꿨습니다. 그러자 팔레트 토큰 세 개가 죽었습니다.
| 죽은 토큰 | 무엇이었나 |
|---|---|
iconVolumeTop |
글리프 광원 쪽 색 |
iconVolumeBottom |
글리프 그늘 쪽 색 |
iconShadow |
글리프 그림자 색 |
44개에서 41개가 됐습니다. 지금 소스의 저장 프로퍼티는 39개입니다. 그 뒤로 둘이 더 죽었습니다.
이 셋이 사라진 것에 부수 효과가 있었습니다. 새 팔레트를 만들 때 "입체 글리프의 광원 색"을 정해야 했습니다. 답하기 어려운 질문입니다. 팔레트 열두 벌을 만들려면 열두 번 답해야 합니다.
토큰을 줄이는 것은 색을 줄이는 것이 아니라 결정해야 할 것을 줄이는 일입니다.
남은 그라디언트는 기능인지 장식인지로 갈랐습니다
넷이 남았습니다. 지울지 판정해야 했습니다.
기준은 하나입니다. 단색으로 바꿨을 때 기능이 유지되는가.
| 그라디언트 | 판정 | 근거 |
|---|---|---|
| 스크롤 페이드 램프 | 유지 | 바 뒤로 콘텐츠를 넘기는 장치. 단색으로 대체 불가 |
| 캡처 무대 어둠 | 유지 | 뒤 콘텐츠 가독 차단. 라이트 모드 실측 근거가 주석에 있음 |
| 목적지 글리프 입체 | 제거 | 장식 |
| 캡처 무대 슬롯 글로우 | 결정 대기 | 시그니처 모션 영역이라 제품 정체성 판단 필요 |
첫 두 개는 그라디언트가 아니면 안 됩니다. 스크롤 페이드는 콘텐츠가 바 뒤로 자연스럽게 사라지게 하는 장치이고, 단색 오버레이는 경계선을 만듭니다.
네 번째는 판정을 미뤘습니다. 캡처 무대는 앱의 시그니처 모션이 있는 곳이고, 거기서 무엇을 뺄지는 제품 정체성 결정입니다. 기술 판단으로 끝낼 일이 아닙니다.
죽은 캐시를 스스로 만들었다가 지웠습니다
이 작업 중에 파생 그라디언트를 캐시하는 코드를 만들었습니다. 그라디언트를 매번 계산하지 않게 하는 최적화였습니다.
그런데 그 캐시가 담당하던 그라디언트가 전부 죽은 코드였습니다. 캐시의 존재 이유가 사라졌습니다.
같은 작업 안에서 만들고 지웠습니다. 최적화를 먼저 하고 사용 여부를 나중에 확인한 순서가 잘못이었습니다.
옮기고 나니 검사가 팔레트 수만큼 필요해졌습니다
색을 값으로 옮기면 새로운 실수가 가능해집니다. 팔레트마다 값이 다르므로 어떤 팔레트에서만 대비가 부족할 수 있습니다.
그래서 팔레트가 만족해야 할 조건을 테스트로 박았습니다.
| 검사 | 무엇 |
|---|---|
| 제목 대비 | 7 이상 |
| 본문 대비 | 3.5 이상 |
| 표식 대비 | 2 이상 |
| 채움 대비 | 3 이상 |
| 캡슐 대비 | 1.1 이상 |
| 표면 사다리 | 지면과 판의 대비 1.05 이상 |
| 다크 채널 델타 | 0.05 이상 |
static let 시절에는 이런 검사가 필요 없었습니다. 값이 하나뿐이고 눈으로 확인하면 끝입니다.
값을 여러 벌 만들 수 있게 되면 그 벌마다 검사가 필요합니다. 자유도를 얻은 대가입니다.
표면 사다리가 가장 자주 걸립니다
지면과 판의 대비 1.05가 특히 걸립니다. 카드가 배경과 구분되어야 한다는 조건입니다.
1.05는 낮은 값입니다. 사람이 눈으로 "다르다"고 느끼는 최소 지점입니다. 그런데 이 값 아래로 떨어지는 팔레트가 자주 나왔습니다.
배경과 카드를 같은 계열에서 고르면 자연스럽게 대비가 낮아집니다. 조용한 인상을 원하면 두 색이 가까워지고, 가까워지면 카드가 사라집니다.
세어 보니 468개 색을 손으로 정한 셈입니다
옮긴 뒤에 값이 얼마나 늘었는지 세어 봤습니다.
| 값 | |
|---|---|
StreamPalette.swift |
967줄 |
| 저장 프로퍼티 | 39개 |
| 팔레트 | 12벌 |
StreamTokens.swift |
1,128줄 |
팔레트 열두 벌이 각자 39개 값을 갖습니다. 468개 색을 손으로 정한 셈입니다. 그래서 토큰 개수를 줄인 것이 그냥 정리가 아니었습니다. 역할 하나를 지우면 열두 번 답하지 않아도 됩니다.
ID enum에 붙은 주석이 팔레트마다 출처를 적고 있습니다.
// StreamPalette.swift:22-60
public enum ID: String, Codable, CaseIterable, Sendable {
case craftLight // 기본 · 밝음
case craftDark // 기본 · 어둠
// MARK: 유료 · 밝은 지면
case blushPaper // Structured
case rosePineDawn // Rosé Pine Dawn
case catppuccinLatte // Catppuccin Latte
case amberLinen // Gentler Streak
case apricotPlum // Tangerine
// MARK: 유료 · 어두운 지면
case nord, gruvbox, kanagawa, everforest, obsidian
}
열두 벌 중 열이 유료이고, 다섯이 밝은 지면 다섯이 어두운 지면입니다. 이름이 출처를 그대로 씁니다. Nord, Gruvbox, Kanagawa, Everforest는 코드를 읽는 화면을 위해 만들어진 팔레트입니다. 어두운 지면을 그쪽에서 가져온 이유가 주석에 적혀 있습니다. 코드를 읽는 지면이 우리가 하는 일과 같습니다.
rawValue가 사용자 설정에 저장됩니다. 그래서 이 문자열을 바꾸는 것은 마이그레이션입니다. enum 위 첫 줄이 그 사실을 적어 두고 있습니다.