이 글의 위치: 기술 블로그 → 인프라 · 「쿠버네티스 SMTP」 시리즈 #1.
앞선 사전 정리편: Postfix·OpenSMTPD·Haraka는 각각 무엇을 맡나 · SMTP·POP3·IMAP·JMAP는 각각 무엇을 하나
작성·검증 방식은 Editorial Policy 를 참고하세요.
시작은 스팸함으로 간 메일 두 통이었다
이 시리즈를 시작한 계기는 기술 선택이 아니라 오래된 아쉬움이었습니다.
예전에 C++로 SMTP 클라이언트를 직접 구현해서 네이버 메일로 보낸 적이 있습니다. 메일은 도착했는데, 받은편지함이 아니라 스팸함에 있었습니다. 구현이 어설퍼서 그런가 싶어, 나중에는 Node.js로 다시 짜서 Gmail로 보내 봤습니다. 결과는 똑같이 스팸함이었습니다.
언어도 다르고 구현도 다르고 받는 쪽도 다른데 결과가 같았습니다. 그때는 그게 무슨 뜻인지 몰라서 답을 찾지 못한 채 넘어갔고, 그 뒤로는 계속 남의 발송 서비스를 썼습니다. (지금 와서 보면 이 "두 번 다 같은 결과"가 원인을 가리키는 가장 중요한 단서였습니다. §7에서 다시 이야기하겠습니다.)
이번에 다시 꺼내 든 이유는 이렇습니다.
- 그때 왜 스팸으로 갔는지 이번엔 끝까지 확인하고 싶었습니다.
- 오픈소스나 무료 티어에는 발송량·기능 제한이 붙는데, 직접 올리면 그 제한에서 자유로워집니다.
- 그리고 솔직히 지적인 호기심입니다. 메일 한 통이 어떤 경로로 남의 서버까지 가고 어디서 판정되는지를 직접 보고 싶었습니다.
그리고 여기에 하나가 더 붙었습니다. 기왕 다시 만든다면 제대로 만들어 보고 싶다는 마음입니다. 예전엔 "보내기"만 짰고 그마저 스팸함으로 갔는데, 이번에는 보내는 쪽과 받아서 보관하는 쪽을 모두 갖춘 형태 — 즉 메일함까지 만들어 보려고 합니다. 메일이 나가는 것만 보는 것과, 나가서 남의 서버에 꽂히고 내 서버로도 들어와 쌓이는 것을 전부 보는 건 다른 경험일 것 같아서입니다.
이 한 줄이 결국 후보 선정을 통째로 결정했습니다.
처음엔 다섯 축으로 걸러 Postal로 정했었습니다. 그런데 정하고 나서 두 가지를 확인하지 않았다는 걸 알았습니다.
- Postal이 무슨 언어로 짜여 있는지 — 저는 인프라를 가급적 Go로, 아니면 Rust로 가져가고 싶습니다.
- Postal에 메일함이 있는지 — 있다고 막연히 가정하고 있었습니다.
둘 다 확인해 보니 예상과 달랐고, 그래서 이 글은 처음 결론을 뒤집는 기록이 됐습니다.
1. 확인해 보니 달랐던 것들
① Postal에는 메일함이 없다
가장 큰 발견입니다. Postal의 공식 기능 목록에 IMAP·POP3·JMAP이 단 한 번도 나오지 않습니다. 수신 메일 처리 항목은 이렇게만 되어 있습니다.
들어온 메일을 HTTP 엔드포인트로 전달 / 다른 SMTP 서버로 전달 / 다른 메일 주소로 전달
즉 Postal은 메일을 보관하지 않습니다. 사람이 로그인해서 받은편지함을 여는 기능이 아예 없습니다. 이건 결함이 아니라 의도된 범위입니다. Postal은 스스로를 이렇게 소개합니다.
Think Sendgrid, Mailgun or Postmark but open source
그 서비스들도 메일함을 주지 않습니다. 메일 프로토콜을 정리한 앞 글의 표현을 빌리면, Postal은 "보내는 절반"만 있는 제품입니다.
메일함까지 만들기로 한 이상, 이 한 줄로 Postal은 후보에서 빠집니다. 기능이 부족해서가 아니라 애초에 다른 물건이기 때문입니다.
② Postal은 Ruby, Stalwart는 Rust
이미지를 열어 직접 확인했습니다.
$ docker run --rm --platform linux/amd64 --entrypoint sh \
ghcr.io/postalserver/postal:3.3.7 -c "ruby -v; head -2 /etc/os-release"
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +PRISM [x86_64-linux]
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
Postal은 Ruby on Rails 애플리케이션입니다. GitHub의 언어 통계도 Ruby로 잡힙니다. Stalwart는 Rust입니다.
여기서 한 가지는 솔직히 짚어야겠습니다. 컨테이너로 돌리는 이상 구현 언어는 운영에서 거의 안 보입니다. 이미지를 받아 command만 주면 되니까요. 언어가 실제로 문제가 되는 건 소스를 고칠 때, 그리고 다음 항목처럼 빌드 산출물이 제약을 만들 때입니다.
③ Postal은 amd64 전용, Stalwart는 arm64도 있다
이건 실제로 배포를 막는 제약입니다.
$ docker manifest inspect ghcr.io/postalserver/postal:3.3.7
linux amd64 ← 이것뿐
$ docker manifest inspect stalwartlabs/stalwart:latest
linux amd64
linux arm64
linux arm v7
linux arm v6
제 로컬은 Apple Silicon이고, 무료 티어로 쓰는 OKE 클러스터도 arm64(Ampere) 노드입니다. Postal을 쓰려면 amd64 노드를 따로 마련하거나 에뮬레이션을 감수해야 합니다. Stalwart는 그 제약이 없어서 지금 가진 무료 클러스터에 그대로 올라갑니다.
④ 그리고 Postal에는 Helm 차트가 없다
처음 정리한 후보표에 "Postal — 커뮤니티 Helm"이라고 적었는데 사실이 아니었습니다. ArtifactHub 검색 결과 0건이고, postalserver 조직 저장소 13개에도 차트가 없습니다(install은 docker-compose 템플릿 전용). Stalwart·Mailu·docker-mailserver·kube-mail·Mailpit은 모두 차트가 있습니다.
한 가지 더. 자료를 보면 "Postal은 MariaDB와 RabbitMQ가 필요하다"는 설명이 많은데, 공식 설치 스크립트를 v2와 v3로 나란히 놓으니 달랐습니다.
# install-ubuntu.v2.sh — 둘 다 띄운다
docker run -d --name postal-mariadb ... mariadb
docker run -d --name postal-rabbitmq ... rabbitmq
# install-ubuntu.v3.sh — MariaDB 하나뿐
docker run -d --name postal-mariadb ... mariadb
v3에서 RabbitMQ 의존이 사라졌습니다. Postal을 고를 경우엔 유리한 변화입니다.
2. Postal vs Stalwart 정면 비교
수치는 2026-08-02 기준으로 GitHub API·Docker 레지스트리·양쪽 공식 문서에서 직접 확인했습니다.
| Postal | Stalwart | |
|---|---|---|
| 성격 | 앱 발송 플랫폼 (자체호스팅 SendGrid) | 메일·협업 서버 전체 |
| 구현 언어 | Ruby (Rails) | Rust |
| 라이선스 | MIT | AGPL-3.0 + 상용(SELv2) 이중 |
| 최초 공개 | 2017-04 | 2023-03 |
| GitHub ★ | 16,711 | 13,964 |
| 포크 / 열린 이슈 | 1,264 / 90 | 859 / 67 |
| 최신 버전 | 3.3.7 (안정) | 0.16.15 (pre-1.0) |
| 컨테이너 아키텍처 | amd64 | amd64 · arm64 · armv7 · armv6 |
| Helm 차트 | 없음 | 커뮤니티 다수 |
| SMTP 송수신 | ✅ | ✅ |
| IMAP / POP3 / JMAP | ❌ / ❌ / ❌ | ✅ / ✅ / ✅ |
| CalDAV·CardDAV·WebDAV | ❌ | ✅ |
| 발송 추적·클릭/오픈 | ✅ 핵심 기능 | ❌ |
| IP 풀·억제 목록 | ✅ | ❌ |
| 인증·전송보안 | DKIM·SPF·DMARC | + ARC·DANE·MTA-STS·TLS-RPT |
| 스팸 필터 | SpamAssassin·ClamAV 연동 | 내장(통계 분류기·DNSBL·greylisting) |
| 상태 저장소 | MariaDB 고정 | RocksDB·FoundationDB·PostgreSQL·MySQL·SQLite·S3·Azure·Redis |
| 클러스터링 | 문서화된 방식 없음 | 무료 판에 포함 |
한 줄로 줄이면 Postal은 좁고 깊고, Stalwart는 넓고 새롭습니다.
Postal에만 있는 것도 분명합니다. 클릭·오픈 추적, IP 풀, 발송 억제 목록 은 "앱이 대량으로 보낸다"는 상황에 특화된 기능이고 Stalwart에는 없습니다. 목적이 마케팅·트랜잭션 메일 대량 발송이라면 Postal이 더 맞는 도구입니다.
3. 확장성 — Stalwart의 클러스터링은 무료인가
제가 가장 먼저 확인하고 싶었던 부분입니다. Stalwart는 AGPL과 상용 라이선스로 나뉘어 있어서, "확장성 기능이 유료 뒤에 있으면 의미가 없다" 고 봤기 때문입니다. 공식 에디션 비교표를 항목별로 확인했습니다.
Community(무료)에 포함되는 것 — 확장성 관련
- 클러스터 코디네이션 (Zenoh 피어투피어, Kafka, Redpanda, NATS, Redis)
- 장애 내성·고가용성
- 쿠버네티스 / Docker Swarm / Mesos 지원
- 자동 노드 ID 생성·관리, 아웃바운드 MTA 클러스터 역할, PROXY 프로토콜
- 모든 스토리지 백엔드 (RocksDB·FoundationDB·PostgreSQL·MySQL·SQLite·S3·Azure·Redis)
- 모든 프로토콜(JMAP·IMAP·POP3·SMTP·CalDAV·CardDAV·WebDAV), 전체 인증 스택(LDAP·OIDC·OAuth2·2FA)
Enterprise에만 있는 것
- 멀티테넌시·테넌트별 할당량·브랜딩
- 읽기 복제본(read replica), 샤딩된 blob·인메모리 저장소
- 계정 아카이빙·삭제 복구, AI/LLM 스팸 분류·Sieve, 마스크드 이메일
- 실시간 대시보드, 라이브 텔레메트리 스트리밍, 메트릭 알림
정리하면 기본 클러스터링·HA·쿠버네티스는 무료이고, 유료 선은 "여러 고객사를 한 배포에 태우는 호스팅 사업"과 "읽기 복제본·샤딩이 필요한 초대형 배포"에 그어져 있습니다. 제 목적은 단일 조직의 자체 운영이라, 유료가 필요한 지점이 아닙니다.
Postal 쪽은 세 컴포넌트가 stateless라 앞단 수평 확장은 자연스럽지만, 상태가 MariaDB 한 곳에 모이고 그 확장·이중화는 Postal이 답해 주지 않습니다. 공식 문서에 클러스터링 항목 자체가 없습니다.
즉 Postal의 확장은 "앞단을 늘리는 것"까지이고, Stalwart의 확장은 "데이터 계층까지 포함" 입니다.
4. Go로 서버를 통째로 고를 수는 없을까
언어 선호가 Go 우선이라 Go 구현체부터 같은 기준으로 봤습니다. 결론부터 적으면 지금 단계에서 Go로 갈 만한 완성된 서버가 마땅치 않습니다.
| 후보 | ★ | 프로토콜 | 최신 릴리스 | 판단 |
|---|---|---|---|---|
| Mox | 5,765 | SMTP + IMAP4 + 웹메일, SPF/DKIM/DMARC/DANE/MTA-STS/ACME | v0.0.15 (2025-04) | 완성도는 좋으나 단일 서버 전제. JMAP·POP3는 로드맵 |
| Maddy | 6,048 | SMTP + IMAP | — | README가 "IMAP 저장소는 베타, 안정성을 원하면 Dovecot을 쓰라" 고 직접 권고 |
| Chasquid | 977 | SMTP만 | — | 메일함 없음. 소규모 개인 서버용 |
Mox는 "낮은 유지보수로 자체호스팅하는 개인·소규모 메일 서버"를 목표로 잡은 프로젝트라 설계가 클러스터를 향하고 있지 않고, Maddy는 만든 사람이 직접 IMAP 저장소의 안정성에 선을 그어 두었습니다.
그래서 언어 선호를 지키면서 메일함과 확장성까지 가져가려면 현실적인 답은 Rust(Stalwart) 입니다.
5. 메일함을 어떻게 얻을 것인가 — 두 가지 안
완성된 Go 서버가 없다는 걸 확인하고 나서, 실제로 저울에 올린 안은 둘이었습니다.
안 A — Postal은 그대로 두고, 그 뒤에 메일함 서비스를 직접 만든다.
Postal은 발송이 강하고 이미 한 번 고른 적이 있습니다. 그리고 Postal의 수신 처리는 "들어온 메일을 HTTP 엔드포인트로 전달" 이라, 그 엔드포인트 뒤에 제가 만든 서비스(가칭 MailBoxService)를 두고 메일을 저장한 뒤 IMAP으로 열어 주면 그림이 완성되긴 합니다. Go로 짜고 싶은 마음도 있었습니다.
안 B — Stalwart를 Mailbox Core로 채택한다.
SMTP와 메일함을 한 제품이 함께 처리하게 두고, 저는 설정과 운영만 합니다.
문제는 안 A의 "그 뒤에 만드는 서비스"가 사실상 메일함 전체라는 점이었습니다. 그래서 "직접 짜면 무엇을 만들어야 하는가"를 세어 봤습니다.
| 항목 | 실측 |
|---|---|
| IMAP4rev2 규격(RFC 9051) 분량 | 약 8,700줄 / 367,000자 |
| IANA에 등록된 IMAP capability(확장) 수 | 78개 |
| Stalwart 소스 규모 | Rust 14.4 MB (전체의 98.3%) |
프로토콜 파싱만 보면 생각보다 할 만해 보입니다. Go에는 이미 쓸 만한 라이브러리가 있습니다.
| 라이브러리 | ★ | 역할 |
|---|---|---|
emersion/go-imap |
2,344 | IMAP 클라이언트·서버 프레임워크 |
emersion/go-smtp |
2,039 | SMTP 클라이언트·서버 |
emersion/go-message |
456 | MIME 메시지 파싱 |
foxcpp/go-imap-sql |
26 | go-imap용 SQL 저장소 백엔드 |
마지막 줄이 제 판단을 굳혔습니다. 프로토콜 라이브러리는 별 2,344개인데, 그 뒤에 붙는 저장소 백엔드는 26개입니다. 이 격차가 정확히 난이도의 격차입니다. 파서는 남이 만들어 줬지만, 메일함의 본체인 저장소는 각자 만들어야 하고 그래서 아무도 완성해 두지 않았다는 뜻으로 읽었습니다.
메일함을 직접 만든다는 건 결국 이런 것들을 만든다는 뜻입니다.
- 메시지 영속화와 UID 관리 — 메일박스마다 단조 증가하는 UID, 재사용 금지,
UIDVALIDITY처리 - 플래그(읽음·답장함·삭제 표시)와 폴더 트리, 이름 변경·이동 시의 정합성
- 동시성 — 같은 메일함을 폰·노트북·웹이 동시에 열어 두고 서로 다른 변경을 밀어 넣는 상황
- 증분 동기화 —
CONDSTORE·QRESYNC같은 확장으로 "지난번 이후 바뀐 것만" 내려주기 - 서버 측 검색 인덱스, 부분 조회(헤더만·본문만), 첨부 처리
- 여기에 SPF·DKIM·DMARC·DANE·MTA-STS 인증 계층까지
그리고 이걸 다 만들어도 제가 원래 풀고 싶던 문제(스팸함으로 가지 않기)는 한 발짝도 진전되지 않습니다. 그 문제는 다음 절에서 보듯 코드가 아니라 DNS와 IP 평판이 결정하기 때문입니다.
그리고 안 A에는 코드 말고도 붙는 비용이 있었습니다. 시스템이 둘이 되면 경계도 둘이 됩니다.
| 안 A — Postal + 자작 MailboxService | 안 B — Stalwart를 Mailbox Core로 | |
|---|---|---|
| 내가 만들 코드 | IMAP 서버 + 저장소 전체 | 없음 (설정만) |
| 운영 대상 | Postal · MariaDB · 자작 서비스 · 그 저장소 | Stalwart 1개 |
| 계정·도메인 관리 | 두 시스템에 이중 등록 | 한 곳 |
| 인증 | Postal 크리덴셜과 메일함 계정을 따로 관리 | LDAP·OIDC로 통합 |
| 제공 프로토콜 | IMAP 하나 만들기도 벅참 | IMAP · POP3 · JMAP · ManageSieve |
| 장애 지점 | Postal ↔ 자작 서비스 사이의 전달 구간이 추가 | 내부 처리 |
| 예상 소요 | 개월 단위 | 설정 시간 |
목적을 다시 확인하니 분명해졌습니다. 제가 하고 싶은 건 "메일 서버를 처음부터 만드는 것"이 아니라 "제대로 도착하는 메일 시스템을 갖고 그 과정을 이해하는 것" 입니다. IMAP 파서와 저장소를 손으로 짜는 데 몇 달을 쓰면, 정작 확인하고 싶었던 인증·평판·운영 층에는 닿지 못한 채 시간이 끝납니다.
그래서 정리하면 이렇습니다. Postal 뒤에 별도 Mailbox Service를 처음부터 만드는 것보다, Stalwart를 Mailbox Core로 채택하는 편이 훨씬 현실적입니다. 만들 것이 없어지는 게 아니라 만들 자리를 옮기는 것 — 파서와 저장소 대신 인증·평판·운영에 시간을 쓰겠다는 선택입니다.
SMTP 클라이언트를 C++과 Node.js로 두 번 짜 본 경험이 여기서 헛되지는 않았다고 봅니다. 그 층을 직접 봤기 때문에, 지금은 무엇을 위임하는지 알고 위임하는 것이니까요.
6. 레퍼런스와 성숙도 — Postal이 앞서는 지점
공정하게, Stalwart를 고를 때 감수하는 것도 적어 둡니다.
- 나이: Postal 2017년 vs Stalwart 2023년. 6년 차이는 "삽질 기록의 양"에서 그대로 드러납니다. 막혔을 때 검색해서 나오는 글의 수가 다릅니다.
- 버전: Postal은 3.3.7, Stalwart는 0.16.15 로 아직 1.0 이전입니다. 릴리스 노트에 "v0.15 이하에서 올린다면 업그레이드 문서를 먼저 읽으라" 는 안내가 붙어 있습니다. 버전을 올릴 때 손이 가는 종류의 프로젝트라는 뜻입니다.
- 라이선스: Postal은 MIT로 단순합니다. Stalwart는 AGPL-3.0이라 수정본을 서비스로 제공할 때 소스 공개 의무가 따라옵니다(개인·사내 사용에는 무관).
- 기여 정책: Stalwart는 "AI 생성 저품질 PR이 많아 기여자를 사전 승인제로 제한하고, 현재는 버그 수정과 번역만 받는다" 고 공지했습니다. 관리가 촘촘하다는 신호이자, 외부 기여로 기능이 빨리 붙기는 어렵다는 신호이기도 합니다.
다만 실사용 지표는 나쁘지 않습니다. Docker Hub 기준 stalwartlabs/stalwart pull 1,135,335회(이전 이름 mail-server 380,629회 별도)로, 2023년 시작 프로젝트치고 상당합니다.
정리하면 "성숙도를 조금 내주고 범위와 확장성을 산다" 가 이번 선택의 성격입니다.
7. 그때 스팸함으로 간 진짜 이유
조사를 하면서 인식이 가장 크게 바뀐 지점이 여기입니다. 저는 막연히 "제대로 된 메일 서버를 쓰면 스팸함을 벗어나겠지"라고 생각했는데, 소프트웨어 선택은 스팸 판정에 거의 영향을 주지 않습니다.
Gmail이 2024년 2월부터 시행 중인 발신자 요구사항을 보면 이유가 분명합니다.
| 대상 | 요구사항 |
|---|---|
| 모든 발신자 | 발신 도메인에 SPF 또는 DKIM 설정 |
| 발신 IP에 유효한 정방향·역방향 DNS(PTR) — 호스트명이 다시 같은 IP로 풀려야 함(FCrDNS) | |
| 전송 시 TLS 연결 사용 | |
| Postmaster Tools 기준 스팸 신고율 0.3% 미만 | |
Gmail From: 헤더 도용 금지 |
|
| 하루 5,000통 이상 | SPF와 DKIM 둘 다 + DMARC 설정 |
정렬(alignment) — From: 도메인이 SPF 또는 DKIM 도메인과 일치 |
|
| 마케팅·구독 메일은 원클릭 수신 거부 헤더 |
이 목록에 "어떤 MTA를 쓰는가"는 없습니다. 전부 도메인과 IP가 스스로를 증명하는가에 대한 것입니다.
두 번 다 같은 결과였다는 것이 단서였다
도입부에서 미뤄 둔 이야기가 여기서 풀립니다. 저는 C++로 짜서 네이버에, 그리고 Node.js로 다시 짜서 Gmail에 보냈습니다. 언어도 다르고, 구현도 다르고, 받는 쪽 회사도 다릅니다. 그런데 결과가 똑같았습니다.
변수가 셋이나 바뀌었는데 결과가 그대로라면, 원인은 바뀌지 않은 쪽에 있습니다. 그때 두 번 모두 동일했던 것은 발신 도메인과 IP, 그리고 인증 레코드가 하나도 없었다는 사실입니다. 코드를 아무리 다시 짜도 결과가 안 바뀐 게 당연했습니다.
그 기준으로 두 번의 발송을 되짚어 보면 이렇습니다.
| Gmail·네이버가 보는 것 | 두 번의 시도에 있었을까 |
|---|---|
| SPF 레코드 | ✗ 도메인에 SPF를 만든 기억이 없습니다 (양쪽 다) |
| DKIM 서명 | ✗ C++·Node.js 어느 쪽에도 서명 코드를 넣지 않았습니다 |
| DMARC 정책 | ✗ |
| PTR·FCrDNS | ✗ 일반 회선/클라우드 IP를 그대로 썼습니다 |
| TLS | △ STARTTLS를 제대로 붙였는지 확실하지 않습니다 |
| IP 평판 | ✗ 이력이 없는 IP는 "좋음"이 아니라 "모름" 이고, 모르는 발신자는 의심받습니다 |
즉 구현이 부족해서가 아니라 신원을 증명할 방법이 하나도 없어서 스팸으로 분류된 것입니다. 언어를 바꿔 다시 짠 두 번째 시도가 같은 결과였던 이유도 이것입니다. RFC 5321을 아무리 정확히 지켜도 이 부분은 채워지지 않습니다. SMTP 규격에는 "이 메일이 진짜인지"를 다루는 항목이 없기 때문입니다.
받는 쪽 두 곳의 DNS를 실제로 조회해 보면 이 생태계가 얼마나 보편적인지 보입니다.
$ dig +short TXT naver.com | grep spf
"v=spf1 ip4:111.91.135.0/27 ip4:125.209.208.0/20 ... ~all"
$ dig +short TXT _dmarc.naver.com
"v=DMARC1; p=none; rua=mailto:dmarc_reports@worksmobile.com"
$ dig +short TXT gmail.com | grep spf
"v=spf1 redirect=_spf.google.com"
$ dig +short TXT _dmarc.gmail.com
"v=DMARC1; p=none; sp=quarantine; rua=mailto:mailauth-reports@google.com"
네이버도 Gmail도 자기 도메인에 SPF와 DMARC를 게시하고 리포트 주소까지 열어 두고 있습니다. 두 곳 모두 같은 인증 체계 위에 서 있고, 제가 보낸 메일도 그 체계로 평가받았던 것입니다. 제가 그 체계에 참여하지 않았을 뿐입니다.
"직접 구현하면 제한이 없다"의 정확한 범위
| 사라지는 제한 | 새로 내 몫이 되는 것 |
|---|---|
| 무료 티어 월 발송 통수 | SPF·DKIM·DMARC 레코드 설계와 관리 |
| 통당·수신자당 과금 | 고정 IP와 PTR 확보 |
| 로그 보존 기간, API 호출 한도 | IP 평판 쌓기(워밍업) — 처음부터 대량 발송하면 역효과 |
| 커스텀 헤더·재시도 정책 제약 | 바운스·수신거부 처리 |
| 발송 기록이 남의 대시보드 안에만 있는 것 | 블록리스트에 오르면 직접 해제 요청 |
제품이 걸어 둔 제한은 사라지고, 수신 측이 요구하는 조건은 그대로 남아 오히려 전부 제 책임이 됩니다. 예전에 스팸함으로 갔던 그 문제는 직접 구현한다고 없어지는 게 아니라, 직접 구현할 때 비로소 정면으로 마주하게 되는 문제였습니다. 그런데 제가 확인하고 싶었던 게 정확히 그 층이라, 동기와는 오히려 맞아떨어집니다.
도달률 도구는 어느 쪽이 나은가
소프트웨어가 판정을 바꾸지는 못해도, 조건을 갖추도록 도와주는 정도는 제품마다 다릅니다. 이 축을 항목별로 다시 확인해 보니 제 예상과 달랐습니다.
| Postal | Stalwart | |
|---|---|---|
| DKIM 서명 | ✅ | ✅ |
| DKIM 키 자동 생성·순환 | 수동 | ✅ |
| SPF·DMARC | ✅ | ✅ |
| ARC · DANE · MTA-STS · TLS-RPT | ❌ | ✅ |
| 자동 DNS 관리 | 내장 DNS 점검 | ✅ 자동 관리 |
| DMARC·TLS-RPT·ARF 리포트 시각화 | ❌ | ✅ |
| ACME 인증서 자동 발급 | 별도 | ✅ 내장 |
| 억제 목록 · IP 풀 · 클릭 추적 | ✅ | ❌ |
인증·전송보안 계열은 오히려 Stalwart가 넓습니다. Postal에 없는 ARC·DANE·MTA-STS·TLS-RPT를 지원하고, DKIM 키 순환과 DMARC 리포트 시각화까지 무료 판에 들어 있습니다. Postal의 강점은 대량 발송 운영 도구(억제 목록·IP 풀·클릭 추적) 쪽이지 인증 쪽이 아니었습니다.
제 문제는 인증·평판 쪽이고, 특히 DMARC 리포트를 눈으로 볼 수 있다는 점이 큽니다. 예전엔 "스팸함에 있다"는 결과만 보고 원인을 못 찾았는데, 리포트가 있으면 어느 수신 서버가 왜 실패로 판정했는지가 남습니다.
8. 그래서 Stalwart로 정했다
축을 늘려 놓고 보니 결정은 취향이 아니라 범위에서 났습니다.
사람이 로그인해서 읽는 메일함이 필요한가?
│
┌─────────────┴─────────────┐
아니오 예 ← 나는 여기
│ │
Postal Postal은 애초에 불가능
(추적·IP풀·억제목록) (IMAP·POP3·JMAP 없음)
│
▼
Stalwart
기준별로 승패를 그대로 적으면 이렇습니다.
| 기준 | 승자 | 근거 |
|---|---|---|
| 메일함(내 요구사항) | Stalwart | Postal은 아예 못 함 |
| 확장성 | Stalwart | 클러스터링·HA가 무료 판에 포함, 스토리지 백엔드 선택 가능 |
| 언어 선호(Go/Rust) | Stalwart | Rust. Postal은 Ruby |
| arm64 노드 | Stalwart | 지금 가진 무료 클러스터에 그대로 올라감 |
| 인증·도달률(ARC·DANE·MTA-STS·리포트) | Stalwart | Postal에 없는 항목이 다수 |
| 대량 발송 운영(추적·IP풀·억제목록) | Postal | Stalwart에 없음 |
| 성숙도·안정성 | Postal | 3.3.7 vs 0.16.x |
| 레퍼런스 양·라이선스 단순함 | Postal | 2017년부터, MIT |
제 요구사항 중 "메일함이 필요하다"가 다른 모든 항목을 앞섭니다. 이건 정도의 문제가 아니라 가능·불가능의 문제라, 나머지 축에서 Postal이 몇 개를 이기든 결과가 바뀌지 않습니다. 그리고 확장성·언어·arm64까지 Stalwart 쪽이라, 제 기준에서는 한 방향을 가리킵니다.
Postal을 버리는 게 아쉬운 지점도 분명히 남습니다. 성숙도, 레퍼런스, MIT 라이선스, 그리고 대량 발송 운영 도구. 만약 목적이 "앱 알림 메일을 많이, 안정적으로"였다면 저는 Postal을 골랐을 겁니다. 목적이 달라졌을 뿐입니다.
다시 검토할 조건도 미리 적어 둡니다.
- Stalwart 업그레이드가 반복적으로 데이터 마이그레이션을 요구할 때 — pre-1.0의 대가가 감당 범위를 넘으면
- 메일함을 실제로 쓰지 않게 됐을 때 — 그러면 애초의 전제가 사라지므로 Postal이 다시 유리해집니다
- 대량 발송이 주 용도가 됐을 때 — 억제 목록·IP 풀이 필요해지면
9. Stalwart는 쿠버네티스에서 어떤 모양이 되나
이미지를 열어 보니 Postal과 구조가 정반대였습니다.
$ docker run --rm --entrypoint sh stalwartlabs/stalwart:latest \
-c "ls -la /usr/local/bin/; stalwart --help; head -2 /etc/os-release"
-rwxr-xr-x 1 root root 86861648 stalwart ← 단일 바이너리 86.8 MB
Stalwart Server v0.16.15
Usage: stalwart [OPTIONS]
Options:
-c, --config <PATH> Start server with the specified configuration file
-e, --export <PATH> Export all store data to a specific path
-i, --import <PATH> Import store data from a specific path
-o, --console Open the store console
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
역할을 나누는 커맨드가 없습니다. 바이너리 하나가 SMTP·IMAP·POP3·JMAP·웹 관리자를 전부 처리하고, 설정 파일 하나로 무엇을 열지 정합니다.
| Postal | Stalwart | |
|---|---|---|
| 실행 단위 | 이미지 1개 + 역할 커맨드 3개 | 단일 바이너리 1개 |
| K8s 오브젝트 | Deployment ×3 + Job + MariaDB StatefulSet | StatefulSet ×1 |
| 필수 외부 상태 | MariaDB (필수) | 없음 — RocksDB 내장. 원하면 PostgreSQL·S3로 분리 |
| 스키마 초기화 | initialize Job 필요 |
첫 기동 시 자체 처리 |
| 열어야 할 포트 | 25/587, HTTP | 25/587/465, 143/993, 110/995, 443 |
| 확장 방식 | 앞단 Deployment 복제 | 노드 추가 + 클러스터 코디네이션 |
작게 시작하기엔 Stalwart 쪽이 오히려 단순합니다. DB를 따로 띄우지 않아도 되기 때문입니다. 내장 RocksDB로 PVC 하나만 붙이면 뜨고, 나중에 규모가 커지면 스토리지 백엔드를 PostgreSQL이나 S3로 갈아끼우는 경로가 열려 있습니다.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: stalwart
spec:
serviceName: stalwart
replicas: 1
template:
spec:
# nodeSelector 불필요 — arm64 이미지가 있어 무료 Ampere 노드에 그대로 올라간다
containers:
- name: stalwart
image: stalwartlabs/stalwart:latest
args: ["--config", "/opt/stalwart/etc/config.toml"]
ports:
- { name: smtp, containerPort: 25 }
- { name: submission, containerPort: 587 }
- { name: imaps, containerPort: 993 }
- { name: https, containerPort: 443 } # JMAP · 웹 관리자
volumeMounts:
- { name: data, mountPath: /opt/stalwart }
volumeClaimTemplates:
- metadata: { name: data }
spec:
accessModes: ["ReadWriteOnce"]
resources: { requests: { storage: 20Gi } }
#2편에서 이 뼈대를 실제로 채워 올릴 생각입니다.
10. 시작 전에 확인할 것
어느 쪽을 고르든 먼저 확인해야 하는 항목들이고, 순서가 중요합니다.
- 아웃바운드 25번 포트가 열려 있는가 — 대부분의 클라우드가 막아 둡니다. 여기서 막히면 이후 설계가 전부 무의미해집니다
- 고정 egress IP와 PTR(역방향 DNS)을 설정할 수 있는가 — Gmail 요구사항이고, IP 평판이 흩어지면 도달률이 무너집니다
- 발신 도메인에 SPF·DKIM·DMARC를 넣을 수 있는가 — 선택이 아니라 필수
- 인바운드 25번을 받을 수 있는가 — 메일함까지 하려면 이제 받는 쪽도 필요합니다. 도메인 MX 레코드를 내 서버로 걸 수 있어야 합니다
- PVC 스토리지클래스(
ReadWriteOnce) 확인
4번이 이번에 새로 늘어난 항목입니다. 발송만 할 때는 나가는 길만 뚫으면 됐는데, 메일함을 두기로 한 이상 MX 레코드와 인바운드 경로가 함께 필요해집니다.
11. 정리
- 두 번의 시도가 언어·구현·수신처를 모두 바꿨는데 결과가 같았다는 것이 원인을 가리키는 단서였습니다. 바뀌지 않은 것 — 인증 레코드와 IP 평판의 부재 — 이 원인이었습니다.
- 동기가 하나 늘면서 결론이 뒤집혔습니다. "기왕이면 제대로, 메일함까지" 를 넣는 순간 Postal은 후보에서 빠집니다. IMAP·POP3·JMAP을 하나도 지원하지 않기 때문이고, 이건 부족해서가 아니라 애초에 발송 전용 제품이기 때문입니다.
- 그래서 Stalwart로 정했습니다. 메일함(IMAP·POP3·JMAP)에 더해 Rust, arm64 이미지, 무료 판에 포함된 클러스터링·HA, 그리고 ARC·DANE·MTA-STS·TLS-RPT와 DMARC 리포트 시각화까지 제 기준을 그대로 만족합니다.
- 대신 성숙도를 내줍니다. Stalwart는 아직 0.16.x(Postal은 3.3.7)이고, 레퍼런스 양과 라이선스 단순함(MIT)은 Postal이 앞섭니다. 이건 감수하는 비용으로 적어 뒀습니다.
- 저울에 올린 안은 둘이었습니다. Postal을 두고 그 뒤에 메일함 서비스를 직접 만드는 안과, Stalwart를 Mailbox Core로 채택하는 안. 결론은 후자가 훨씬 현실적이었습니다. Postal의 수신 처리가 "HTTP 엔드포인트로 전달"이라 앞의 그림이 성립하긴 하지만, 그 뒤에 만들 서비스가 사실상 메일함 전체입니다.
- 규모를 재 보면 IMAP4rev2 규격만 약 8,700줄에 IANA 등록 확장이 78개이고, 무엇보다 프로토콜 라이브러리(go-imap ★2,344)와 저장소 백엔드(go-imap-sql ★26)의 격차가 진짜 난이도를 보여 줍니다. 어려운 건 파서가 아니라 UID·플래그·동시성·증분 동기화를 갖춘 저장소입니다. 여기에 시스템이 둘이 되면서 생기는 계정 이중 관리·인증 분리·전달 구간 장애 지점까지 붙습니다.
- 그걸 다 만들어도 원래 풀고 싶던 문제는 진전되지 않습니다. 스팸 판정은 코드가 아니라 SPF·DKIM·DMARC·PTR·IP 평판이 결정하니까요. Gmail 요구사항 목록에 "어떤 MTA를 쓰는가"는 없습니다.
- 그래서 프레임워크를 Mailbox Core로 가져다 쓰고, 아낀 시간을 인증·평판·운영에 쓰기로 했습니다. 만들 것이 없어지는 게 아니라 만들 자리를 옮기는 선택입니다. SMTP 클라이언트를 C++과 Node.js로 두 번 짜 본 경험은 헛되지 않았습니다. 그 층을 봤기 때문에 무엇을 위임하는지 알고 위임할 수 있습니다.
다음 편에서는 Stalwart를 실제로 클러스터에 올립니다. 단일 바이너리를 StatefulSet으로 세우고, 내장 RocksDB로 시작해 설정 파일에서 어떤 프로토콜을 열지 정하고, 웹 관리자에 처음 로그인하기까지의 로그를 그대로 남길 생각입니다.
그리고 #3편이 사실상 이 시리즈의 본론이 될 것 같습니다. SPF·DKIM·DMARC를 실제로 세팅하고 PTR을 붙인 뒤, 예전에 스팸함으로 갔던 그 두 곳 — 네이버와 Gmail — 로 다시 보내서 이번에는 받은편지함에 들어가는지 확인하려고 합니다. 오래 미뤄 둔 답을 그때 받을 수 있을 것 같습니다.
함께 읽으면 좋은 글:
- SMTP·POP3·IMAP·JMAP는 각각 무엇을 하나 — 이 글의 판단 기준이 된 프로토콜 정리
- [메일 인프라] Postfix·OpenSMTPD·Haraka는 각각 무엇을 맡나 — MTA들의 리눅스 프로세스 모델 실측
- [로컬 서버 운영기] Docker Compose에서 K3s로 — 컴포즈 전제 스택을 클러스터로 옮길 때의 문제들
- [OKE 인프라 학습 시리즈] arm64 노드에 실제 서비스 얹기 — arm64 이미지 유무가 왜 중요한지
- [인프라 비용] Bitnami Helm으로 무료 인프라? — 차트에 기대는 것과 직접 쓰는 것의 비용 비교
참고 (1차 출처)
- Stalwart 저장소 · 에디션 비교 · Enterprise
- Postal 저장소 · 기능 목록 · postalserver/install
- Mox · Maddy · emersion/go-imap · emersion/go-smtp
- IMAP4rev2 RFC 9051 · IANA IMAP Capabilities 레지스트리
- Gmail 이메일 발신자 가이드라인 — 2024-02-01 시행 요구사항
- SPF RFC 7208 · DKIM RFC 6376 · DMARC RFC 7489
- 확인 시점: 2026-08-02. GitHub API(별·언어·릴리스),
docker manifest inspect·이미지 직접 실행(아키텍처·바이너리), Docker Hub API(pull 수),dig(SPF·DMARC)
답글 남기기