
lint는 PASS했지만 한 줄도 읽지 않았다: 자작 게이트가 속이는 세 가지 유형
요약
AI를 활용한 콘텐츠 자동화 공정에서 검증 게이트(Lint)가 데이터 형식을 제대로 인식하지 못해 발생하는 오류 유형을 다룹니다. 정규 표현식 매칭 실패가 '위반 없음'으로 오인되는 문제를 분석하고 해결 방안을 제시합니다.
핵심 포인트
- 검증 게이트가 대상을 읽지 못해도 '0건'으로 판단하여 PASS할 수 있음
- 정규 표현식 설계 시 데이터 부재와 위반 부재를 구분해야 함
- 출력 결과에 반드시 '검사 대상 수'와 '탐지 건수'를 명시해야 함
이 글은 무엇에 관한 것인가
개인적으로 팟캐스트를 2개 운영하고 있습니다. 소재 찾기, 대본 작성, 음성 합성, 썸네일 생성, 업로드까지 거의 자동화하여 진행합니다. 주당 10편입니다. 모든 것을 눈으로 확인하는 것은 초기에 불가능했습니다.
그래서 '기계가 합불을 판정하는 게이트'를 만들었습니다. 어미 금지 패턴, 추임새 중복, 제목 금지 표현, 썸네일 글자 크기, 소재의 신선도 등입니다. CI(Continuous Integration)와 같은 사상입니다. 통과하지 못하면 다음 공정으로 진행할 수 없습니다.
약 1년 정도 운영하면서 확실히 알게 된 것이 있습니다.
게이트는 거짓말을 합니다.
게다가 고장 나는 방식이 매번 같아서, 세 가지 유형으로 정리되었습니다. 이 세 가지 모두 'FAIL해야 할 때 PASS하는' 경우는 아닙니다. 더 까다롭습니다. 애초에 대상을 보고 있지 않음에도 불구하고 녹색 체크 표시를 반환해 옵니다.
이 글은 그 세 가지 유형과, 어떻게 알아차렸는지, 그리고 어떻게 해결했는지에 대한 기록입니다.
미리 말씀드립니다
이 글에서 나오는 게이트를 작성한 것은 제가 아닙니다. 사양을 정한 것은 저이지만, 코드는 거의 Claude Code가 작성했습니다. 대기 시간을 8초로 정한 것도 Claude Code입니다. 즉 'AI의 출력을 검사하는 메커니즘' 자체가 AI 제품이며, 앞으로 쓸 실패는 대부분 거기서 기인합니다.
덧붙여, 아래에서 나오는 세 가지 실패는 어떤 특정 도구에 고유한 것이 아닙니다. 사람이 작성해도 같은 형태로 발생합니다. 다만, AI에게 쓰게 하면 양이 나오기 때문에 인지하지 못한 채 수가 늘어납니다. 제 경우에는 45편까지도 깨닫지 못했습니다.
유형 1: 게이트가 대상을 한 줄도 읽지 않았다
무슨 일이 일어났는가
대본의 말투를 체크하는 게이트를 두 개 가지고 있습니다.
ending_lint.py
— 금지된 어미가 나오지 않았는지, 버릇이 규정 횟수를 초과하지 않았는지 -
echo_check.py
— 상대방의 대사를 따라 하지 않았는지
이를 새로 만든 기사 포맷에도 적용했습니다. 둘 다 PASS였습니다. BLOCK 0건. '그대로 재사용할 수 있겠네요'라고 보고했습니다.
거짓이었습니다.
대사 추출이 이렇게 되어 있었기 때문입니다.
# 화자: 대사를 형식으로 가정하고 있었다
m = re.match(r'^(.+?):(.+)$', line)
새로운 포맷의 대사는 이런 작성 방식이었습니다.
👩「나루호, 그것은 몰랐습니다」
콜론(:)이 없습니다. 한 줄도 매치하지 않습니다. 그렇다면 게이트는 무엇을 읽고 있었던가? frontmatter의 각 행과 본문에 붙인 URL의 https: 부분이었습니다. 전각 콜론이 아닌 반각 콜론에도 매치하는 정규 표현식이었기 때문에,
https://...
이 '화자 https
, 대사 //...
'로 잡히고 있었습니다. 대화 34행의 기사에 대해 게이트의 표시는 다음과 같았습니다.
대화 5행 — BLOCK 0건 ✅
이 5행은 단 한 줄도 대사가 아닙니다.
그리고 최악이었던 것은, 이 상태로 기존 기사 45편이 이미 운영되고 있었다는 것입니다. 한 편도 체크되지 않았는데, 45번 'PASS'를 보고 있었습니다.
왜 깨닫지 못했는가
PASS / FAIL
만 봤기 때문입니다.
일반적인 lint라면 이것으로 문제가 없습니다. 대상 파일은 반드시 존재하고, 파서는 반드시 그것을 구문 트리로 만듭니다. 읽을 수 없으면 에러로 떨어집니다.
자작 게이트는 떨어지지 않습니다. 매치하지 못하면 '0건'을 반환하고 정상 종료합니다. 0건은 '위반이 없다'와 '대상이 없다'를 모두 의미할 수 있는데, 표시는 같은 녹색입니다.
대처법
게이트의 출력에 반드시 '무엇을 몇 건 잡았는지'를 내도록 했습니다.
print(f
## 유형 2: 검증하는 측이 실제 환경과 동일한 허점을 가지고 있는 경우
### 무슨 일이 일어났는가
음성 합성의 오독(Misreading) 대책입니다. 대본의 한자를 잘못 읽은 채 음성이 나오면 듣는 사람에게는 그저 사고이기 때문에, 기계로 검출하고 싶었습니다.
구성 방식은 다음과 같았습니다.
대본 ──┬─→ AivisSpeech (실제 합성 엔진) → 읽기 A
└─→ pyopenjtalk (검증용) → 읽기 B
A ≠ B 라면 오독 의심 → 사람이 확인
이중 체크를 의도한 것입니다. 차이가 발생하면 알아챌 수 있으니까요.
차이는 거의 발생하지 않았습니다. **매우 깨끗한 상태라고 생각했습니다.**
그러던 어느 날, 귀로 듣다가 "하나째(一つめ)"가 **이치츠메(イチツメ)**라고 읽히는 것을 발견했습니다. 게이트는 아무런 말도 하지 않았습니다. 손으로 직접 확인해 보니 다음과 같았습니다.
| 단어 | AivisSpeech | pyopenjtalk (검증 측) | 차이 |
|---|---|---|---|
| 一つめ (하나째) | イチツメ (이치츠메) | イチツメ (이치츠메) | 없음 |
| 少しずつ (조금씩) | '스코시즈츠'가 아닌 오독 | 동일한 오독 | 없음 |
**둘 다 틀렸고, 둘 다 똑같은 방식으로 틀리고 있었습니다.**
이유는 단순했습니다. AivisSpeech도 pyopenjtalk도 **같은 OpenJTalk 계열**이었기 때문입니다. 사전도 알고리즘도 계통이 같습니다. 동일한 입력에 대해 동일한 습관으로 넘어집니다. 차이가 제로라는 것은 "정답"이 아니라 "**동시에 같은 구덩이에 빠졌다**"는 뜻이었습니다.
이것에는 이름이 있습니다. 바로 **상관 고장 (Common-cause failure)**입니다. 중복 시스템을 구축했다고 생각했지만, 공통 원인으로 인해 동시에 죽어버리는 현상입니다. 항공기나 원자력 발전소 설계에서 예전부터 알려진 문제가 그대로 나타난 것입니다.
### 왜 이것이 2026년의 이야기인가
이것은 "같은 라이브러리를 두 번 호출하는 멍청한 설계"에 대한 이야기가 아닙니다. **지금, 거의 모든 사람이 같은 형태의 실수를 범하고 있습니다.**
- LLM이 작성한 문장을 LLM에게 리뷰하게 함
- LLM이 작성한 코드를 동일한 모델에게 테스트하게 함
- 생성된 요약의 타당성을 동일한 계열의 모델에게 채점하게 함
**동일한 학습 데이터에서 유래된 습관은 검증 측에도 들어 있습니다.** 놓치는 부분이 독립적이지 않기 때문에, 이중 방어 체계가 제대로 작동하지 않습니다. 게다가 검증을 통과해 버리기 때문에, 품질이 조용히 떨어집니다.
### 대처
계통이 다른 오라클 (Oracle, 즉 정답을 알려주는 참조처)을 하나 추가했습니다. 형태소 분석기인 **Sudachi**입니다. OpenJTalk 계열이 아닙니다.
그리고 중요한 것은 바로 이 지점이었습니다.
```python
# 3개 중 하나라도 읽기가 갈라지면, 어느 쪽이 맞는지 추측하지 않는다
if len({reading_aivis, reading_openjtalk, reading_sudachi}) > 1:
return BLOCK # 다수결을 따르지 않는다. 사람에게 넘긴다
다수결을 하지 않았습니다. 2 대 1로 다수가 옳다는 보장이 없기 때문입니다 (실제로 이번에는 다수파인 2개가 일치하여 틀렸습니다). 의견이 갈리면 멈춥니다. 판정 불가능한 상태를 판정 가능한 것으로 간주하지 않습니다.
그 후, 설계 자체를 바꿨습니다.
엔진이 읽기를 추측하게 하는 것을 그만두었습니다.
올바른 읽기는 작성하는 시점에 인간이 이미 알고 있습니다. "하나째(一つめ)"는 히토츠메(ヒトツメ)입니다. 그것을 나중에 엔진이 추측하게 하고, 틀리면 사전에 추가하는—이러한 운영 방식이 끝없는 추격전의 원인이었습니다. 새로운 단어는 얼마든지 계속 나옵니다.
블랙리스트(Blocklist)에서 화이트리스트(Allowlist)로 전환하는 것과 같습니다. 검출을 강화하는 것보다, 추측하게 만드는 지점을 없애는 것이 더 빠릅니다.
유형 3: 하나의 카운터가 두 가지 사건을 세고 있는 경우
무슨 일이 일어났는가
이것은 최근 브라우저 확장 프로그램을 작성하다가 겪은 일입니다. 도메인은 음성도 기사도 아닙니다. 그럼에도 형태는 같았습니다.
페이지를 순서대로 열어서 그곳에 나열된 항목 목록을 수집하는 처리입니다. 1건도 가져오지 못했을 때의 카운터를 다음과 같이 설정해 두었습니다.
if (items.length === 0) {
stats.empty++; // ← 여기
continue;
}
실행하면 empty: 15라고 나옵니다.
저는 이를 "내용이 비어 있는 페이지가 많구나"라고 해석했습니다.
틀렸습니다. empty에는 성질이 완전히 다른 두 가지가 섞여 있었습니다.
- 정말로 항목이 0건이었던 페이지
- 렌더링이 8초 안에 완료되지 않아 판정할 수 없었던 페이지
1은 사양대로 작동하는 정상적인 케이스이므로 아무것도 할 필요가 없습니다. 2는 **버그 (Bug)**로, 타임아웃을 늘리거나 대기 방식을 바꾸면 전부 잡아낼 수 있습니다. 이 두 가지가 같은 숫자로 합산되어 있었기에, "수율이 낮은 것은 데이터가 원래 그렇기 때문이다"라고 결론짓고 반나절을 허비했습니다.
분리해 보니 대부분이 2였습니다.
"0건"은 정상의 증거가 되지 않는다
같은 일이 다른 곳에서도 일어나고 있습니다.
외부 사이트를 순회하며 소재를 수집하는 스크립트에 업데이트 빈도가 낮은 사이트를 추가했습니다. 실행 결과는 0건.
구현을 실수했어도 0건. 올바르게 작동하고 있어도 0건. 어느 쪽인지 판정할 수 없습니다. "에러가 발생하지 않았으니 작동하고 있다"는 이 경우 전혀 근거가 되지 않습니다.
이를 확인했다고 말할 수 있게 된 것은, 취득 기간을 넓혀서 비제로(non-zero) 값을 1회 확인했을 때였습니다.
대처
실패 이유를 반드시 별도의 태그로 카운트하도록 했습니다.
// Before
stats.empty++;
// After
...
그리고 리포트를 읽는 법도 바꿨습니다.
0건을 발견하면 우선 "비제로 값을 1회 출력하기"를 시도합니다. 출력할 수 없는 동안에는 그 게이트(Gate)는 검증되지 않은 것입니다.
3가지 유형에 공통되는 한 문장
유형 1은 "대상을 읽지 않는다".
유형 2는 "검증 측이 운영 환경과 동일한 허점을 가지고 있다".
유형 3은 "이상과 정상을 같은 숫자로 세고 있다".
제각각인 것처럼 보이지만 말하고자 하는 바는 하나입니다.
게이트의 출력은 "위반이 없었다"가 아니라 "위반을 찾아내지 못했다"라고만 말하고 있습니다.
이 두 가지를 혼동하면 게이트는 안심을 배포하는 장치가 됩니다. 게다가 직접 눈으로 확인하는 것보다 성질이 나쁩니다. 직접 보고 있다면 "오늘은 대충 훑어봤구나"라는 자각이 남지만, 초록색 체크 마크에는 자각이 남지 않습니다.
그래서 게이트를 추가할 때는 반드시 세트로 배치하도록 하고 있습니다.
| 질문 | 구현 |
|---|---|
| 대상을 읽고 있는가 | 추출 건수를 출력한다. 0건은 BLOCK |
| 검출할 수 있는가 | 기지의 위반 사항을 하나 심어두고, 빨간색이 되는지 확인한다 |
| 검증 측은 독립적인가 | 운영 환경과 별개의 계통인 오라클 (Oracle)을 사용한다. 결과가 어긋나면 다수결을 하지 않고 중단한다 |
| 그 0은 정상인가 | 실패 이유를 별도의 카운터로 나눈다. 비제로 값을 1회 출력한다 |
"게이트를 감시하는 게이트"가 필요하다는 이야기가 아닙니다. 게이트를 추가할 때의 습관에 위의 4줄을 넣는 것만으로도 이 글의 실패는 거의 전부 방지할 수 있었습니다.
부록: 게이트를 만들면 모델을 낮출 수 있다
여기까지는 실패담이지만, 예상치 못한 부수적인 효과가 있었습니다.
읽기 체크 게이트는 AI에게 다음과 같은 형태로 질의합니다.
- 선택지는 ok / ng 이지선다 - 참조할 사전을 전달
- 망설여지면 ng (안전한 쪽 = 히라가나로 개방)로 기울이도록 명시
이 구조로 만들었더니, 판단력이 약한 (즉, 저렴한 모델) 경우에도 결과가 거의 변하지 않게 되었습니다.
이유는 명확합니다. 판단의 자유도를 좁히고, 실패 시의 디폴트(Default)를 안전한 쪽으로 고정해 두었기 때문입니다. 모델의 똑똑함이 결과의 질로 직결될 여지가 애초에 남아 있지 않습니다.
반대로 말하면, 폴백 (Fallback)을 정의할 수 없는 태스크에는 사용할 수 없습니다. 대본을 쓰는 것 자체에는 이 형식을 적용할 수 없었습니다 ("망설여지면"의 안전한 쪽이 존재하지 않기 때문입니다).
품질 문제를 위해 게이트를 만들었는데, 운영 비용의 문제가 된 것은 의외였습니다. "똑똑한 모델이 아니면 품질을 유지할 수 없다"고 생각하는 부분이 있다면, 모델을 올리기 전에 판단의 자유도를 좁힐 수 없는지를 살펴볼 가치는 있다고 생각합니다.
요약
- 유형 1— 게이트가 대상을 읽지 않고 PASS 한다. 추출 건수를 출력하고, 0건은 BLOCK으로 처리한다.
- 유형 2— 검증 측이 운영 환경과 동일한 계통이면, 함께 틀려서 차이점이 사라진다. 별도의 계통인 오라클을 사용하고, 결과가 어긋나면 다수결을 하지 않는다.
- 유형 3— 이상과 정상이 같은 카운터에 들어간다. 실패 이유를 나누고, 0건은 "판정 불능"으로 의심한다.
그리고 관통하는 핵심.
PASS는 "대상을 제대로 읽었다"는 증거가 아니다.
자작 체크를 CI에 배치하고 있는 분들은, 지금 초록색으로 표시된 것 중 하나를 골라 일부러 망가뜨려 보세요. 빨간색이 되지 않는다면, 이 글의 유형 1입니다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기