감시 도구의 알림은 두 방향으로 실패한다. 하나는 너무 많이 보내는 것이다. SMTP 점검은 5분마다 돈다. 메일 서버가 반나절 멈추면 "접속 실패" 메일이 백 통 넘게 쌓이고, 그다음부터는 InfraWatch 메일을 아무도 열지 않는다. 다른 하나는 꼭 보내야 할 것을 빠뜨리는 것이다. 장애 메일은 받았는데 복구 메일이 오지 않으면 직접 들어가 확인해야 하고, 인증서 만료 30일 전 알림을 받고 갱신을 잊었는데 7일 전 알림이 오지 않으면 그대로 만료된다.
그래서 알림은 "상태가 바뀔 때 한 번, 다시 좋아지면 한 번"을 기본으로 하고, 예외를 하나씩 규칙으로 정했다. 이 글은 「InfraWatch 개발기」 연재 6편으로, 그 규칙을 코드로 옮긴 AlertRuleEngine과 발송·재시도·일일 요약을 다룬다. 지난 글은 RBL 점검이었고, 전체 흐름은 연재 첫 글에 있다.
규칙은 순수 함수로 분리했다
알림 처리는 세 단계로 나눴다.
| 단계 | 위치 | 하는 일 |
|---|---|---|
| 규칙 | Core/Alerts/AlertRuleEngine |
이번 결과로 보낼 알림 목록 결정 (DB·메일 모름) |
| 발송 | Notifications/AlertDispatcher |
중복 확인 → 이력 기록 → 발송 → 결과 기록 |
| 연결 | Processing/AlertObserver |
점검 결과 저장 직후 규칙 입력을 모아 호출 |
핵심은 첫 줄이다. 규칙 엔진은 정적 메서드 하나이고, 입력은 레코드 하나다.
public sealed record AlertRuleInput(
AlertCheckInfo Check,
CheckStatus PreviousStatus,
CheckStatus NewStatus,
string Summary,
int ConsecutiveFailures,
DateTime? ExpiresAt,
IReadOnlyList<CheckChange> Changes,
string? NewStateJson,
AlertKind? LastAlertKind,
DateTime NowUtc);
직전·이번 상태, 연속 실패 수, 만료일, 감지된 변경, 마지막에 나간 알림의 종류, 현재 시각까지 전부 값으로 받는다. 출력은 보낼 알림 목록(AlertDecision: 종류, 등급, DedupeKey, 중복 방지 기간, 제목)이다. DB 조회와 시계는 호출하는 쪽(AlertObserver)이 맡는다.
이렇게 나눈 이유는 테스트다. 알림 규칙은 "주의에서 심각으로 가면 보낸다, 심각에서 주의로 가면 안 보낸다" 같은 조합 문제라서, 조합을 표로 늘어놓고 확인하는 것이 가장 확실하다. 순수 함수로 두니 xUnit [Theory]의 InlineData 한 줄이 상태 전이 한 칸이 됐다.
[Theory]
[InlineData(CheckStatus.Ok, CheckStatus.Warning, "주의")]
[InlineData(CheckStatus.Ok, CheckStatus.Critical, "심각")]
[InlineData(CheckStatus.Warning, CheckStatus.Critical, "심각")]
[InlineData(CheckStatus.Pending, CheckStatus.Critical, "심각")] // 새로 등록한 점검이 처음부터 비정상
[InlineData(CheckStatus.Error, CheckStatus.Warning, "주의")] // 접속 실패 뒤 실제 결과가 비정상
public void 나빠지는_전이는_상태변경_알림(CheckStatus previous, CheckStatus next, string grade)
상태 전이 표
점검 상태는 다섯 가지다. 대기(Pending, 아직 한 번도 안 돈 점검), 정상, 주의, 오류, 심각. 심각도 순서는 Ok < Warning < Error < Critical로 정했다. 오류(점검 자체 실패)를 주의보다 위에 둔 것은 "모른다"가 "조금 이상하다"보다 나쁘다고 봤기 때문이고, 심각보다 아래에 둔 것은 "확실히 문제가 있다"가 더 급하기 때문이다. 대시보드의 현재 비정상 목록도 이 순서로 정렬한다.
알림 기준은 이렇다.
| 직전 → 이번 | 알림 |
|---|---|
| 정상·대기·오류 → 주의 | 상태 변경 (주의) |
| 정상·대기·오류 → 심각 | 상태 변경 (심각) |
| 주의 → 심각 | 상태 변경 (심각) |
| 심각 → 주의 | 없음 (나아짐) |
| 같은 상태 유지 | 없음 |
| 대기 → 정상 | 없음 |
| 아무 상태 → 오류 | 연속 실패가 기준 이상일 때만 (오류) |
| 비정상 → 정상 | 직전에 상태 알림이 나갔을 때만 (복구) |
설계 문서에는 Ok → Warning/Critical과 Warning → Critical만 적었는데, 구현하면서 두 줄을 더 넣었다. 대기에서 바로 비정상으로 가는 경우와 오류 뒤에 실제 결과가 비정상인 경우다. 새로 등록한 점검이 처음부터 인증서 만료 임박이면 알려야 하고, 접속 실패가 풀렸더니 RBL에 등재돼 있었다면 그것도 알려야 한다. "직전이 정상일 때만"으로 두면 둘 다 조용히 넘어간다.
오류는 연속 실패로 판단한다
네트워크가 한 번 흔들려 SMTP 접속이 타임아웃 나는 일은 흔하고, 5분 뒤 다시 돌면 대개 정상이다. 그래서 오류는 연속 실패 수가 기준(FailuresBeforeAlert, 기본 2회) 이상일 때만 보낸다. 연속 실패 수는 결과를 저장하는 쪽에서 세는데, 오류일 때만 1씩 늘리고 그 외 결과면 0으로 되돌린다. 주의·심각은 점검 자체는 성공한 것이라 실패로 세지 않는다. 제목에는 점검 실패 3회 연속 — {요약}처럼 연속 횟수를 넣는다. 오류가 계속되면 규칙은 매 실행마다 알림을 "결정"하고, 실제로 보낼지는 다음 장의 DedupeKey가 정한다.
DedupeKey: 같은 알림인지는 키가 정한다
규칙 엔진은 보낼 알림을 정하고, 중복인지는 따지지 않는다. 대신 알림마다 DedupeKey를 붙인다. 발송기는 같은 키로 성공 발송된 기록이 중복 방지 기간 안에 있으면 보내지 않는다.
| 종류 | DedupeKey | 중복 방지 기간 |
|---|---|---|
| 상태 변경 | status:{점검Id}:{상태} |
24시간 (설정) |
| 복구 | recover:{점검Id}:{yyyyMMddHH} |
24시간 |
| 만료 임계값 | expiry:{점검Id}:{만료일}:{기준일수} |
없음 (영구 1회) |
| DNS 변경 | dns:{점검Id}:{상태 해시} |
없음 (영구 1회) |
| 인증서 갱신 | cert:{점검Id}:{상태 해시} |
없음 (영구 1회) |
| 일일 요약 | daily:{yyyyMMdd} |
없음 (영구 1회) |
키에 무엇을 넣느냐가 곧 "무엇을 같은 알림으로 볼 것인가"다.
- 상태 변경 키에는 상태만 들어간다. 오류가 이틀 계속되면
status:7:Error가 24시간마다 한 번씩만 나간다. 5분마다 결정은 나지만 대부분 생략된다. 주의에서 심각으로 바뀌면 키가status:7:Critical로 달라지므로 바로 나간다. - 만료 임계값 키에는 만료일이 들어간다. 기간 제한 없이 같은 키는 한 번뿐이다. 이유는 다음 장에서 설명한다.
- DNS 변경과 인증서 갱신은 새 상태 JSON의 SHA-256 앞 16자리를 키에 넣는다. 같은 변경을 두 번 알리지 않고, 다른 값으로 또 바뀌면 다시 알린다.
중복 확인은 "성공 발송된 기록"만 본다. 발송에 실패한 알림은 중복이 아니다. 실패한 알림 때문에 다음 알림까지 막히면 안 되기 때문이다.
만료 임계값: 넘어선 것 중 가장 낮은 하나만
SSL 인증서와 도메인은 남은 일수가 30·14·7·3·1일 기준을 넘을 때 알린다. 여기서 따져 봐야 할 경우가 있다. 남은 일수가 5일인 인증서를 처음 점검하면 30·14·7 세 기준을 한꺼번에 넘은 상태다. 세 통을 보내는 것은 의미가 없다. 그래서 넘어선 기준 중 가장 낮은(급한) 것 하나만 보낸다.
var days = ExpiryEvaluator.RemainingDays(expires, input.NowUtc);
var crossed = settings.ExpiryThresholdDays.Where(t => days <= t).ToList();
if (crossed.Count == 0)
return null;
var threshold = crossed.Min();
// ...
return new AlertDecision(
AlertKind.ExpiryThreshold, grade,
$"expiry:{c.CheckId}:{KoreanTime.FromUtc(expires):yyyyMMdd}:{threshold}", null, /* ... */);
5일 남은 인증서는 :7 키로 한 번 나가고, 이틀 뒤 3일이 되면 :3 키로 한 번 더 나간다. 그 사이 매 점검마다 :7 결정이 나지만 같은 키라 생략된다. 남은 일수는 (만료 − 현재)의 일수를 버림한 값이다.
키에 만료일을 넣은 것이 이 규칙의 핵심이다. 인증서가 갱신되면 만료일이 바뀌므로 키도 바뀐다. 다음 주기에 다시 30일 전이 오면 새 키 expiry:8:{새 만료일}:30으로 알림이 정상적으로 나간다. 만료일 없이 expiry:8:30처럼 만들면 첫 갱신 이후로는 만료 알림이 영원히 오지 않는다. 기간 제한 없는 1회성 키는 이런 식으로 "언제 초기화되는가"를 키 안에 담아야 한다.
몇 가지 규칙을 더 정했다.
- 점검이 오류일 때는 만료 알림을 내지 않는다. 접속에 실패하면 직전 만료일을 그대로 유지하는데(대시보드 만료 임박 목록에서 사라지지 않게), 이 값으로 알림까지 내면 "접속 실패"와 "만료 임박"이 겹쳐 혼란스럽다.
- 만료 알림과 상태 변경 알림이 같은 실행에서 나오면 만료 알림만 보낸다. SSL 점검의 기본 주의 기준도 30일이라, 30일 기준을 넘는 순간 상태도 정상에서 주의로 바뀌는데, 두 메일은 사실상 같은 내용이다. 단, 상태 변경이 오류일 때는 둘 다 보낸다.
복구 알림과 인증서 갱신
복구 알림의 조건은 세 가지를 모두 만족해야 한다.
- 이번 결과가 정상
- 직전 상태가 정상·대기가 아님
- 이 점검으로 마지막에 나간 상태 알림이 상태 변경 또는 만료 임계값
3번이 중요하다. 오류 1회로 끝난 경우를 생각해 보자. 연속 실패 기준 미만이라 오류 알림은 나가지 않았다. 다음 실행에서 정상으로 돌아오면, 직전 상태만 보고 판단하면 "복구" 메일이 나간다. 받는 사람은 장애 메일 없이 복구 메일만 받게 된다. 그래서 장애를 알린 적이 있을 때만 복구를 알린다.
마지막 알림을 찾을 때는 복구 알림도 함께 본다(StateAlertKinds에 상태 변경·만료 임계값·복구 세 종류). 이미 복구 알림이 나갔다면 마지막 상태 알림은 복구이므로 다시 보내지 않는다.
인증서 갱신은 설계 문서에 없던 알림 종류(CertificateRenewed)로 추가했다. SSL 점검은 이전 실행의 인증서 지문을 상태로 들고 있다가, 지문이 바뀌면 "인증서가 바뀌었습니다 (갱신)"이라는 변경을 결과에 싣는다. 규칙 엔진은 이것을 등급 "정보" 알림으로 보낸다. Let's Encrypt 자동 갱신이 실제로 돌았는지 메일로 확인할 수 있다. 알림 종류는 DB에 이름 문자열로 저장하고 있어서, 열거형 값을 하나 추가해도 마이그레이션이 필요 없었다.
만료 임박 상태에서 인증서가 갱신돼 정상으로 돌아오면 "복구"와 "인증서 갱신"이 동시에 생긴다. 이때는 복구 알림 하나에 갱신 내용을 담고, 제목을 "인증서 갱신으로 정상 복구"로 바꾼다. 같은 사건을 두 통으로 나눠 보내지 않으려는 것이다.
발송과 재시도: 다시 보내도 소용없는 실패는 포기한다
AlertDispatcher의 흐름은 "중복 확인 → 이력(Alerts)에 먼저 기록 → 발송 → 결과 기록"이다. 발송 전에 기록하는 이유는 재시도 때문이다. 이력에는 제목·수신자와 함께 HTML 본문 전체를 저장한다. 재시도는 규칙을 다시 돌리지 않고 저장된 본문을 그대로 다시 보낸다. 그사이 점검 상태가 바뀌어도 "그때 보내려던 메일"이 간다.
재시도는 별도 백그라운드 서비스(AlertRetryService)가 1분마다 실패한 알림을 모아 처리한다. 최초 1회에 재시도 최대 3회, 모두 4번까지 시도한다. 15초 간격으로 도는 점검 스케줄러 루프에 붙이면 1분도 안 돼 3회를 다 써 버려서 짧은 SMTP 장애를 넘기지 못한다. 그래서 간격을 따로 뒀다.
모든 실패를 재시도하지는 않는다.
/// <summary>
/// 다시 보내도 같은 결과인 실패: SMTP 5xx 응답(발신 주소 거부 등), 인증 실패.
/// 접속 불가·4xx(일시 거부)·시간 초과는 재시도한다.
/// </summary>
public static bool IsPermanent(Exception ex) => ex switch
{
MailKit.Net.Smtp.SmtpCommandException smtp => (int)smtp.StatusCode >= 500,
MailKit.Security.AuthenticationException => true,
_ => false,
};
SMTP 5xx는 "영구 거부"이고 인증 실패는 비밀번호가 틀린 것이다. 1분 뒤에 다시 보내도 결과가 같고, 같은 오류가 로그에 세 번 더 쌓일 뿐이다. 영구 오류는 시도 횟수를 바로 한도까지 채워 재시도 대상에서 뺀다. 설정 화면의 테스트 메일도 재시도하지 않는다. 화면에서 다시 누르면 되기 때문이다.
이 구분은 실제로 쓸모가 있었다. Phase 6 첫 테스트 발송이 이렇게 실패했다.
553 5.7.1 <...>: Sender address rejected: not owned by user <...>
처음에는 기존 메일 계정으로 SMTP 로그인을 하고, 발신 주소만 InfraWatch용 별칭으로 바꿔 보냈다. 메일 서버의 Postfix가 "로그인한 계정이 소유하지 않은 발신 주소"를 거부한 것이다. 설계할 때부터 이 가능성을 염두에 두고 있어서, 발송기의 오류 설명(Describe)에는 553이면 해결 방법을 덧붙이게 만들어 두었다. 메일 서버에 발신 권한 매핑(smtpd_sender_login_maps)을 추가하는 방법도 있었지만, 나는 알림 전용 메일 계정을 실제 계정으로 만들고 로그인 계정과 발신 주소를 하나로 맞추는 쪽을 택했다. 계정을 바꾸고 보낸 테스트 메일은 외부 그룹웨어의 받은편지함에 정상 도착했다. 이 553은 영구 오류이므로 재시도하지 않는다는 것도 테스트로 고정했다(영구오류_553은_재시도하지_않는다).
일일 요약은 하루 1회를 어떻게 보장하나
매일 아침 한 통, 전체 상태를 요약해 보낸다. 들어가는 내용은 상태별 점검 수, 현재 비정상 목록(심각도 순), 30일 안에 만료되는 인증서·도메인, 최근 24시간 동안의 점검 실패 횟수다. 기본 발송 시각은 한국 시간 08:50이고 설정 화면에서 바꾼다.
"매일 08:50에 실행"을 타이머로 예약하는 방식은 쓰지 않았다. 워커가 그 시각에 재시작 중이거나 운영자가 발송 시각을 바꾸면 그날 요약이 빠지거나 두 번 나갈 수 있다. 대신 DailySummaryService가 1분마다 같은 질문을 한다.
var localNow = KoreanTime.FromUtc(nowUtc);
if (TimeOnly.FromDateTime(localNow) < settings.DailySummaryTime)
return;
var key = $"daily:{localNow:yyyyMMdd}";
if (await store.HasSentAsync(key, null, ct))
return;
"설정 시각이 지났는가, 그리고 오늘 날짜 키로 성공 발송된 적이 없는가." 둘 다 참일 때만 보낸다. 워커가 08:49에 꺼졌다가 09:10에 살아나면 09:10에 보낸다. 발송 시각을 07:00으로 앞당겨도 오늘 이미 보냈다면 다시 보내지 않는다. 발송이 실패하면 성공 기록이 없으니 다음 분에 다시 시도된다. 중복 방지를 DedupeKey 하나로 통일해 둔 덕분에 일일 요약도 같은 장치로 해결됐다.
메일 제목과 알림 이력 화면
메일 제목은 받은편지함 목록에서 바로 읽히도록 형식을 고정했다.
[InfraWatch][심각] mail.addsoft.co.kr · SSL 인증서(465) 인증서 7일 후 만료
[InfraWatch][주의] mail.addsoft.co.kr · SSL 인증서(465) 인증서 20일 후 만료
[InfraWatch][복구] mail.addsoft.co.kr · SMTP(587) 정상 복구
[InfraWatch][정보] 2026-10-05 일일 요약
등급은 정보·주의·심각·오류·복구 다섯 가지다. 그다음 호스트, 점검 종류와 포트, 내용 순서다. 메일 프로그램에서 [심각]으로 필터를 걸 수 있고, 제목만 보고 어느 서버의 무엇인지 안다. 제목은 300자를 넘으면 자른다.
본문은 HTML이다. 메일 프로그램마다 CSS 지원이 달라서 표 레이아웃과 인라인 스타일로만 만들었고, 텍스트만 보는 환경을 위해 텍스트 대체 본문도 함께 넣었다. 대상·점검·상태·요약·상세 표와 점검 상세 화면 링크가 들어간다. 본문에 들어가는 값은 모두 HTML 인코딩한다. 점검 요약에는 원격 서버가 돌려준 SMTP 배너나 DNS TXT 레코드 같은 외부 문자열이 섞일 수 있다.
웹의 알림 이력 화면(/Alerts)에서는 저장된 본문을 그대로 열어 볼 수 있다. 메일이 실제로 어떻게 보였는지 확인하는 용도다. 여기서 한 번 더 조심했다. 인코딩을 했더라도 저장된 HTML을 관리 화면과 같은 출처에서 그대로 내보내는 것은 위험하다. 그래서 본문 보기 응답에만 CSP를 따로 붙였다.
Response.Headers.ContentSecurityPolicy = "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:";
return Content(body, "text/html; charset=utf-8");
sandbox가 붙은 문서는 스크립트가 실행되지 않고 고유한 출처로 취급돼 관리 화면의 쿠키에 접근할 수 없다. default-src 'none'으로 외부 리소스도 막고, 메일 본문에 필요한 인라인 스타일과 data URI 이미지만 허용했다.
정리
- 알림 규칙은 DB·메일·시계를 모르는 순수 함수로 분리해, 상태 전이 표를
[Theory]한 줄씩으로 고정했다. - "같은 알림"은 DedupeKey가 정한다. 키에 무엇을 넣느냐가 곧 중복의 정의다. 만료 임계값 키에 만료일을 넣어 갱신되면 초기화되게 한 것이 대표적이다.
- 복구는 장애를 알린 적이 있을 때만 보내고, 같은 사건은 한 통으로 합친다(만료+상태 변경, 복구+인증서 갱신).
- 재시도는 1분 간격 최대 3회, SMTP 5xx·인증 실패는 영구 오류로 포기한다. 일일 요약은 "시각이 지났고 오늘 키로 아직 안 보냈는가"를 1분마다 확인해 하루 1회를 지킨다.
다음 글에서는 메일 서버 PC 안쪽을 들여다보는 이야기를 한다. SSH 대신 API로 결과를 올리는 에이전트와 Postfix 큐·Rspamd 감시다.
InfraWatch 개발기
- 인증서·도메인 만료를 메일로 알려 주는 「InfraWatch」 개발기
- SslStream으로 인증서를 직접 읽는다 — STARTTLS 메일 포트까지 SSL 점검
- .kr은 WHOIS, 나머지는 RDAP — 도메인 만료일 자동 조회
- SPF 조회 10회, DMARC는 상위 도메인까지 — 메일 DNS 레코드 점검과 변경 감지
- 공용 DNS로 RBL을 조회하면 "미등재"를 믿을 수 없다 — 블랙리스트 점검
- 같은 알림은 한 번만, 복구는 꼭 (이 글)
- SSH 대신 API로 — 메일 서버 PC 에이전트와 Postfix 큐·Rspamd 감시 (10월 10일 공개)
- 운영 배포와 첫날 점검 — Web Deploy, NAT 루프백, 외부 노출 점검 (10월 10일 공개)