헬스체크는 살아있냐만 묻습니다 — 17시간 30분 멈췄는데 알람이 0건이었던 이유
어느 날 아침 프랜차이즈 ERP 대시보드의 전일 매출 카드가 전부 0으로 떠 있었습니다. 매출이 없었던 것이 아닙니다. 매출을 긁어오는 수집 워커가 전날부터 17시간 30분 동안 멈춰 있었습니다.
그 사이 알람은 0건이었습니다. 발견 경로도 감시가 아니었습니다. 워커가 재개되면서 진행 중이던 병렬 잡 2건이 실패했고, 그 알람을 본 사용자가 문의를 넣어서 알았습니다. 조사하다 보니 열흘쯤 전에도 약 9시간 미가동이 있었습니다. 그때도 아무도 몰랐습니다.
감시가 없었던 것이 아니라 켤 수 없었습니다
감시 코드는 있었습니다. 워커가 헬스 엔드포인트를 열어두고 백엔드가 주기적으로 HTTP 로 찔러보는 방식이었습니다. 다만 설정으로 꺼져 있었습니다.
꺼둔 이유는 네트워크 방향이었습니다. 백엔드는 바깥 서버에 있고 워커는 안쪽에 있어서, 워커에서 백엔드로 나가는 통신은 되지만 백엔드에서 워커로 들어가는 통신은 안 됩니다.
구조적으로 켤 수 없는 감시가 코드에만 남아 있었습니다. 코드를 읽는 사람은 「감시가 있구나」라고 읽습니다. 설정을 보는 사람은 「누가 꺼뒀구나」라고 읽습니다. 둘 다 「감시가 없다」로는 읽지 않습니다.
살아있냐고 물어도 답이 되지 않습니다
네트워크만 열면 되는 문제였다면 방향을 뚫는 쪽으로 갔을 겁니다. 그런데 헬스 엔드포인트를 찌르는 방식 자체에 더 큰 구멍이 있었습니다. 프로세스가 떠 있어도 데이터는 안 들어올 수 있습니다.
워커 안에는 큐를 폴링하는 부분과 잡을 실행하는 부분이 따로 있습니다. 폴러가 멎거나 실행기가 죽어도 HTTP 서버는 그대로 200 을 돌려줍니다. 이번 사고 때 워커가 완전히 죽었는지 망가진 채 살아 있었는지는 지금도 모릅니다.
「살아있나」는 프록시 지표입니다. 진짜 알고 싶은 것은 「내가 보낸 일이 처리됐나」입니다. 프록시 지표는 본 지표와 어긋나는 순간부터 조용히 거짓말을 합니다.

내가 쏜 것과 들어온 것을 맞춰봅니다
그래서 감시가 묻는 것을 바꿨습니다. 백엔드가 큐로 작업을 쏠 때마다 발사 기록 테이블에 한 행을 남깁니다. 워커는 처리 결과를 수집 로그 테이블에 남깁니다. 두 테이블을 요청 식별자로 대사해서, 쏘긴 했는데 처리 흔적이 없는 행을 찾습니다. 이 상태를 미소화라고 부르겠습니다.
미소화 상태가 15분을 넘고 동시에 실행 중인 잡이 하나도 없으면 알람을 올려 메신저로 보냅니다. 워커가 회복해서 미소화가 사라지면 알람도 자동으로 해소됩니다.
이 방식은 네트워크 방향 제약을 우회합니다. 백엔드가 워커를 찌를 수는 없지만, 백엔드가 이미 가진 사실(내가 쏜 기록)과 워커가 남긴 사실(처리 로그)은 백엔드가 읽을 수 있는 DB 에 있습니다. 양쪽 사실이 한 곳에 모인다는 것이 핵심입니다.
결정적인 이유가 하나 더 있었습니다. 워커 코드를 한 줄도 안 건드립니다. 감시가 전부 백엔드 쪽 테이블과 배치로 끝나서 안쪽 워커를 재배포할 필요가 없었습니다. 감시는 감시 대상을 건드리지 않고 붙일수록 오래 삽니다.

스케줄러가 몇 번 돌았는지가 아니라 실제로 큐에 넣은 건만 남깁니다. 배치 실행 이력에 발사 건수 컬럼이 이미 있었지만 쓰지 않았습니다. 큐를 안 타는 시스템 태스크와 대상이 없어 0건을 쏘는 태스크가 섞여 오탐이 납니다.
기록이 본체를 깨면 안 됩니다. 호출처가 읽기 전용 트랜잭션이라 기록은 별도 트랜잭션으로 분리했고, 기록에서 나는 예외는 삼킵니다.
임계값은 전부 재서 정했습니다
15분이라는 유예는 감으로 정한 값이 아닙니다. 먼저 평상시 분포와 가장 긴 케이스를 쟀습니다.
측정 항목 | 실측값 | 설계에 반영 |
|---|---|---|
발사 → 집기 평상시 대기 | 최대 23~30분 | 「실행 중 잡 없음」 조건으로 억제 |
가장 오래 걸리는 잡 | 68분 | 억제 상한 90분 |
후순위 백필 레인 대기 | 345~361분 | 감시 대상에서 제외 |
평상시 대기가 23~30분인데 유예가 15분이면 매번 울릴 것 같습니다. 그런데 그 구간에는 앞 잡이 실행 중이라 「실행 중인 잡 없음」 조건에 걸려 억제됩니다. 워커가 일하고 있다는 증거가 있는 동안은 기다립니다.
억제에는 상한 90분을 뒀습니다. 워커가 강제로 죽으면 실행 상태가 안 닫힌 채 남아서, 상한이 없으면 영원히 「일하는 중」으로 보입니다. 90분은 최장 잡 68분에서 나왔습니다.
후순위 백필 레인은 아예 뺐습니다. 원래 5~6시간씩 기다리는 것이 정상이라 넣으면 매일 오탐이 납니다. 오탐은 알람을 죽입니다. 매일 울리는 알람은 며칠 안에 아무도 안 봅니다.

