2026년 8월 15일 · 8분 읽기
앱 테마 팔레트 감사: 웹 팔레트에서 가져온 20벌을 12벌로 줄인 기준
웹 랜딩 팔레트에서 기계적으로 뽑은 유료 테마 20벌의 색이 앱에서 어긋난 이유를 찾아 12벌로 줄였습니다. 카드가 배경에서 사라진 8벌부터 확인합니다.
2026년 8월 23일 업데이트
사용자가 말했습니다. 카드와 배경 색이 정확히 안 들어온 것 같습니다.
유료 테마 20벌 중 17벌을 웹 팔레트 사이트에서 가져왔습니다. Happy Hues의 팔레트 1번부터 17번까지입니다. 사이트가 배경, 제목, 본문, 버튼, 카드 등 열 가지 색을 제시하므로 그대로 옮기면 될 것 같았습니다.
감사해 보니 옮긴 hex는 하나도 틀리지 않았습니다. 그런데 화면의 카드 색은 사이트와 달랐습니다.
한눈에 보기
| 문제 | 사용자가 말했습니다. |
| 결정 | 열일곱 벌을 만들었는데 카드는 세 종류였습니다. |
| 결과 | 방법을 바꿨습니다. |
| 제약 | 두 번째 표가 필요한 이유가 있습니다. |
원색은 100% 일치했고 카드만 달랐습니다
먼저 옮긴 값이 맞는지 확인했습니다. 사이트의 원본 HTML을 받아 코드의 인자와 기계 대조했습니다.
배경, 제목, 본문, 획, 버튼, 버튼 텍스트, 하이라이트, 보조, 삼차, 카드. 열 항목 전부 사이트 값과 일치했습니다.
예외가 하나 있었습니다. 13번 팔레트에는 사이트에 획(Stroke) 역할이 아예 없는데 코드가 검정을 임의로 채워 넣었습니다.
그런데 화면 픽셀은 달랐습니다
앨범에 저장된 팔레트 갤러리 스크린샷에서 픽셀을 샘플링했습니다.
| 역할 | 화면 값 | 사이트 값과 |
|---|---|---|
| 지면 | #fffffd, #f2f6f5, #fdc7d7, #f9f5f2 |
일치 (JPEG 오차 ±1) |
| 강조 | #3ea9fb, #00ecc8, #fbae2c, #6246e8 |
일치 |
| 카드 | 17벌 중 3벌만 팔레트 색 | 14벌은 기계 파생값 |
지면과 강조는 맞았습니다. 카드가 문제였습니다.
14벌의 카드 색이 paper.steppedSurface(0.10)이었습니다. 지면 색을 10% 어둡게 만드는 함수의 결과입니다. 사이트에서 받은 값이 아니라 우리 코드가 계산한 값입니다.
같은 값이 여러 팔레트에서 반복됐습니다. #e5e5e5가 세 벌에서 나왔습니다. 지면이 흰색에 가까우면 파생값도 같아집니다.
그 함수가 무엇을 하는지 보면 반복이 당연합니다.
// ColorMixing.swift:41,50-54
/// 이 색이 종이라면 그 위의 잉크는 검은 쪽인가 흰 쪽인가.
public var prefersDarkInk: Bool { relativeLuminance > 0.45 }
/// 표면을 **한 단 옮긴다.** 밝은 종이는 어두운 쪽으로, 어두운 종이는 밝은 쪽으로 —
/// 방향을 팔레트가 정하지 않아도 위계가 늘 같은 방향으로 쌓인다.
public func steppedSurface(_ amount: Double) -> Color {
mixed(with: prefersDarkInk ? .black : .white, by: amount)
}
입력이 지면 색 하나와 비율 하나입니다. 지면이 비슷하면 결과도 비슷합니다. 팔레트가 열일곱 벌이어도 지면이 흰색 계열인 것들은 카드가 같은 회색으로 모입니다.
판정 기준은 WCAG 상대 광도이고 문턱이 0.45입니다. 그 값을 넘으면 밝은 종이로 보고 검정을 섞습니다. 넘지 않으면 흰색을 섞습니다. 그래서 어두운 팔레트에서는 카드가 지면보다 밝아집니다.
이 함수 자체는 문제가 아닙니다. 팔레트가 카드 색을 주지 않을 때 위계를 만들려면 필요합니다. 문제는 사이트가 카드 색을 줬는데도 이 함수가 이긴 것입니다.
열일곱 벌을 만들었는데 카드는 세 종류였습니다.
원인은 한 페이지 안에서 섹션이 달랐다는 것입니다
Happy Hues의 팔레트 페이지는 한 팔레트를 여러 섹션으로 보여 줍니다. 섹션마다 지면 색을 바꿔 가며 같은 팔레트가 어떻게 보이는지 시연합니다.
여기에 함정이 있었습니다.
우리 코드는 지면을 첫 섹션에서 가져왔고, 카드를 다른 섹션에서 가져왔습니다. 17벌 중 16벌에서 두 값의 출처 섹션이 달랐습니다.
한 섹션 안에서 (지면, 카드) 쌍은 성립합니다. 사이트가 그 둘을 나란히 놓고 보여 주니까요. 섹션이 다르면 성립하지 않습니다.
그래서 카드가 지면과 같은 색이 됐습니다
11벌에서 카드 인자가 첫 섹션의 배경과 같은 색이 됐습니다. 다른 섹션의 카드는 그 섹션의 배경 위에 놓여 있었고, 그 배경이 우연히 첫 섹션의 배경과 같았던 것입니다.
우리 코드에는 이런 검사가 있습니다.
separates = 대비비 >= 1.05카드와 지면이 구분되는지 봅니다. 같은 색이면 대비가 1.0이므로 탈락합니다. 탈락하면 기계 파생으로 폴백합니다.
검사가 작동했습니다. 잘못된 값을 걸러 냈습니다. 문제는 걸러 낸 다음에 무엇을 쓸지였습니다. 파생값으로 조용히 채웠고, 사용자는 팔레트가 반영되지 않은 화면을 봤습니다.
두 벌은 방향이 반대라 탈락했습니다. 카드가 지면보다 밝았습니다.
놓친 값은 카드가 놓인 섹션의 배경이었습니다
정답이 사이트에 있었습니다. 카드가 놓여 있던 그 섹션의 배경 색입니다.
그것이 팔레트의 두 번째 표면색입니다. 사이트가 "이 둘을 나란히 놓아도 갈린다"고 스스로 증명한 쌍입니다.
| 팔레트 | 놓친 두 번째 표면색 |
|---|---|
| 2번 | #f2f4f6 |
| 3번 | #d8eefe (연하늘) |
| 4번 | #242629 |
| 14번 | #e3f6f5 (민트) |
| 17번 | #f3d2c1 |
이 다섯 벌은 우리 위계에도 맞았습니다. 대비 1.10에서 1.32 사이입니다.
같은 페이지에서 값을 가져오더라도 어느 섹션에서 가져왔는지를 함께 기록해야 했습니다.
미사용 파라미터 셋이 조용히 있었습니다
감사 중에 다른 것도 발견했습니다.
happyHues(...) 함수의 인자 목록에 highlight, secondary, tertiary가 있었습니다. 함수 본문에서 한 번도 쓰이지 않았습니다.
사이트가 주는 열 가지 색 중 세 가지가 앱에 도달하지 않았습니다.
Swift는 사용하지 않는 파라미터를 경고하지 않습니다. 지역 변수는 경고하는데 파라미터는 경고하지 않습니다. 그래서 드러나지 않았습니다.
열일곱 번 값을 넘겼고 열일곱 번 버려졌습니다.
사이트가 주는 색과 앱이 쓰는 역할이 다릅니다
이것이 웹 팔레트를 앱에 그대로 옮길 때의 근본 문제입니다.
웹 랜딩 페이지의 색 역할은 배경, 제목, 본문, 버튼입니다. 앱의 색 역할은 지면, 판, 눌린 자리, 괘선, 표식, 스위치 채움 같은 것들입니다.
겹치는 것도 있지만 대응하지 않는 것이 더 많습니다. 앱에는 웹에 없는 상태가 있습니다. 눌린 상태, 선택된 상태, 비활성 상태입니다.
대응하지 않는 자리를 파생 함수로 채우면 그 자리는 팔레트의 성격을 갖지 못합니다.
표면 위계가 사이트와 반대였습니다
더 깊은 불일치가 남았습니다.
여덟 벌에서 사이트는 "은은한 지면 + 그보다 밝은 카드"를 씁니다. 카드가 배경에서 떠오르는 인상입니다.
우리 규칙은 반대입니다. 코드 주석에 이렇게 적혀 있습니다.
카드는 지면에서 뜬 물건이 아니라 지면에 놓인 자리.
그래서 카드가 지면보다 어둡습니다. 종이에 인쇄된 면이라는 은유입니다.
사이트 값을 그대로 받으면 이 결정을 뒤집습니다. 그리고 테스트 하나를 건드립니다.
DesignTokensTests.testDarkPaperIsVisiblyOffBlack다크 모드에서 판이 종이보다 밝아야 한다는 계약입니다.
이건 기술 판단이 아닙니다. 제품이 어떤 인상을 갖는지에 대한 결정입니다. 판정을 사용자에게 올렸습니다.
집 팔레트와 수집 팔레트를 다르게 썼습니다
집 팔레트는 규칙을 지킵니다. 카드가 지면보다 어둡습니다.
실측해서 가져온 팔레트는 그 앱의 방향을 따릅니다. blushPaper가 그렇습니다. 지면이 블러시 #FAE4E6이고 카드가 흰색입니다. 분홍 종이 위에 흰 카드를 얹는 구조가 그 앱이 파는 인상이고, 우리 화면은 목록과 상세와 설정이 전부 카드라 그 구조가 그대로 성립합니다.
대신 눌린 자리는 지어냈습니다. 그쪽에는 세 번째 표면이 없습니다. 실측된 #F2E2E4를 그대로 쓰면 지면과 대비가 1.03이라 표면 셋이 한 면으로 합쳐집니다. 그래서 #F1D6DA로 한 단 더 눌렀습니다.
지어낸 값이 있는 팔레트는 어느 값을 지어냈고 왜 그랬는지 선언 위에 적어 뒀습니다. 실측값과 지어낸 값을 구분해 두지 않으면 다음에 고칠 때 무엇이 근거 있는 값인지 알 수 없습니다.
20벌을 12벌로 줄이고 출처를 바꿨습니다
웹 랜딩 페이지 대신 실제 앱과 실제 팔레트 프로젝트에서 가져왔습니다.
앱 스크린샷에서 색 면적을 실측했습니다
인디 앱 다섯 개를 골랐습니다. 대기업 UI는 제외했습니다.
각 앱의 US App Store 스크린샷 여섯 장을 받아 중앙 62%를 크롭하고, 색이 차지하는 면적 비율을 측정했습니다.
| 앱 | 주요 색 | 면적 |
|---|---|---|
| Structured | 블러시 #FAE4E6 |
15.9% |
| Gentler Streak | 호박 #F9A81F |
3.9% |
| Tangerine | 살구 #FFF2EA |
14.7% |
| Kino | 검정 #131313 |
16.5% |
| Kino | 페리윙클 #8885F2 |
4.2% |
굳이 면적을 잰 이유가 있습니다. 팔레트 사이트는 색을 나란히 보여 주므로 각 색이 같은 비중처럼 보입니다. 실제 앱에서는 배경이 15%를 차지하고 강조색은 4%입니다.
강조색은 조금 쓰기 때문에 강조색입니다. 그 비율을 알아야 팔레트를 옮길 때 어느 색이 지면이고 어느 색이 표식인지 정할 수 있습니다.
팔레트 프로젝트는 그대로 받았습니다
색 이론으로 만들어진 팔레트가 이미 있습니다. 에디터 테마로 널리 쓰이는 것들입니다.
Rosé Pine Dawn, Catppuccin Latte, Nord, Gruvbox, Kanagawa, Everforest를 썼습니다. 전부 MIT 라이선스입니다.
이들은 웹 랜딩 팔레트와 다릅니다. 배경, 표면, 표면2, 표면3처럼 여러 단계의 표면색을 정의합니다. 코드 에디터가 그것을 필요로 하기 때문입니다.
우리도 같은 것이 필요했습니다. 지면, 판, 눌린 자리입니다.
파생 함수를 지웠습니다. 출처가 세 번째 표면을 정의하므로 계산할 이유가 없습니다.
20벌에서 12벌로 줄였습니다
| 전 | 후 | |
|---|---|---|
| 기본 | 2벌 | 2벌 |
| 유료 | 18벌 | 10벌 (라이트 5, 다크 5) |
여덟 벌을 버렸습니다. 카드 색이 세 종류인 열일곱 벌보다 제대로 된 열 벌이 낫습니다.
은퇴한 이름을 두 곳에 기록했습니다
이미 그 테마를 쓰던 사용자가 있습니다. 이름이 사라지면 앱이 그 값을 못 찾습니다.
은퇴한 18개 이름에서 후계 팔레트로 가는 표를 두 곳에 뒀습니다.
| 어디 | 무엇을 위해 |
|---|---|
| DB 마이그레이션 v94 | 로컬에 저장된 행 |
StreamPalette.ID.succession |
마이그레이션이 닿지 않는 자리 |
두 번째 표가 필요한 이유가 있습니다. iCloud에 동기화된 행과 위젯이 App Group에 저장한 문자열은 DB 마이그레이션이 건드리지 못합니다. 다른 기기에서 온 값이거나 다른 프로세스가 쓴 값입니다.
위젯이 테마를 읽는 경로도 이 해석 함수를 타게 배선했습니다.
페이월 문구도 고쳤습니다. "19 themes"가 "10 paid themes"가 됐습니다.
계약이 초안 네 곳을 되돌렸습니다
새 팔레트 열 벌을 만들고 계약 테스트를 돌렸습니다. 78건입니다.
네 곳이 떨어졌습니다.
| 팔레트 | 무엇이 걸렸나 | 어떻게 고쳤나 |
|---|---|---|
| blushPaper | 눌린 자리가 지면과 1.03 (하한 1.05) | #F2E2E4 → #F1D6DA |
| amberLinen | 눌린 자리가 판과 1.04 | 실측값 #E5E3E9로 |
| everforest | bg0/bg1 채널 델타 0.039 (하한 0.05) | 판을 bg2로 바꿔 0.075 |
| obsidian | 지면 #0A0A0A/판 #161616이 0.047 |
순검정 + #131313 |
숫자 하나가 소수 둘째 자리에서 걸립니다. 1.03과 1.05의 차이는 눈으로 구분하기 어렵습니다. 그래서 테스트가 필요합니다.
검은 종이에서는 괘선이 밝아야 합니다
obsidian에서 문제가 하나 더 나왔습니다. 괘선이 #060606으로 나와 사실상 보이지 않았습니다.
라이트 테마에서 괘선은 종이보다 어둡습니다. 그 규칙을 그대로 적용하니 검은 종이에서 더 검은 선이 됐습니다.
검은 종이에서는 괘선 재료가 종이보다 밝아야 합니다. #8E8E93으로 바꿨습니다.
파생 규칙이 밝기 방향을 가정하고 있으면 다크 테마에서 뒤집힙니다.
리서치 방법도 한 번 바꿨습니다
인디 앱의 색을 찾으려고 웹 검색을 했습니다. SEO를 위한 색상 소개 사이트만 나왔습니다. 실제 앱의 색이 아니라 "2026년 인기 색상" 같은 목록입니다.
방법을 바꿨습니다. iTunes Lookup API로 앱 정보를 받고, 스크린샷을 내려 색 면적을 측정했습니다.
앞서 Happy Hues를 읽을 때도 비슷한 일이 있었습니다. reader 모드로 페이지를 읽으면 hex 값이 전부 제거됐습니다. 원본 HTML을 받아 DOM에서 색 이름과 hex 쌍을 파싱해야 했습니다.
색을 다루는 데이터는 텍스트 추출 도구가 버립니다.