AI 보조 코딩이 가져온 799개의 하드코딩된 폰트 크기
요약
AI 보조 코딩 과정에서 발생하는 'AI 코드 드리프트' 현상을 분석합니다. 디자인 토큰이 존재함에도 불구하고 AI가 임의의 픽셀 값을 사용하여 코드 일관성이 무너지는 문제를 다루며, 이를 방지하기 위한 기술적 방어책을 제시합니다.
핵심 포인트
- AI 보조 코딩 시 디자인 토큰을 무시한 하드코딩된 값이 발생하는 'AI 코드 드리프트' 현상 발생
- 개별 변경 사항은 무해해 보여 코드 리뷰만으로는 드리프트를 잡아내기 어려움
- 프롬프트 개선만으로는 한계가 있으며, 강력한 린터(Linter)와 스냅 규칙 설정이 필수적임
- 디자인 토큰 매핑과 에러 레벨의 린터 설정을 통해 코드 일관성을 강제해야 함
감사(Audit)로 시작된 것은 아니었습니다. 그것은 마음 한구석을 괴롭히는 느낌, 즉 인터페이스가 불안정해 보인다는 느낌에서 시작되었습니다. 첫눈에는 알아채지 못할 수도 있지만, 두 번째나 세 번째로 다시 보면 보입니다. 여기서는 저기보다 약간 더 큰 타임스탬프, 옆의 대화창(Dialog)보다 아주 조금 더 작은 대화창 제목 같은 것들 말입니다. 편지나 이력서에서 지켜야 할 규율은 누구나 알고 있습니다. 동일한 서체, 동일한 폰트 크기, 동일한 정렬을 사용하는 것입니다. 애플리케이션 인터페이스도 마찬가지이지만, 위반 사항이 어느 한 곳에 집중되어 나타나지 않는다는 점이 다릅니다. 대신 여러 화면에 걸쳐 퍼져 있는 막연한 불안정함으로 나타납니다.
오직 그 느낌 하나 때문에 프론트엔드 전체에 카운팅 명령을 실행했고, 그 결과가 설명을 제공했습니다: text-[13px]와 같은 생(raw) 픽셀 폰트 크기가 74개의 파일에 걸쳐 25개의 서로 다른 픽셀 값으로 총 799번 발견되었습니다. 이는 이미 6개의 토큰으로 구성된 문서화된 폰트 크기 스케일(font-size scale)이 존재하던 프로젝트였습니다. 해당 스케일은 263번 사용되었지만, 약 1,180번은 무시되었습니다. 이것은 10년에 걸쳐 성장한 오래된 코드베이스(Legacy codebase)에서 발견되는 나태함이 아닙니다. 이는 첫날부터 Claude Code를 이용한 AI 보조 방식으로 구축된 코드베이스의 상태입니다. 이것이 바로 **AI 코드 드리프트 (AI code drift)**의 모습입니다. 모든 개별 제안은 국소적으로는 타당해 보이지만, 그것들이 쌓여 측정하기 전에도 눈에 보이는 불안정함을 만들어냅니다.
이 글은 실제 수치, 문제를 종결시킨 규칙, 그리고 산문(Prose) 속에만 존재하는 컨벤션(Convention)은 언어 모델(Language model)을 이길 수 없다는 솔직한 고백을 담은 경험 보고서입니다.
핵심 요점:
- 발견 사항 (The finding): 스케일(scale)이 문서화되어 있었음에도 불구하고, 263개의 토큰 사용량(token usages)에 대해 799개의 가공되지 않은 픽셀(raw pixel) 폰트 크기가 발견되었습니다. 이 수치 자체보다 중요한 것은 그 비율입니다.
- 드리프트(drift)의 가장 큰 원인은 토큰 사이의 값에 있습니다:
11px나12px와 같이 토큰이 아예 존재하지 않는 값들이 약 400회 발생하며 대다수를 차지했습니다. - 코드 리뷰로는 이를 잡아낼 수 없습니다. 왜냐하면 개별적인 변경 사항(diff) 하나하나만 보면 무해해 보이기 때문입니다.
- 프롬프트를 개선하면 적중률(hit rate)은 높아지지만 드리프트를 완전히 제거할 수는 없습니다. 생성량이 많아지면 남아있는 미세한 비율도 결국 눈에 띄게 됩니다.
- 유효한 방어책: 6단계의 스케일, 고정된 요소-토큰 매핑(element-to-token mapping), 모든 중간값에 대한 명확한 스냅 규칙(snap rule), 그리고
error레벨의 린터(linter) 설정입니다.
전제 조건 (Prerequisites): 이 예제는 Next.js 프로젝트에서 Tailwind CSS와 ESLint를 사용합니다. 이 패턴은 프레임워크와 관계없이 디자인 토큰(design tokens)이 있는 모든 코드베이스에 적용되며, 기사 마지막 부분에서 보여주듯 SQL 컨벤션(convention)에도 동일하게 적용됩니다.
발견 사항: 263개에 대비한 799개
이 수치들의 배경이 되는 프로젝트는 DI²로, Next.js와 PostgreSQL을 기반으로 구축된 ETL 생성기이며, 코드가 거의 전적으로 AI의 보조를 받아 작성되었습니다. 폰트 크기에 대해서는 명확한 컨벤션이 존재했습니다. Tailwind 설정에 정의되어 있고 에이전트가 모든 프론트엔드 작업 시 로드하는 브랜드 규칙 파일(brand rule file)에 기술된 text-di-h1 (18px)부터 text-di-label (10px)까지의 6개 명명된 토큰이 그것입니다. 따라서 다음에 이어지는 내용은 단일 프로젝트에 대한 측정 결과이며, 하나의 데이터 포인트일 뿐 증거는 아닙니다. 이 사례를 프로젝트 이상의 흥미로운 주제로 만드는 것은 그 이면에 있는 메커니즘이며, 앞으로 살펴보겠지만 그 메커니즘은 특정 프로젝트에 국한된 것이 아닙니다.
2026년 6월 25일 기준, 모든 src/**/*.tsx 파일을 대상으로 한 인벤토리(inventory) 조사 결과 세 가지 카테고리가 도출되었습니다:
| 선언 스타일 (Declaration style) | 발생 횟수 (Occurrences) | 파일 수 (Files) | 평가 (Assessment) |
|---|---|---|---|
Raw pixels text-[Xpx] | 799 | 74 | 드리프트 (drift), 25개의 서로 다른 픽셀 값 |
| ... | |||
이 세 줄은 동일한 폰트 크기를 선언하는 세 가지 방식을 구분합니다. text-[13px]는 Tailwind의 임의 값 구문 (arbitrary-value syntax)입니다. 픽셀 값이 대괄호 안에 문자 그대로 들어가며 인라인 font-size처럼 작동합니다. 어떤 값도 가능하기 때문에 25개의 서로 다른 값이 생겨나게 된 것입니다. 반면 text-xs, text-sm, text-base는 이름이 지정된 크기 단계 (named size steps)이지만, 프레임워크의 번들 기본 스케일 (bundled default scale, 12, 14, 16 픽셀)에 속합니다. 이는 스케일을 따르고 있으므로 규율이 있어 보입니다. 하지만 잘못된 스케일입니다. 프로젝트 자체의 스케일이 아닌 Tailwind의 일반적인 스케일을 따르고 있기 때문입니다. 프로젝트의 자체 스케일은 6개의 토큰(token)을 가지고 있으며, 그 안에는 이 세 가지 값이 포함되어 있지도 않습니다. 마지막 세 번째 줄은 프로젝트 자체의 스케일로, 우리가 목표로 하는 패턴입니다. 표를 위에서 아래로 읽으면 하나의 사다리와 같습니다: 자유로운 값, 외부 스케일, 자체 스케일. |
참고로, 두 스케일이 공존하는 것은 우연이 아니라 프레임워크의 기본 설정 때문입니다. Tailwind는 모든 프로젝트에 완전한 크기 사다리를 함께 제공하며, 프로젝트 자체의 토큰은 theme.extend를 통해 추가되었습니다. 여기서 extend는 말 그대로 '확장'을 의미하며 '교체'를 의미하지 않습니다. 그 시점부터 모든 text-sm은 모든 text-di-body만큼이나 아무 문제 없이 컴파일됩니다. 프로젝트가 외부 스케일에 동의하는 순간도, 이를 공표하는 순간도 없습니다. 그저 첫날부터 작은 텍스트가 필요한 사람이나 모델에게 통계적으로 가장 가까운 선택지로서 그 자리에 존재할 뿐입니다.
두 번째 용어는 이 글 전체에서 반복되므로 설명이 필요합니다. **앱 범위 (app scope)**란 로그인 이후의 애플리케이션, 즉 대시보드와 관리 페이지 및 이들이 렌더링하는 컴포넌트들을 의미합니다. 토큰 스케일 (token scale)은 오직 그 영역에만 적용됩니다. 그 외에는 고유한 권한을 가진 두 개의 영역이 존재합니다. 하나는 의도적으로 더 큰 형식을 사용하는 공개 페이지 (랜딩, 법률, 로그인)이고, 다른 하나는 내부적으로 Tailwind 기본값을 사용하는 번들된 UI 베이스 컴포넌트들입니다. 따라서 text-sm 자체가 틀린 것은 아닙니다. 마케팅 페이지에서는 정당한 사용입니다. 그렇기에 두 번째 행에는 **앱 범위 내 (within the app scope)**에서의 드리프트 (drift)라고 명시되어 있습니다. 383개의 발생 사례 중 애플리케이션 내부에서의 공유 사례만이 드리프트로 간주됩니다.
결과적으로, 컨벤션 (convention)은 약 1:4.5의 비율로 무시되었습니다. 여러분의 프로젝트에서 이 인벤토리 (inventory)를 다시 실행해 보려면 ripgrep과 같은 검색 도구만 있으면 됩니다 (예제는 PowerShell로 작성되었습니다):
# 원시 픽셀 폰트 크기: 총 발생 횟수
rg --no-filename -o 'text-\[[0-9.]+px\]' src | Measure-Object -Line
...
타임라인은 그 자체로 반전(punchline)을 선사합니다. 첫 번째 집계와 정리 마이그레이션 (cleanup migration)이 시작되기 직전인 3주 후의 재집계 사이에서, 총 발생 횟수는 799개에서 813개로, 고유 값은 25개에서 26개로 늘어났습니다. 제거 계획이 이미 세워져 있는 동안에도 드리프트는 계속 커지고 있었습니다. 강제되지 않는 컨벤션은 단 한 번에 무너지는 것이 아니라, 매일 조금씩 더 무너집니다.
왜 아무도 발견하지 못했는가
이런 숫자를 마주했을 때 가장 먼저 드는 생각은 '어떻게 이런 일이 빠져나갈 수 있었지?'일 것입니다. 답은 불편합니다. 이는 태만이 아니라 구조적인 결함을 설명하기 때문입니다.
text-[12px]는 단일 디프 (diff) 상으로는 틀린 것이 아닙니다. 올바르게 렌더링되고, 프리뷰 (preview)에서도 괜찮아 보이며, 테스트를 통과합니다. 새로운 다이얼로그 (dialog)의 디프를 읽는 리뷰어는 로직, 상태, 접근성 (accessibility)을 확인합니다. 이 파일의 폰트 크기가 다른 73개 파일의 폰트 크기와 일치하는지 비교하지는 않습니다. 드리프트는 어느 한 파일 안에 존재하는 것이 아닙니다. 그것은 파일들 **사이 (between)**에 존재합니다.
그럼에도 불구하고 그것은 눈에 보였습니다. 결함으로서가 아니라, 처음부터 느껴지던 찝찝한 기분으로서 말입니다. 불안정한 인터페이스는 위치가 아닌 증상을 보여줍니다. 단 한 줄도 그 자체로는 틀리지 않았을 때, 74개의 파일 중 어느 것을 지목하시겠습니까? 그래서 그 인상은 오랫동안 아무런 결과 없이 남아 있었습니다. 그것은 어떤 디프 (diff)에도 고정될 수 없었고, 디프에 고정될 수 없는 것은 결국 리뷰 코멘트나 티켓(ticket)에 남지 못합니다. 시각적 회귀 테스트 (Visual regression tests) 역시 보통 이러한 형태의 불일치를 잡아내지 못하는데, 왜냐하면 각 뷰 (view)를 각각의 베이스라인 (baseline)과 비교하기 때문입니다. 둘 다 완전히 새로운 두 뷰는 차이점이 드러날 수 있는 공유된 베이스라인이 없습니다. 오직 인벤토리 (inventory)만이 그 느낌을 숫자가 있는 발견으로 바꾸어 놓았고, 이를 통해 수정 가능한 것으로 만들었습니다.
다음은 속도입니다. 하루에 하나의 다이얼로그 (dialog)를 만드는 사람은 일주일에 몇 번의 폰트 크기 (font-size) 결정을 내리며, 근육 기억 (muscle memory)이 이를 상당히 안정적으로 유지해 줍니다. 반면 같은 주에 20개의 컴포넌트 (component)를 생성하는 에이전트 (agent)는 동일한 결정을 수백 번 반복하며, 각 결정은 이전의 모든 것들과의 일관성이 아니라 로컬 (local) 수준에서 최적화됩니다. 불일치는 부주의함에서 오는 것이 아니라, 독립적인 단일 결정들이 만들어내는 엄청난 양에서 비롯됩니다. 인벤토리에서 가장 밀도가 높은 단일 파일 하나가 무려 70개의 가공되지 않은 픽셀 크기를 차지하고 있었습니다.
인간도 똑같은 드리프트 (drift)를 만들어냅니다 — 정직하게 말하자면 그렇습니다. 차이점은 실수의 종류가 아니라 속도와 양에 있습니다. 팀이 2년 동안 급격한 성장 과정에서 축적하는 것을, AI 보조 개발은 단 한 분기 만에 해냅니다.
드리프트는 토큰 사이에 존재한다
인벤토리에서 가장 시사하는 바가 큰 부분은 25개의 픽셀 값의 분포입니다. 이는 세 가지 클래스로 나뉘는데, 그중 중간 클래스가 흥미롭습니다:
- 정확한 토큰 일치 값 (Exact token matches): 원시(raw)로 작성된 토큰에 해당하는 값들 — 예를 들어
text-di-body대신13px가 사용되거나,text-di-meta대신11.5px가 사용되는 경우(각각 166회, 46회), 그리고 나머지 토큰 값들이 해당합니다. 이 클래스는 기계적으로 수정 가능하며 시각적으로는 아무 변화를 주지 않습니다 (no-op). - 토큰 외 값 (Off-token values): 아예 어떤 토큰도 존재하지 않는 값들에 대한 대략 400건의 발생 사례입니다. 이 중
11px가 196회,12px가 143회로 가장 많았으며,12.5px(42회)와14px(19회)도 포함됩니다. - 고의적인 예외 (Deliberate exceptions): 앱 스케일(app scale) 밖에 의도적으로 위치하는, 자체적으로 큰 포맷을 가진 마케팅 및 법률 페이지들입니다. 예를 들어
22px나44px같은 값들이 있습니다.
두 번째 클래스는 다시 한번 살펴볼 가치가 있습니다. 11px와 12px는 모두 동일한 토큰인 text-di-meta (11.5px) 옆에 위치하며, 나중에 이 토큰으로 통합되었습니다. 이렇게 두 가지 다른 픽셀 값이 같은 의미적 역할 — 즉 메타 텍스트(meta text), 타임스탬프, 보조 라인 — 을 수행한 사례가 총 339건입니다. 아무도 이 두 값 모두 존재해야 한다고 결정하지 않았습니다.
그것은 그리 멀리 가지 못하며, 그 이유는 모델이 작동하는 방식에 있습니다. 언어 모델 (Language Model)은 방대한 양의 공개 코드, 그중에서도 수많은 Tailwind 프로젝트를 기반으로 학습되며, 그 안에서 text-sm 또는 text-[12px]는 작은 텍스트를 작성하는 압도적으로 가장 흔한 방식입니다. 반면 text-di-meta와 같은 프로젝트 특정 컨벤션 (Convention)은 규칙 디렉토리 내의 단 하나의 파일에 불과합니다. 매 생성 단계마다 해당 규칙은 그 가중치와 경쟁하게 되며, 자주 승리하지만 항상 승리하는 것은 아닙니다. 천 번의 결정 중 적중률이 90%라고 해도 여전히 백 군데의 드리프트 (Drift) 지점이 남으며, 훨씬 더 높은 비율이라 할지라도 충분한 볼륨 앞에서는 수십 개의 오류로 귀결됩니다.
산문 형태의 규칙은 적중률을 높이고, 이유를 명시한 규칙은 이를 더욱 향상시킵니다. 에이전트 (Agent)가 실제로 따르는 규칙 세트가 어떻게 형성되는지는 방법론 아티클 Deriving SQL Conventions with Claude Code에서 설명되어 있습니다. 하지만 100% 미만의 모든 비율은 대량의 데이터에서 드리프트 (Drift)를 의미합니다. 예외 없이 유지되어야 하는 컨벤션 (Convention)을 위해 프롬프트 (Prompt)는 잘못된 도구입니다. 지치지 않는 검증 절차가 필요합니다.
효과적인 방법: 스케일 (Scale), 카테고리 매핑 (Category Mapping), 스냅 룰 (Snap Rule)
정리 작업은 결합되었을 때만 작동하는 세 가지 구성 요소로 이루어졌습니다.
첫째, 스케일 (Scale) 그 자체입니다. Tailwind 설정의 정확히 한 곳에 정의된 6단계이며, 각 단계에는 줄 높이 (Line height)가 내장되어 있습니다:
// tailwind.config.ts — 단일 진실 공급원 (Single source of truth)으로서의 스케일
fontSize: {
'di-h1': ['18px', { lineHeight: '23.4px' }],
...
중요한 결정은 스케일을 6단계로 유지하는 것이었습니다. 유혹적인 대안은 11px와 12px를 위한 새로운 토큰 (Token)을 생성하여 현재의 상태를 합법화하는 것이었습니다. 그렇게 했다면 드리프트 (Drift)는 공식적인 8단계 스케일로 변했을 것이고, 그다음 중간값은 다시 공백을 찾아냈을 것입니다.
둘째, 요소-카테고리 매핑 (element-category mapping)입니다. 규칙 파일 내의 테이블은 어떤 종류의 요소가 어떤 토큰 (token)을 사용하는지 정의합니다. 섹션 제목은 di-h1, 다이얼로그 제목은 di-h2, 테이블 셀과 버튼은 di-body, 타임스탬프와 보조 텍스트는 di-meta, 카운터 필(pill)은 di-micro, 대문자 레이블은 di-label을 할당받습니다. 이를 통해 "이 요소에는 어떤 크기가 필요한가?"라는 질문은 더 이상 취향의 문제가 아니라 조회 (lookup)의 문제가 됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기