배포하자마자 5분마다 에러가 났습니다
구현에 150분, 결손 복구에 90분이 걸렸습니다. 영수증 6,013건은 정규 배치가 자동으로 따라잡았고, 상품별 매출 7,178건은 병렬 큐로 직접 트리거해 4분 53초에 끝났고, 일 단위 매출 집계 187건은 화면 버튼으로 즉시 재생성했습니다.
배포 직후 사고가 하나 났습니다. 새 발사 기록 테이블 DDL 에 정렬 규칙(collation)을 명시하지 않아 DB 기본값이 붙었고, 정렬 규칙이 다른 기존 수집 로그 테이블과 조인할 때마다 오류가 났습니다. 감시가 5분마다 에러를 내는 상태로 배포됐습니다.
같은 함정을 전에 한 번 밟았습니다. 테이블을 변환해 고치고, 이번에는 설계 문서에 경고로 남겼습니다.
고친 뒤 발화 실험을 했습니다. 발사 기록이 정상으로 쌓이고 미소화가 0건인 것을 확인한 뒤 가짜 미소화 행을 하나 넣었습니다. 29초 만에 알람이 울렸고, 행을 지우니 자동으로 해소됐습니다. 안 울리는 알람은 배포 시점에는 조용해서 정상처럼 보입니다.
켤 수 없던 옛 감시는 컴포넌트와 설정을 통째로 지웠습니다. 남겨두면 다음 사람이 「감시가 있구나」라고 읽습니다.
이 방식이 못 보는 것
결과를 묻는 감시에도 사각지대가 있습니다. 세 가지는 알고 감수했습니다.
발사가 없으면 감시도 없습니다. 새벽처럼 스케줄이 뜸한 시간대에 멈추면 다음 발사까지 침묵합니다. 최악 약 2시간 45분인데 그 구간에는 실제 손실도 없어 감수했습니다. 발사 간격이 긴 시스템에서는 이 방식만으로 부족합니다.
쏘는 쪽이 멈추면 아무도 모릅니다. 백엔드 스케줄러 자체가 죽으면 발사 기록도 안 생기고 미소화도 안 생깁니다. 결과 기반 감시의 구조적 사각지대입니다.
감시를 화면에서 끌 수 있습니다. 주기 실행을 코드에서 배치 스케줄 테이블로 옮겨 운영자가 주기를 볼 수 있게 했는데, 그 대가로 운영자가 배치를 비활성화하면 감시도 같이 멈춥니다. 메모에 주의 문구를 달아뒀지만 문구는 방어가 아닙니다.
대사 방식은 「요청 하나 = 로그 하나」가 성립할 때만 유효합니다. 재시도나 제자리 갱신처럼 원본 행을 덮어쓰는 경로가 있으면 요청 식별자가 갱신되지 않아 영구 미소화로 오탐이 납니다.
두 번째 사각지대는 서버 다운 감시에서 한 번 겪은 것과 같은 종류입니다. 아무 일도 안 일어나는 것은 이벤트가 아니라서 이벤트 기반으로는 못 잡습니다. 주기적으로 세는 다른 축이 필요합니다.
감시를 붙이기 전에 확인할 것
지금 감시가 묻는 것이 「살아있나」인지 「처리됐나」인지. 프로세스 상태와 데이터 유입이 따로 놀 수 있는 구조면 후자가 필요합니다
설정으로 꺼진 감시가 있다면 왜 꺼져 있는지. 구조적으로 못 켜는 것이면 지웁니다
임계값을 평상시 분포와 최장 케이스를 재고 정했는지. 감으로 정한 값은 오탐이 됩니다
가짜 데이터를 넣어 실제로 울리는지, 지우면 해소되는지 확인했는지
켤 수 없는 감시는 없는 감시입니다
이 연작에서 다룬 사례들의 공통점은 에러가 안 났다는 것입니다. 로그는 조용했고, 화면은 정상처럼 보였고, 프로세스는 떠 있었습니다. 에러가 나는 문제는 시끄러워서 잡힙니다. 조용히 틀리는 문제는 감시가 무엇을 묻고 있는지를 다시 봐야 잡힙니다.
이번 건에서 감시는 「살아있나」를 묻고 있었고, 그마저도 켤 수 없는 상태였습니다. 켤 수 없는 감시는 없는 감시와 같습니다. 코드에 남은 감시 컴포넌트는 안전감만 줬고, 17시간 30분 동안 아무것도 묻지 않았습니다.
연작 「에러 없이 조용히 틀린다」
이 글은 다섯 편 중 5편입니다. 에러가 한 번도 안 났는데 실제로는 일이 안 되고 있던 사례를 이어서 다룹니다.