관리하는 도메인과 서버가 하나둘 늘어나면 만료일이 제일 먼저 문제가 된다. 회사 홈페이지, 블로그, 고객사 사이트, 메일 서버. 인증서는 대부분 Let's Encrypt라 자동으로 갱신된다고 믿고 지낸다. 그런데 자동 갱신은 조용히 멈춘다. 방화벽 규칙 하나, 바뀐 DNS 하나로 갱신이 실패해도 아무도 알려 주지 않는다. 알게 되는 건 브라우저가 경고 화면을 띄운 다음이다.

도메인 만료도 비슷하다. 1년이나 몇 년 단위라 더 잊기 쉽다. 등록 업체가 보낸 안내 메일은 다른 메일에 묻힌다.

메일 서버는 조금 더 복잡하다. 웹 인증서만 보면 안 되고 465·587·993 같은 메일 포트의 인증서도 봐야 한다. SMTP가 응답하는지, SPF·DKIM·DMARC 레코드가 그대로인지, 서버 IP가 블랙리스트에 올라가지 않았는지, 메일 큐가 쌓이지 않았는지도 궁금하다. 이걸 확인하려면 매번 명령어 몇 개를 돌리고 사이트 몇 군데를 열어야 한다.

그래서 이 모든 것을 정해진 주기로 점검하고, 이상이 생기면 메일로 알려 주고, 화면 하나에서 상태를 볼 수 있는 도구를 만들었다. 이름은 InfraWatch다. 이 연재에서는 그 과정에서 내린 결정과 부딪힌 문제를 차례로 정리한다. 첫 글에서는 전체 흐름을 먼저 본다.

어떤 서비스인가

항목 내용
목적 웹·메일 서버의 인증서, 도메인 만료, DNS, 메일 서버 상태를 주기 점검하고 이상 시 메일 알림
사용자 관리자 1인 (나중에 다중 사용자로 넓힐 수 있게 설계)
구성 관리 웹(ASP.NET Core MVC) + 점검 워커(Windows 서비스) + 메일 서버 PC 에이전트
스택 .NET 10, EF Core 10 + MySQL, Identity, Tailwind CSS v4, DnsClient, MailKit, SSH.NET, Serilog
테스트 xUnit v3 + Shouldly

점검 종류는 아홉 가지다. 주기는 기본값이고 점검마다 바꿀 수 있다.

종류 기본 주기 보는 것
SSL 6시간 인증서 만료일, 호스트명 일치, 체인 오류, TLS 버전. 메일 포트는 STARTTLS로
도메인 만료 1일 .kr은 WHOIS, 그 외는 RDAP로 만료일 조회
DNS 1시간 A·MX 기대값, SPF·DKIM·DMARC·PTR, 레코드 변경 감지
SMTP 5분 배너, EHLO 확장, STARTTLS, 응답 시간 (메일은 보내지 않음)
IMAP / POP3 5분 접속과 Capability 확인 (로그인하지 않음)
RBL 1시간 서버 IP의 블랙리스트 등재 여부
Postfix 큐 5분 큐 건수, 가장 오래된 메시지, 지연 사유
Rspamd 통계 15분 검사·스팸·햄 건수, 액션별 건수

화면은 대시보드, 점검 대상, 만료 현황, 점검 상세(이력과 그래프), 알림 이력, 설정, 그룹 관리로 구성된다. 도메인 하나를 넣으면 권장 점검을 한 번에 만들어 주는 빠른 등록도 있다. 웹 도메인이면 SSL 443·도메인 만료·DNS를, 메일 서버면 SMTP 25·465·587, IMAP 993, POP3 995, 각 메일 포트의 SSL, RBL을 만든다.

먼저 설계 문서를 썼다

