AI 에이전트의 첫 진단이 틀렸을 때: 로그가 잡아낸 6건
AI 코딩 에이전트가 자신 있게 틀린 6건. MySQL 접속 한도, PHP include 깊이, robots.txt 순서, CDN, 휴대폰 그림, 셸 루프까지, 그것을 드러낸 로그와 측정을 정리했다.
AI 코딩 에이전트에게 운영 서버의 장애 원인 분석과 수정을 맡기고 있다면, 에이전트가 실제로 어떤 모습으로 틀리는지 알아 두는 게 좋습니다. 운영 사이트와 앱 API를 AI 에이전트와 함께 나흘 동안 운영하면서, 첫 설명이 자신 있고 그럴듯한데 틀렸던 경우가 6건 모였습니다. 모두 같은 방법으로 잡았습니다. 코드를 더 꼼꼼히 읽어서가 아니라, 로그나 파서나 측정이 반대 결과를 내서 잡았습니다.
AI 코딩 에이전트에게 운영 서버를 맡겼을 때 실제로 버틴 안전장치에서 다룬 실수 다섯 가지와는 다릅니다. 그쪽은 에이전트가 만든 결과물의 오류였고, 이번에는 믿음의 오류입니다. 한도를 어떻게 세는지, 디렉터리가 몇 단계인지, 파서가 파일을 어떻게 읽는지에 대한 믿음이었습니다. 각 사례는 전제 → 확인 → 증거 → 수정 순서로 정리했습니다.
사례 1: "모두가 시간당 한도 하나를 나눠 쓴다"
전제. 공유 호스팅의 크론이 MySQL 오류 1226(max_connections_per_hour, 한도 500)으로 실패했습니다. 에이전트의 진단은 이랬습니다. 앱 API, 크론, 옛 페이지가 모두 같은 DB 사용자로 접속하니 시간당 500번을 다 같이 나눠 쓰고, API 접속이 많아서 크론 몫이 바닥났다. 말이 됐고, 저희는 그 설명으로 글까지 공개했습니다.
확인. 누가 한도를 쓰는지 보려고 에이전트는 공용 DB 접속 함수에 한 줄짜리 기록기를 넣었습니다. 접속할 때마다 시각, 출처, 클라이언트 종류만 적고, 응답 내용은 바꾸지 않았습니다.
증거. 몇 시간 만에 로그가 진단을 뒤집었습니다. 저녁 두 시간 동안 앱 API만으로 1,557번과 1,503번 접속했는데, 한도의 약 세 배인데도 요청이 모두 성공했습니다. 한도 500을 정말 다 같이 쓴다면 실패했어야 합니다. 차이는 접속 경로였습니다. API는 localhost로 접속하고, 실패한 크론은 DB 서버의 원격 호스트 이름으로 접속했습니다. 외부 수집기가 쓰는 경로와 같은 쪽입니다.
수정. MySQL 계정은 user@host 단위이고, 한도는 로컬이 아니라 원격 경로 쪽에서 세어지고 있었습니다. 크론은 로컬 접속을 먼저 시도하게 바꿨고, 글도 같은 날 저녁에 새 측정으로 무엇이 바뀌었는지를 덧붙여 정정했습니다. 자세한 내용은 MySQL 1226 max_connections_per_hour: 공유 호스팅 대처법에 있습니다.
교훈. 첫 진단은 문서와 추론으로 쌓은 것이었고, 두 번째는 세어 본 결과였습니다. 맞은 것은 두 번째뿐이었습니다.
사례 2: "다섯 단계 위가 홈 디렉터리"
전제. 접속 기록기는 웹 공개 영역 밖, 계정 홈 아래 비공개 폴더에 뒀습니다. 옛 PHP 파일 10개가 이것을 불러와야 해서, 에이전트는 @include_once(dirname(__FILE__, 5) . '/<private>/connlog.php')라고 쓰고 "5단계 위가 홈"이라고 주석을 달았습니다. @는 의도한 것입니다. 기록기가 없어도 페이지는 돌아가야 하니까요.
확인. 배포 뒤 에이전트는 "페이지가 열린다"에서 멈추지 않았습니다. 그날 로그를 서버에서 내려받아 출처별로 줄 수를 셌습니다.
증거.
new-format lines: 22
[(('api', 'dart'), 22)]
22줄 모두 앱 API였고, 옛 페이지에서 온 줄은 0이었습니다. 바로 전에 에이전트가 옛 페이지를 직접 몇 번 열었는데도요. 페이지 바이트는 배포 전과 똑같았습니다. 그게 문제였습니다. 페이지를 지키려고 붙인 @가 실패까지 숨긴 것입니다. 이 경로는 틀리기 쉽습니다. 같은 구조를 자리표시자 이름으로 재현해 PHP 8.5에서 돌렸습니다.
<?php
// A file inside the web root of a shared host (placeholder path)
$file = '/home/user/domains/example.com/www/app/lib/db.php';
foreach ([1, 5, 6] as $n) {
printf("dirname(\$file, %d) = %s\n", $n, dirname($file, $n));
}
// What @include_once does with a missing file: nothing visible
$r = @include_once dirname($file, 5) . '/private/log.php';
var_dump($r);
dirname($file, 1) = /home/user/domains/example.com/www/app/lib
dirname($file, 5) = /home/user/domains
dirname($file, 6) = /home/user
bool(false)
에이전트는 lib/ 위의 폴더를 셌지만, dirname()은 파일에서부터 셉니다. 첫 단계가 파일이 든 폴더 자신이라서, 홈까지는 5가 아니라 6단계였습니다.
수정. 10개 파일 모두 dirname(__FILE__, 6)으로 바꿔 다시 배포하고 로그를 다시 읽었습니다. 옛 페이지 줄이 2줄 나타났고, 페이지 바이트는 여전히 같았습니다.
교훈. 실패가 일부러 조용하게 설계돼 있다면, 성공을 증명하는 것은 기대한 부수 효과뿐입니다. 그것을 확인하세요.
사례 3: "먼저 전부 허용하고, 예외를 나열한다"
전제. 스크래퍼 대책으로 robots.txt에 규칙을 더했습니다. 일반 그룹은 이미 Allow: / 다음에 API와 옛 앱 화면의 Disallow: 줄이 오는 형태였습니다. 전부 허용하고 예외를 적는다. 자연스럽게 읽힙니다.
확인. 배포 전에 에이전트는 빌드된 파일을 Python 표준 라이브러리 urllib.robotparser에 넣고, (봇, 경로) 쌍과 기대하는 답을 늘어놓고 확인했습니다.
증거. Googlebot /api/v1/x 허용. 막으려던 경로였습니다. Python 파서는 처음 맞는 규칙을 쓰고, Allow: /는 모든 경로에 맞습니다. Google은 가장 긴 규칙을 쓴다고 공개해 두었으니 Googlebot이라면 막았겠지만, 모든 수집기가 Google 방식을 따르지는 않습니다. 최소 재현입니다(Python 3.9).
from urllib.robotparser import RobotFileParser
ALLOW_FIRST = """User-agent: *
Allow: /
Disallow: /api/
Disallow: /legacy-app/
"""
DISALLOW_FIRST = """User-agent: *
Disallow: /api/
Disallow: /legacy-app/
Allow: /
"""
for name, text in [("Allow first", ALLOW_FIRST), ("Disallow first", DISALLOW_FIRST)]:
rp = RobotFileParser()
rp.parse(text.splitlines())
print(name)
for path in ["/api/v1/list", "/legacy-app/page.php", "/en/some-post/"]:
print(f" {path:22} {'allowed' if rp.can_fetch('Googlebot', 'https://example.com' + path) else 'blocked'}")
Allow first
/api/v1/list allowed
/legacy-app/page.php allowed
/en/some-post/ allowed
Disallow first
/api/v1/list blocked
/legacy-app/page.php blocked
/en/some-post/ allowed
수정. Disallow 줄을 Allow: / 앞으로 옮겼습니다. 이 순서면 먼저 맞는 줄을 쓰는 파서와 가장 긴 규칙을 쓰는 파서가 같은 답을 냅니다. 파서로 다시 확인하고, 배포 뒤에는 공개된 파일을 받아 순서를 확인했습니다.
교훈. "Google 기준으로 맞다"와 "맞다"는 다른 주장입니다. 다르게 읽는 파서로도 시험하세요.
사례 4: "Googlebot UA를 보내면 Googlebot이 보는 화면을 알 수 있다"
전제. 같은 작업에서, 옛 페이지에 오는 공격적인 수집 봇에게 403을 돌려주는 .htaccess 규칙도 넣었습니다. 검색엔진은 영향을 받으면 안 됩니다. 에이전트는 시험 항목을 하나 넣었습니다. Googlebot의 User-Agent로 옛 페이지를 요청해서 200을 기대하는 것입니다.
확인. 늘 하던 대로 운영 서버의 일회용 폴더에서 규칙 전체를 시험했습니다.
증거. 그 항목만 빼고 모두 통과했습니다.
OK 403 GET 봇차단: AhrefsBot → 옛 화면 403
OK 200 GET 앱 Dart → API 200
OK 200 GET 안드로이드 웹뷰 → 옛 화면 200
FAIL 403 GET Googlebot → 옛 화면 200
첫 짐작은 "새 규칙이 너무 넓게 걸렸다"였습니다. 에이전트는 규칙을 고치기 전에 그 짐작부터 확인했습니다. 같은 Googlebot 문자열을 규칙 대상이 아닌 페이지(블로그 첫 화면과 글 하나)에 보냈더니 둘 다 403이었고, Bingbot 문자열도 403이었습니다. 저희 규칙으로는 생길 수 없는 결과입니다. 호스팅의 CDN이 Googlebot이나 Bingbot을 자칭하면서 그 네트워크에서 오지 않은 요청을 사이트 전체에서 거부하고 있었습니다.
수정. 규칙에는 문제가 없었으니 그대로 뒀습니다. 시험 항목만 빼고, 가짜 Googlebot으로는 밖에서 시험할 수 없는 이유를 주석으로 남겼습니다. 진짜 Googlebot이 보는 화면은, 진짜 Googlebot 요청을 보내는 Search Console의 URL 검사로만 확인할 수 있습니다.
교훈. 실패한 시험은 내가 쓰지 않은 층까지 포함한 경로 전체에 대한 증거입니다. 코드를 고치기 전에 어느 층이 거부했는지부터 찾으세요.
사례 5: "그림은 문제없다", 휴대폰 폭에서 보기 전까지는
전제. 본문 그림은 YAML에서 만드는 SVG이고, 휴대폰용 세로 배치본도 자동으로 나옵니다. 세로 배치의 열 간격은 28px이었습니다. 데스크톱에서는 멀쩡했고 폭 검사도 경고 0건이었습니다.
확인. 옛 글에 그림을 넣는 에이전트들에게는 세로 배치본까지 PNG로 그려서 눈으로 보도록 했습니다.
증거. 여러 에이전트가 따로따로 같은 것을 보고했습니다. 휴대폰 배치에서 옆으로 가는 화살표의 라벨("예", "아니오", 짧은 조건)이 상자 테두리 위에 겹친다는 것입니다. 한 보고서는 원인까지 적었습니다. "그 열 간격이 28px뿐이다." 폭 검사로는 보이지 않습니다. 너무 넓은 것은 없고, 그냥 겹쳤을 뿐이니까요.
수정. 렌더러 한 줄로 간격을 28px에서 54px로 넓혔습니다. 당시 그림 41개를 모두 다시 검사해 오류 0·경고 0을 확인했고, 메인 세션이 헤드리스 브라우저로 그림 두 개를 358px 폭에서 찍어 결과를 봤습니다.
OG 카드의 일본어 제목에서도 같은 종류의 실수를 했습니다. 첫 줄바꿈 처리는 "本番"의 "本"과 "番" 사이처럼 단어 중간에서 줄을 바꿨고, 사람이 그려진 카드를 보기 전까지 모든 검사를 통과했습니다. 고친 방법은 Pillow로 OG 이미지 카드 자동 생성: 한·일 제목 줄바꿈 처리에 있습니다.
교훈. 눈에 보이는 결과물은, 사람들이 실제로 보는 크기의 스크린샷까지가 시험입니다.
사례 6: "안 쓰는 이미지 75개를 모두 옮겼다", 이 글을 쓰다가 발견
전제. 옛 글 54편을 보강한 뒤, 메인 세션은 어느 글도 참조하지 않는 옛 이미지 75개를 목록 파일에 적고, 셸 루프로 하나씩 로컬 백업으로 옮겼습니다. 그다음 배포에서 서버에서도 지웠습니다.
확인. 당시에는 이것을 확인하는 검사가 없었습니다. 찾은 것은 이 글을 쓰려고 다시 세었을 때입니다. 같은 미사용 이미지 스크립트를 오늘 돌린 결과입니다.
29 files, 28 referenced, 1 unused
/static/img/legacy/2021/07/st.jpg
증거. 목록에 있었는데 왜 남았을까요. 목록 파일에는 75개가 있었지만 마지막 줄 뒤에 줄바꿈이 없었고, 그냥 쓴 while read 루프는 줄바꿈 없는 마지막 줄을 읽지 않습니다. 그날 로그에 이미 어긋남이 찍혀 있었습니다. 목록의 unused: 75와 드라이런의 prune would delete: 74입니다. 두 숫자를 비교한 사람이 없었을 뿐입니다. zsh에서 재현했습니다.
printf '/img/a.png\n/img/b.png\n/img/c.jpg' > list.txt
echo "lines by wc -l: $(wc -l < list.txt) entries by grep -c: $(grep -c . list.txt)"
echo "-- while read"; while read u; do echo "move $u"; done < list.txt
echo "-- while read || [ -n ]"; while read -r u || [ -n "$u" ]; do echo "move $u"; done < list.txt
lines by wc -l: 2 entries by grep -c: 3
-- while read
move /img/a.png
move /img/b.png
-- while read || [ -n ]
move /img/a.png
move /img/b.png
move /img/c.jpg
빠진 파일은 제3자의 상품 사진이었습니다. 어느 글에서도 쓰지 않는데 옛 주소로는 여전히 공개돼 있었습니다. 이 글을 쓰려고 세다가 찾았고, 이 글을 올리는 배포에서 함께 지웠습니다.
수정. while read -r u || [ -n "$u" ]를 쓰고, 목록 개수, 옮긴 개수, 지운 개수가 같아지기 전에는 정리가 끝났다고 하지 않습니다.
6건의 공통점
| 사례 | 에이전트의 믿음 | 반대 결과를 낸 검사 |
|---|---|---|
| MySQL 1226 | 모든 클라이언트가 한도 하나를 공유 | 접속별 로그: localhost로 시간당 약 1,500번 성공 |
| include 깊이 | 다섯 단계 위가 홈 | 나와야 할 로그: 옛 페이지에서 0줄 |
| robots.txt | Allow가 먼저여도 괜찮다 | 먼저 맞는 줄을 쓰는 표준 파서: 막은 경로 허용 |
| 가짜 Googlebot | 시험이 내 규칙을 보고 있다 | 규칙 대상이 아닌 페이지에서도 같은 403 |
| 휴대폰 그림 | 폭 경고가 없으면 괜찮다 | 세로 배치를 그린 PNG |
| 이미지 정리 | 목록이 75개면 75개를 옮겼다 | 목록 75, 드라이런 74, 파일은 계속 공개 중 |
특별한 이야기는 하나도 없습니다. 경력 있는 개발자도 똑같이 믿을 만한 것들입니다. 에이전트에게 필요했던 것은 더 똑똑해지는 것이 아니라, 자기 생각이 틀렸음을 보여 줄 수 있는 검사와, 완료라고 말하기 전에 그 검사를 돌리는 습관이었습니다.
점검 목록: 에이전트에게 증명하게 하라
AI 에이전트의 "고쳤습니다"를 받아들이기 전에, 주장에 맞는 증거를 요구하세요.
- "원인은 X입니다": X가 거짓이라면 다르게 보였을 측정을 내놓게 한다. 문단이 아니라 개수, 로그, 시간으로.
- "변경이 동작합니다": 그것을 증명하는 부수 효과(로그 한 줄, 헤더, DB 행)를 지목하게 하고, 배포 뒤에 보여 주게 한다. "오류 없음"만으로는 안 된다.
- "안전을 위해 오류를 숨겼습니다": 그렇다면 코드가 실제로 돌았다는 적극적인 증거를 내게 한다. 오류는 아무것도 알려 주지 않으니까.
- "이 파일은 맞습니다": 신경 쓰는 쪽과 다르게 읽는 표준 파서에 넣어 보게 한다.
- "시험이 실패한 건 이번 변경 때문입니다": 변경이 적용되지 않는 곳에서 같은 요청을 해 보게 한다. 그래도 실패하면 원인은 다른 데 있다.
- "보기에 괜찮습니다": 사용자가 보는 크기로, 줄바꿈 방식이 다른 언어마다 스크린샷을 내게 한다.
- "N개를 모두 처리했습니다": 들어온 N, 처리한 N, 나간 N을 나란히 놓고 비교하게 한다.
- 검사는 남긴다. 위 검사는 모두 스크립트나 메모로 남아 있어서, 같은 믿음은 다음에 더 빨리 잡힙니다.
에이전트의 속도는 코드를 쓰는 능력에서 나오고, 신뢰는 이런 검사에서 나옵니다. 그리고 검사를 요구하는 데는 큰 품이 들지 않습니다.
자주 묻는 질문
이건 환각(hallucination)이었나요?
아닙니다. 모두 그럴듯한 전제가 이 서버에서는 마침 거짓이었던 경우입니다. 한도를 세는 방식, 경로의 단계 수, 파서가 파일을 읽는 방식, CDN이 User-Agent를 다루는 방식입니다. 그래서 코드를 다시 읽어서는 못 찾았고, 로그나 측정으로 찾았습니다.
이런 오류를 가장 싸게 잡는 방법은요?
주장이 틀렸음을 보여 줄 수 있는 검사를 요구하고, 완료라고 말하기 전에 돌리게 하는 것입니다. 이번에는 나와야 할 로그 한 줄, 실제 파일에 돌린 표준 파서, 휴대폰 폭 스크린샷, 같아야 할 두 개수였습니다.
사용자에게 영향이 간 것도 있나요?
짧게 하나 있었습니다. MySQL 오류 원인을 잘못 설명한 글이, 같은 날 저녁 새 측정으로 정정될 때까지 공개돼 있었습니다. 또 정리 작업 뒤에도 안 쓰는 이미지 하나가 공개된 채 남아 있었고, 이 글을 쓰다가 찾았습니다. 나머지는 배포 전이나 직후에 잡아서 보이는 영향은 없었습니다.