2026년 11월 11일 수요일
미단재

AI티 나는 디자인 패턴 걷어내기: overused-font 훅 오탐 처리와 접근성 대비 수정

시리즈 Claude Code 워크플로 현재 위치 19 / 29
  1. git worktree로 AI 세션 여러 개를 동시에 돌리기: 브랜치 충돌과 stash 공유 사고를 피하는 운영 규칙
  2. AI에게 기억을 심는 법: 메모리 파일로 삽질 교훈을 세션 간에 유지하기
  3. 스킬로 반복 작업 자동화: 이슈 착수부터 종료까지 한 명령으로 묶은 dev-issue 사례
  4. 코드 그래프(graft)로 AI의 탐색 비용 줄이기: grep/read 대비 토큰 절감 체감
  5. AI 코드 리뷰를 규칙으로 고정하기
  6. 반복되는 Git 워크플로우와 브랜치 정리 자동화
  7. 같은 요청을 여러 저장소에 반복하는 법: 한 줄 프롬프트 + 기준 문서 하나
  8. 이슈 하나를 한 줄로 개발 끝내는 법 — 내가 가장 많이 쓰는 Claude Code 스킬들
  9. 블로그 글을 스무 번 부탁하고 나서, 스킬로 만들었다
  10. 마라톤 세션이 토큰을 먹는 구조: 로그로 찾고, /compact와 서브에이전트로 줄이기
  11. 1인 개발자가 클로드코드로 SaaS 11개 만든 기록
  12. 이슈 하나에 30~60분, 어디서 새고 있었나: skill-creator로 dev-issue 스킬을 만든 과정
  13. 도면을 AI에게 읽히려다 AI를 뺀 이야기
  14. 스쿼시 머지 vs 머지 커밋: 1인 개발자의 PR 전략
  15. "난이도가 높아서 오래 걸리는 거야?" — AIFid 개발 기록으로 따져 본 소요 시간
  16. 엑셀 간편장부를 웹 ERP로 옮기기 (AICassa)
  17. Impeccable 스킬 적용한 게시글 상세 화면 구조 개선
  18. CLAUDE.md로 팀 규칙을 AI에게 가르치는 법
  19. AI티 나는 디자인 패턴 걷어내기: overused-font 훅 오탐 처리와 접근성 대비 수정
  20. CI4 브랜드 에러 페이지: 설계서 한 장에서 구현까지
  21. Claude에 MCP로 디자인 툴 붙이기 — DesignCraft·PhotoCraft 설치부터 명함, 카툰 효과까지
  22. AI 실수 복구: 메인 체크아웃을 dev 상태로 되돌린 기록
  23. 870줄 CLAUDE.md를 116줄로 줄인 실전 리팩터링
  24. AI로 AI 강의안을 만들다 — 실용인문학 5·6강 준비기
  25. Claude Code 토큰 절감 실전: 3개월 로그 분석과 5단계 전략
  26. 개발 기록을 재료로 AI가 쓰는 블로그 글 만들기
  27. Claude Vision으로 영수증·세금계산서 자동 장부 입력 구현
  28. AI가 만든 실제 실수 4가지 패턴과 교훈
  29. GitHub 이슈·PR 기반의 월간 작업일지 자동 생성 Claude 스킬 만들기

시리즈 소개 보기

시리즈 AI로 디자인 현재 위치 5 / 7
  1. Claude에 MCP로 디자인 툴 붙이기 — DesignCraft·PhotoCraft 설치부터 명함, 카툰 효과까지
  2. 코드 대신 그림을 그린 하루 — AI로 명함 한 장 만들기
  3. 개발자가 Claude Design으로 디자인하기 — 미단재 로고를 다시 만들며
  4. 썸네일에 마우스를 올리면 펜 선화가 뭉개지는 이유
  5. AI티 나는 디자인 패턴 걷어내기: overused-font 훅 오탐 처리와 접근성 대비 수정
  6. AI로 AI 강의안을 만들다 — 실용인문학 5·6강 준비기
  7. CMS 에디터가 내 CSS를 지웠다 — 소개 페이지 삽질기

시리즈 소개 보기

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

이어 읽을 글

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.