이번에도 코드보다 설계 문서를 먼저 썼다. 최종적으로 700줄 정도가 됐고, 앞의 550줄이 설계, 뒤가 진행 기록이다. 들어간 내용은 이렇다.

  1. 제품 개요와 환경 정보 — 목적, 운영 URL, 배포 서버, 메일 서버 구성, 알림 메일 계정
  2. 기술 스택 — MySQL EF Core 공급자는 "Pomelo가 EF Core 10을 지원하면 Pomelo, 아니면 Microting 포크"처럼 판단 규칙으로 적었다
  3. 솔루션 구조 — Core / Data / Checks / Notifications / Worker / Web / Tests와 참조 방향
  4. 데이터 모델 — 테이블, 컬럼, 인덱스. 시각은 모두 UTC 저장
  5. 점검 항목 명세 — 종류별 설정, 구현 방법, 수집 항목, 판정 기준
  6. 워커와 알림 규칙 — 스케줄러 주기, 동시 실행 수, 알림 종류별 조건과 중복 방지 키
  7. 웹 화면 — 화면별 경로와 내용
  8. 구현 단계 Phase 0~9 — 단계마다 할 일과 "✅ 확인" 기준
  9. 코딩 규칙과 범위 외

가장 공을 들인 곳은 세 군데다.

첫째, 점검 명세와 판정 기준. 점검마다 "무엇을 모으고, 어떤 조건이면 주의이고, 어떤 조건이면 심각인가"를 문장으로 적었다. 예를 들어 SSL은 "만료됨·호스트명 불일치·체인 오류는 심각, 남은 일수가 심각 기준 이하면 심각, 주의 기준 이하면 주의, TLS 1.0·1.1 협상은 주의"다. 상태도 네 가지로 의미를 나눴다. Error는 점검 자체가 실패한 것(접속 불가 등), Critical은 점검은 됐는데 심각한 문제가 있는 것, Warning은 주의할 것이다. 이 구분이 있어야 "서버가 잠깐 응답하지 않은 것"과 "인증서가 만료된 것"을 다르게 다룰 수 있다. 판정 로직은 순수 함수로 만들어 단위 테스트로 고정한다는 것도 이때 정했다.

둘째, 알림 규칙. 모니터링 도구가 실패하는 가장 흔한 이유는 알림이 너무 많아서 결국 아무도 읽지 않게 되는 것이다. 그래서 알림 종류를 표로 정하고, 종류마다 중복 방지 키를 따로 정의했다.

종류 조건 중복 방지 키
상태 변경 정상 → 주의·심각, 주의 → 심각. 오류는 연속 실패 기준 이상일 때만 status:{점검}:{상태}
복구 알림이 나갔던 비정상 상태 → 정상 recover:{점검}:{시각}
만료 임계값 남은 일수가 30/14/7/3/1일을 처음 넘을 때, 임계값마다 1회 expiry:{점검}:{만료일}:{임계값}
DNS 변경 레코드 집합이 바뀜 dns:{점검}:{상태 해시}
일일 요약 매일 지정 시각 daily:{날짜}

만료 임계값 키에 만료일을 넣은 것이 포인트다. 인증서가 갱신되면 만료일이 바뀌니 키도 바뀌어 다음 주기의 30일 알림이 다시 나갈 수 있다. 이 규칙 엔진은 6편에서 자세히 다룬다.

셋째, 범위 외와 비밀값 규칙. 텔레그램·슬랙 알림, HTTP 응답 코드 감시, 공개 상태 페이지, 다중 사용자, 디스크·CPU 감시, 인증서 자동 갱신 연동은 2차로 미뤘다. 하나같이 있으면 좋은 기능이지만 이번 목표는 "만료와 메일 서버 이상을 놓치지 않는 것"이다.

비밀값 규칙은 처음부터 강하게 적었다. 비밀번호·SSH 키·API 키는 절대 git에 커밋하지 않는다. 개발은 dotnet user-secrets, 운영은 git에서 제외한 appsettings.Production.json을 쓴다. 점검별 비밀값(Rspamd 비밀번호, Spamhaus DQS 키 등)은 DB에 평문으로 두지 않고 ASP.NET Core Data Protection으로 암호화해 저장한다. 웹과 워커가 같은 키를 읽도록 키 저장 경로와 애플리케이션 이름을 맞춘다. 운영 배포 후 외부 노출 점검을 할 때 git 이력 전체를 훑어도 자리표시자 말고는 비밀값이 나오지 않았던 것은 이 규칙 덕분이다.

Phase 0~9 진행

구현은 Cliply 때처럼 설계 문서에 나눠 둔 단계 순서로 했다. 단계마다 빌드·테스트를 돌리고 확인 기준에 맞는지 직접 점검했고, 이슈 하나와 커밋 하나로 정리했다.

