핵심 한 줄

신뢰성 도구의 역설 — 가장 바쁠 때(알림 폭주) 스스로 노이즈가 된다. 같은 에러가 수십 번 울리면 분석·멘션·PR이 도배된다. 그래서 dedup을 한 곳이 아니라 수집·알림·행동 세 레이어에 분산 배치했다. 파이프의 단위는 에러가 아니라 알림(alert): webhook 1건 = Context 1개 = 분석 1회 = PR 최대 1개 + Slack 1메시지.

시리즈 — LLM을 장애대응 파이프에 안전하게 엮기


1. 배경 — 신뢰성 도구의 역설

장애 대응 도구는 보통 장애가 폭주할 때 가장 많이 호출된다. 그런데 그때 도구가:

  • 같은 에러 수십 건마다 분석을 돌리고
  • 담당자를 수십 번 멘션하고
  • 같은 수정 PR을 수십 개 만든다면?

도구가 인시던트를 돕는 게 아니라 노이즈를 더한다. 폭주 상황에서 스스로 조용해지는 설계가 필요했다.


2. 결정

두 가지를 정했다.

  1. 처리 단위를 “알림(alert)“으로 고정 — webhook 1건 = Context 1개 = 분석 1회 = PR 최대 1개 + Slack 1메시지. 에러 개수가 아니라 알림 건수가 단위다.
  2. dedup을 세 레이어에 분산 — 수집(가장 최근 1건만) · 알림(쿨다운) · 행동(PR dedup/cap). 한 곳에서 다 막지 않는다.

3. 대안 비교

방식장점단점채택
dedup 안 함단순폭주 때 도배X
한 곳(입구)에서만 dedup한눈에 보임한 계층이 못 막는 폭주 유형 존재 (수집량·멘션·PR은 성격이 다름)X
수집·알림·행동 3계층 분산각 계층이 자기 성격의 폭주를 막음 (defense in depth)dedup 로직이 분산O

4. 선택 이유

세 종류의 “도배”는 성격이 다르다 — 수집량 폭주(트레이스 수십 개), 멘션 폭주(같은 알림 반복), PR 폭주(같은 suspect 반복). 하나의 게이트로 셋을 다 막으려 하면 어딘가 샌다. 그래서 각 계층이 자기 성격의 폭주만 책임지게 분산했다 — defense in depth. 이건 graceful을 여러 경계에 분산한 것과 같은 결의 결정이다.


5. 설계 상세 — 3계층

graph TD
    W["webhook 1건 (alert)"] --> L1["① 수집: 최근 1건만"]
    L1 --> L2["② 알림: AlertThrottle 5분 쿨다운"]
    L2 -->|처리| AN["분석 1회"]
    L2 -->|non-critical 쿨다운| SK["통째 스킵"]
    AN --> L3["③ 행동: FixProposer dedup + cap"]
    L3 --> N["notifier: 사유별 1메시지"]

① 수집 계층 — 가장 최근 1건만

윈도에 트레이스가 수십 개여도 가장 최근 1건만 가져온다. Loki 쿼리에 limit=1 + direction=backward.

// collector/LokiCollector.java
.queryParam("direction", "backward")
.queryParam("limit", "1")
// ...
return new SourceResult(source(), SourceStatus.COLLECTED, truncate(line),
    "stack from Loki near onset; correlation, not proof of cause");

게다가 그 1건도 truncate80줄 / 8KB 상한, 앞부분(throw site)을 보존하고 잘리면 마커를 단다. 폭주 시 입력량 자체를 결정론으로 묶는다.

② 알림 계층 — AlertThrottle 쿨다운

key는 service|errorType, 윈도는 5분. 같은 알림이 윈도 안에 반복되면 분석·멘션을 억제한다.

// throttle/AlertThrottle.java — state.compute(key, ...)
if (entry == null || !now.isBefore(entry.cooldownUntil())) {
  out[0] = new ThrottleDecision(true, true);          // 새 키/만료 → 처리 + 멘션
  return new Entry(now.plus(COOLDOWN), true);
}
if (alert.severity() == Severity.CRITICAL) {
  boolean mention = !entry.mentioned();
  out[0] = new ThrottleDecision(true, mention);        // CRITICAL 쿨다운 중 → 처리하되 멘션 1회만
  return mention ? new Entry(entry.cooldownUntil(), true) : entry;
}
out[0] = new ThrottleDecision(false, false);           // non-critical 쿨다운 중 → 통째 스킵
return entry;

세 갈래의 차등이 핵심이다.

상황처리멘션
새 키 / 윈도 만료OO
CRITICAL · 쿨다운 중O1회만
non-critical · 쿨다운 중스킵

CRITICAL은 놓치면 안 되니 처리는 하되 멘션 도배만 막고, non-critical 반복은 통째로 조용히 한다.

③ 행동 계층 — FixProposer dedup + cap

같은 service + 같은 suspect 파일의 열린 auto-PR이 있으면 새로 안 만들고 기존 PR을 가리킨다(DEDUP_SKIPPED). 열린 auto-PR이 상한(기본 5) 이상이면 CAP_SKIPPED. (자세히는 [[4.LLM이 코드를 고치되 머지는 사람만|#4]].) dedup은 "🤖 Proposed fix: <file>:" 구분자 매칭이라 Work.java vs PayWork.java 거짓충돌을 피한다.

사용자 통지 — “왜 PR이 없는지”를 사람에게

세 계층이 무언가를 막았을 때, notifier가 Status enum으로 사유를 남긴다: CREATED+링크 / DEDUP+기존링크 / CAP / FAILED / LIST_FAILED. 조용히 막되, 사람에겐 왜 막혔는지 보인다.

근거: orchestrator/Orchestrator.java, collector/LokiCollector.java, throttle/AlertThrottle.java, fixproposal/FixProposer.java, ADR(throttle/fixproposal).


6. 결과 / 트레이드오프

보장된 것

  • 알림 폭주에도 입력량(1건)·멘션(쿨다운)·PR(dedup/cap)이 각각 묶인다.
  • CRITICAL은 누락 없이 처리하되 멘션 도배만 막는다.
  • 막힌 이유가 Slack에 사유로 남는다.

감수한 한계

  • dedup 로직이 세 곳에 분산돼 “전역 억제 정책”을 한눈에 보기 어렵다.
  • AlertThrottle 상태는 in-memory라 재시작 시 소실된다(데모 스케일에서 수용).

7. 교훈

신뢰성 도구는 가장 바쁠 때 조용해져야 한다

폭주를 한 게이트로 막으려 하지 말 것. 도배의 종류(수집량·멘션·PR)가 다르면 각 계층이 자기 성격의 폭주를 책임지게 분산하는 게 견고하다. 그리고 조용히 막되, “왜 막혔는지”는 사람에게 남겨야 한다.

면접 한 마디: “장애 도구가 정작 폭주 때 노이즈가 되지 않게, dedup을 수집·알림·행동 세 레이어에 나눠 깔았습니다. 단위를 에러가 아니라 알림으로 잡아서, webhook 1건당 PR 최대 1개·Slack 1메시지로 묶었습니다.”


관련 글