사내 애플리케이션은 대개 메일 서버에 직접 붙지 않고 사내 SMTP 릴레이 서버를 거칩니다. 릴레이는 사내망 안에서만 열려 있어 인증 없이 25번 포트로 받아 주는 경우가 많습니다. 외부로 나가는 구간(465·587)은 STARTTLS 와 인증을 요구하지만, 폐쇄망 내부 구간은 정책이 훨씬 느슨합니다.
포트별 관례는 다음과 같습니다.
| 포트 | 용도 | 비고 |
|---|---|---|
| 25 | 서버 간 릴레이 | 사내망 내부, 대개 인증 없음 |
| 465 | SMTPS | 연결 시작부터 TLS |
| 587 | 제출용(submission) | STARTTLS + 인증 필수 |
이 레슨의 SmtpClient 는 폐쇄망 사내 릴레이를 흉내 내어 TLS·인증 없이 평문 소켓으로 25번 포트 대화를 구현합니다. 운영에서 외부로 나가는 메일을 직접 짤 때는 javax.net.ssl.SSLSocket 이나 STARTTLS 처리가 추가로 필요합니다.
SMTP 는 클라이언트가 명령을 보내고 서버가 한 줄 응답하는 단순한 텍스트 프로토콜입니다. 순서는 항상 같습니다.
EHLO localhost -> 250
MAIL FROM:<보내는사람> -> 250
RCPT TO:<받는사람> -> 250 / 4xx / 5xx
DATA -> 354
(메일 본문, 마지막 줄 ".")
-> 250
QUIT -> 221응답 코드의 첫 자리가 의미를 결정합니다.
| 코드대 | 의미 | 애플리케이션 대응 |
|---|---|---|
| 2xx | 성공 | 다음 단계 진행 |
| 4xx | 일시 실패 | 재시도 대상, 원인 예 큐 가득참 |
| 5xx | 영구 실패 | 즉시 중단, 재시도해도 항상 실패 |
RCPT TO 단계에서 받는 4xx·5xx 를 구분하지 않고 똑같이 재시도하면, 존재하지 않는 주소(5xx)에 계속 헛발질하며 로그만 쌓입니다. 이 레슨의 예제 서버는 fail-temp@ 수신자에 451, fail-perm@ 수신자에 550 을 돌려주어 두 경로를 재현합니다.
메일 본문은 헤더와 본문을 빈 줄로 구분한 텍스트입니다. 헤더의 Content-Type 이 본문 형식을 정합니다.
| 헤더 | 값 | 의미 |
|---|---|---|
MIME-Version |
1.0 |
MIME 메일임을 표시 |
Content-Type |
multipart/mixed; boundary="..." |
본문+첨부 묶음 |
Content-Transfer-Encoding |
base64 |
8비트 데이터를 안전하게 전송 |
multipart/mixed 는 --경계문자열 로 파트를 구분합니다. 각 파트는 자신만의 Content-Type 을 갖고, 텍스트 파트는 text/plain, 첨부는 application/octet-stream 을 씁니다. 경계문자열은 본문에 절대 나오지 않을 문자열이어야 하므로, 이 레슨은 System.nanoTime() 을 붙여 만듭니다.
한글 제목은 그대로 넣으면 깨집니다. RFC 2047 은 =?문자셋?인코딩?데이터?= 형식을 정의합니다.
Subject: =?UTF-8?B?OeyblCDsoJXsgrAg66as7Y+s7Yq4?=B 는 base64, Q 는 quoted-printable 을 뜻합니다. 이 레슨은 구현이 간단한 base64(B)를 씁니다.
정산 리포트 같은 정형 메일은 문자열 템플릿에 값만 치환해서 만듭니다. Java 텍스트 블록(""")에 %s 자리표시자를 두고 String.format 으로 채우면 충분합니다. 별도 템플릿 엔진은 메일 본문 정도 규모에서는 과합니다.
HTML 메일은 Content-Type: text/html 로 보내지만 인라인 CSS 만 지원하는 메일 클라이언트가 많아 <style> 블록은 무시될 수 있습니다. 이미지도 외부 URL 참조는 폐쇄망 수신자 환경에서 차단되므로, 반드시 필요하면 Content-ID 로 첨부해 인라인 참조(cid:)해야 합니다.
첨부는 파일을 base64 로 인코딩해 별도 MIME 파트에 담고, Content-Disposition: attachment; filename="..." 로 파일명을 지정합니다. 한글 파일명 인코딩은 14. 문자 인코딩 레슨의 RFC 5987·URL 인코딩 규칙을 그대로 따릅니다.
첨부 크기는 릴레이 서버와 수신 서버 양쪽에 한도가 있습니다. 사내 릴레이가 보통 10~25MB 를 넘기면 5xx 로 거절하므로, 큰 파일은 첨부 대신 사내 공유 스토리지 링크를 본문에 넣는 편이 안전합니다.
4xx 는 즉시 포기하지 않고 지수 백오프로 재시도합니다. 재시도 횟수와 간격 설계는 11. 외부 API 연동 레슨의 백오프 전략과 같습니다. 이 레슨 예제는 10ms·20ms·40ms 세 번만 재시도합니다.
5xx 는 재시도해도 결과가 같으므로 즉시 중단하고 실패로 기록합니다. 발송 실패를 로그에만 남기면 아무도 못 보고 지나가므로, 실패 건수를 별도 테이블이나 모니터링 지표로 쌓아 운영자가 확인할 수 있게 합니다.
배치 작업은 중간에 죽었다가 재실행되는 일이 흔합니다. 이미 보낸 메일을 또 보내지 않으려면 발송 이력 테이블에 멱등키를 남기고, 발송 전에 먼저 조회합니다.
멱등키는 "무엇을 언제 누구에게" 를 유일하게 식별하는 문자열이 좋습니다. 예를 들어 배치종류:기준일:수신자 조합입니다. Oracle 이라면 이 키에 유니크 제약을 걸어 두면 애플리케이션 로직이 실수해도 DB 가 중복 삽입을 막아 줍니다.
수백~수천 명에게 보낼 때는 요청을 처리하는 스레드에서 직접 보내면 안 됩니다. 사용자는 화면 응답을 기다리는데 메일 발송이 몇 초씩 걸리면 타임아웃이 납니다. 별도 스레드나 큐로 넘기는 방식은 16. 비동기 처리 레슨의 @Async·스레드 풀 패턴을 그대로 씁니다.
릴레이 서버도 초당 처리량 한도가 있어 한꺼번에 쏟아부으면 4xx 를 받기 시작합니다. 수신자를 100~200명 단위로 묶고, 묶음 사이에 짧게 쉬어 릴레이 부하를 분산합니다.
메일 본문에 주민등록번호나 계좌번호 전체를 그대로 적으면 안 됩니다. 마지막 네 자리만 남기고 마스킹하거나, 상세 내용은 사내 시스템 링크로 유도합니다. 첨부 파일 안 데이터도 같은 기준을 적용합니다.
로그에 수신자 이메일 전체를 남길지는 회사 개인정보 정책을 따릅니다. 남겨야 감사 추적이 되지만, 로그가 외부로 유출되면 개인정보 유출이 됩니다. 감사가 필요한 발송은 별도 이력 테이블에 남기고 애플리케이션 로그에는 마스킹된 값만 남기는 절충이 흔합니다.
참조·숨은참조도 구분해서 씁니다. To 는 업무상 받는 사람, Bcc 는 받는 사람끼리 서로의 주소를 몰라야 하는 대량 발송에 씁니다. 대량 발송에 To 를 여러 명 나열하면 모든 수신자의 메일 주소가 서로에게 노출됩니다.
장애 알림은 메일만으로는 늦게 확인될 수 있어 사내 메신저 웹훅을 함께 씁니다. 웹훅은 대개 HTTP POST 로 JSON 바디를 정해진 URL 에 보내는 단순한 구조입니다.
{"text": "주문 배치 타임아웃 발생"}문제는 장애 하나가 반복 감지되면 같은 알림이 수백 번 쏟아진다는 점입니다. 담당자가 못 견디고 채널을 음소거하면 정작 새로운 장애를 놓칩니다. 대응은 같은 오류 키에 대해 일정 시간(예 5분)에 한 번만 보내는 억제 창(suppression window)입니다.
이 레슨의 notifyAlert 는 Map<String, Long> 에 오류 키별 마지막 발송 시각을 기록하고, 창 안에 들어오면 건너뜁니다. 운영 규모가 커지면 이 맵은 Redis 같은 공유 저장소로 옮겨 여러 인스턴스가 같은 억제 상태를 공유해야 합니다.
메일 발송 코드는 실제 메일 서버 없이 테스트할 수 있어야 합니다. MailHog·smtp4dev 같은 도구는 로컬에 가짜 SMTP 서버를 띄우고 웹 화면으로 받은 메일을 보여 줍니다. Spring Boot 는 spring.mail.host 를 로컬 주소로 바꾸기만 하면 운영 코드 변경 없이 이 도구들을 씁니다.
이 레슨의 FakeSmtpServer 는 MailHog 의 최소 기능만 흉내 낸 버전입니다. 소켓으로 SMTP 응답 코드를 돌려주고 받은 원문을 리스트에 저장해, 테스트 코드가 "제목이 제대로 인코딩됐는지", "재시도가 실제로 일어났는지" 를 검증합니다.
| Spring 요소 | 역할 |
|---|---|
spring.mail.host / port |
SMTP 서버 주소·포트 |
spring.mail.username / password |
인증 정보(필요한 경우) |
spring.mail.properties.mail.smtp.starttls.enable |
STARTTLS 사용 여부 |
JavaMailSender |
메일 발송 인터페이스 |
MimeMessageHelper |
제목·본문·첨부 조립 도우미 |
@Async |
요청 스레드 밖에서 발송 |
폐쇄망 반입은 jakarta.mail(구 javax.mail) jar 와 그 의존성을 사내 Nexus 나 오프라인 저장소에 등록하는 절차가 필요합니다. MimeMessageHelper 를 쓰면 이 레슨에서 손으로 만든 MIME 조립·RFC 2047 인코딩을 라이브러리가 대신해 줍니다.