Phase 내용
0 솔루션 골격, 프로젝트 참조, MySQL 공급자 결정
1 엔티티·DbContext·첫 마이그레이션·리포지토리
2 로그인, 그룹·대상·점검 항목 관리, 빠른 등록
3 점검 엔진 1 — SSL·도메인 만료·DNS
4 점검 엔진 2 — SMTP·IMAP·POP3·RBL
5 워커 서비스 — 스케줄러·하트비트·이력 정리, 지금 점검
6 알림 — 규칙 엔진, 메일 발송, 재시도, 일일 요약
7 대시보드·점검 상세·알림 이력
8 메일 서버 내부 감시 — Postfix 큐·Rspamd, 메일 서버 PC 에이전트
9 운영 배포 — 원격 배포 스크립트, 서버 마이그레이션, 보안 헤더

중간에 계획에 없던 일이 세 번 끼어들었다.

Bootstrap에서 Tailwind로. 설계 문서의 처음 UI 스택은 Bootstrap 5였다. Phase 0 커밋 직후, 화면을 하나도 만들기 전에 Tailwind CSS v4로 바꾸기로 정했다. 갖고 있던 Tailwind Plus Application UI 컴포넌트의 사이드바 셸과 폼·표 예제를 Razor로 옮겨 쓰는 편이 화면 품질과 속도 모두 낫다고 판단했다. 화면이 생기기 전이라 바꾸는 비용이 거의 없었다. 원본 Styles/app.css를 Tailwind CLI로 빌드해 wwwroot/css/app.css를 만들고, 빌드 결과는 커밋해 둔다. Node.js가 없는 PC에서도 실행은 되게 하려는 것이다. 다크 모드와 모바일 메뉴도 이때 같이 넣었다. 설계 문서의 3장·9장도 그 자리에서 고쳤다.

발신 계정 553 문제. Phase 6에서 테스트 메일을 보내자 메일 서버가 거부했다. 처음 설계는 내 메일 계정으로 SMTP 인증을 하고 발신 주소만 InfraWatch용 별칭으로 쓰는 것이었다. Postfix가 "로그인한 사용자가 소유하지 않은 발신 주소"라며 553 5.7.1 Sender address rejected를 돌려줬다. 사실 설계할 때부터 이 가능성은 염두에 두고 있었다. 발신자 로그인 대조(reject_sender_login_mismatch)가 걸려 있으면 553이 날 수 있고, 그때는 메일 서버 쪽 설정이 필요하다는 것이다. 메일 서버 설정에 예외를 넣는 대신 알림 전용 메일 계정을 실제로 만들어 로그인 계정과 발신 주소를 하나로 맞췄다. 서버 보안 설정을 느슨하게 하지 않아도 되고, 나중에 알림 계정만 따로 관리하기도 쉽다. 다시 보낸 테스트 메일은 외부 그룹웨어의 받은편지함에 스팸 분류 없이 들어왔다.

메일 서버 테넌트 자동 등록. 감시 대상 메일 서버는 여러 회사 도메인을 한 인스턴스에서 함께 처리하는 멀티테넌트 메일 서버다. 테넌트 도메인을 하나씩 손으로 등록하면 새 테넌트가 생길 때 빠뜨리기 쉽다. 그래서 Phase 8과 9 사이에 자동 등록을 넣었다. 메일 서버 PC의 에이전트가 테넌트 도메인 목록을 읽기 전용으로 조회해 웹 API로 보고하면, 웹이 도메인마다 DNS·도메인 만료 점검 대상을 만든다. 규칙은 보수적으로 정했다. 손으로 등록한 대상은 절대 건드리지 않는다. 목록에서 사라지거나 비활성화된 도메인은 삭제하지 않고 끄기만 한다. 빈 목록 보고는 거부한다. 조회가 잘못돼 대상이 한꺼번에 사라지는 사고를 막기 위해서다. 동기화 계획은 Core의 순수 함수로 만들어 테스트로 고정했다.

