메일 서버를 운영하면서 가장 늦게 알아채는 장애가 블랙리스트 등재다. 서버는 멀쩡히 돌고, 포트도 열려 있고, 인증서도 정상이다. 그런데 어느 날부터 특정 회사로 보낸 메일만 반송되거나 상대편 스팸함으로 들어간다. 원인을 찾아보면 우리 메일 서버 IP가 RBL(Realtime Blackhole List)에 올라가 있다. 받는 쪽 메일 서버가 이 목록을 보고 메일을 거부한 것이다.
RBL 등재는 보통 받는 사람이 "메일이 안 왔다"고 연락해야 알게 된다. 그래서 「InfraWatch」에 메일 서버 IP의 RBL 등재 여부를 매시간 확인하는 점검을 넣었다. 구현 자체는 DNS 조회 몇 번이면 끝나는 간단한 일이었다. 문제는 조회 결과가 "미등재"라고 나와도 그 말을 믿을 수 없는 경우가 있다는 것이었다.
이 글은 「InfraWatch 개발기」 연재 5편이다. 지난 글에서는 SPF·DKIM·DMARC 같은 메일 DNS 레코드 점검을 다뤘다. 전체 흐름은 연재 첫 글에 있다.
RBL은 DNS로 묻는다
RBL 조회는 별도 API가 아니라 DNS다. 확인하려는 IP의 옥텟을 거꾸로 뒤집고, 뒤에 RBL 영역(zone) 이름을 붙여 A 레코드를 조회한다.
| 단계 | 예 |
|---|---|
| 확인할 IP | 203.0.113.25 |
| 옥텟 역순 | 25.113.0.203 |
| 조회 이름 | 25.113.0.203.zen.spamhaus.org |
| 응답 없음(NXDOMAIN) | 미등재 |
127.0.0.x 응답 |
등재 (x는 목록 종류·사유) |
등재돼 있으면 RBL 운영자의 DNS 서버가 127.0.0.2 같은 루프백 대역 주소를 돌려준다. 실제로 접속할 주소가 아니라 "이 IP는 목록에 있다"는 신호다. 마지막 옥텟으로 어느 하위 목록에 올랐는지 알려 주는 곳도 있다. 등재되지 않았으면 그런 이름이 없다는 NXDOMAIN이 온다.
조회 이름을 만드는 코드는 이렇다. IPv6는 RFC 5782에 따라 16진수 니블 단위로 뒤집는다.
/// <summary>조회 이름: IPv4 는 옥텟 역순 (1.2.3.4 → 4.3.2.1.zone), IPv6 는 니블 역순.</summary>
public static string QueryName(IPAddress ip, string zone)
{
var bytes = ip.GetAddressBytes();
string reversed = ip.AddressFamily == AddressFamily.InterNetwork
? string.Join('.', bytes.Reverse())
: string.Join('.', bytes.SelectMany(b => new[] { b >> 4, b & 0xF }).Reverse().Select(n => n.ToString("x")));
return $"{reversed}.{zone.Trim().TrimEnd('.')}";
}
DNS 조회는 DNS 점검과 같은 DnsLookup 래퍼(DnsClient 라이브러리)를 쓴다. 이 래퍼는 NXDOMAIN이면 빈 목록을 돌려주고, 타임아웃이나 SERVFAIL 같은 서버 오류면 DnsLookupException을 던진다. RBL에서는 이 구분이 중요하다. "응답 없음"은 미등재지만 "조회 실패"는 아무것도 모르는 상태다. 둘을 섞으면 DNS 장애가 날 때마다 "미등재 정상"이 찍힌다.
어떤 IP를, 어느 목록에
점검 설정은 네 가지다. 조회할 Ip, RBL 영역 목록 Zones, 조회에 쓸 DNS 서버 Resolver, 그리고 Spamhaus DQS 키 SpamhausDqsKey다. IP는 비워 두는 것이 기본이다. 메일 서버를 점검 대상으로 등록하면 호스트 이름은 이미 있으니, 그 이름에서 IP를 찾는다.
- 설정에 IP가 있으면 그 값
- 대상 호스트가 IP 그 자체면 그 값
- 대상 호스트의 A 레코드 첫 번째
- A 레코드가 없으면 MX 첫 번째 호스트의 A 레코드
4번은 도메인만 등록했는데 도메인 자체에 A 레코드가 없는 경우를 위한 것이다. 어디서 IP를 가져왔는지는 결과 상세(IpSource)에 "MX mx1.example.com A 레코드"처럼 남겨, 엉뚱한 IP를 점검하고 있지 않은지 바로 확인할 수 있게 했다.
기본 목록은 다섯 곳으로 정했다.
| 영역 | 운영 |
|---|---|
zen.spamhaus.org |
Spamhaus (SBL·XBL·PBL 통합) |
b.barracudacentral.org |
Barracuda |
bl.spamcop.net |
SpamCop |
psbl.surriel.com |
PSBL |
bl.mailspike.net |
Mailspike |
받는 쪽 메일 서버가 가장 많이 참고하는 곳이 Spamhaus이고, 나머지는 메일 관리자들이 흔히 함께 확인하는 대표적인 목록이다. 목록은 화면에서 한 줄에 하나씩 고칠 수 있고, 비우면 기본 5개로 돌아간다. 다섯 곳은 Task.WhenAll로 동시에 조회한다.
응답 해석: 127.0.0.x와 127.255.255.x
응답이 오면 해석은 순수 함수 RblInterpreter.Interpret가 맡는다. 결과는 네 가지 상태 중 하나다.
| 상태 | 응답 | 의미 |
|---|---|---|
NotListed |
없음 (NXDOMAIN) | 미등재 |
Listed |
127.0.0.2 ~ 127.0.0.255 |
등재 |
Blocked |
127.255.255.x |
조회 자체가 거부됨 |
Error |
그 밖의 응답, DNS 오류 | 판단 불가 |
설계 문서를 쓸 때 가장 신경 쓴 줄이 Blocked다. Spamhaus는 조회를 받아 줄 수 없을 때 127.255.255.x로 답한다. 이것도 127.로 시작하는 A 레코드라서, "응답이 있으면 등재"로 단순하게 짜면 조회가 막혔다는 신호를 등재로 오판한다. 그러면 멀쩡한 메일 서버에 "블랙리스트 등재, 심각" 메일이 날아간다.
// 127.255.255.x: Spamhaus 등의 "조회 거부" 코드 (252 형식 오류, 254 공용 리졸버, 255 한도 초과)
if (addresses.Any(b => b![1] == 255 && b[2] == 255))
{
var code = addresses.First(b => b![1] == 255 && b[2] == 255)![3];
var reason = code switch
{
254 => "공용 DNS(8.8.8.8 등) 사용으로 조회 차단",
255 => "조회 한도 초과",
252 => "조회 형식 오류",
_ => "조회 차단",
};
return (RblZoneState.Blocked, $"{reason} ({string.Join(", ", answers)})");
}
if (addresses.Any(b => b![1] == 0 && b[2] == 0 && b[3] >= 2))
return (RblZoneState.Listed, $"등재 ({string.Join(", ", answers)})");
return (RblZoneState.Error, $"해석할 수 없는 응답: {string.Join(", ", answers)}");
Blocked는 점검 결과에서 Error와 같은 무게로 다룬다. 등재도 미등재도 아닌 "모름"이다. 해석 규칙에서 정한 것을 몇 가지 더 적어 둔다.
- 등재 코드와 차단 코드가 섞여 오면 차단을 우선한다. 차단된 조회의 다른 응답은 믿을 근거가 없다.
127.0.0.1,127.1.0.2,10.0.0.1처럼 약속된 형식이 아닌 응답은Error다. 와일드카드 DNS를 쓰는 리졸버가 엉뚱한 주소를 돌려줄 때 등재로 읽지 않으려는 것이다.- 설계 문서에는 등재 범위를
127.0.0.2~127.0.0.11로 적었는데, 구현하면서127.0.0.2이상 전부로 넓혔다. 실제로 Mailspike는 테스트 항목에127.0.0.10,127.0.0.12같은 응답을 함께 돌려줬다. 11에서 자르면 이런 응답을 오류로 읽게 된다.
함정: 차단 코드 대신 NXDOMAIN이 온다
여기까지 만들고 개발 PC에서 실제 메일 서버 IP로 점검을 돌려 보려다가 한 가지가 걸렸다. 개발 PC의 시스템 DNS가 구글 공용 DNS(8.8.8.8)였다. Spamhaus는 대형 공용 DNS를 거친 조회를 받지 않는다고 알려져 있다. 그렇다면 127.255.255.254가 와야 하는데, 대상 IP 조회에는 그냥 NXDOMAIN이 왔다. 정말 미등재인지, 막혀서 없다고 하는 것인지 이 응답만으로는 구분할 수 없었다.
확인하는 방법이 있다. RFC 5782는 모든 DNS 블랙리스트가 테스트 항목 127.0.0.2를 항상 등재해 두도록 정해 두었다. 이 주소를 조회하면 정상 동작하는 목록은 반드시 등재 응답을 준다. 2026-10-04에 구글 DNS로 다섯 곳의 테스트 항목을 조회한 결과는 이랬다.
| 영역 | 테스트 항목 127.0.0.2 응답 |
|---|---|
zen.spamhaus.org |
응답 없음 (NXDOMAIN) |
b.barracudacentral.org |
127.0.0.2 |
bl.spamcop.net |
127.0.0.2 |
psbl.surriel.com |
127.0.0.2 |
bl.mailspike.net |
127.0.0.10, 127.0.0.12 등 |
Spamhaus만 테스트 항목에도 NXDOMAIN을 돌려줬다. 반드시 등재돼 있어야 할 주소가 "없다"고 나온다는 것은, 이 경로로 받은 Spamhaus 응답은 전부 NXDOMAIN이라는 뜻이다. 차단 코드를 주지 않고 조용히 "없음"으로 답하고 있었다. 앞에서 차단 코드를 꼼꼼히 해석해 둔 것이 이 경우에는 아무 소용이 없었다. 지금 상태로는 메일 서버가 실제로 Spamhaus에 올라가도 InfraWatch는 계속 "미등재"를 보여 준다. 감시 도구가 가장 하면 안 되는 일이다.
그래서 매 조회마다 대상 IP와 함께 테스트 항목을 조회하도록 바꿨다. 테스트 항목이 "등재"로 나와야만 대상 IP의 응답을 믿는다.
// 대상 IP 와 테스트 항목(127.0.0.2)을 함께 조회해 응답을 검증한다
var targetTask = QueryWithRetryAsync(dns, RblInterpreter.QueryName(ip, zone), ct);
var testTask = QueryWithRetryAsync(dns, RblInterpreter.QueryName(RblInterpreter.TestPoint, zone), ct);
var answers = await targetTask;
var (state, message) = RblInterpreter.Verify(RblInterpreter.Interpret(answers), await testTask);
public static (RblZoneState State, string Message) Verify(
(RblZoneState State, string Message) target, IReadOnlyList<string> testPointAnswers)
{
if (target.State is RblZoneState.Blocked or RblZoneState.Error)
return target;
var test = Interpret(testPointAnswers);
return test.State switch
{
RblZoneState.Listed => target,
RblZoneState.Blocked => test,
_ => (RblZoneState.Blocked, "검증 실패: 테스트 항목(127.0.0.2)에도 응답 없음 — 공용 DNS 차단 가능성, 결과를 믿을 수 없음"),
};
}
조회 수는 두 배가 되지만 한 시간에 한 번이니 부담이 없다. 이제 구글 DNS를 쓰는 개발 PC에서는 Spamhaus가 "미등재" 대신 "차단"으로 나오고, 점검 요약에는 미등재 4/5 · 조회 실패: zen.spamhaus.org(차단) 같은 줄이 찍힌다. 덜 깔끔하지만 이쪽이 사실이다.
같은 시험에서 하나 더 걸렸다. 구글 DNS가 RBL 조회에 가끔 SERVFAIL을 돌려줬다. bl.spamcop.net 조회가 한 번씩 오류로 떨어지는 식이었다. 바로 다시 물으면 대개 정상 응답이 온다. 이런 일시 오류로 상태가 주의와 정상을 오가면 알림만 시끄러워진다. 그래서 조회가 서버 오류(DnsLookupException)로 실패하면 1초 뒤 한 번만 다시 조회한다(QueryWithRetryAsync). NXDOMAIN은 예외가 아니라 빈 목록이므로 재조회 대상이 아니다.
국내 통신사 DNS도 막혔다 — Spamhaus DQS
개발 단계에서는 공용 DNS를 그대로 두기로 했다. 해결 방법은 정해져 있었다. 직접 운영하는 재귀 DNS 서버를 Resolver에 지정하거나, Spamhaus의 DQS(Data Query Service) 키를 받아 쓰는 것이다. 어느 쪽이든 운영 서버에서 정하면 되는 일이라, 그때까지 RBL 점검은 "주의" 상태로 두었다.
운영 배포 뒤 배포 서버에서 점검을 돌려 보니 결과가 같았다. 배포 서버는 국내 통신사가 기본으로 주는 DNS를 쓰고 있었는데, Spamhaus는 이 경로도 막고 있었다. 수많은 사용자가 함께 쓰는 통신사 DNS도 공용 리졸버와 같은 취급을 받는 셈이다. 테스트 항목 검증이 없었다면 운영에서도 "미등재 5/5 정상"이 찍혔을 것이다.
자체 DNS 서버를 따로 두기보다 DQS를 택했다. DQS는 Spamhaus가 주는 무료 키로, 조회 이름에 키를 넣어 공용 DNS를 거쳐도 응답을 받을 수 있게 해 준다. 영역 이름이 zen.spamhaus.org 대신 {키}.zen.dq.spamhaus.net이 된다.
RblInterpreter.ResolveZones가 설정의 영역 목록을 정리하면서, 키가 있으면 zen.spamhaus.org만 이 이름으로 바꾼다. 다른 목록은 그대로다.
키가 조회 이름에 들어가므로, 화면과 결과 상세와 오류 메시지에 그대로 찍히면 키가 노출된다. 그래서 표시용 이름은 MaskZone을 거쳐 (DQS).zen.dq.spamhaus.net으로 바꾼다. DNS 오류 메시지에도 조회 이름이 들어 있어서, 예외 메시지 안의 영역 이름도 같은 표시용 이름으로 치환했다.
DQS를 붙이면서 알게 된 점을 정리한다.
| 항목 | 내용 |
|---|---|
| 비용 | 무료 계정 |
| 활성화 | 계정을 만들고 이용 약관에 서명한 뒤 몇 분 지나서 키가 동작 |
| 활성화 전·틀린 키 | NXDOMAIN이 아니라 SERVFAIL |
| 유효 기간 | 1년마다 갱신 |
활성화 전 응답이 SERVFAIL이라는 점은 직접 확인했다. 일부러 아무 문자열이나 넣은 가짜 키로 테스트 항목을 조회하면 공용 DNS든 통신사 DNS든 "DNS server failure"가 온다. InfraWatch 입장에서는 DnsLookupException이므로 1초 뒤 재조회를 거쳐 Error가 된다. 덕분에 키를 잘못 넣으면 "미등재"가 아니라 조회 실패로 바로 드러난다. 키를 넣은 직후 실패가 나면 활성화를 몇 분 기다리면 되고, 오래 지나도 계속 실패하면 키를 다시 복사해 넣으면 된다.
키를 저장하고 다시 점검하자 결과가 미등재 5/5 정상으로 바뀌었다. Spamhaus 응답도 이제 테스트 항목 검증을 통과한 "미등재"다. 1년 갱신은 잊기 쉬우니 갱신일을 진행 기록에 적어 두었다. 키가 무효가 돼 조회가 실패하면 그 목록은 "조회 실패"가 되고 RBL 점검은 주의 상태로 바뀐다. 갱신을 잊어도 감시가 조용히 "미등재"라고 거짓말을 하지는 않는다.
비밀값은 Data Protection으로 암호화해 저장
DQS 키는 점검 설정(Checks.SettingsJson)에 들어가는 값이다. 설정 JSON에 평문으로 넣으면 DB 백업이나 조회 화면 어디서든 키가 보인다. Rspamd 비밀번호, SSH 키 경로도 마찬가지다. 그래서 설정 클래스의 속성에 [Secret]을 붙이면 저장할 때 자동으로 암호화하도록 했다.
/// <summary>점검 설정 비밀값을 Data Protection 으로 암호화한다. 저장 형식: "enc:" + 보호된 문자열.</summary>
public sealed class DataProtectionSecretProtector(IDataProtectionProvider provider) : ISecretProtector
{
private const string Prefix = "enc:";
private readonly IDataProtector _protector = provider.CreateProtector("InfraWatch.CheckSecrets.v1");
public string Protect(string plaintext) => Prefix + _protector.Protect(plaintext);
public string Unprotect(string stored) =>
stored.StartsWith(Prefix, StringComparison.Ordinal) ? _protector.Unprotect(stored[Prefix.Length..]) : stored;
}
규칙은 이렇게 정했다.
- 인터페이스
ISecretProtector는 Core에, 구현은 Data에 둔다. 저장값에는enc:접두어를 붙이고, 접두어가 없는 값은 평문으로 보고 그대로 돌려준다. - 화면에는 저장된 비밀값을 다시 보여 주지 않는다. 입력란을 비우고 저장하면 기존 값을 유지하고, 지우려면 "저장된 값 지우기"를 따로 누른다.
- 점검을 실행할 때만 복호화한 사본을 만들어 넘긴다. DB의 값은 항상 암호문이다.
- 웹(설정 저장)과 워커(점검 실행)가 같은 키 폴더와 애플리케이션 이름을 써서 서로의 암호문을 풀 수 있다. 운영 서버에서는 키 파일 자체도 머신 범위 DPAPI로 한 번 더 암호화했다.
메일 서버 PC의 에이전트(7편에서 다룬다)는 점검 설정을 웹 API로 받아 가는데, 에이전트가 담당하는 점검 종류의 설정만 복호화해 내려 준다. RBL은 메인 워커가 실행하는 점검이라 DQS 키는 배포 서버 밖으로 나가지 않는다.
판정과 테스트
영역별 결과 다섯 개를 모아 점검 상태 하나로 만드는 규칙(Evaluate)은 단순하다.
| 결과 | 상태 |
|---|---|
| 한 곳이라도 등재 | 심각 (Critical) |
| 일부 목록 조회 실패·차단 | 주의 (Warning) |
| 모든 목록 조회 실패·차단 | 오류 (Error) |
| 모두 검증된 미등재 | 정상 (Ok) |
등재가 하나라도 있으면 다른 목록의 조회 실패와 관계없이 심각이다. 전부 실패하면 "점검 자체가 안 된 것"이므로 오류로 둔다. 오류는 알림 규칙에서 연속 실패 기준을 따로 적용받는다(다음 글에서 다룬다). 요약 한 줄은 IP · 등재: 목록 · 미등재 n/5 · 조회 실패: 목록(차단|오류) 형식이고, 측정값은 등재된 목록 수다. 점검 상세 화면의 그래프에서 등재 수가 0에서 벗어나는 순간이 바로 보인다.
해석·검증·판정은 모두 RblInterpreter의 순수 함수라 DNS 없이 단위 테스트로 고정했다. 차단_코드는_등재가_아니라_차단(.252·.254·.255), 등재와_차단이_섞이면_차단_우선, 해석할_수_없는_응답은_오류, 테스트항목에도_응답이_없으면_차단으로_본다, 판정_일부_조회실패면_주의_전부면_오류, DQS_키가_있으면_spamhaus_영역을_바꾸고_화면에서는_가린다 같은 항목이다. IPv6 니블 역순 조회 이름도 테스트로 확인했다.
이 가운데 테스트항목에도_응답이_없으면_차단으로_본다에는 "Spamhaus는 구글 공용 DNS에 차단 코드 대신 NXDOMAIN을 돌려준다"는 실측 날짜를 주석으로 남겼다. 나중에 누가 이 검증을 "쓸데없이 조회를 두 번 한다"며 지우려 할 때 이유가 테스트 옆에 있어야 한다.
정리
- RBL 조회는 IP 옥텟을 뒤집고 영역 이름을 붙인 A 레코드 조회다.
127.0.0.x는 등재, NXDOMAIN은 미등재다. 127.255.255.x는 등재가 아니라 조회 차단이다. 등재로 읽으면 멀쩡한 서버에 심각 알림이 간다.- 더 큰 함정은 차단 코드조차 주지 않는 경우다. Spamhaus는 구글 공용 DNS에 NXDOMAIN을 돌려줘서, 그대로 두면 "미등재"로 오판한다.
- RFC 5782 테스트 항목
127.0.0.2를 매번 함께 조회해, 테스트 항목이 등재로 나오지 않는 목록은 "검증 실패(차단)"로 처리한다. 일시적인 SERVFAIL은 1초 뒤 한 번 재조회한다. - 국내 통신사 DNS도 막혀 있어서 운영은 Spamhaus DQS 무료 키로 해결했다. 활성화 전·틀린 키는 SERVFAIL이고, 약관 서명 후 몇 분 뒤 활성화되며, 1년마다 갱신한다.
- 키 같은 비밀값은
[Secret]속성 하나로 Data Protection 암호화 저장, 화면 비표시, 실행 시에만 복호화되게 했다.
다음 글에서는 이렇게 나온 점검 상태를 메일로 바꾸는 알림 규칙 엔진을 다룬다. 같은 알림을 한 번만 보내고, 복구는 빠뜨리지 않는 규칙이다.
InfraWatch 개발기
- 인증서·도메인 만료를 메일로 알려 주는 「InfraWatch」 개발기
- SslStream으로 인증서를 직접 읽는다 — STARTTLS 메일 포트까지 SSL 점검
- .kr은 WHOIS, 나머지는 RDAP — 도메인 만료일 자동 조회
- SPF 조회 10회, DMARC는 상위 도메인까지 — 메일 DNS 레코드 점검과 변경 감지
- 공용 DNS로 RBL을 조회하면 "미등재"를 믿을 수 없다 (이 글)
- 같은 알림은 한 번만, 복구는 꼭 — 상태 전이 알림 규칙 엔진 (10월 9일 공개)
- SSH 대신 API로 — 메일 서버 PC 에이전트와 Postfix 큐·Rspamd 감시 (10월 10일 공개)
- 운영 배포와 첫날 점검 — Web Deploy, NAT 루프백, 외부 노출 점검 (10월 10일 공개)