한 줄 요약 — 클로드코드가 만든 화면에서 "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

결과는 바로 나왔습니다. 금지 폰트도 잡았지만, 폰트와 상관없는 파일까지 하루에 수십 번씩 걸렸습니다. 클로드는 훅 메시지를 지시로 받아들이니, 오탐이 날 때마다 멀쩡한 코드를 "고치려고" 들었습니다.

오탐 다섯 가지와 처리 방법

오탐은 다섯 갈래로 정리됐습니다. 공통 원인은 하나였습니다. "폰트 이름이 등장하는가"를 검사했지, "이 화면의 글꼴로 선언됐는가"를 검사하지 않았습니다.

오탐

걸린 예

처리

부분 문자열

IntersectionObserver, setInterval, interactive

font-family·font: 선언 안에서만, 단어 경계로 검사

대체 폰트 스택

Pretendard, -apple-system, Roboto, sans-serif

스택의 첫 번째 폰트만 검사

외부 라이브러리

public/assets/vendor/, *.min.css

경로 제외 목록

규칙 문서 자체

금지 목록을 적은 CLAUDE.md, 블로그 초안

.md 제외

의도한 브랜드 폰트

명함 SVG 렌더링의 Space Grotesk

허용 목록 파일 + 선언 옆 주석 표시

가장 오래 고민한 것은 마지막 줄입니다. 금지 목록에 있어도 일부러 고른 폰트라면 막으면 안 됩니다. 그렇다고 예외를 훅 안에 박아 두면, 클로드가 "예외 처리"라는 이름으로 아무 데나 같은 표시를 붙일 수 있습니다. 그래서 허용은 두 단계로 나눴습니다.

  1. 프로젝트 루트의 .claude/font-allowlist에 폰트 이름과 이유를 적습니다. 이 파일은 사람만 고칩니다.
  2. 실제 선언 옆에 /* 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

#FF8C42 / 흰색

jblove 강조 링크

2.31:1

미달

미달

#9CA3AF / 흰색

보조 설명, 입력창 안내문

2.54:1

미달

미달

#8A8178 / 한지색 #F6F1E7

미단재 날짜·메타 정보

3.40:1

미달

통과

#8B6EFF / 흰색

AIvance 링크, 흰 글자 버튼

3.64:1

미달

통과

#D4582C / 흰색

jblove 버튼 배경

4.01:1

미달

통과

#B4432A / 한지색 #F6F1E7

미단재 강조

4.94:1

통과

통과

#5B3DF0 / 흰색

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 */
}

바꾸기 전

바꾼 뒤

대비

링크 #8B6EFF

#5B3DF0

3.64 → 6.23:1

보조 글자 #9CA3AF

#6B7280

2.54 → 4.83:1

jblove 링크 #FF8C42

#B4481E

2.31 → 5.40:1

jblove 버튼 #D4582C + 흰 글자

#B4481E + 흰 글자

4.01 → 5.40:1

미단재 메타 #8A8178

#6E655C

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];
    }
}

새 색 조합을 쓰려면 이 목록에 한 줄을 추가해야 하고, 기준에 못 미치면 셀프호스티드 러너에서 바로 빨간불이 들어옵니다. 사람이 일일이 대비 계산기를 열지 않아도 됩니다.

돌아보며

  1. AI티는 결정하지 않은 자리에서 생긴다. 폰트도 색도 제가 정해 두지 않은 곳에서 기본값이 들어왔습니다. 금지 목록보다 "이 제품은 이 폰트, 이 색"을 먼저 정하는 편이 효과가 컸습니다.
  2. 훅의 오탐은 AI에게 잘못된 지시가 된다. 사람에게 오탐은 무시하면 그만인 경고입니다. 클로드에게는 고쳐야 할 문제입니다. 훅을 만들 때는 "잡을 것"만큼 "잡지 않을 것"을 신경 써야 합니다.
  3. 예외는 사람만 연다. 허용 목록을 사람만 고치는 파일로 둔 덕분에, 클로드가 예외 표시로 검사를 피해 가는 일이 없었습니다. 막다른 곳에서는 멈추고 묻게 만드는 안내 문장이 핵심이었습니다.
  4. 눈은 기준이 아니다. 제 모니터에서 잘 보이던 색 다섯 개가 기준에 못 미쳤습니다. 대비는 계산할 수 있는 값이니 계산에 맡기고, 테스트로 고정합니다.
  5. 브랜드와 가독성은 싸우지 않는다. 브랜드 색을 버릴 필요는 없었습니다. 장식과 글자를 다른 토큰으로 나누자 둘 다 살았습니다.

AI로 만든 화면에서 AI티를 걷어내는 일은 결국 사람이 고른 것을 화면에 남기는 일이었습니다. 그리고 그 선택이 지켜지는지는 훅과 테스트가 매번 확인합니다. 다음에는 폰트와 색 다음 단계인 레이아웃, 카드 세 장 반복을 어떻게 걷어냈는지 정리해 볼 생각입니다.