Phase 8에서도 큰 결정이 하나 있었다. 처음에는 메일 큐를 SSH로 원격 실행해 읽도록 설계했는데, 실제 메일 서버 PC는 Windows에서 Docker로 메일 컨테이너를 돌리고 있었고 SSH 서버가 없었다. Rspamd 컨트롤러도 루프백에만 열려 있었다. SSH를 새로 여는 대신 메일 서버 PC에 에이전트를 두고, 에이전트가 웹 API로 할 일을 받아 결과를 올리게 정했다. 에이전트는 DB 접속 정보를 갖지 않는다. 이 구조는 7편에서 다룬다.

Phase 9 운영 배포는 블로그와 같은 방식으로 개발 PC에서 Web Deploy로 원격 배포했다. 설계 문서에는 "서버에서 efbundle로 마이그레이션"이라고 적었지만, 앱 DB 계정이 서버 로컬 전용이라 워커 실행 파일에 --migrate 옵션을 넣어 서버에서 실행하는 방식으로 바꿨다. 순서는 워커 → 웹이다. 배포 이야기는 8편에서 한다.

운영을 시작하고 고친 것

운영 배포 뒤 실제 점검 대상을 등록했다. 메일 서버 PC에 에이전트를 설치하고, 4개 그룹에 점검 대상 25개를 넣었다. 등록 도메인은 도메인 만료까지, 하위 도메인은 SSL만 본다. 몇 가지를 손보고 나니 10월 5일 기준으로 사용 중인 점검 44개가 모두 정상이었다.

운영 화면을 직접 쓰면서 바로 고친 것들이 있다.

  • 에이전트 로그 위치 — 에이전트 로그가 워커 폴더에 섞여 쌓였다. 에이전트 패키지를 만들 때 로그 경로를 따로 지정하게 고쳤다.
  • Postfix 큐 점검 실패 — 에이전트가 LocalSystem 계정으로 돌다 보니 PATH에 docker가 없었다. 실행 명령을 전체 경로로 바꿨다.
  • RBL 차단 응답 — 공용 DNS로 Spamhaus를 조회하면 결과를 믿을 수 없다. 무료 DQS 키를 발급받아 설정에 암호화 저장했더니 5개 목록 모두 "미등재"로 정상이 됐다. 5편의 주제다.
  • NAT 루프백 — 배포 서버가 자기 공인 주소로 접속하면 시간 초과가 났다. 네트워크 장비가 NAT 루프백을 지원하지 않아서다. 배포 서버의 hosts 파일로 해결했다.

화면 쪽은 세 가지다.

만료 현황 화면. 대시보드에는 60일 안에 만료되는 것만 나온다. 전체 인증서와 도메인이 각각 며칠 남았는지 한 화면에서 보고 싶었다. 그래서 「만료 현황」 메뉴를 추가했다. 종류(전체/인증서/도메인) 탭과 그룹 필터가 있고, 남은 일수 순으로 정렬한다. 단계별 건수(만료됨/심각/주의/여유/확인 불가)는 점검마다 설정한 주의·심각 기준으로 나눈다. 남은 일수는 막대로 보여 주는데 1년이면 가득 찬다. 운영 데이터로 확인하니 인증서 27건, 도메인 5건이 나왔다.

가로 넘침 수정. 운영 화면 17개를 데스크톱 폭(1280px)과 모바일 폭(375px)으로 열어 스크립트로 가로 넘침, 버튼 줄바꿈, 같은 줄 컨트롤의 높이 차이를 쟀다. 원인은 두 가지였다. 하나는 표 마지막 열 머리글의 sr-only다. 이 클래스는 position: absolute인데, 표를 감싼 스크롤 카드(overflow-x-auto)에 위치 기준이 없으니 카드 밖으로 빠져나가 페이지 전체에 가로 스크롤이 생겼다. 대상 수정 화면에서는 127px이 넘쳤다. 표를 감싸는 스크롤 카드에는 항상 relative를 붙인다는 규칙을 구현 메모에 남겼다. 다른 하나는 점검 요약 칸의 max-w-md truncate가 열 최소 폭을 448px로 고정한 것이었다. line-clamp-2 break-words로 바꿔 두 줄까지 보이게 했다. 검색 버튼 글자가 세로로 줄바꿈되던 문제는 공통 버튼 클래스에 whitespace-nowrap shrink-0을 넣어 해결했다.

