핵심 한 줄
스택트레이스가 명백한데 수정 PR이 안 올라왔다. LLM 탓인 줄 알았는데, 범인은 제어 흐름이었다 — 분석 JSON이
max-tokens에서 잘려 parse가 실패했고, 그게catch로 빠지면서 그 뒤의 backstop을 통째로 건너뛰었다. “보장”은 해피패스가 아니라 실패 경로에서 검증된다.
시리즈 — LLM을 장애대응 파이프에 안전하게 엮기
1. 배경 — 무엇을 보장하기로 했나
이 파이프의 fix-proposal 단계는 한 가지를 보장한다: 스택트레이스에서 의심 위치(suspect)가 특정되면, LLM이 패치를 못 만들더라도 결정론 backstop이 carrier draft PR을 반드시 만든다. “suspect 있으면 무조건 draft PR”이 계약이었다.
2. 증상 — PR이 안 올라온다
docker 기반 e2e 데모를 돌렸더니:
- Slack에는 분석 메시지만 도착
- 그런데 수정 PR은 안 올라옴
- 스택트레이스는 멀쩡히 있었다 → suspect가 특정됐어야 정상
계약대로면 draft PR이 떠야 했다. 첫 의심은 “LLM이 패치를 안 만들었나?”였다.
3. 추적 1 — 분석 JSON이 잘렸다
로그를 따라가니 LLM 응답 자체가 문제였다.
- 한국어 분석 결과 JSON이
max-tokens한도에서 잘려서 도착 - 잘린 JSON →
BeanOutputConverter의 parse 실패 → 예외 발생 analyze()의try가 이 예외를 잡아 degraded Analysis로 흡수
여기까지는 의도된 graceful 동작이다. 분석이 깨져도 placeholder로 흡수하는 것 자체는 맞다.
4. 추적 2 — 진짜 버그는 제어 흐름
문제는 그 다음이었다. 당시 코드는 backstop(결정론 carrier PR 생성)이 try 안쪽, 분석 변환 뒤에 있었다.
try {
analysis = convert(...) // ← 여기서 예외 (JSON 잘림)
proposal = backstop(...) // ← 예외로 점프하며 이 줄이 통째로 스킵됨 ❌
} catch (e) {
analysis = degraded; // 흡수는 됐지만 backstop은 이미 건너뜀
}
즉 분석 parse가 실패하면 그 예외가 catch로 점프하면서, 같은 try 블록 뒤에 있던 backstop까지 건너뛰었다. “suspect 있으면 무조건 PR”이라는 보장이, 하필 분석이 깨지는 바로 그 경우에 무너진 것이다. 가장 보장이 필요한 순간에.
5. 수정 — 보장을 try 밖으로 꺼내다
backstop과 proposal 수집을 try 밖으로 옮겼다. 분석이 깨지든 말든 독립적으로 실행되게.
// analyzer/TriageAgent.java (수정 후)
Analysis analysis;
try {
// ... 모델 호출 + tool 루프 + JSON 변환
analysis = converter.convert(extractJson(...));
} catch (Exception e) {
analysis = new Analysis("분석 미완 — tool-loop 상한 또는 변환 실패", "수동 확인 필요", List.of());
}
// ↓ try 밖: 분석이 degraded여도 backstop은 반드시 평가된다
Optional<FixProposal> proposal = patchTool.proposal();
if (proposal.isEmpty() && suspect.isPresent()) {
proposal = backstop(suspect.get(), analysis); // suspect 있으면 carrier PR 보장
}
return new TriageResult(analysis, proposal);함께 max-tokens를 4096으로 올려 정상 JSON이 잘리는 빈도 자체도 줄였다. 핵심 수정은 commit 230d13f.
코드 주석에도 이 의도를 박아뒀다 — “The backstop runs regardless of whether the analysis parsed… patchTool.proposal() and backstop() are non-throwing, so analyze() still never throws.”
6. 다르게 갔다면 (대안)
- max-tokens만 올리기: 증상은 줄지만 근본 원인(제어 흐름)이 남는다. 더 긴 분석에선 다시 잘려 재발. → 미봉책.
- catch 안에서 backstop 호출: 동작은 하나, 정상 경로와 실패 경로 두 곳에 backstop이 중복돼 분기가 늘고 빠뜨리기 쉽다.
- backstop을 try 밖 단일 지점으로 (채택): 분석 성공/실패와 무관하게 한 번만 평가 → 보장이 제어 흐름에 의존하지 않는다.
7. 교훈
"보장"은 해피패스가 아니라 실패 경로에서 검증된다
보장하려던 부수효과(backstop PR)가 그것을 깨뜨리려는 예외와 같은 try 블록 안에 있으면, 가장 필요한 순간에 함께 무너진다. 반드시 일어나야 하는 일은 실패를 흡수하는
catch밖, 독립된 지점에 둬야 한다.
부수적으로: 이 버그는 단위 테스트가 아니라 e2e 데모에서만 드러났다. 분석 parse 실패 + suspect 존재라는 두 조건이 겹쳐야 재현되기 때문. 실패 경로의 조합은 통합 레벨에서만 보인다.
관련 글
- 2.장애 도구가 장애에 죽지 않게 — 같은 “실패 경로 우선” 원칙
- 3.LLM에게 도구를 주되 가두기 — backstop이 감싸는 LLM 에이전트