직접 해 본 결과만 적습니다 · 확인하지 못한 것은 그렇게 표시합니다
써 AI 마케팅 적용기 시중에 나온 AI 방법을 실무에 넣어 보고, 쓸만한지 남깁니다
콘텐츠 만들기

검사에 실패하면 통과시키고 있었습니다 — AI검수 게이트를 fail-closed로 바꾸기

계속 쓴다2026-08-10·읽기 6분·글 써봄

AI검수 게이트를 붙여 뒀는데, 조건 한 줄이 잘못돼 있었습니다.

if verdict != "차단":
    publish()          # 검사가 실패해 결과가 비어도 여기로 온다

"차단이 아니면 통과" 입니다. 그런데 검사가 실패해서 결과가 비어 있으면 그것도 "차단" 이 아닙니다. 그래서 검사기가 죽은 날 무검수 글이 그대로 나갈 수 있었습니다.

1. 조건을 "통과가 확인된 경우에만" 으로 바꿉니다 2. 검사 실패를 별도 상태로 다룹니다 3. 한 건 실패가 전체를 막지 않게 합니다 4. 바꾼 뒤 실제로 재현해서 확인합니다

AI검수 증상 — 검사기가 죽어도 발행이 됩니다

증상 — 검사기가 응답을 못 주거나 결과 형식이 깨졌는데도 글이 나갑니다. 로그에는 "검수 완료" 로 보입니다.

부정 조건과 긍정 조건이 갈리는 지점을 정리한 터미널 화면

원인 — 검사 결과가 가질 수 있는 값이 셋이라고 생각했는데, 실제로는 다섯이었습니다.

검사 결과가 가질 수 있는 다섯 가지 값을 정리한 터미널 화면

값 뜻 != "차단" 판정
통과 문제 없음 통과 ✅ 맞음
교정필요 고치면 됨 통과 ✅ 맞음
차단 막아야 함 차단 ✅ 맞음
빈 값 검사를 못 했음 통과 ❌ 틀림
파싱 실패 결과를 모름 통과 ❌ 틀림

아래 둘이 문제입니다. "모른다" 를 "괜찮다" 로 읽고 있었습니다.

저는 이 조건을 처음 쓸 때 "차단만 막으면 되지" 라고 생각했습니다. 검사기가 실패하는 경우를 아예 안 떠올렸습니다. 검사기는 당연히 답을 준다고 전제하고 있었던 겁니다.

실제로는 응답이 늦어 시간 초과가 나기도 하고, 형식이 깨져 파싱이 안 되기도 했습니다. 그때마다 조용히 통과했습니다. 저는 그 사실을 로그를 뒤지다가 알았습니다.

해결 — 자동화 게이트를 fail-closed 로

해결 — 조건을 뒤집습니다. 부정으로 쓰지 말고 긍정으로 씁니다.

# 나쁨: 차단이 아니면 통과 (모르는 값이 통과로 샌다)
if verdict != "차단":
    publish()

# 좋음: 통과가 확인된 것만 통과
if verdict in ("통과", "교정필요"):
    publish()
else:
    hold_for_review(verdict)      # 빈 값·파싱 실패도 여기로

목록에 없는 값은 전부 막힙니다. 나중에 검사기가 새로운 상태값을 돌려줘도 안전합니다. 부정 조건은 새 값이 생길 때마다 구멍이 늘어납니다.

이걸 fail-closed 라고 부릅니다. 모르면 막는 쪽이 기본값입니다.

fail-open fail-closed
검사 실패 시 통과 차단
새로운 상태값 통과 차단
검사기가 죽으면 전부 나감 전부 멈춤
사고 위험 있음 없음
대신 생기는 문제 — 아무것도 안 나가는 걸 모를 수 있음

마지막 줄이 대가입니다. 그래서 며칠 연속 0건이면 알림이 오게 같이 붙여야 합니다.

저는 이 알림을 안 붙인 상태로 fail-closed 로 먼저 바꿨다가, 다른 이유로 검사기가 막혀서 며칠간 아무것도 안 나간 적이 있습니다. 안전해진 대신 조용히 멈춘 겁니다. 막는 쪽으로 바꾸면 멈춤 감시를 같이 붙여야 합니다.

함께 있던 문제 — 한 건이 큐를 막습니다

같이 발견한 것이 있습니다. 한 항목이 실패하면 예외가 위로 올라가 전체가 멈췄습니다.

한 건 실패가 나머지 전부를 막는 구조를 정리한 터미널 화면

증상 — 큐에 30건이 있는데 1번이 실패하면 2~30번은 시도조차 안 됩니다. 매일 같은 1번에서 멈추니 큐가 영원히 안 줄어듭니다.