쿠키 Secure. 마지막으로 외부 네트워크에서 운영 사이트에 실제 요청을 보내 노출 점검을 했다. 설정 파일·DLL·.git 같은 경로는 모두 404, 로그인이 필요한 화면과 JSON은 로그인으로 돌려보내고, 에이전트 API는 키가 없거나 틀리면 401이었다. 한 가지가 걸렸다. 위조 방지 쿠키에 Secure 플래그가 없었다. 운영에서는 로그인 쿠키와 위조 방지 쿠키 모두 CookieSecurePolicy.Always로, 개발에서는 기본 실행 주소가 http라 SameAsRequest로 두었다. 배포 후 응답 헤더에서 secure; samesite=strict; httponly를 확인했다.

만든 방식

설계 문서로 점검 명세, 판정 기준, 알림 규칙, 단계별 확인 기준을 먼저 고정해 두었기 때문에 구현하는 동안 판단이 흔들리지 않았다. 단계마다 진단 명령으로 실제 서버를 점검해 결과를 보고, 테스트 메일이 받은편지함에 들어오는지 확인하고, 운영 화면을 두 가지 폭으로 열어 쟀다. 문제는 그 단계 안에서 고치고 넘어갔다.

진행하면서 알게 된 함정과 결정은 설계 문서 끝의 진행 기록·구현 메모 절에 계속 쌓았다. 예를 들면 이런 것들이다.

  • STARTTLS 평문 구간을 StreamReader로 읽으면 TLS 핸드셰이크 바이트를 버퍼에 삼킨다 — 바이트 단위로 읽는다
  • 구글 공용 DNS로 Spamhaus를 조회하면 차단 코드 대신 NXDOMAIN이 와서 "미등재"로 잘못 판정된다 — 테스트 항목 127.0.0.2를 함께 조회해 검증한다
  • 하위 도메인에 DMARC가 없으면 상위(조직) 도메인 정책을 적용한다
  • 워커의 콘텐츠 루트를 실행 파일 폴더로 고정하지 않으면, 다른 폴더에서 실행할 때 설정 파일을 못 읽어 로그가 하나도 남지 않는다
  • 웹 JSON에서 열거형을 문자열로 주고받지 않으면 에이전트 결과 업로드가 400으로 실패한다
  • Windows PowerShell 5.1은 BOM 없는 UTF-8 스크립트를 CP949로 읽어 한글 주석 뒤 줄이 사라질 수 있다

최종 규모는 C# 약 8,200줄(마이그레이션 제외)과 Razor 약 1,700줄, 테스트 메서드 147개다. 순수 로직 단위 테스트(Fact 102개, Theory 21개)와 테스트용 MySQL에 실제 마이그레이션을 적용해 돌리는 통합 테스트(DbFact 24개)로 나뉜다. 통합 테스트는 테스트 DB 연결 문자열이 환경 변수에 있을 때만 돈다.

연재 목차

  1. 인증서·도메인 만료를 메일로 알려 주는 「InfraWatch」 개발기 (이 글)
  2. SslStream으로 인증서를 직접 읽는다 — STARTTLS 메일 포트까지 SSL 점검 (10월 8일 공개)
  3. .kr은 WHOIS, 나머지는 RDAP — 도메인 만료일 자동 조회 (10월 8일 공개)
  4. SPF 조회 10회, DMARC는 상위 도메인까지 — 메일 DNS 레코드 점검과 변경 감지 (10월 9일 공개)
  5. 공용 DNS로 RBL을 조회하면 "미등재"를 믿을 수 없다 — 블랙리스트 점검 (10월 9일 공개)
  6. 같은 알림은 한 번만, 복구는 꼭 — 상태 전이 알림 규칙 엔진 (10월 9일 공개)
  7. SSH 대신 API로 — 메일 서버 PC 에이전트와 Postfix 큐·Rspamd 감시 (10월 10일 공개)
  8. 운영 배포와 첫날 점검 — Web Deploy, NAT 루프백, 외부 노출 점검 (10월 10일 공개)

다음 글에서는 InfraWatch의 출발점인 SSL 점검을 다룬다. HttpClient 대신 TcpClient와 SslStream으로 직접 핸드셰이크를 하고, 587번처럼 평문으로 시작하는 메일 포트는 STARTTLS로 올려서 인증서를 읽는 이야기다.