범용 게이트 하나를 만들고 앞으로 마주칠 모든 입력이 설계 당시 상정한 형태에 맞을 거라 가정하는 건 반복되는 실수 패턴이다 — 해법은 항상 더 똑똑한 게이트가 아니라, 일부 입력이 다른 범주에 속하며 명시적이고 좁은 예외가 필요하다는 걸 인식하는 것이다.
배경
Argus(2편에서 다룬 다단계 파이프라인 프레임워크)는 여러 범주의 파이프라인을 실행한다 — 사람의 검토를 받을 텍스트를 생성하는 콘텐츠 생성형 파이프라인도 있고, 그냥 스크립트를 실행하고 지표만 기록하는 운영형 파이프라인(텍스트 콘텐츠가 전혀 없는)도 있다. 모든 파이프라인 실행이 끝날 때 범용 품질 게이트가 하나 있어서, 완료로 표시하기 전에 그 실행이 만들어낸 결과물을 안전/품질 기준으로 점검한다.
몇 달 간격을 두고 벌어진 서로 무관한 두 사건이, 결국 같은 근본원인으로 귀결됐다: 한 범주의 작업을 위해 만든 인프라가, 그걸 위해 설계된 적 없는 완전히 다른 범주에 조용히 적용되고 있었던 것.
사건 A — 콘텐츠가 없는 파이프라인에 적용된 콘텐츠 게이트
Project Persona(2편의 그 파이프라인)에는 이미 다룬 네 개 버그와는 별개로 더 오래 지속된 문제가 있었다: 그 버그들을 다 고친 뒤의 성공한 실행들을 포함해서, 모든 실행이 범용 품질 게이트에 의해 [ABORT] score=0 또는 [REQUEST_CHANGES] score=0로 플래그되고 있었다 — 4개월간, 산발적인 날짜에 걸쳐 대략 열두 번.
이 게이트의 역할은 파이프라인 실행이 만들어낸 텍스트를 두 개의 독립된 검토 패스에 넘기고(각각 품질/안전 이슈를 확인), 완료로 표시하려면 둘 다 승인해야 한다는 것이다. Project Persona의 네 단계 — 성과 추적, 캘린더 생성, 참여 타겟 발굴, 자동 감사 — 는 검토할 만한 텍스트 콘텐츠를 애초에 생성하지 않는다. 데이터를 옮기고 로그를 남기는 운영 스크립트일 뿐이다. 이 파이프라인을 상대로 게이트가 실행될 때마다, 검토할 게 있다면 Project Persona 자신의 상태 로그뿐이었다 — 그리고 두 검토 패스 모두 정확하고 일관되게 "평가할 만한 실제 콘텐츠가 여기 없다"고 결론 내렸는데, 게이트의 점수 로직은 이걸 0점 실패로 기록했다. 검토자들이 틀린 게 아니었다. 게이트가 애초에 이 파이프라인에 전혀 해당되지 않는 질문을 던지고 있었을 뿐이다.
더 나쁜 건: Project Persona에는 이 파이프라인의 실제 산출물을 점검하도록 특별히 설계된 자체 전용 감사 단계(4단계)가 이미 있었다는 것이다 — 범용 게이트는 이미 존재하고 이미 올바르게 범위가 잡혀있던 메커니즘 위에 완전히 중복으로 얹혀 있었다. 게다가 매 실행마다 약 4분 타임아웃을 가진 시스템 간 "최종 판정" 왕복 호출과, 무관한 파싱 문제로 자체 약 4분 타임아웃까지 나며 실패하는 별도의 자동개선 트리거까지 함께 발동됐다 — 원래는 3~4초여야 할 파이프라인 실행이 매번 250초 이상으로 부풀려졌고, 이게 4개월 내내, 절대 통과할 수 없었던 게이트 점검 하나 때문에 계속됐다.
# 수정 — 게이트 자체를 바꾸는 게 아니라, 프로젝트별 명시적 옵트아웃
PROJECTS["project_persona"] = {
...,
"auto_gate": False, # 이 파이프라인엔 검토 가능한 텍스트 콘텐츠가 없음
}
# 오케스트레이터의 실행완료 처리부에서
if project.get("auto_gate", True) is False:
log("auto_gate=False — 범용 콘텐츠 게이트 skip")
else:
run_quality_gate(project, output)
실제 재실행으로 검증: 네 단계 전부 성공적으로 완료됐고, skip이 명시적으로 로그에 남았으며, 전체 실행시간이 249초 이상에서 약 3.4초로 떨어졌다. 플래그 변경 범위도 올바르게 확인했다 — Argus에 등록된 다른 모든 프로젝트는 게이트를 기본값(활성)으로 그대로 뒀으므로, 이건 공유 동작을 바꾸는 게 아니라 한 줄, 한 프로젝트만의 수정이었다.
사건 B — 완전히 다른 종류의 작업을 게이트가 잘못 읽는 경우
별개로, Argus 안에서 도는 관련 오케스트레이션 레이어가 여러 전문화된 자율 역할(전략, 보안 검토, 배포, 마케팅 등)을 상시 운영하는데, 각 역할은 자기만의 "완료 정의" — 그 역할의 완료 요약에 반드시 포함돼야 하는 필수 키워드 집합 — 를 갖고 있어서 시스템이 작업을 완료로 인정하려면 이걸 충족해야 한다. 백그라운드 유지보수 루틴이 주기적으로 어떤 역할이 너무 오래 조용했는지 확인하고, 그렇다면 그 역할을 위한 "재활성화" 태스크를 만든다 — 가벼운 하우스키핑 태스크이지 그 역할의 평소 업무가 아니다.
그런데 완료 확인 로직은 이 재활성화 태스크에도 그 역할의 평소 키워드 요건을 그대로 적용하고 있었다. 재활성화 태스크의 실제 완료 계약은 단순했다(짧은 자기평가 작성, 이벤트 기록) — 하지만 완료 확인기는 그 역할의 평소 출력 어휘(특정 승인 단어, 특정 도메인 용어)를 기대했고, 두 줄짜리 하우스키핑 요약이 그런 걸 담고 있을 리가 없었다. 이건 오탐 루프를 만들어냈다: 재활성화 태스크가 올바르게 완료됨 → 완료 확인기가 어쨌든 거부함 → 시스템이 존재하지도 않는 문제를 "고치기" 위해 새 태스크를 자동 생성함 → 그것도 올바르게 완료됨 → 다시 거부됨.
이건 이미 두 번 패치된 적이 있었는데, 매번 그다음으로 이걸 겪은 역할에 빠진 특정 키워드를 추가하는 식이었다 — 세 번째, 네 번째, 다섯 번째 역할에서 예측 가능하게 계속 재발하는 키워드 두더지잡기 패턴이었다. 여섯 번째 키워드 패치를 추가하는 대신, 실제 범주 오류를 해결했다.
REACTIVATION_DONE_KEYWORDS = [
r"activity log",
r"(?:evolution|progress record)",
r"(?:self-assessment|review note)",
]
def _is_reactivation_task(task: dict) -> bool:
title = str(task.get("title") or "")
return "[Reactivation]" in title
def check_done(task, board):
if _is_reactivation_task(task):
required_keywords = REACTIVATION_DONE_KEYWORDS
else:
required_keywords = ROLE_DONE_KEYWORDS[task["assignee"]]
...
이전에 이 버그를 겪었던 모든 역할(다섯 개)과, 재활성화 마커가 전혀 없는 합성 일반 태스크를 대상으로 검증해서, 이 수정이 실제로 미완료인 일반 업무를 실수로 통과시키지 않는다는 것도 확인했다 — 일반 태스크 점검은 실패해야 할 때 여전히 정확하게 실패했다.
두 사건 모두 서로 다른 계층에서 벌어진 같은 범주의 실수다: 범용 게이트 하나를 만들고, 앞으로 마주칠 모든 입력이 그걸 설계할 때 상정한 형태에 맞을 거라고 가정한 것. 두 경우 모두 해법은 "게이트를 더 똑똑하게 만들자"가 아니라, 어떤 입력은 그냥 완전히 다른 범주에 속하며 예외를 쌓아서 하나의 규칙을 모든 경우에 늘려 맞추려 하는 대신 명시적이고 좁은 탈출구가 필요하다는 걸 인식하는 것이었다. 사건 B의 반복된 키워드 패치 패턴은 특별히 짚어둘 가치가 있다: 증상(키워드 하나 누락)을 패치하는 건 매 발생마다 딱 한 번만 효과가 있고, 같은 근본 범주 공백의 새 사례가 다른 이름으로 다음에 또 나타날 거라는 걸 보장할 뿐이다. "같은 클래스의 버그가 다른 옷을 입고 세 번째 나타났다"는 걸 알아차리는 게 바로 증상 패치를 멈추고 범주 구분 자체를 고쳐야 한다는 신호다.
첫 댓글을 남겨보세요