가이드 / 알림 유실 사고 기록
알림이 한 번 유실됐다
— 큐 없이 만든 알림의 최후
BatchWatch 운영 기록 · 2026-08-14
모니터링 서비스에서 알림을 한 번 놓치는 것은 기능 하나가 고장 난 게 아니라 존재 이유가 사라지는 일입니다. 저희는 그걸 외부 사용자에게서 제보받았습니다. 아래는 원인을 추적하고 구조를 갈아엎은 기록이고, 중간에 Postgres의 LIMIT이 지켜지지 않는 함정도 하나 나옵니다.
1. “누수 상태인데 알림이 안 왔어요”
사용자가 등록해둔 모니터가 장애 상태로 바뀌었는데 텔레그램 알림이 오지 않았다는 제보였습니다. 화면에는 장애가 정상적으로 기록돼 있었습니다. 즉 감지는 됐는데 전달만 안 된 상황.
로그를 보니 원인은 허무할 만큼 단순했습니다.
[notify] TELEGRAM 발송 실패: FetchError: request to
https://api.telegram.org/bot.../sendMessage failed,
reason: connect ETIMEDOUT텔레그램 API가 그 순간 응답하지 않았습니다. 문제는 그다음입니다 — 당시 코드는 한 번 보내보고, 실패하면 로그만 남기고 끝이었습니다.
try {
await sendToChannel(channel, message);
} catch (e) {
console.error("[notify] 발송 실패:", e); // ← 여기서 알림은 영원히 사라진다
}네트워크는 원래 가끔 실패합니다. 그런데 이 구조에서는 딱 한 번의 일시적 실패가 곧 알림 유실입니다. 재시도도 없고, 어딘가에 남지도 않고, 운영자인 저조차 사용자가 말해주기 전까지 몰랐습니다.
2. 메모리에 담아두면 안 되는 이유
가장 먼저 떠오르는 해법은 “실패하면 몇 초 뒤 다시 보내자”입니다. 하지만 재시도 상태를 프로세스 메모리에 들고 있으면 이런 경우에 또 사라집니다:
- 재시도를 기다리는 중에 배포가 나가서 프로세스가 재시작됨
- 서버가 OOM으로 워커를 죽임 (저희는 램 1GB 서버입니다)
- 텔레그램 장애가 5분 넘게 이어져 재시도 몇 번으로는 부족함
그래서 보낼 알림을 DB에 먼저 적어두고, 워커가 그 줄을 읽어 보내고, 성공했을 때만 지우는 구조로 바꿨습니다. 흔히 아웃박스(outbox) 패턴이라고 부릅니다.
Redis나 SQS 같은 걸 붙이는 선택지도 있었지만, 1인 운영에서 관리할 대상이 하나 더 늘어나는 게 더 큰 위험이라고 판단했습니다. 이미 있는 Postgres로 전부 해결했고, 아래는 그 과정에서 밟은 함정들입니다.
3. 함정 ① 장애 기록과 알림 적재가 따로 놀면 안 된다
장애를 기록한 다음 알림을 적재하는 사이에 프로세스가 죽으면, 장애는 남았는데 알림은 없는 상태가 됩니다. 사고 당시와 결과가 똑같아지죠. 그래서 둘을 하나의 트랜잭션으로 묶었습니다.
await prisma.$transaction(async (tx) => {
await tx.incident.create({ data: { monitorId, cause } });
await tx.monitor.update({ where: { id }, data: { status: "DOWN" } });
await enqueueAlerts(tx, monitor, { kind: "missed" }); // 같은 트랜잭션
});이제 “장애로 판정됐다”와 “알림이 큐에 있다”는 반드시 함께 참이거나 함께 거짓입니다.
4. 함정 ② 워커가 두 대면 알림도 두 번 간다
서버를 늘리려면 워커도 여러 대가 됩니다. 그러면 두 워커가 같은 줄을 동시에 읽어 같은 알림을 두 번 보냅니다. 모니터링에서 중복 알림은 신뢰를 갉아먹는 문제라 그냥 넘길 수 없습니다.
Postgres에는 이걸 위한 도구가 있습니다. FOR UPDATE SKIP LOCKED는 다른 트랜잭션이 잠근 행을 기다리지 않고 건너뜁니다. 워커 A가 1~5번을 잡으면 워커 B는 6번부터 가져갑니다.
5. 함정 ③ LIMIT 5인데 7건이 선점됐다
여기가 이 글에서 제일 값진 부분입니다. 처음엔 이렇게 썼습니다.
UPDATE "AlertDelivery"
SET "lockedUntil" = now() + interval '3 minutes'
WHERE id IN (
SELECT id FROM "AlertDelivery"
WHERE "sentAt" IS NULL AND "nextAttemptAt" <= now()
ORDER BY id
LIMIT 5
FOR UPDATE SKIP LOCKED
)
RETURNING id;보기엔 완벽합니다. 그런데 테스트에서 대기 7건을 넣고 돌렸더니 7건이 전부 선점됐습니다. 상한을 5로 줬는데도요.
원인은 플래너입니다. IN (서브쿼리)는 세미조인(semi-join)으로 변형될 수 있고, 그 과정에서 서브쿼리의 LIMIT이 의도대로 적용되지 않습니다. 문법 오류도 경고도 없이 조용히 상한만 사라집니다.
이게 왜 위험하냐면 — 저희는 선점 잠금을 3분으로 잡고 “5건 × 최대 20초 = 100초 < 180초”라고 계산해뒀습니다. 그런데 한 번에 50건이 잡히면 잠금이 만료된 뒤에도 발송이 진행 중인 상태가 되고, 다른 워커가 같은 알림을 다시 집어갑니다. 중복 발송의 문이 열리는 겁니다.
해법은 고를 행을 먼저 확정시키는 것입니다. MATERIALIZED를 붙인 CTE는 플래너가 본문으로 밀어넣지 못하므로 LIMIT이 실제로 지켜집니다.
WITH picked AS MATERIALIZED (
SELECT id FROM "AlertDelivery"
WHERE "sentAt" IS NULL
AND "deadAt" IS NULL
AND attempts < 8
AND claims < 20
AND "nextAttemptAt" <= now()
AND ("lockedUntil" IS NULL OR "lockedUntil" <= now())
ORDER BY id
LIMIT 5
FOR UPDATE SKIP LOCKED
)
UPDATE "AlertDelivery" d
SET "lockedUntil" = now() + interval '3 minutes',
claims = d.claims + 1,
"lockToken" = $1
FROM picked
WHERE d.id = picked.id
RETURNING d.id;더 무서운 건 이 버그가 한동안 운영에 있었다는 사실입니다. 저는 배치 크기를 50에서 5로 줄이는 커밋을 넣고 “처리했다”고 생각했지만, 실제로는 그 숫자가 아무런 효과가 없었습니다. 테스트에서 반환된 행 수를 직접 세보고 나서야 알았습니다.
교훈: 큐 선점 로직은 “몇 건 잡혔는지”를 세는 테스트가 반드시 있어야 합니다. 눈으로 보는 SQL 리뷰로는 절대 못 잡습니다.
6. 함정 ④ 잠금이 풀린 워커가 뒤늦게 덮어쓴다
워커 A가 알림을 잡았는데 GC나 네트워크로 3분 넘게 멈췄다고 해봅시다. 잠금이 만료되고 워커 B가 같은 줄을 가져가 발송을 끝냅니다. 그때 A가 깨어나서“내가 실패했으니 재시도 예약”이라고 쓰면? 이미 발송된 알림이 다시 큐에 올라갑니다.
그래서 선점할 때 토큰을 하나 심고, 이후 모든 쓰기는 그 토큰이 아직 자기 것일 때만 성공하게 했습니다. 분산 시스템에서 펜싱 토큰(fencing token)이라 부르는 방식입니다.
UPDATE "AlertDelivery"
SET ...
WHERE id = $1
AND "lockToken" = $2 -- 잠금을 잃었으면 0행 → 아무 일도 일어나지 않는다업데이트된 행이 0이면 “나는 이미 소유권을 잃었다”는 뜻이므로 발송을 중단합니다.
7. 함정 ⑤ 실패 횟수와 선점 횟수는 다른 숫자다
처음엔 카운터가 하나였습니다. 그런데 발송 도중 프로세스가 죽는 경우를 생각해보면 문제가 보입니다. 실패로 기록할 기회조차 없이 죽으므로 카운터는 그대로고, 잠금이 만료되면 다시 잡히고, 또 죽고… 무한 반복입니다.
그래서 둘을 분리했습니다.
attempts— 실제로 보내보고 실패한 횟수. 백오프와 포기 판정의 기준.claims— 집어간 횟수. 크래시 무한 반복만 막는 안전장치(상한 20).
8. 재시도·대체 발송·포기
실패했다고 1초마다 두드리면 상대 서버를 더 괴롭힙니다. 간격을 늘려갑니다.
const BACKOFF_MIN = [1, 2, 5, 10, 30, 60, 120];
// 1분 → 2분 → 5분 → 10분 → 30분 → 1시간 → 2시간, 총 8회 시도 후 포기다만 여기엔 다른 문제가 있습니다. 텔레그램이 두 시간 죽어 있으면 알림도 두 시간 늦습니다. 새벽 배치 장애를 아침에 아는 건 의미가 없죠. 그래서 3회 실패 시점(약 8분)에 계정 이메일로 한 번 대체 발송합니다. 채널이 죽어도 “알림 자체”는 도달시키는 게 목적입니다.
8회를 다 소진하면 deadAt을 찍고 큐에서 내립니다(dead-letter). 그리고 이때 운영자에게 별도로 통지합니다 — 알림 시스템이 고장 난 걸 사용자가 알려주는 일은 두 번 겪을 만한 게 아니니까요.
9. 최종 테이블
model AlertDelivery {
id BigInt @id @default(autoincrement())
monitorId String
channelId String
message String // 렌더링 완료된 본문
attempts Int @default(0) // 실제 발송 실패 횟수
claims Int @default(0) // 선점 횟수 (크래시 무한루프 방지)
lockedUntil DateTime? // 선점 잠금 만료
lockToken String? // 소유권 토큰 (fencing)
nextAttemptAt DateTime @default(now())
sentAt DateTime? // null = 미발송
deadAt DateTime? // 포기 (운영자 확인 대상)
fallbackAt DateTime? // 이메일 대체 발송 시각
lastError String?
createdAt DateTime @default(now())
@@index([sentAt, nextAttemptAt])
}메시지 본문을 적재 시점에 렌더링해서 저장하는 점도 의도적입니다. 나중에 보낼 때 다시 만들면, 그사이 모니터가 삭제되거나 이름이 바뀌어 “무엇에 대한 알림인지”가 달라질 수 있습니다.
10. 정리 — 알림을 보내는 코드에 꼭 있어야 할 것
- 보낼 것은 먼저 DB에 남긴다. 프로세스가 죽어도 살아남아야 한다
- 발생 기록과 알림 적재는 한 트랜잭션에. 따로 두면 반드시 어긋난다
- 선점은
FOR UPDATE SKIP LOCKED, 단 CTE는MATERIALIZED로.IN (…LIMIT n)은 상한이 조용히 사라진다 - 잠금에는 소유권 토큰을. 늦게 깨어난 워커가 덮어쓰는 걸 막는다
- 크래시 카운터와 실패 카운터를 분리한다
- 채널이 죽어도 도달할 대체 경로를 둔다. 재시도만으로는 늦는다
- 포기했으면 운영자가 알아야 한다. 조용한 포기가 최악이다
이런 구조를 직접 만들지 않으셔도 됩니다
BatchWatch는 배치·크론이 예정 시간에 실행되지 않으면 디스코드·슬랙·텔레그램·이메일로 알려드립니다. 위의 아웃박스·재시도·대체 발송이 전부 들어가 있고, 배치에는 curl 한 줄만 붙이면 됩니다.
함께 보면 좋은 글: DB 백업이 진짜 됐는지 검증하는 법 · Linux cron 실행 확인과 실패 알림 받는 법