규칙을 지나지 않는 길이 네 군데 있었습니다 — 자동화 게이트 우회로 찾는 법
자동화에 AI검수 게이트를 붙여 두고 안심하고 있었습니다. 문서에도 "조건을 못 채우면 중단" 이라고 적어 뒀습니다.
확인해 보니 코드가 달랐습니다. 게이트를 지나지 않는 경로가 네 군데 있었습니다.
1. 게이트 함수를 부르는 곳을 전부 나열합니다 2. 발행하는 함수를 부르는 곳도 전부 나열합니다 3. 둘을 대조해 게이트를 안 지나는 경로를 찾습니다 4. 찾은 것을 실제로 재현해서 확인합니다
자동화 점검 증상 — 문서와 코드가 다릅니다
증상 — 문서에 적힌 대로 동작할 거라고 믿고 있는데, 실제로는 다르게 돕니다.

| 문서 | 코드 |
|---|---|
| 근거 자료가 없으면 중단 | 경고만 남기고 계속 진행 |
| 점수 미달이면 발행 안 함 | 점수를 못 재면 그냥 통과 |
| 민감 항목은 사람 승인 뒤에 | 특정 경로에서는 승인 없이 나감 |
| 강제 옵션은 한 가지만 연다 | 나머지 게이트까지 같이 열림 |
원인 — 문서를 먼저 쓰고 코드를 나중에 고치면서 둘이 벌어졌습니다. 저는 문서를 기준으로 생각하고 있었는데, 실제로 도는 것은 코드입니다.
특히 두 번째가 무섭습니다. "미달이면 막는다" 와 "못 재면 막는다" 는 다른 말입니다. 채점기가 죽으면 점수가 없고, 없으면 미달도 아닙니다.
AI검수 게이트 우회로를 찾는 법
해결 — 코드를 읽어 내려가지 말고, 부르는 쪽을 전부 나열합니다.

# 게이트를 부르는 곳
grep -rn "check_gates\|safety_check" --include="*.py" .
# 발행을 부르는 곳
grep -rn "publish\|deploy_post" --include="*.py" .
두 목록을 나란히 놓고 봅니다. 발행 목록에는 있는데 게이트 목록에는 없는 파일이 우회로입니다.
제 경우 이런 것들이 나왔습니다.
| 우회로 | 왜 생겼나 |
|---|---|
| 재시도 로직 | 처음 시도에서 검사했으니 재시도는 건너뛰게 만듦 |
| 수동 실행 경로 | 사람이 부르는 것이니 괜찮다고 판단 |
| 강제 옵션 | 한 게이트를 열려다 전부 열림 |
| 다른 모듈의 직접 호출 | 나중에 추가하면서 게이트를 몰랐음 |
전부 그럴듯한 이유가 있었습니다. 일부러 뚫은 게 아니라 하나씩 편의를 봐준 결과였습니다.
저는 이 네 개를 만들 때마다 나름의 판단이 있었습니다. 재시도는 이미 검사했으니 두 번 할 이유가 없다고 봤고, 수동 실행은 사람이 보고 있으니 괜찮다고 봤습니다.
문제는 그 판단들이 쌓이면서 원래 규칙이 사실상 없어졌다는 점입니다. 하나씩 보면 다 말이 되는데, 전부 모아 놓으면 게이트를 지나는 경로가 오히려 소수였습니다.
재현해서 확인합니다
주의 — 코드를 읽어서 판단하지 마세요. 실제로 돌려 봐야 압니다.

