한 줄 요약 — 클로드코드가 만든 화면에서 "AI가 만들었네" 소리가 나는 패턴을 훅으로 잡기 시작했고, 그 훅이 쏟아낸 오탐을 걸러냈습니다. 같은 점검에서 브랜드 색 여러 개가 접근성 대비 기준(4.5:1)을 넘지 못한다는 것도 발견해 고쳤습니다.
클로드코드로 제품을 여러 개 만들다 보니 화면들이 서로 닮아 갔습니다. 보라색 그라디언트 히어로, Inter 아니면 Space Grotesk, 큼직한 둥근 카드 세 장, 연한 회색 본문. 따로 만든 제품인데 나란히 띄우면 한 회사 템플릿처럼 보였습니다.
처음에는 매번 "그 폰트 쓰지 마"라고 말했습니다. 하지만 채팅으로 한 당부는 세션이 끝나면 사라집니다. 그래서 규칙을 CLAUDE.md에 적고, 어기면 바로 알려 주는 훅을 붙였습니다. 이 글은 그 훅이 처음에 엉뚱한 곳을 잡아낸 이야기와, 정리하다 덤으로 찾은 대비 문제를 어떻게 고쳤는지에 대한 기록입니다.
AI티의 정체: 기본값이 겹친 결과
AI티는 대부분 "아무도 고르지 않은 기본값"입니다. 모델이 가장 무난한 선택을 반복하니, 사람이 결정하지 않은 자리마다 같은 답이 들어갑니다. 제 화면들에서 자주 보인 패턴은 이랬습니다.
|
패턴 |
왜 티가 나나 |
이번에 정한 대응 |
|---|---|---|
|
Inter, Space Grotesk, Poppins 같은 첫 폰트 |
수많은 랜딩 페이지와 겹침 |
훅으로 검사, 제품별 지정 폰트만 허용 |
|
보라→파랑 그라디언트 히어로 |
템플릿의 대표 이미지 |
장식에만 쓰고 글자에는 단색 |
|
이모지 아이콘(🚀 ✨) |
의미보다 분위기용 |
아이콘 세트 하나로 통일 |
|
둥근 카드 3개 + 그림자 |
정보 구조보다 레이아웃 먼저 |
내용에 맞는 목록·표 우선 |
|
연한 회색 본문 |
세련돼 보이지만 안 읽힘 |
대비 4.5:1 이상 토큰만 사용 |
이 중 기계로 확실히 잡을 수 있는 것은 폰트와 색입니다. 레이아웃은 사람이 봐야 합니다. 그래서 폰트는 훅에, 색은 대비 검사에 맡기고 나머지는 CLAUDE.md의 디자인 원칙으로 남겼습니다.
아이러니하게도 AIvance 로고의 보라색 그라디언트(#8B6EFF → #5B3DF0 → #3B22B0)와 명함의 Space Grotesk도 이 목록에 걸립니다. 둘 다 제가 직접 고른 브랜드 요소라 지우지 않았습니다. 이 "내가 고른 기본값"이 뒤에서 오탐 문제로 돌아옵니다.
overused-font 훅: 첫 버전
훅은 클로드코드가 파일을 쓰거나 고친 직후(PostToolUse) 실행됩니다. 금지 폰트가 보이면 종료 코드 2로 끝내고, 그 메시지가 클로드에게 그대로 전달돼 스스로 고치게 만드는 구조입니다.
.claude/settings.json에는 이렇게 걸었습니다.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{ "type": "command", "command": ".claude/hooks/overused-font.sh" }
]
}
]
}
}
첫 버전 스크립트는 단순했습니다. 바뀐 파일에서 폰트 이름을 찾기만 했습니다.
#!/usr/bin/env bash
# v1 — 너무 순진했던 버전
file=$(jq -r '.tool_input.file_path // empty')
[ -f "$file" ] || exit 0
if grep -nE 'Inter|Roboto|Poppins|Space Grotesk|Montserrat' "$file"; then
echo "overused-font: $file 에 금지 폰트가 있습니다. 프로젝트 지정 폰트로 바꾸세요." >&2
exit 2
fi
결과는 바로 나왔습니다. 금지 폰트도 잡았지만, 폰트와 상관없는 파일까지 하루에 수십 번씩 걸렸습니다. 클로드는 훅 메시지를 지시로 받아들이니, 오탐이 날 때마다 멀쩡한 코드를 "고치려고" 들었습니다.
오탐 다섯 가지와 처리 방법
오탐은 다섯 갈래로 정리됐습니다. 공통 원인은 하나였습니다. "폰트 이름이 등장하는가"를 검사했지, "이 화면의 글꼴로 선언됐는가"를 검사하지 않았습니다.
|
오탐 |
걸린 예 |
처리 |
|---|---|---|
|
부분 문자열 |
|
|
|
대체 폰트 스택 |
|
스택의 첫 번째 폰트만 검사 |
|
외부 라이브러리 |
|
경로 제외 목록 |
|
규칙 문서 자체 |
금지 목록을 적은 |
|
|
의도한 브랜드 폰트 |
명함 SVG 렌더링의 Space Grotesk |
허용 목록 파일 + 선언 옆 주석 표시 |
가장 오래 고민한 것은 마지막 줄입니다. 금지 목록에 있어도 일부러 고른 폰트라면 막으면 안 됩니다. 그렇다고 예외를 훅 안에 박아 두면, 클로드가 "예외 처리"라는 이름으로 아무 데나 같은 표시를 붙일 수 있습니다. 그래서 허용은 두 단계로 나눴습니다.
- 프로젝트 루트의
.claude/font-allowlist에 폰트 이름과 이유를 적습니다. 이 파일은 사람만 고칩니다. - 실제 선언 옆에
/* font-ok: brand */주석을 답니다. 허용 목록에 있는 폰트에만 효력이 있습니다.
고친 스크립트의 핵심 부분입니다.
#!/usr/bin/env bash
# v2 — 선언만, 첫 폰트만, 제외 경로와 허용 목록 반영
file=$(jq -r '.tool_input.file_path // empty')
[ -f "$file" ] || exit 0
case "$file" in
*.md|*/vendor/*|*.min.css|*/node_modules/*) exit 0 ;;
*.css|*.scss|*.php|*.html|*.svg|*.js) ;;
*) exit 0 ;;
esac
BANNED='Inter|Roboto|Poppins|Space Grotesk|Montserrat'
ALLOW=$(grep -v '^#' .claude/font-allowlist 2>/dev/null | cut -d'|' -f1 | paste -sd'|' -)
# font-family / font 선언에서 첫 번째 폰트만 뽑는다
hits=$(grep -nE '(font-family|font)\s*[:=]' "$file" \
| grep -v 'font-ok:' \
| sed -E 's/.*(font-family|font)\s*[:=]\s*["'\'']?([^,;"'\'']+).*/\0\t\2/' \
| awk -F'\t' -v b="^($BANNED)$" -v a="^($ALLOW)$" '
{ f=$2; gsub(/^ +| +$/, "", f) }
f ~ b && (a == "^()$" || f !~ a) { print $1 }')
if [ -n "$hits" ]; then
printf 'overused-font: 첫 폰트로 금지 폰트가 선언됐습니다.\n%s\n' "$hits" >&2
echo '프로젝트 지정 폰트로 바꾸세요. 의도한 브랜드 폰트면 사람에게 허용 목록 추가를 요청하세요.' >&2
exit 2
fi
마지막 안내 문장이 생각보다 중요했습니다. "사람에게 요청하라"고 적어 두자, 클로드가 브랜드 폰트를 만났을 때 멋대로 바꾸지 않고 작업을 멈추고 물어 왔습니다.
바꾼 뒤에는 훅 자체에 테스트를 붙였습니다. 걸려야 할 예와 걸리면 안 되는 예를 tests/hooks/fixtures/에 파일로 두고, 스크립트의 종료 코드를 확인합니다. 오탐을 하나 고칠 때마다 그 예를 고정 파일로 추가하니 같은 오탐이 다시 생기지 않았습니다.
덤으로 찾은 것: 브랜드 색이 대비 기준에 못 미쳤다
폰트를 정리하면서 색도 같이 봤습니다. 결과는 예상보다 나빴습니다. 로고에서 가장 눈에 띄는 색들이 정작 글자색으로는 기준을 넘지 못했습니다.
기준은 WCAG 2.1 AA(KWCAG도 같은 수치)입니다. 일반 글자는 배경과 4.5:1 이상, 큰 글자(18pt, 굵게는 14pt 이상)는 3:1 이상이어야 합니다.
|
색 조합 (글자 / 배경) |
쓰던 곳 |
대비 |
일반 글자 4.5 |
큰 글자 3.0 |
|---|---|---|---|---|
|
|
jblove 강조 링크 |
2.31:1 |
미달 |
미달 |
|
|
보조 설명, 입력창 안내문 |
2.54:1 |
미달 |
미달 |
|
|
미단재 날짜·메타 정보 |
3.40:1 |
미달 |
통과 |
|
|
AIvance 링크, 흰 글자 버튼 |
3.64:1 |
미달 |
통과 |
|
|
jblove 버튼 배경 |
4.01:1 |
미달 |
통과 |
|
|
미단재 강조 |
4.94:1 |
통과 |
통과 |
|
|
AIvance 그라디언트 중간색 |
6.23:1 |
통과 |
통과 |
대비는 순서를 바꿔도 같습니다. 흰 배경의 #8B6EFF 글자가 3.64:1이면, #8B6EFF 버튼 위의 흰 글자도 3.64:1입니다. 그래서 그라디언트 버튼에 흰 글자를 올린 곳은 가장 밝은 쪽 끝이 기준을 깎아 먹고 있었습니다.
눈으로 보면 다 읽힙니다. 문제는 제 눈이 27인치 모니터를 정면에서 보는 눈이라는 점입니다. 햇빛 아래 휴대폰이나, 제 AI 교육을 듣는 어르신들의 눈에는 다르게 보입니다.
고친 방법: 브랜드 색과 글자색을 나눈다
브랜드 색을 바꾸지는 않았습니다. 대신 "보이는 색"과 "읽는 색"을 다른 토큰으로 나눴습니다. 그라디언트와 밝은 주황은 장식(배경 띠, 아이콘, 일러스트)에만 남기고, 글자와 버튼에는 같은 계열의 더 진한 색을 씁니다.
:root {
/* 장식 전용 — 글자에 쓰지 않는다 */
--brand-1: #8B6EFF;
--brand-2: #5B3DF0;
--brand-3: #3B22B0;
/* 글자·버튼용 — 흰 배경 기준 4.5:1 이상 */
--text-accent: #5B3DF0; /* 6.23:1, 링크·강조 */
--text-muted: #6B7280; /* 4.83:1, #9CA3AF 대체 */
--button-bg: #5B3DF0; /* 흰 글자 6.23:1 */
}
|
바꾸기 전 |
바꾼 뒤 |
대비 |
|---|---|---|
|
링크 |
|
3.64 → 6.23:1 |
|
보조 글자 |
|
2.54 → 4.83:1 |
|
jblove 링크 |
|
2.31 → 5.40:1 |
|
jblove 버튼 |
|
4.01 → 5.40:1 |
|
미단재 메타 |
|
3.40 → 5.07:1 |
밝은 주황을 꼭 버튼에 쓰고 싶다면 방향을 바꾸는 방법도 있습니다. #FF8C42 배경에 흰 글자 대신 먹색 #1F1B16 글자를 올리면 7.40:1이 나옵니다.
다시 어긋나지 않게: 대비를 테스트로 고정
토큰을 나눠도 새 화면을 만들 때 클로드가 --brand-1을 글자에 쓰면 그만입니다. 그래서 두 가지를 더 했습니다.
CLAUDE.md에 "--brand-*는 장식 전용, 글자에는--text-*만"이라고 한 줄 적었습니다.- 글자·배경 조합 목록을 PHPUnit 테스트로 만들어 CI에서 돌립니다.
final class ContrastTest extends \CodeIgniter\Test\CIUnitTestCase
{
/** 글자색, 배경색, 최소 대비 */
public static function pairs(): array
{
return [
'accent on white' => ['#5B3DF0', '#FFFFFF', 4.5],
'muted on white' => ['#6B7280', '#FFFFFF', 4.5],
'button text' => ['#FFFFFF', '#5B3DF0', 4.5],
'meta on hanji' => ['#6E655C', '#F6F1E7', 4.5],
];
}
#[\PHPUnit\Framework\Attributes\DataProvider('pairs')]
public function testContrastMeetsAA(string $fg, string $bg, float $min): void
{
$this->assertGreaterThanOrEqual($min, $this->ratio($fg, $bg));
}
private function ratio(string $a, string $b): float
{
[$l1, $l2] = [$this->luminance($a), $this->luminance($b)];
return (max($l1, $l2) + 0.05) / (min($l1, $l2) + 0.05);
}
private function luminance(string $hex): float
{
$c = array_map(
static fn (string $h): float => hexdec($h) / 255,
str_split(ltrim($hex, '#'), 2),
);
$c = array_map(
static fn (float $v): float => $v <= 0.03928 ? $v / 12.92 : (($v + 0.055) / 1.055) ** 2.4,
$c,
);
return 0.2126 * $c[0] + 0.7152 * $c[1] + 0.0722 * $c[2];
}
}
새 색 조합을 쓰려면 이 목록에 한 줄을 추가해야 하고, 기준에 못 미치면 셀프호스티드 러너에서 바로 빨간불이 들어옵니다. 사람이 일일이 대비 계산기를 열지 않아도 됩니다.
돌아보며
- AI티는 결정하지 않은 자리에서 생긴다. 폰트도 색도 제가 정해 두지 않은 곳에서 기본값이 들어왔습니다. 금지 목록보다 "이 제품은 이 폰트, 이 색"을 먼저 정하는 편이 효과가 컸습니다.
- 훅의 오탐은 AI에게 잘못된 지시가 된다. 사람에게 오탐은 무시하면 그만인 경고입니다. 클로드에게는 고쳐야 할 문제입니다. 훅을 만들 때는 "잡을 것"만큼 "잡지 않을 것"을 신경 써야 합니다.
- 예외는 사람만 연다. 허용 목록을 사람만 고치는 파일로 둔 덕분에, 클로드가 예외 표시로 검사를 피해 가는 일이 없었습니다. 막다른 곳에서는 멈추고 묻게 만드는 안내 문장이 핵심이었습니다.
- 눈은 기준이 아니다. 제 모니터에서 잘 보이던 색 다섯 개가 기준에 못 미쳤습니다. 대비는 계산할 수 있는 값이니 계산에 맡기고, 테스트로 고정합니다.
- 브랜드와 가독성은 싸우지 않는다. 브랜드 색을 버릴 필요는 없었습니다. 장식과 글자를 다른 토큰으로 나누자 둘 다 살았습니다.
AI로 만든 화면에서 AI티를 걷어내는 일은 결국 사람이 고른 것을 화면에 남기는 일이었습니다. 그리고 그 선택이 지켜지는지는 훅과 테스트가 매번 확인합니다. 다음에는 폰트와 색 다음 단계인 레이아웃, 카드 세 장 반복을 어떻게 걷어냈는지 정리해 볼 생각입니다.
댓글 0개