Skip to Content
← 블로그
개선기

"확인했습니다"라고 쓴 것 중 셋이 틀렸습니다

감시 도구를 고치는 동안 같은 병에 세 번 걸렸다. 설계서에서도, 구현에서도

만화로 보는 요약 — 먼저 읽어보세요
만화로 보는 요약 — 먼저 읽어보세요

한 달 전 글의 마지막 문장은 이랬습니다.

안전망이 무엇을 못 보고 있는지는, 안전망 자신이 알려주지 않습니다.

그 안전망을 고치는 작업을 했습니다. 작업하는 내내 같은 병에 걸렸습니다. 이건 그 기록입니다.

세 줄로 요약하면 다음과 같습니다.

  • 모니터링이 "정상"이라 말하던 걸 고치려고 설계서를 썼는데, 검토에서 제가 "확인했다"고 단정한 사실 셋이 전부 틀렸다는 게 드러났습니다.
  • 구현을 마치고 마지막 리뷰에서 또 나왔습니다. 발송 로그가 "성공 2건"이라 말하는데 실제 전달 여부는 알 수 없는 구조였습니다.
  • 원인은 셋 다 같습니다. 확인하지 않은 것을 확인한 것처럼 적었습니다. 코드에, 문서에, 로그에.

설계서가 거부당했습니다

지난 글에서 감지 로직의 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였습니다.


손으로 계산해야 보이는 구멍이 있었습니다

같은 검토에서 나온 다른 종류의 결함입니다. 이건 사실 오류가 아니라 설계 자체의 구멍이었습니다.

규칙마다 적용 시간대가 다릅니다. 주간(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분간 거래가 없으면 여전히 임원 채널로 갈 수 있고, 운영에서 관찰해야 할 항목으로 남겨뒀습니다.

이 글을 쓰면서도 처음엔 "같이 사라졌다"고 적었습니다. 시각을 대입해보기 전까지는요. 이건 코드를 읽어서는 안 보입니다. 시각을 넣고 손으로 따라가야 보입니다.


구현이 끝나고 마지막 리뷰에서 또 나왔습니다

설계를 고치고 구현했습니다. 계획을 태스크 15개로 쪼개고, 태스크마다 구현자와 리뷰어를 따로 붙였습니다. 테스트 73건이 통과했고 전 모듈 빌드도 통과했습니다.

마지막으로 브랜치 전체를 한 번에 보는 리뷰를 붙였습니다. 태스크별 리뷰는 자기 조각만 보니까, 경계를 넘는 문제를 찾으라고 했습니다. 두 개가 나왔습니다.

발송 로그가 거짓을 말합니다

앞의 세 번째 오류 기억하시나요. 발송은 예외를 던지지 않습니다.

저는 설계서를 개정하면서 이걸 반영했습니다. "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순위입니다.


남은 생각

이번에 배운 건 기술적인 게 아닙니다. "확인했다"와 "그럴 것이다"를 구분해 쓰는 것입니다. 설계 문서에서 이 둘은 같은 문장 모양을 합니다. 읽는 사람은 구별할 수 없고, 쓴 사람도 며칠 지나면 어느 쪽이었는지 기억하지 못합니다. 그래서 개정판에는 확인한 것에 파일과 줄 번호를 붙였습니다. 근거를 못 붙이는 문장은 "확인되지 않음"으로 남겼습니다.

그리고 자기 검토로는 이게 안 잡힙니다. 저는 자체 점검을 세 패스나 돌렸는데 사실 오류 셋을 하나도 못 찾았습니다. 제가 쓴 문장을 제가 읽으면 "확인했다"고 쓴 것은 확인한 것으로 읽힙니다. 그 기억이 실제로 확인한 기억인지 확인했다고 쓴 기억인지 구별되지 않기 때문입니다.

지난 글은 이렇게 끝났습니다.

안전망이 무엇을 못 보고 있는지는, 안전망 자신이 알려주지 않습니다.

한 층 위에도 같은 말이 성립합니다. 안전망을 고치는 사람이 무엇을 못 보고 있는지도, 그 사람 자신은 알려주지 않습니다.