주장: 근거 자료가 없으면 발행되지 않는다
확인: 자료를 지우고 실제로 실행한다
결과: 발행됨 ← 문서가 틀렸다
저는 네 군데를 전부 이렇게 확인했습니다. 코드를 읽었을 때 "이건 괜찮아 보이는데" 싶었던 곳이 실제로는 뚫려 있었습니다. 반대로 뚫린 줄 알았던 곳이 다른 데서 막히고 있기도 했습니다.
읽어서 아는 것과 돌려서 아는 것은 다릅니다.
강제 옵션을 어디까지 열 것인가
이게 제일 헷갈리는 부분이었습니다. 급할 때 쓰라고 만든 옵션이 다른 게이트까지 같이 열고 있었습니다.
해결 — 강제 옵션은 그 한 가지만 엽니다.
def can_publish(post, force_score=False, allow_no_source=False):
if post.gates: # 안전 게이트는 무엇으로도 안 열린다
return False, post.gates
if not post.source and not allow_no_source:
return False, "근거 자료 없음"
if post.score < TARGET and not force_score:
return False, f"{post.score}점"
return True, ""
옵션마다 여는 것이 하나씩 대응돼야 합니다. 하나로 전부 여는 옵션은 만들지 않는 편이 낫습니다. 급할 때 그걸 쓰게 되고, 급할 때가 제일 위험합니다.
그리고 어떤 옵션으로도 안 열리는 항목을 따로 둡니다. 저는 법 위반 표현과 개인정보를 그렇게 뒀습니다.
다시 벌어지지 않게 — 구조로 막기
한 번 맞춰도 시간이 지나면 또 벌어집니다.
| 장치 | 무엇을 막나 |
|---|---|
| 발행 경로를 한 곳으로 모음 | 새 경로가 게이트를 빠뜨리는 것 |
| 게이트를 발행 함수 안에 둠 | 부르는 쪽이 잊는 것 |
| 우회로 점검을 정기적으로 | 시간이 지나며 다시 벌어지는 것 |
| 재현 시험을 회귀 검사로 | 고친 것이 다시 풀리는 것 |
두 번째가 제일 확실합니다. 게이트를 발행 함수 밖에 두면 부르는 쪽마다 챙겨야 하는데, 안에 두면 어디서 불러도 지납니다.
def publish(post, *, force_score=False):
ok, reason = can_publish(post, force_score=force_score) # 안에서 반드시 지난다
if not ok:
raise BlockedError(reason)
_do_publish(post)
_do_publish 는 밖으로 노출하지 않습니다. 이름 앞에 _ 를 붙여 두면 최소한 실수로 부르는 일은 줄어듭니다.
정기 점검에 넣을 항목도 정해 뒀습니다. 한 달에 한 번쯤 돌립니다.
| 점검 항목 | 명령 |
|---|---|
| 발행 호출 지점 | grep -rn "_do_publish" --include="*.py" . |
| 게이트 우회 여부 | 위 결과가 publish() 안뿐인지 |
| 부정 조건 | grep -rn "!= \"차단\"" . |
| 강제 옵션 범위 | force_ 로 시작하는 인자 목록 |
저는 이걸 문서에만 적어 뒀다가 안 돌렸습니다. 점검도 자동으로 돌게 만들어야 실제로 합니다. 지금은 정기 점검 스크립트 안에 넣어 뒀습니다.
확인 못 한 것
우회로를 코드에서 자동으로 찾아내는 방법은 아직 못 만들었습니다. 지금은 검색해서 눈으로 대조합니다.
호출 관계를 그려서 발행에 닿는 모든 경로를 뽑으면 될 것 같은데, 조건 분기까지 따라가야 해서 간단하지 않아 보입니다. 실제로 될지는 확인하지 못했습니다.
판정 — 계속 쓴다
우회로 점검은 반드시 해야 합니다. 게이트를 붙였다고 안전한 게 아니라, 지나야 안전합니다.
게이트를 발행 함수 안에 두는 구조가 가장 확실했습니다. 구조로 막는 쪽이 규율로 막는 것보다 오래 갑니다.
문서를 믿지 않는 습관도 같이 필요합니다. 저는 제가 쓴 문서를 기준으로 판단하다가 네 군데를 놓쳤습니다.
직접 하실 분께
하나. 발행 함수를 부르는 곳을 전부 나열해 보세요. 검색 한 줄이면 됩니다.
둘. 찾은 것은 실제로 재현해서 확인합니다. 읽어서 판단하지 마세요.
셋. 강제 옵션은 하나에 하나씩 대응시킵니다. 전부 여는 옵션은 만들지 않습니다.
넷. 게이트는 발행 함수 안에 둡니다. 밖에 두면 새 경로가 생길 때마다 빠집니다.
다음에는 발행에 닿는 경로를 코드에서 자동으로 뽑아내는 방법이 될지 해보려고 합니다. 되면 이어서 적겠습니다.