한 달 전 글의 마지막 문장은 이랬습니다.
안전망이 무엇을 못 보고 있는지는, 안전망 자신이 알려주지 않습니다.
그 안전망을 고치는 작업을 했습니다. 그리고 작업하는 내내 같은 병에 걸렸습니다. 이건 그 기록입니다.
세 줄 요약
- 모니터링이 "정상"이라 말하던 걸 고치려고 설계서를 썼는데, 검토에서 제가 "확인했다"고 단정한 사실 셋이 전부 틀렸다는 게 드러났습니다.
- 구현을 마치고 마지막 리뷰에서 또 나왔습니다 — 발송 로그가 "성공 2건"이라 말하는데 실제 전달 여부는 알 수 없는 구조였습니다.
- 원인은 셋 다 같습니다. 확인하지 않은 것을 확인한 것처럼 적었습니다. 코드에, 문서에, 로그에.
1막 — 설계서가 거부당했습니다
지난 글에서 감지 로직의 1차 수정을 했습니다. 그 뒤 요구사항이 확정되어 규칙 6개 기반으로 전면 재설계하게 됐고, 설계서를 썼습니다.
자체 점검도 돌렸습니다. 플레이스홀더 스캔, 내부 일관성, 모호성 — 세 패스를 거쳐 모호한 곳 셋을 고치고 커밋했습니다. 꽤 꼼꼼했다고 생각했습니다.
그다음 검토를 붙였습니다. 네 개의 시선으로 나눴습니다 — 결제 도메인, 아키텍처, 적대적 검토, 그리고 문서와 실제 코드의 사실 대조.
마지막 것이 문제를 찾았습니다.
"종전에는 채널마다 배치가 하나였다"
설계서 §3의 첫 문단입니다. 이 서술 위에 "채널이 입력에서 출력으로 바뀐다"는 아키텍처 설명 전체가 얹혀 있었습니다.
실제로는 스케줄러가 하나뿐이었습니다. 설정 파일에 크론 표현이 세 개 있었지만, 코드가 읽는 건 첫 번째 하나였고 나머지 둘은 어떤 코드도 참조하지 않는 죽은 설정이었습니다.
@Scheduled(cron = "${...audits[0].cron}") // 목록의 첫 번째 하나만 읽는다
public void requestAudit() {
auditService.audit(개발채널); // 채널도 하나로 고정
}
파급이 작지 않았습니다. 이번 장애에서 대표·영업이 통보를 못 받은 이유를 저는 "시간대 설정 문제"로 적었는데, 애초에 그 채널로 알람이 나간 적이 한 번도 없었습니다. 판정 로직과 무관한 별개 결함이었고, 설계서는 그걸 못 짚고 있었습니다.
"이 문법은 이번에 처음 쓴다"
두 번째입니다. 열거형을 문자 코드로 변환하는 컨버터가 걸린 컬럼에 IN 절을 쓰는 게 처음이라고 적었고, 그래서 "가장 가능성 높은 실패 지점"으로 위험 순위 1위에 올렸습니다.
그 전제 위에서 쿼리 전략을 결정했습니다. 더 효율적인 방식(시각 버킷으로 묶어 한 번에 집계)을 "검증되지 않은 환경에서 위험하다"며 기각했습니다.
검토가 반증했습니다. 같은 모듈의 다른 배치가 이미 10분마다 그 문법으로 돌고 있었습니다. 다른 모듈까지 합치면 네 곳이었습니다.
결론(쿼리를 창별로 나눠 조회)은 살아남았습니다. 하지만 근거가 틀렸으므로 다음 사람이 같은 판단을 재현할 수 없습니다. 그래서 개정판에서는 이유를 바꿔 적었습니다 — "성능이 아니라, 검증 절차가 단순 쿼리일 때 가장 잘 성립하기 때문"이라고.
"발송이 실패하면 예외가 온다"
세 번째가 가장 아팠습니다. 뒤에서 다시 나옵니다.
설계서 §9에 "한 채널 발송이 실패해도 다른 채널은 발송된다"는 격리 설계를 써뒀습니다. try/catch로 감싸는 그림이었습니다.
알림 발송 구현은 이랬습니다.
for (int i = 1; i <= maxRetries; i++) {
try { restClient.post()...; return; }
catch (Exception e) {
if (i == maxRetries) log.error("Telegram Send Message Error : ...");
Thread.sleep(2000);
}
}
// 3회 실패해도 정상 리턴한다
예외를 삼킵니다. try/catch로는 아무것도 잡을 수 없습니다.
이 셋의 공통점
전부 5분이면 반증되는 것들이었습니다. grep 한 번, 파일 하나 열기. 그런데 저는 확인하지 않고 적었고, 그 서술 위에 설계 결정을 쌓았습니다.
검토 결과는 REJECT였습니다.
2막 — 손으로 계산해야 보이는 것
같은 검토에서 나온 다른 종류의 결함입니다. 이건 사실 오류가 아니라 설계 자체의 구멍이었습니다.
규칙마다 적용 시간대가 다릅니다. 주간(08시~다음날 01시)에 도는 규칙과 야간(01~08시)에 도는 규칙이 나뉩니다. 그리고 배치는 30분마다 돌면서 직전에 완결된 구간을 봅니다.
저는 "실행 시각이 어느 시간대에 속하는가"로 규칙을 골랐습니다. 자연스러워 보였습니다.
적대적 검토가 시각을 대입해봤습니다.
[00:30, 01:00) 구간을 30분 창으로 보는 실행은 T=01:00 하나뿐이다.
그런데 01:00 은 야간이다. → 주간 규칙이 통째로 빠진다.
손으로 이어보면 이렇게 됩니다.
| 실행 | 시간대 | 결과 |
|---|---|---|
| 00:30 | 주간 | 창 [00:00,00:30) → 00:00~00:20 승인 존재 → 정상 |
| 01:00 | 야간 | 주간 규칙 평가 제외. 야간 규칙의 창은 [23:00,01:00) → 앞부분에 승인 존재 → 정상 |
| 01:30+ | 야간 | 복구됨 → 정상 |
00:20~01:00, 40분짜리 전면 장애가 알람 0건으로 사라집니다.
지난 글에서 제가 "종전 구현의 결함"이라며 지적했던 게 "오래된 승인 한 건이 이후 실행을 계속 덮는다" 였습니다. 그걸 고쳤다고 썼는데, 축소된 형태로 같은 게 남아 있었습니다.
기준을 두 번 바꿨습니다
처음엔 "창의 종료 시각으로 판정하자"고 제안했습니다. 대입해보니 안 풀렸습니다 — 창 [00:30, 01:00)의 종료는 01:00이고, 그건 여전히 야간입니다.
시작 시각으로 바꾸니 이 케이스는 풀렸지만 다른 구멍이 생겼습니다. 시간대가 바뀐 직후 창 길이만큼 야간 규칙이 안 도는 구간이 남았습니다.
최종적으로 겹침으로 정했습니다. 규칙의 적용 시간대와 창이 조금이라도 겹치면 평가합니다.
01:00 실행: 주간규칙 창 [00:30,01:00) ∩ 주간 = 전체 → 평가
야간규칙 창 [23:00,01:00) ∩ 야간 = 없음 → 스킵
02:00 실행: 주간규칙 창 [01:30,02:00) ∩ 주간 = 없음 → 스킵
야간규칙 창 [00:00,02:00) ∩ 야간 = 겹침 → 평가
공백도 중복도 없습니다. 덤으로 08시 정각 실행이 새벽 한 시간을 통째로 주간 임계로 판정하던 문제가 없어졌습니다 — 그 창은 이제 주간과 전혀 겹치지 않아 건너뜁니다.
다만 완전히 사라진 건 아닙니다. 08:30 실행의 창은 [07:30, 08:30)이라 절반이 아직 새벽이고, 주간과 30분 겹치므로 평가됩니다. 오탐 구간이 한 시간에서 30분으로 줄어든 것이지 없어진 게 아닙니다. 개장 직후 30분간 거래가 없으면 여전히 임원 채널로 갈 수 있고, 운영에서 관찰해야 할 항목으로 남겨뒀습니다.
이 글을 쓰면서도 처음엔 "같이 사라졌다"고 적었습니다. 시각을 대입해보기 전까지는요.
이건 코드를 읽어서는 안 보입니다. 시각을 넣고 손으로 따라가야 보입니다.
3막 — 구현이 끝나고, 마지막 리뷰에서
설계를 고치고 구현했습니다. 계획을 태스크 15개로 쪼개고, 태스크마다 구현자와 리뷰어를 따로 붙였습니다. 테스트 73건이 통과했고 전 모듈 빌드도 통과했습니다.
마지막으로 브랜치 전체를 한 번에 보는 리뷰를 붙였습니다. 태스크별 리뷰는 자기 조각만 보니까, 경계를 넘는 문제를 찾으라고 했습니다.
두 개가 나왔습니다.
발송 로그가 거짓을 말합니다
1막의 세 번째 오류 기억하시나요. 발송은 예외를 던지지 않습니다.
저는 설계서를 개정하면서 이걸 반영했습니다. "try/catch로는 실패를 알 수 없으니, 성공·실패 건수를 로그에 남기자"고 썼고, 그렇게 구현했습니다.
try {
telegramProvider.sendNotification(channel, message);
succeeded++; // ← 여기
} catch (Exception e) {
failed++;
}
log.info("dispatch 대상 {}건 · 성공 {} · 실패 {}", targets, succeeded, failed);
그리고 클래스 주석에 이렇게 적었습니다.
tally 를 로그에 남긴다 — "로그엔 발송으로 찍혔는데 아무도 못 받은" 상태를 사후에 구별하기 위해서다.
구별할 수 없습니다.
예외를 안 던지는 함수의 반환에서 파생된 숫자입니다. catch 블록은 운영에서 도달하지 않습니다. 알림 서버가 완전히 죽어 있어도 로그는 항상 이렇게 찍힙니다.
dispatch 대상 2건 · 성공 2 · 실패 0
리뷰어의 문장이 정확했습니다.
8월 7일 장애의 본질이 "지표가 정상이라고 말했다"였다. 알람 경로에 같은 성질을 남긴 채 병합하면, 다음 장애 때 "로그에 성공 2로 찍혔는데 대표님은 못 받았다"가 된다.
고치려던 병을, 고치는 도구에 넣었습니다.
안전장치가 아무 데도 연결돼 있지 않았습니다
두 번째입니다.
창이 세 개(30·60·120분)라 스냅샷도 세 개입니다. 규칙마다 자기 창의 스냅샷을 읽어야 하는데, 60분 규칙이 30분 스냅샷을 읽으면 조용히 틀립니다. 로그는 정상으로 보입니다.
그래서 래퍼를 만들었습니다.
public PaymentAuditSnapshot forWindow(Duration lookback) {
PaymentAuditSnapshot snapshot = byWindow.get(lookback);
if (snapshot == null)
throw new IllegalStateException("스냅샷이 수집되지 않은 창입니다: " + lookback);
return snapshot;
}
주석까지 달았습니다. "조용히 null 이나 0 을 돌려주면 사고가 로그상 정상으로 보인다."
그런데 서비스가 이 클래스를 안 썼습니다. 원시 Map을 그대로 들고 다니며 .get()을 호출하고 있었습니다. 프로덕션 참조 0건. 계획서를 쓸 때 제가 태스크를 나눠놓고, 나중 태스크에서 그 산출물을 연결하는 걸 빠뜨린 겁니다.
지금은 창이 항상 채워져 있어서 무해합니다. 문제는 다른 데 있습니다.
읽는 사람은 이 안전장치가 작동 중이라고 오해한다.
없는 것보다 나쁩니다. 없으면 만들 텐데, 있으면 있다고 믿습니다.
왜 세 번 다 같은 병인가
왼쪽이 실제로 확인된 것이고, 오른쪽이 사람에게 전달된 말입니다. 넷 다 그 사이가 비어 있습니다.
그리고 넷 다 틀렸다는 신호와 정상이라는 신호가 같은 모양입니다. 이게 지난 글의 결론이었는데, 정작 그걸 고치면서 세 번 반복했습니다.
고친 방법도 같습니다
전부 말을 사실에 맞추는 것으로 고쳤습니다.
tally 는 succeeded를 attempted로 바꿨습니다. 로그 문구도 정직하게 바꿨습니다.
dispatch 대상 2건 · 시도 2 · 예외 0
— 전달 확인 불가, 실패는 "Telegram Send Message Error" 로그를 대조하라
숫자는 그대로인데 읽는 사람이 속지 않습니다. catch 블록은 방어용으로 남겼고, 그 테스트에는 "실제 협력자는 던지지 않는다 — mock 방어 경로만 검증한다"는 주석을 붙였습니다.
래퍼는 삭제하지 않고 실제로 배선했습니다. 삭제가 더 쌌지만, 창이 세 개인 이상 그 사고는 언젠가 납니다. 이제 창 미스는 조용한 NullPointerException이 아니라 명시적 예외가 됩니다.
설계서는 틀린 문장 셋을 고치고, "초판 대비 변경" 표에 무엇이 왜 틀렸는지 남겼습니다. 지우지 않았습니다. 다음 사람이 같은 근거로 같은 판단을 하지 않도록.
무엇이 이걸 잡았나
세 번 다 제가 아니라 다른 시선이 잡았습니다. 그게 이 작업에서 제일 값진 부분입니다.
① 사실 대조를 별도 역할로 분리한 것. 도메인·아키텍처 검토와 나란히, "문서의 주장을 실제 코드와 하나씩 맞춰보라"는 역할을 따로 뒀습니다. 사실 오류 셋은 전부 여기서 나왔습니다. 다른 셋은 못 찾았습니다 — 그들은 설계의 타당성을 보느라 전제를 의심하지 않았습니다.
② 교차 확인. 네 검토가 각자 지적을 냈는데, 둘 이상이 같은 코드 근거로 확인한 것만 채택했습니다. 단독 지적은 파일을 직접 열어 확인했습니다. 이렇게 하니 신뢰도가 자동으로 걸러졌습니다.
③ 손으로 계산시킨 것. "이 경계값이 맞는지 직접 대입해 확인하라"고 요구했습니다. 40분 사각지대는 코드를 읽어서는 안 나오고 시각을 넣어봐야 나옵니다.
④ 마지막에 전체를 한 번 더 본 것. 태스크별 리뷰는 자기 조각만 봅니다. tally 문제와 미배선 래퍼는 조각을 다 조립한 뒤에야 보이는 것들이었습니다.
구현 중에도 비슷한 일이 있었습니다. 제가 계획서에 써둔 테스트 단언이 틀렸는데(수신자가 두 채널인 규칙과 한 채널인 규칙을 헷갈렸습니다) 구현자가 찾아냈고, 리뷰어가 "검증력을 약화시킨 게 아니라 계획 오류를 고친 것"이라고 판정했습니다. 계획서를 쓴 사람과 구현하는 사람이 다르면, 계획서의 오류가 드러납니다.
아직 안 끝났습니다
정직하게 적어두겠습니다.
저장소 계층은 실행된 적이 없습니다. 인터넷망에서 DB를 띄울 수 없어 단위 테스트를 만들지 않았습니다. 의도한 결정이지만, 새 쿼리가 SQL로 변환되어 돌아본 적이 한 번도 없다는 뜻입니다. 그래서 로그와 SQL을 대조하는 절차를 문서에 넣었고, 그 대조가 성립하도록 구간을 30분 경계에 정렬했습니다. 검증 가능성 때문에 감지 지연 30분을 감수한 겁니다.
임원 채널은 한 번도 발송된 적이 없습니다. 이 작업의 최상위 산출물인 Critical 알람이 검증된 적 없는 대화방으로 갑니다. 배포 전에 실제로 한 번 보내 도착을 눈으로 확인해야 합니다. 그러지 않으면 방이 잘못돼 있어도 — tally를 고친 지금도 — 로그로는 알 수 없습니다.
창보다 짧은 장애는 원리적으로 못 잡습니다. 판단 조건이 "건수가 0인가"라서 창 전체가 장애에 덮여야 발동합니다. 30분 창이면 10:15~10:45 사이의 30분 장애는 두 창 모두 정상 승인을 포함해 알람이 0건입니다. 요구사항이 그렇게 정해졌고, 이건 한계로 문서에 적었습니다. 숨기는 것보다 적어두는 게 낫습니다.
감사 배치 자신의 실패는 아무에게도 통보되지 않습니다. DB가 죽으면 조용히 멈추고 경고 로그 한 줄만 쌓입니다. 설계서에 "알리는 경로를 둔다"고 써놓고 구현하지 않았습니다 — 상태 저장이 범위 밖이라 "연속 N회"를 셀 수 없다는 이유였는데, 돌이켜보면 "이번 회차 실패"만 알려도 충분했습니다. 후속 1순위입니다.
남은 생각
이번에 배운 건 기술적인 게 아닙니다.
"확인했다"와 "그럴 것이다"를 구분해 쓰는 것. 설계 문서에서 이 둘은 같은 문장 모양을 합니다. 읽는 사람은 구별할 수 없고, 쓴 사람도 며칠 지나면 어느 쪽이었는지 기억하지 못합니다. 그래서 개정판에는 확인한 것에 파일과 줄 번호를 붙였습니다. 근거를 못 붙이는 문장은 "확인되지 않음"으로 남겼습니다.
그리고 자기 검토로는 이게 안 잡힙니다. 저는 자체 점검을 세 패스나 돌렸는데 사실 오류 셋을 하나도 못 찾았습니다. 제가 쓴 문장을 제가 읽으면 "확인했다"고 쓴 것은 확인한 것으로 읽힙니다. 그 기억이 실제로 확인한 기억인지 확인했다고 쓴 기억인지 구별되지 않기 때문입니다.
지난 글은 이렇게 끝났습니다.
안전망이 무엇을 못 보고 있는지는, 안전망 자신이 알려주지 않습니다.
한 층 위에도 같은 말이 성립합니다. 안전망을 고치는 사람이 무엇을 못 보고 있는지도, 그 사람 자신은 알려주지 않습니다.