원인 — 실패를 예외로 던지고 상위에서 안 잡았습니다.

해결 — 실패는 세고 다음으로 넘어갑니다. 다만 같은 항목이 반복 실패하면 보류시킵니다.

for item in queue[:MAX_PER_RUN]:
    try:
        process(item)
        item["fails"] = 0
    except Exception as e:
        item["fails"] = item.get("fails", 0) + 1
        log(f"{item['id']} 실패({item['fails']}회): {e}")
        if item["fails"] >= 3:
            hold(item)            # 큐에서 빼고 사람이 볼 목록으로
        continue                  # 다음 항목으로

넘어가되 세는 것이 핵심입니다. 그냥 넘어가면 실패가 사라지고, 안 넘어가면 큐가 막힙니다.

바꾼 뒤 확인하는 법 (재현 점검)

주의 — 조건을 바꿨다고 끝이 아닙니다. 실제로 재현해서 확인해야 합니다.

# 검사기가 빈 값을 돌려주는 상황을 일부러 만든다
def fake_check(_): return {}          # 결과 없음

result = decide(fake_check(text))
assert result == "보류", f"검사 실패인데 {result} 로 판정됨"

저는 조건만 고치고 넘어갔다가, 다른 경로에 같은 부정 조건이 남아 있는 걸 나중에 발견했습니다. 한 군데 고쳤다고 전부 고친 게 아닙니다.

!= 로 검사 결과를 비교하는 곳을 코드 전체에서 찾아보는 편이 빠릅니다.

grep -rn 'verdict *!=\|status *!=\|result *!=' --include="*.py" .

찾은 곳을 하나씩 봅니다. 부정 비교가 전부 나쁜 것은 아닙니다. 값이 두 개뿐이면 괜찮습니다. 문제는 값이 늘어날 수 있는 자리에 부정 비교를 쓴 경우입니다.

저는 이 검색으로 세 군데를 더 찾았습니다. 그중 하나는 overall != "block" 이었고, 다른 하나는 score != 0 이었습니다. 뒤엣것은 점수를 못 잰 경우가 None 이라 비교 자체가 참이 되고 있었습니다.

위험한 패턴 왜
verdict != "차단" 빈 값·새 상태가 통과로 샙니다
score != 0 None 이 통과로 샙니다
if not blocked blocked 를 못 구하면 통과로 샙니다
errors == [] 검사가 안 돌아도 빈 목록입니다

마지막 줄이 특히 헷갈립니다. "오류가 없다" 와 "검사를 안 했다" 가 같은 값으로 표현됩니다. 그래서 저는 검사 여부를 별도 필드로 남기게 바꿨습니다 — checked: true 가 없으면 통과시키지 않습니다.

확인 못 한 것

검사기가 부분적으로만 응답할 때 — 예를 들어 항목 다섯 중 셋만 돌려줄 때 — 어떻게 다뤄야 하는지는 아직 정하지 못했습니다.

지금은 형식이 완전하지 않으면 전부 보류로 보냅니다. 다만 그게 너무 빡빡한 것인지, 부분 결과라도 쓰는 게 나은지는 확인하지 못했습니다. 그런 경우가 자주 생기면 다시 볼 생각입니다.

판정 — 계속 쓴다

조건을 긍정으로 쓰는 것은 값어치가 큽니다. 한 줄 바꾸는 일인데, 검사기가 죽은 날 사고가 나느냐 안 나느냐가 갈립니다.

한 건 실패가 큐를 막지 않게 하는 것도 같이 해야 합니다. 안 그러면 fail-closed 로 바꾼 뒤 큐가 영영 안 줄어듭니다.

대신 "아무것도 안 나가는 것" 을 감시해야 합니다. 막는 쪽이 기본값이면 조용히 멈출 수 있습니다.

직접 하실 분께

하나. 검사 결과 조건을 긍정으로 씁니다. != "차단" 이 아니라 in ("통과", "교정필요") 입니다.

둘. 검사 결과가 가질 수 있는 값을 전부 적어 봅니다. 빈 값과 파싱 실패를 빠뜨리기 쉽습니다.

셋. 실패는 세고 넘어갑니다. 그냥 넘기면 사라지고, 안 넘기면 큐가 막힙니다.

넷. 고친 뒤 검사 실패를 일부러 만들어 재현합니다. 조건만 보고 넘어가지 마세요.

다음에는 검사기가 부분적으로만 응답할 때를 어떻게 다룰지 정리해보려고 합니다. 되면 이어서 적겠습니다.