
AI가 리뷰하고 AI가 수정하면 무한 루프에 빠진다, 이를 방지하기 위한 설계에 관한 이야기
요약
AI 코드 리뷰와 수정 프로세스가 반복될 때 발생하는 무한 루프와 비용 문제를 방지하기 위한 아키텍처 설계 방안을 다룹니다. 머지 시점에만 수정 태스크를 실행하고 봇이 생성한 PR을 제외하는 등의 장치를 통해 시스템의 수렴성을 보장합니다.
핵심 포인트
- AI 리뷰-수정 루프에서 발생하는 무한 루프 및 LLM 비용 문제 해결
- Handoff 시점을 finding 발견 시가 아닌 'merge 시'로 제한하여 비용 제어
- 봇이 생성한 PR을 자동 handoff 대상에서 제외하여 루프 직접 차단
- 아키텍처 설계를 통해 모델의 성능이 아닌 시스템 차원의 수렴성 보장
AI 코드 리뷰 제품(Orange Codens)을 만들면서 설계상의 큰 고비가 하나 있었다. 리뷰하는 쪽도 AI, 수정하는 쪽도 AI인 구성은 방심하면 무한 루프에 빠지게 된다.
순서대로 살펴보면 다음과 같다. Orange가 PR을 리뷰하여 문제를 발견한다. 지적에서 끝내지 않고, 수정 태스크를 다른 Codens(Purple / Red)로 handoff 하여 수정 PR을 만들게 한다. 그런데 그 수정 PR 또한 「PR」이다. Orange가 다시 리뷰한다. 새로운 코드에 새로운 지적이 나온다. 다시 handoff 한다. 수정한다. 다시 리뷰한다.
인간 리뷰어라면 "이 정도면 충분해"라며 멈춘다. AI는 멈추지 않으며, 1 루프마다 LLM 비용이 발생한다. 따라서 "멈추는 것"과 "비용의 상한"을 모델의 똑똑함이 아니라 아키텍처 측면에서 보장해야 한다. Orange에 도입한 장치를 실제 코드와, 실제로 겪었던 비수렴(non-convergence) 사고 사례와 함께 기록해 둔다.
루프의 근원이 되는 구조
먼저 단순하게 만들면 어떻게 돌아가는가.
PR open
→ Orange review → finding 발견
→ handoff → Purple/Red가 수정 PR을 생성
...
멈춰야 할 지점은 한 곳이 아니다. "언제 handoff 할 것인가", "누구의 PR을 handoff 대상으로 할 것인가", "동일한 finding을 중복해서 던지지 않을 것인가", "리뷰 자체가 언제 수렴할 것인가"를 각각 별도의 장치로 제어해야 한다. 순서대로 작성한다.
장치 1: handoff는 finding 발견 시가 아니라 「merge 시」에 실행한다
가장 효과적인 것이 이것이다. finding을 발견한 순간에는 수정 태스크를 던지지 않는다. PR이 merge 되었을 때만 던진다.
webhook 핸들러는 다음과 같이 되어 있다.
_REVIEWED_ACTIONS = {"opened", "synchronize", "reopened"}
async def execute(self, payload):
action = payload.get("action", "")
...
merge 전에는 GitHub의 suggestion(코멘트상의 제안)을 내놓을 뿐이다. 실제로 Purple/Red를 깨우는 것은 closed 이벤트에서 merged=true일 때뿐이다.
그리고 merge되지 않고 닫힌 PR은, 큐(queue)에 대기 중인 handoff를 파기한다.
if not merged:
# 코드가 main에 들어가지 않으므로, 큐에 대기 중인 handoff를 버림
discarded = 0
...
이로 인해 무엇이 변하는가. 머지되지 않은 PR은 하류(downstream) 비용이 제로가 된다. 시행착오 중인 PR, draft, 거절된 PR 등, 그들에 몇 건의 finding이 붙더라도 main에 들어가지 않는 한 수정 태스크는 단 한 건도 발송되지 않는다. 리뷰는 얼마든지 돌아갈 수 있지만, handoff라는 "비용과 새로운 PR이 발생하는 작업"은 merge라는 인간(또는 명시적 게이트)의 판단을 반드시 거치게 된다.
장치 2: bot이 생성한 PR은 자동 handoff 대상에서 제외한다
루프를 직접적으로 끊어내는 것이 이것이다. 수정 PR을 만드는 것은 purple-codens[bot]이나 red-codens[bot]이다. 해당 bot이 작성한 author의 PR은 자동 handoff 대상에서 제외한다.
_CODENS_BOT_LOGINS = {"purple-codens[bot]", "red-codens[bot]", "orange-codens[bot]"}
def _should_handoff(self, finding, policy, is_bot_pr):
# 큐에 대기 중인 것 (인간이 수동으로 누른 것)은 mode/bot에 관계없이 기표
...
bot의 수정 PR은 Orange가 리뷰는 하지만(품질은 확인해야 하므로), 거기서부터 자동으로 다음 수정 태스크를 생성하지는 않는다. 이것이 루프를 근본적으로 차단하는 방법이다.
주목해야 할 점은 첫 번째 분기문인 handoff_queued_at이다.
(사람이 수동으로 "이것을 수정해줘"라고 queue에 넣은 것)은 bot PR이든 mode가 off 상태든 반드시 티켓을 생성한다. 인간의 명시적인 의사는 루프 가드(loop guard)보다 강력하다. 자동화된 연쇄는 차단하지만, 인간이 "이 bot PR의 이 지적은 수정할 가치가 있다"라고 판단한 것은 통과시킨다. 자동과 수동을 통해, 안전 측의 기본값(default)과 탈출구(escape hatch)를 분리해 두었다.
장치 3: verify 실행에서 유래한 finding은 handoff 하지 않는다
또 다른 루프 경로를 차단한다. Purple은 스스로 verify 사이클(구현→테스트→수정)을 돌린다. 그 실행을 Orange가 리뷰하여 발견한 finding은 handoff 대상에서 제외한다.
# purple_verify run의 finding은 제외 (루프 가드)
purple_verify_run_ids = {
r.review_run_id for r in runs if r.triggered_by == TriggeredBy.PURPLE_VERIFY
...
여기서 handed_off_task_id is None도 효과를 발휘한다. 한 번 handoff 한 finding은 두 번 던지지 않는다. 동일한 문제에 대해 수정 태스크가 2개 생성되는, 미묘한 루프(혹은 발산)를 방지한다.
사고 1: 동일 파일의 여러 finding이 서로의 수정 PR을 데드락(deadlock)에 빠뜨리다
이후부터는 설계대로 만들었음에도 실제로 겪었던 비수렴(non-convergence) 사례다.
E2E 테스트에서 app/api/notes/search.py에 3건의 finding(SQLi, 하드코딩된 AWS 키, 기타)이 발생했다. 단순하게 "finding당 1개 태스크" 방식으로 handoff 했더니, 각각 별개의 수정 PR을 생성했다.
문제는 여기서부터다. SQLi를 수정한 PR을 merge하려고 하면, Orange가 해당 PR을 리뷰하며 "동일한 파일에 아직 AWS 키 finding이 남아 있다"라고 carry-over를 통해 REQUEST_CHANGES를 내보낸다. AWS 키를 수정한 PR 역시 SQLi가 남아 있는 한 같은 이유로 차단된다. 3개의 수정 PR이 서로의 미수정 finding을 이유로 전부 머지(merge)할 수 없게 된 것이다.
해결 방법은 handoff 단계에서 "동일 파일 × 동일 대상"인 finding을 하나의 태스크로 묶는 것이다.
# (target_codens, file_path)로 coalesce 한다. 동일 파일의 여러 finding이
# 동일한 서비스로 간다면 1개 태스크로 합쳐서, 수정 PR이 일괄적으로 해소되도록 한다.
# 그렇지 않으면 각 finding의 수정 PR이, 동일 파일의 다른 미수정 finding에 대해
...
한 파일의 문제는 모아서 하나의 PR로 수정한다. 당연해 보이지만, "finding 단위로 단순하게 병렬화"하면 리뷰어(Orange 자신)가 자신이 내놓은 다른 지적 때문에 자신의 수정을 차단하는 자기 데드락(self-deadlock)에 빠지게 된다. 병렬화의 단위를 잘못 설정하면 리뷰와 수정이 맞물리지 못하고 멈춰버린다.
사고 2: 새 코드에 새로운 지적이 계속 나와 리뷰가 수렴하지 않다
또 다른 사례다. bot의 수정 PR을 Orange가 리뷰할 때, 라운드마다 새로운 코드에 대해 새로운 high 수준의 지적(테스트 부족, style 등)을 계속 내놓아 4라운드 연속 REQUEST_CHANGES가 발생하고, 상한선(max_review_iterations=5)에 도달하여 escalate 되는 비수렴 현상을 겪었다.
수정한 부분은 "bot 수정 PR의 확정 리뷰 판정" 로직이다.
- carry-over(이전 리뷰의 미해결 지적)가 severity ≥ high인 경우 → 차단(제대로 수정되었는지 확인)
- 신규 지적은
blocker만 차단(수정이 새로운 중대 문제를 가져온 경우에만) - 신규 critical 미만 지적은 inline / 본문에 남기되 APPROVE 함
인간의 PR에 대한 동작(COMMENT만 남기고 멋대로 차단하지 않음)은 바꾸지 않았다. bot의 수정 PR에 대해서만 수렴을 우선시하는 판정으로 변경했다.
포인트는 리뷰어가 완벽주의자라면 수렴하지 않는다는 것이다. 수정할 때마다 새로운 사소한 지적을 만점 기준으로 계속 요구하면 PR은 영원히 닫히지 않는다. "이전의 중대한 지적이 해소되었는가"를 주축으로 삼고, 신규의 사소한 지적은 기록하되 통과시킨다. 리뷰를 멈추기 위한, 리뷰 기준의 설계다.
요약: 멈추는 것은 모델이 아니라 아키텍처가 보장한다
AI가 작성자(author)이면서 동시에 리뷰어(reviewer)인 구성에서는, 설계의 역할이 "종료될 것"과 "비용의 상한선"을 보장하는 것이다. 모델이 아무리 똑똑해지더라도 그 부분은 보장해주지 않는다. 배선(wiring)이 보장한다.
Orange에 도입한 것은 다음 5가지다.
- handoff(핸드오프)는 발견(finding) 시점이 아니라 머지(merge) 시점에 실행한다 (머지되지 않는 PR은 하류 비용이 0이다)
- bot author(봇 작성자)의 PR은 자동 handoff 대상에서 제외한다 (루프를 직접적으로 차단)
- verify(검증) 실행에서 유래한 finding은 handoff 하지 않는다
- handoff 된 finding은 두 번 던지지 않는다 (멱등성, Idempotency)
- 사람이 수동으로 큐(queue)에 넣은 finding은 가드를 넘어 통과시킨다 (escape hatch, 탈출구)
그리고 실제로 경험하며 추가한 2가지.
- 동일한 파일의 finding은 1개의 태스크로 묶는다 (자기 데드락(deadlock) 회피)
- bot 수정 PR의 리뷰는 carry-over(이월)된 중대 지적 + 신규 blocker(차단 요소)만으로 차단한다 (수렴 우선)
"AI가 코드를 작성한다"를 실무에서 돌리면, 반드시 "누가 그것을 리뷰할 것인가"라는 문제에 부딪힌다. 리뷰도 AI에게 맡긴다면, 멈추는 메커니즘까지 세트로 설계하지 않으면 무한히 계속 수정하거나, 비용이 발산하거나, 스스로를 데드락 상태로 만들게 된다.
Orange Codens는 이 모든 부분을 제품에 통합해 두었다.
Discussion

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