
AI에게 이미지를 던지며 '여기 틀리지 않았어?'라고 매번 묻는 것에 지쳐서, PowerShell + Chrome headless로 기계적인
요약
AI의 반복적인 규칙 위반 실수를 방지하기 위해 AI의 자기 규율 대신 외부에서 강제하는 기계적 검사 시스템을 구축하는 방법을 다룹니다. PowerShell과 Chrome headless를 활용하여 linter와 이미지 검출 로직을 구현하여 프로세스의 안정성을 높이는 과정을 설명합니다.
핵심 포인트
- AI의 '주의하겠다'는 약속은 신뢰할 수 없으므로 외부 시스템으로 강제해야 함
- API 키, 사내 고유명사, 금지 문자 등을 grep 패턴으로 검사하는 linter 구축
- Chrome headless를 이용해 HTML 기반 이미지의 프레임 초과 여부를 기계적으로 검출
- 판정 로직을 AI로부터 분리하여 수치 기반의 기계적 검사로 전환
반나절 동안 4번, AI가 같은 종류의 실수를 저질렀습니다. 전부 제가 저지른 실수입니다.
- 비밀 정보가 포함될 수 있는 파일을
grep -iE 'vision|gemini|ai' /etc/xxx/config/xxx.toml와 같이 대충 실행하여, API 키 생값을 콘솔에 출력해 버림 - 고객용 복사 붙여넣기 문구에서
-행 시작 기호를 사용함 (복사 대상에서 문자로 남는 운영 규칙 위반) - Qiita 기사 초안에 사내 프로젝트의 고유명사를 그대로 기입함 (공개 직전에 스스로 발견)
- 1Password의 가격을 출처 URL 병기 없이 "월 3.99 달러"라고 작성함 (게다가 연 결제 시의 실질 가격은 별도이며, 당연히 오래된 정보임)
모두 규칙으로서 존재하는 실수들입니다. CLAUDE.md에도 적혀 있고, skill 본문에도 적혀 있습니다. 적혀 있는 대로 실행하지 않았을 뿐입니다. "주의하겠습니다"라고 해서 고쳐지느냐 하면 고쳐지지 않는다는 사실은 4연속 실수를 통해 증명되었습니다.
같은 날, 기사에 첨부할 그림(fig)을 HTML에서 PNG로 변환하고 있었는데, 이 역시 세로 길이가 초과되어 내용이 프레임 밖으로 넘쳤습니다. 육안으로 발견 → HTML 수정 → PNG 재생성 → 다시 육안 확인, 이라는 루프. 그림 3장을 만드는 데 30분을 허비했습니다.
역시, 이것은 이제 시스템으로 막을 수밖에 없습니다.
AI에게 "다음부터는 주의하겠습니다"라고 말하게 하는 방식은 파탄 났습니다. 이유는 3가지입니다.
- skill 본문이나 CLAUDE.md는 기동 시에 한 번 읽을 뿐이라서, 작업 중에는 "읽은 내용을 실행 타이밍에 떠올리는" 기구가 없음
- 현재 Claude Code / Codex / OpenCode / Cursor / Grok / Ollama 6개의 CLI를 병행 운용하고 있으므로, Claude Code 전용 hook을 심어도 다른 5개에는 효과가 없음
- 규칙을 추가해도 그 규칙을 읽지 않으므로, "읽지 않는 실수"에 대해 "읽게 만드는 규칙 추가"는 자기 참조적인 모순
즉, AI의 자기 규율에 의존하지 않고, 외부에서 강제하는 시스템이 필요합니다. git pre-commit hook 같은 프로세스의 빈틈은 아니며(그것도 유효하지만 오늘은 보류), 우선 수치로 때릴 수 있는 기계적 검사를 먼저 준비합니다.
하기로 결정한 것은 2가지입니다.
- 기사의 md에 대한 canonical grep 검사 (linter)
- fig의 HTML에 대한 Chrome headless를 이용한 세로 길이 초과 검출
둘 다 AI가 실행할 필요는 없습니다. 실행하는 것은 저(인간)이거나, 혹은 AI가 결과를 수치로 받기만 하면 됩니다. 판정 로직을 AI로부터 분리하여 기계 안에 가둡니다.
grep 패턴의 canonical화입니다. 오늘 발생한 4종을 모두 잡아낼 수 있도록, 6개 항목을 기계적으로 실행합니다.
- kb 유래 고유명사 (사내 프로젝트명, 환경명, 인물명, 회사명, 내부 호스트명)
- API 키 생값 (
AIzaSy.../sk-.../sk-proj-.../ghp_.../xox.../AKIA...) - 금지 대시 (U+2014 em dash / U+2015 horizontal bar)
- 내부 호스트 · 프라이빗 IP (RFC1918 계열의 프라이빗 주소, 및 internal / corp / local을 포함하는 사내 호스트명)
- 수치 주장과 URL의 근접성 (가격, iter 횟수, bit / byte 등의 직후 ±15행 이내에 URL이 있는지 확인)
- 실존 인물 성명의 의심 (자기 참조 이외의 경우 육안 경고)
PowerShell로 작성한 본체는 이런 느낌입니다.
param(
[Parameter(Mandatory = $true, Position = 0)][string]$File
)
...
출력은 위반 규칙별로 그룹화하여 행 번호, 해당 행, 상세 내용을 출력합니다. 모두 OK라면 exit 0과 OK 메시지만, NG라면 exit 1과 위반 리스트를 출력합니다.
OK: article-lint 전 6개 항목 합격
대상: C:\dev\workshop\docs\articles\plan\...\qiita.md
수치 주장과 URL의 근접성 검사는 \$\d+와 같은 가격 표현이나 \d+\s*bit...
와 같은 스펙 값을 검출했을 때, 해당 값의 ±15행 이내에 URL이 있는지 확인합니다. 근접 범위가 너무 좁으면 오검출(False Positive)이 난무하기 때문에, 실측하면서 조정했습니다. 처음에는 ±4행으로 설정했더니 "같은 섹션 내에서 URL을 위쪽에 몰아서 배치해 둔 기사"들이 오검출의 폭풍을 일으켰습니다. ±15행이 현재로서는 가장 적절한 균형입니다.
이쪽은 Chrome의 Headless 모드를 사용합니다. HTML을 JS 실행을 포함하여 읽어 들인 뒤, .frame, .card, .zone 등의 요소에 대해 scrollHeight - clientHeight를 계산합니다. 차이가 2px 이상이면 "내용물이 박스 밖으로 넘쳤다(overflow)"라고 판정합니다.
위의 그림처럼, 이전에는 "HTML 작성 → PNG 생성 → 사람이 육안으로 확인 → AI에게 '여기 어긋났어'라고 전달 → AI가 이미지를 읽음(read) → 수정안 → PNG 재생성"의 루프였습니다. 지금은 "HTML 작성 → PNG 생성 → 자동 overflow 검출 → NG라면 위치와 px 수 출력 → 수정 → 재생성"으로, 사람과 AI 모두의 시간을 단축합니다.
구현의 핵심은 JS 주입(Injection)과 DOM 덤프(Dump)의 조합입니다.
# 검사 JS 스크립트. head 끝에 주입함
$checkScript = @'
<script>
...
이것을 HTML의 </head> 직전에 삽입한 임시 파일을 만들어, Chrome headless로 읽게 합니다. JS 실행 후의 DOM을 --dump-dom으로 추출하여, __fig_overflow_marker__의 data-count와 textContent를 PowerShell 측에서 정규 표현식으로 가져옵니다.
"구현 30분, 디버깅 2시간"의 전형적인 사례입니다. 세 가지 함정에 빠졌습니다.
함정 1: PowerShell의 & 연산자로 Chrome의 stdout을 가져올 수 없음
처음에는 이렇게 작성했습니다.
$dom = & $chrome --headless=new --disable-gpu --dump-dom $uri 2>$null
이렇게 하면 작동해야 했지만, $dom이 빈 문자열로 반환됩니다. --dump-dom은 stdout에 덤프해야 하는데, 왜 비어 있는 걸까요?
원인은 PowerShell의 & 연산자가 "별도 프로세스의 stdout을 캡처하는 경로가 환경에 따라 누락될 수 있다"는 점인 것 같습니다. .NET의 Process.Start를 사용하여 명시적으로 RedirectStandardOutput = true로 설정하면 가져올 수 있습니다.
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $chrome
$psi.Arguments = "--headless=new --disable-gpu --dump-dom `"$uri`""
...
이렇게 하면 가져올 수 있게 되었습니다. &를 반드시 사용해야 하는 케이스(자작 skill에서 호출되는 runtime 제약 등)도 있으므로, 두 방식의 차이를 알아두는 것은 가치가 있습니다.
함정 2: 일반 실행 중인 Chrome이 headless 세션을 가로챔
위의 Process.Start로 전환해도 여전히 stdout이 비어 있는 경우가 있습니다. Chrome의 시작 로그를 보면 "기존 브라우저 세션에서 열고 있습니다"라고 나옵니다.
Chrome은 "이미 열려 있는 프로세스에 URL을 전달"하는 동작이 기본값이기 때문에, headless로 실행했다고 생각해도 일반 실행 중인 Chrome으로 URL이 전달되어 headless 프로세스는 아무것도 하지 않고 종료됩니다. 이 과정에서 약 15분 정도를 허비했습니다.
대책은 --user-data-dir로 완전히 독립된 프로필을 지정하는 것입니다.
$userdata = Join-Path $env:TEMP "chrome-figcheck-profile"
# --user-data-dir=$userdata 를 Arguments 에 추가
이렇게 하면 기존 Chrome 세션의 간섭을 받지 않게 됩니다. 개발용 PC에서 평소 사용하는 Chrome을 열어둔 채로 headless 검사를 실행하는 워크플로우가 성립하게 되었습니다.
함정 3: JS 실행 타이밍과 --dump-dom의 동기화
--dump-dom은 페이지 로딩이 완료된 시점의 DOM을 출력하지만, 비동기적으로 실행되는 JS(setTimeout 등)의 결과는 기다리지 않습니다. 처음에 setTimeout(run, 300)으로 작성했을 때는 marker가 영원히 생성되지 않았습니다.
대책은 --virtual-time-budget=2000을 추가하여 (Chrome의 내부 시간을 2초 앞당김) + JS 측에서는 setTimeout을 사용하지 않고 DOMContentLoaded에서 즉시 실행하는 것입니다. 두 가지가 모두 갖춰지면, --dump-dom 출력물에 JS가 생성한 marker가 포함됩니다.
& $chrome --headless=new --virtual-time-budget=2000 --dump-dom $uri
여기까지 하면 도구(tool)는 작동합니다. 다음으로는 "이것이 AI 토큰 측면에서 실제로 얼마나 이득인가"를 실질적인 금액으로 산출해보고 싶습니다.
전제 조건이 되는 1차 자료
- Claude API 요금표 (https://platform.claude.com/docs/en/about-claude/pricing)
- Claude Vision의 이미지 토큰 계산식 (https://platform.claude.com/docs/en/build-with-claude/vision)
Opus 4.8의 현행 요금 (전자의 자료 기준).
- Input: $5 / MTok
- Output: $25 / MTok
이미지 토큰은 후자의 공식 문서에 나와 있습니다.
Each patch is a 28×28-pixel block of the image, referred to as a visual token. An image, therefore, costs ⌈width / 28⌉ × ⌈height / 28⌉ visual tokens.
Opus 4.8은 High-resolution tier (장변 최대 2576 px, 최대 4784 tokens)를 사용합니다.
위의 그림과 같이, 제가 사용하는 fig PNG는 1280×720을 2배 스케일로 내보낸 2560×1440입니다. 이 이미지의 시각적 토큰은 ⌈2560/28⌉ × ⌈1440/28⌉ = 92 × 52 = 4784 토큰 (High-res tier의 상한선과 거의 일치)입니다.
이를 금액으로 환산합니다 (Opus 4.8의 Input $5 / MTok · Output $25 / MTok은 공식 표 https://platform.claude.com/docs/en/about-claude/pricing을, 이미지 토큰 계산식은 https://platform.claude.com/docs/en/build-with-claude/vision을 따름).
- 1장 로드 시: 4784 tokens × $5 / 1,000,000 = $0.0239 ≒ 3.6엔 (1 USD = 150 JPY 환산)
"AI에게 이미지를 던져 수정을 시키는" 1회 왕복(round-trip)의 내역을 구성해 보겠습니다.
- 입력: 이미지 1장 (4784 tok) + 대화 프롬프트 (500 tok) + 과거 대화 내용 (약 5000 tok) = 약 10000 input tok
- 출력: 수정 제안 문장 (1000 tok)
- 입력 비용: 10000 × $5 / M = $0.05
- 출력 비용: 1000 × $25 / M = $0.025
- 1회 왕복 합계: $0.075 ≒ 11엔
fig 3장이 각각 평균 3회의 육안 리뷰가 필요하다고 가정하면, 3 × 3 × 11 = 99엔 / 기사가 됩니다.
이것이 linter를 경유하게 되면 어떻게 될까요 (계속해서 동일한 pricing 표 https://platform.claude.com/docs/en/about-claude/pricing의 Opus 4.8 단가로 시산).
- 입력: linter의 출력 (예:
.card[0]: overflow 130px) + 대화 (500 tok) = 약 600 input tok - 출력: 수정 제안 (500 tok)
- 1회 왕복: 600 × $5 / M + 500 × $25 / M = $0.0155 ≒ 2.3엔
게다가 linter (린터)의 경우 "130 px 초과"와 같이 수치로 알 수 있기 때문에, 한 번의 수정 성공률이 높습니다. 실측 결과 평균 1회 왕복 내에 해결되었습니다. 그림 3장의 합계로 3 × 1 × 2.3 = 6.9엔.
절약액: 99엔 - 6.9엔 = 약 92엔 / 기사 (산출 근거는 상기 pricing / vision 2개의 URL)
연간 50개의 기사를 쓴다고 가정하면 92 × 50 = 4600엔 / 년.
덤으로 인간의 육안 검토 시간도 30분 단축되므로, 시급으로 환산하면 효과는 더욱 커집니다.
article-lint 도 마찬가지 논리입니다.
- 육안 리뷰: 이시자카 씨 + AI가 기사 전문(~8,000자 = 약 3,000 tok)을 서로 읽으며 수 회 왕복
- linter:
L86: kb-identifier "myapp" 검출
이와 같이 100 tok 정도의 입력으로 1회 만에 수정
기사당 15~20엔 수준의 절약이 됩니다.
tokenizer (토크나이저) 차이 주의 사항
동일한 pricing 페이지(https://platform.claude.com/docs/en/about-claude/pricing)의 각주에, Opus 4.7 이후, Fable 5, Mythos 5, Sonnet 5는 새로운 tokenizer를 사용하여 약 30% 더 많은 토큰이 생성된다고 적혀 있습니다.
Claude Opus 4.7 and later Opus models, Claude Fable 5, Claude Mythos 5, Claude Mythos Preview, and Claude Sonnet 5 use a newer tokenizer that contributes to their improved performance on a wide range of tasks. This tokenizer produces approximately 30% more tokens for the same text.
(출처: https://platform.claude.com/docs/en/about-claude/pricing)
위의 산출은 "이미지 토큰은 공식 표의 값을 그대로 사용", "텍스트 토큰은 개략적인 수치 사용"이므로, 텍스트 부분은 실제로는 조금 더 많을 수도 있습니다. 하지만 규모(order)는 변하지 않습니다.
이것은 Claude Code 전용 hook (훅)으로 만들지 않은 것이 컸습니다. PowerShell 단독으로 동작하는 CLI 독립 도구로 만들었기 때문에, Codex든 Cursor든 Grok이든, & 'C:\dev\tools\article-lint.ps1' '<file>'
을 실행하면 동일한 검사가 수행됩니다.
사고가 발생하면 linter에 규칙을 한 줄 추가하는 것만으로, 6마리(AI 모델들) 모두에게 자동으로 새로운 체크가 적용됩니다. AI 측의 설정을 건드리지 않습니다. 이 점이 매우 큽니다.
또 하나, 부수적인 효과라기보다는 덤에 가까운 대책으로, 과거의 실패 사례집을 글로벌 ~/.claude/guides/에 두었습니다. 규칙 조문이 아니라, 사고의 실물을 "상황 → 수행한 작업 → 증상 → 원인 1줄 → 교훈 1줄"로 나열합니다.
추상화하지 않고 생생하게 남기는 방침으로 작성했습니다. "비밀이 포함될 수 있는 파일에 대한 grep은 -c / -o / ^KEY_NAME으로 제한한다"와 같이 교훈화하는 것보다, "grep -iE 'vision|gemini|ai'를 실행했을 때 ai의 두 글자가 chatgpt_api_key에 매칭되어 생값이 유출되었다"라고 실물을 남기는 편이, 다음에 읽는 AI(또는 나)가 더 잘 걸려들 것 같기 때문입니다.
이는 linter로 막을 수 없는 종류의 실수(설계 판단 실수나 조사 접근 방식의 실수)를 위한, 감정적 기억에 의존하는 보험입니다. 다만 본래 목적은 어디까지나 기계적 검사 (linter)이며, 사고 사례집은 "읽기만 할 뿐 실행은 하지 않는" 타입이기에 효과는 제한적이라고 선을 긋고 있습니다.
대단한 사상도 아니고, 단순히 "규칙을 AI에게 읽히고 실행하게 만들기"를 포기한 이야기입니다. 실행해 주지 않으니, 실행하지 않더라도 기계가 대신 호출하는 형태로 가져가는 것이 더 빠릅니다.
흥미로웠던 배움 4가지.
- AI에게 "주의해 주세요"라고 말하는 것은 원리적으로 통하지 않는다. 태만해서가 아니라 구조적인 한계다. 실행하는 타이밍에 규칙을 떠올리지 못한다.
- canonical grep 패턴을 파일에 고정하는 것만으로, 6개의 CLI 모두에서 동일한 검사가 돌아간다. CLI 독립적으로 만드는 것이 강력하다.
- fig의 세로 오버(vertical overflow) 같은 시각적 판정도 DOM의
scrollHeight/clientHeight차이로 기계화할 수 있다. "왠지 어긋나 있다"를 AI에게 판정하게 할 필요는 없다. - Opus 4.8의 실가격으로 계산하면, 육안 루프에서 기계 검사 루프로의 전환은 기사 1건당 약 90엔 정도의 절약이다. 연간 50건이면 4,500엔, 10년이면 4.5만 엔이다. 인간의 시간 비용은 별개다.
"AI에게 성실하게 체크하게 만드는 것"보다 "기계가 확실하게 체크하게 하는 것"이 금전적으로나 시간적으로나, 그리고 정밀도 면에서도 유리하다. 당연한 것을 당연하게 할 뿐이다.
내일 이후, 동일한 유형의 사고가 발생하면 linter에 규칙을 추가해 나가겠습니다. 사고를 교과서로 만드는 방식입니다. 아마 내일이나 모레, 새로운 종류의 사고가 발생할 것이므로 그것도 기사로 쓸 수 있을 것 같습니다.
📎 도해판·관련 링크를 정리한 페이지가 있습니다:
작성자: ishizakahiroshi
Web 엔지니어 18년 차. 백엔드(Backend)·인프라(Infrastructure)·AI 연동이 주 전장입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기