문의 폼 이메일이 도착하지 않는 원인과 해결 방법

· · 이메일 발송, SPF, DKIM, DMARC, 문의 경로 개선, B2B

요약

문의 폼 알림 메일이 “발송은 성공했는데 도착하지 않는” 가장 큰 원인은 SMTP 연결 자체가 아니라, 눈에 보이는 발신자(From:)와 실제로 인증되는 발신자(SPF의 MAIL FROM / DKIM의 d=)가 맞지 않는 것입니다. DMARC는 RFC5322.From을 기준으로, SPF나 DKIM 중 하나라도 얼라인먼트(alignment)까지 포함해서 통과하고 있는지를 봅니다. 즉, 폼 발신자의 Gmail이나 회사 주소를 그대로 From:에 넣는 설계는 답장하기 쉬워 보여도 실제 운영에서는 실패하기 쉬운 설계입니다.

실무상의 결론은 단순합니다. 문의 알림 메일의 From:은 자사 도메인으로 고정하고, 폼 이용자의 주소는 Reply-To:에 넣는 것이 기본입니다. Sender:는 “작성자와 실제 발송 주체가 다를” 때만 사용하며, Return-Path는 헤더를 직접 작성하는 것이 아니라 MTA나 메일 서비스 쪽에서 envelope 발신자로 설정합니다.

또한 전달(forwarding)은 SPF를 쉽게 깨뜨리고, 메일링 리스트나 중계 서버에서 본문이나 헤더가 변경되면 DKIM도 깨집니다. 그래서 문의 폼에서는 SPF에만 의존하지 말고 DKIM을 반드시 활성화하며, DMARC는 먼저 p=none으로 관측한 다음 quarantine / reject로 진행하는 것이 안전합니다.

나아가 Google은 Gmail 수신 메일에 대해 발신 도메인에 SPF 또는 DKIM을 설정할 것, 일정 규모 이상의 발송에서는 SPF·DKIM·DMARC를 모두 갖출 것, 그리고 From: 헤더의 도메인이 SPF 또는 DKIM으로 인증된 도메인과 일치할 것을 요구하고 있습니다. 발송 문제의 본질은 대개 “메일 코드”가 아니라 “발신자 설계”에 있습니다.

SPF・DKIM・DMARC와 From 헤더의 역할

메일 전송에서는 envelope헤더를 구분해서 생각해야 합니다. SPF가 주로 검증하는 것은 SMTP 세션 중의 MAIL FROM으로, 이것은 전송상의 발신자입니다. 최종 전달 시점에는 그 반환 경로가 Return-Path로 단 하나만 남아야 하며, 발신측 SMTP 시스템은 처음부터 Return-Path 헤더를 가진 메시지를 만들어서는 안 됩니다. 한편 From:은 본문 헤더 쪽에서 “누구에게서 온 메일로 보이는가”를 나타내고, Reply-To:는 답장 주소, Sender:는 실제로 발송한 주체를 나타냅니다.

RFC 5322는 From:메시지의 작성자, Sender:실제 발송 주체로 정의하고 있습니다. 작성자와 발송 주체가 같다면 Sender:는 사용하지 않아야 하며, 답장 주소를 작성자와 다르게 하고 싶을 때는 Reply-To:를 쓰는 것이 올바른 방식입니다. 게다가 RFC 5322는 From:에 작성자가 아닌 주소를 넣어서는 안 된다고 명시하고 있습니다. 문의 폼 알림은 보통 사용자 본인이 MUA에서 보내는 것이 아니라 사이트 쪽 시스템이 만드는 알림이므로, 이용자 주소를 From:에 넣는 설계는 사양의 취지와도 어긋나기 쉽습니다.

DKIM은 메시지의 일부 헤더와 본문에 서명을 붙이고, 수신측이 DNS에 있는 공개 키로 검증합니다. 서명 도메인은 DKIM-Signatured=로 나타나며, 공개 키는 selector._domainkey.example.com처럼 셀렉터로 조회합니다. DKIM은 “전달되어도 비교적 잘 살아남는” 인증으로 활용되지만, 본문이나 서명 대상 헤더가 중간에 변경되면 bh=의 본문 해시나 서명 검증이 실패합니다.

DMARC는 SPF나 DKIM의 성공 여부만이 아니라, 그 인증된 도메인이 From: 도메인과 일치하는지를 봅니다. 이 부분이 핵심입니다. SPF가 통과하더라도 MAIL FROM=bounces.vendor.net이고 From: contact@example.com이라면 SPF의 얼라인먼트는 실패합니다. DKIM이 d=example.com으로 통과하면 DMARC는 pass할 수 있지만, DKIM도 없으면 DMARC는 실패합니다. DMARC에는 adkim / aspf에 의한 strict / relaxed 얼라인먼트 모드, p=none|quarantine|reject의 정책, rua / ruf의 리포트 수신 주소가 정의되어 있습니다.

Gmail의 최근 가이드라인은 이러한 설계 사상을 그대로 운영 요건으로 옮겨 놓았습니다. Google은 “From: 헤더를 위장하지 말 것”, “다이렉트 메일에서는 From: 헤더 내 도메인이 SPF 도메인 또는 DKIM 도메인과 일치할 것”을 명시하고 있습니다. 문의 폼 알림은 마케팅 메일이 아니지만, 수신측 필터의 기본 로직은 동일하므로 이 사고방식을 무시하면 전달률이 떨어집니다.

수신측 MXDNS발신 MTA / SMTP 서비스웹 애플리케이션폼 이용자수신측 MXDNS발신 MTA / SMTP 서비스웹 애플리케이션폼 이용자폼 제출From / Reply-To / Sender 결정SMTP 발송 요청DKIM 서명 부여MAIL FROM / RCPT TO / DATASPF 조회(MAIL FROM)DKIM 공개키 조회(selector._domainkey)DMARC 조회(_dmarc + From 도메인)Alignment 판정수신・스팸함・거부・바운스

이 흐름에서 중요한 것은 DMARC의 판정 기준이 끝까지 From: 도메인 중심이라는 점입니다. 애플리케이션 쪽에서는 From:, SMTP 쪽에서는 MAIL FROM, DNS 쪽에서는 SPF/DKIM/DMARC를 각각 따로 다루기 때문에, 어느 하나만 고쳐서는 미도착 문제가 해결되지 않습니다.

문의 폼에서 자주 발생하는 실패 시나리오

가장 많은 것은 폼 이용자의 주소를 From:에 넣어버리는 경우입니다. 예를 들어 사이트의 SMTP에서 발송하면서 From: taro@gmail.com으로 설정하면, SPF나 DKIM이 통과하는 것은 보통 사이트 쪽 도메인이지 Gmail 쪽 도메인이 아닙니다. 그 결과 From:은 Gmail, 인증된 도메인은 example.com이라는 불일치가 생겨 DMARC 얼라인먼트에 실패합니다. Google 스스로도 From: 위장 방지와 From:과 SPF/DKIM 도메인의 일치를 요구하고 있습니다.

다음으로 많은 것은 전달(forwarding)로 SPF가 깨지는 경우입니다. 메일 전달에서는 최종 수신자 입장에서 본 발신 IP가 “원래 발신 도메인의 SPF에 등록되지 않은 중계 서버”가 되기 쉬워서, 정상적인 메일이라도 SPF가 실패합니다. Google도 “전달된 메일은 SPF에 실패하기 쉬우므로 DKIM을 반드시 사용해야 한다”고 안내하고 있습니다. 게다가 중계지에서 본문이나 제목 접두사, 푸터 추가 등을 하면 DKIM까지 깨집니다.

외부 SMTP 서비스 이용 시 초기 설정 부족도 전형적인 사례입니다. SendGrid에서는 Domain Authentication을 설정하지 않으면 발송이 안 되는 경우가 있으며, Automated Security를 켜면 CNAME 기반으로 인증 레코드가 생성되고, 필요에 따라 Custom Return Path나 Custom DKIM Selector를 설정할 수 있습니다. SES에서는 기본적으로 amazonses.com의 MAIL FROM이 사용되어 SPF는 암묵적으로 성립하지만, 사이트 도메인과의 SPF 얼라인먼트를 맞추고 싶다면 커스텀 MAIL FROM을 설정해야 합니다. Mailgun에서도 발신 도메인의 SPF/DKIM과 필요한 MX가 설정되어 있지 않으면 올바른 발신 서명이 성립하지 않습니다.

공유 호스팅의 로컬 MTA는 놓치기 쉬운 지뢰입니다. 자사 사이트가 인증되어 있어도, 실제로 외부로 나가는 IP가 공유 IP라서 레퓨테이션이 나쁘거나, PTR이 없거나, 호스팅 쪽에서 DKIM을 지원하지 않으면 전달률이 떨어집니다. Google은 발신 IP의 PTR을 요건화하고 있으며, 공유 IP의 평판이 나쁘면 5.7.1 계열 오류의 원인이 될 수 있다고 안내하고 있습니다.

PHP의 mail()이나 발송 라이브러리 사용법도 실패하기 쉬운 부분입니다. PHP 매뉴얼은 mail()From 헤더가 필요하다는 것, 추가 파라미터로 sendmail -f를 통한 envelope 발신자 지정이 가능하다는 것을 설명하고 있습니다. 즉, Return-Path: 헤더를 직접 작성하는 것이 아니라 envelope 발신자를 MTA에 전달하는 것이 올바른 방식입니다. 이 부분을 오해하면 SPF 판정에 사용되는 발신자와 애플리케이션이 가정하는 발신자가 어긋나게 됩니다.

마지막으로 Reply-To를 써야 할 상황에서 SenderFrom을 건드리는 경우가 있습니다. 답장 주소를 이용자에게 향하게 하고 싶을 뿐이라면 Reply-To로 충분합니다. Sender는 “작성자와 실제 발송 주체가 다르다”는 것을 명시하고 싶을 때 사용하는 헤더로, 문의 폼에서 상시로 쓸 것은 아닙니다. 설계의 목적이 “답장을 이용자에게 돌려주고 싶다”인지, “알림의 책임 주체를 표시하고 싶다”인지를 구분해서 생각하면 사고를 줄일 수 있습니다.

진단 절차와 명령어

먼저 해야 할 일은 코드를 보기 전에 실제 메일 헤더를 보는 것입니다. Gmail에서는 “메시지 원본 보기”, Outlook에서는 “메시지 세부 정보” 또는 “인터넷 헤더”에서 Authentication-Results, Return-Path, From, Reply-To, DKIM-Signature, Received를 확인할 수 있습니다. 이것만 봐도 문제가 “발송이 안 되는 것”인지 “인증과 정합성이 깨진 것”인지를 상당히 정확하게 구분할 수 있습니다.

헤더에서 가장 먼저 볼 포인트

최우선으로 볼 것은 다음 5가지입니다.

  1. From:의 도메인은 무엇인가
  2. Return-Path:의 도메인은 무엇인가
  3. Authentication-Results:에서 spf=pass / dkim=pass / dmarc=pass가 나오는가
  4. dkim=pass일 때 header.i= 또는 d=가 어느 도메인인가
  5. dmarc=fail일 때 그 이유가 인증 실패인지 alignment failure인지

Google의 DMARC 트러블슈팅에도 메시지가 다른 인증에는 통과하더라도 헤더가 얼라인되어 있지 않으면 DMARC에 실패한다고 명시되어 있습니다.

DNS를 확인하는 명령어

dig는 DNS 트러블슈팅용 대표적인 도구로, BIND 매뉴얼에서도 유연하고 명확한 출력을 가진 DNS 조회 도구로 설명되어 있습니다. nslookup은 비대화형 모드에서도 사용할 수 있는, 더 가벼운 확인용 명령어입니다. 문의 폼의 인증 확인에서는 최소한 SPF, DKIM, DMARC 세 가지를 조회합니다.

# SPF
dig +short TXT example.com

# DKIM
dig +short TXT form2026._domainkey.example.com

# DMARC
dig +short TXT _dmarc.example.com

# Windows라면
nslookup -type=txt example.com
nslookup -type=txt _dmarc.example.com
nslookup -type=txt form2026._domainkey.example.com

예상되는 출력 예시는 다음과 같은 형태입니다.

"v=spf1 include:sendgrid.net include:mailgun.org ip4:203.0.113.10 -all"
"v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ..."
"v=DMARC1; p=none; rua=mailto:dmarc-agg@example.com; ruf=mailto:dmarc-afrf@example.com; adkim=r; aspf=r; fo=1"

여기서 봐야 할 것은 SPF 레코드가 하나로 정리되어 있는지, DKIM 공개 키가 조회되는지, DMARC에 p=가 있는지입니다. SPF는 DNS 조회를 발생시키는 메커니즘을 합계 10개까지로 제한해야 한다고 RFC 7208이 정하고 있으며, 이를 넘으면 permerror의 원인이 됩니다.

SMTP 연결과 TLS 확인

openssl s_client는 OpenSSL의 범용 SSL/TLS 클라이언트로, SMTP 서버의 STARTTLS나 인증서 체인 확인에 편리합니다. -starttls smtp로 SMTP의 STARTTLS를 시작하고, -showcerts로 서버가 반환한 인증서 목록을 확인할 수 있습니다.

printf 'QUIT\r\n' | openssl s_client \
  -connect smtp.example.com:587 \
  -starttls smtp \
  -servername smtp.example.com \
  -showcerts \
  -brief

출력 예시입니다.

CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Verification: OK
250 CHUNKING

이것으로 최소한 SMTP 서버에 연결할 수 있는지, STARTTLS가 활성화되어 있는지, 인증서 검증에서 명백한 이상이 없는지를 확인할 수 있습니다. Google은 일정 규모 이상의 발송자에게 TLS를 요구하고 있으며, TLS 미사용은 5.7.29의 원인이 될 수 있습니다.

실제 발송 재현 테스트

swaks는 SMTP 테스트 전용 실전 도구로, TLS, 인증, SMTP 확장을 포함한 발송 테스트를 유연하게 재현할 수 있습니다. 문의 폼 미도착 조사에서는 “애플리케이션을 거치지 않고, 같은 SMTP / 같은 From / 같은 Reply-To / 같은 수신처로” 한 통만 보내서 재현하는 것이 효과적입니다.

swaks \
  --server smtp.example.com \
  --port 587 \
  --tls \
  --auth LOGIN \
  --auth-user contact@example.com \
  --auth-password '********' \
  --from bounce@example.com \
  --to yourtest@gmail.com \
  --h-From "사이트 알림 <contact@example.com>" \
  --h-Reply-To "홍길동 <visitor@gmail.com>" \
  --header "Subject: swaks test" \
  --body "This is a test"

발송 성공 시 전형적인 모습은 다음과 같습니다.

=== Trying smtp.example.com:587...
=== Connected to smtp.example.com.
<-  250-STARTTLS
<-  250-AUTH LOGIN PLAIN
->  STARTTLS
<-  220 Ready to start TLS
...
<-  250 2.0.0 Ok: queued as ABC123DEF

애플리케이션 경유로는 실패하고 swaks로는 통과한다면, 원인은 라이브러리의 헤더 구성이나 envelope 발신자 설정에 있을 가능성이 큽니다. 반대로 swaks로도 마찬가지로 실패한다면 DNS・SMTP・수신측 정책 문제로 좁혀집니다.

mail-tester로 외형 진단하기

mail-tester는 무작위로 발급된 테스트용 주소로 메일을 보내게 하고, 그 메시지와 발신 서버, 발신 IP를 분석해 상세 리포트를 반환하는 서비스입니다. 로컬 MTA나 공유 서버에서 “왠지 도착하지 않는” 경우의 1차 진단에 적합합니다.

사용법은 단순합니다.

  1. mail-tester에서 발급된 테스트 주소를 가져온다
  2. 문의 폼과 같은 경로로 한 통을 보낸다
  3. 점수와 SPF / DKIM / DMARC / 역방향 조회 / 블랙리스트 / 본문 구성에 대한 지적을 확인한다

mail-tester의 점수만으로 실제 발송을 판단해서는 안 되지만, 적어도 “SPF가 아예 보이지 않는다”, “DKIM 공개 키가 조회되지 않는다”, “본문이나 발신자 구성이 부자연스럽다” 등은 빨리 찾아낼 수 있습니다.

헤더 분석 샘플

실패 예시입니다.

Return-Path: <bounce-123@vendor.example.net>
Authentication-Results: mx.google.com;
       spf=pass (google.com: domain of bounce-123@vendor.example.net designates 198.51.100.10 as permitted sender) smtp.mailfrom=vendor.example.net;
       dkim=none;
       dmarc=fail (p=quarantine sp=quarantine dis=none) header.from=gmail.com
From: 홍길동 <visitor@gmail.com>
Reply-To: 홍길동 <visitor@gmail.com>
Subject: 문의

이 메일은 SPF 자체는 통과했지만 From:gmail.com이기 때문에 DMARC는 실패합니다. 문의 폼에서 이용자 주소를 From:에 넣은 전형적인 실패 사례입니다.

성공 예시는 다음과 같습니다.

Return-Path: <bounce@example.com>
Authentication-Results: mx.google.com;
       spf=pass smtp.mailfrom=example.com;
       dkim=pass header.i=@example.com header.s=form2026;
       dmarc=pass header.from=example.com
From: Example Site <contact@example.com>
Reply-To: 홍길동 <visitor@gmail.com>
Subject: 문의 알림

이 형태라면 이용자에게 답장하기 쉬운 편의성은 Reply-To:로 확보하면서도, 눈에 보이는 발신자와 인증된 발신자가 모두 example.com으로 일치하기 때문에 전달률이 크게 안정됩니다.

권장하는 From 설계 패턴

문의 폼에서 흔들리지 않는 원칙은 “인증에 사용하는 도메인”과 “수신자에게 보여주는 From 도메인”을 일치시키는 것입니다. 그 위에서 답장 주소만 Reply-To:로 분리합니다. Sender:는 필요한 경우에만, Return-Path는 envelope으로 설정한다는 3층 구조로 나누면 설계가 안정됩니다.

패턴 헤더 예시 적합한 경우 장점 주의점
권장 패턴 From: contact@example.com
Reply-To: visitor@gmail.com
Return-Path: bounce@example.com
거의 모든 문의 폼 DMARC를 통과하기 쉬움/답장하기 쉬움/구현이 단순함 Reply-To를 잊으면 답장 주소가 사이트 쪽이 됨
서브도메인 분리 From: contact@form.example.com
Reply-To: visitor@gmail.com
Return-Path: bounce.form.example.com
폼 알림을 본체 메일과 분리하고 싶은 경우 레퓨테이션 분리가 쉬움/관리하기 쉬움 SPF/DKIM/DMARC를 서브도메인 쪽에서도 갖춰야 함
Sender 명시형 From: contact@example.com
Sender: mailer@example.com
Reply-To: visitor@gmail.com
발송 주체를 명시하고 싶은 특수 요건 운영 책임 주체를 보여줄 수 있음 보통은 불필요함. 작성자와 발송 주체가 같으면 중복
비권장 패턴 From: visitor@gmail.com
Reply-To: visitor@gmail.com
답장 주소를 눈에 띄게 하고 싶을 뿐인 구현 보기에는 자연스러움 DMARC 실패의 원인이 되기 쉬움. 문의 알림에서는 피해야 함

이 표의 근거는 RFC 5322의 From / Sender / Reply-To 의미 규정과 RFC 5321의 Return-Path 처리, 그리고 DMARC가 From: 기준으로 정합성을 판정한다는 사양입니다. 폼 알림의 기본값은 첫 번째 줄의 “권장 패턴”으로 충분합니다. From:에 이용자 주소를 넣고 싶어지는 상황에서도, 답장 주소를 Reply-To:에 넣으면 목적을 달성할 수 있습니다.

특히 기억해 두어야 할 것은, Return-Path는 “편집하는 헤더”가 아니라 “전송에 사용되는 envelope 발신자의 결과”라는 점입니다. PHP mail()이라면 -f, SMTP 서비스라면 Custom MAIL FROM / Return Path / bounce domain 같은 설정 항목으로 다루는 것이 올바른 구현입니다.

구성별 설정 가이드

여기서부터는 현장에서 흔한 3가지 패턴별로 설정 방식을 정리합니다. 전제로, DNS에 넣을 정확한 값은 각 서비스의 관리 화면에서 제공하는 값을 우선하시기 바랍니다. 아래의 레코드 예시는 구조를 이해하기 위한 대표 예시입니다.

사이트가 외부 SMTP를 사용하는 경우

외부 SMTP에서 가장 중요한 점은 자사 도메인의 인증을 먼저 마치는 것입니다. Google도 메일 서비스 제공업체를 사용할 경우, 그 서비스가 자사 도메인의 SPF와 DKIM을 인증하고 있는지 확인하도록 안내하고 있습니다.

권장 구성

  • From:contact@example.com 또는 contact@form.example.com
  • Reply-To:는 폼 이용자 주소
  • Return-Path / MAIL FROM은 bounce.example.com자신이 관리하는 바운스용 서브도메인
  • DKIM은 example.com 또는 발송용 서브도메인에서 서명
  • DMARC는 눈에 보이는 From: 도메인에 설정

SendGrid의 일반적인 설정

SendGrid에서는 Domain Authentication이 전제이며, Automated Security를 켜면 3개의 CNAME이 생성됩니다. 끄면 1개의 MX와 2개의 TXT가 생성되고, Custom Return Path나 Custom DKIM Selector도 설정할 수 있습니다.

; 예: SendGrid(실제 값은 관리 화면에서 생성된 값을 사용)
em123.example.com.           CNAME   u123456.wl.sendgrid.net.
s1._domainkey.example.com.   CNAME   s1.domainkey.u123456.wl.sendgrid.net.
s2._domainkey.example.com.   CNAME   s2.domainkey.u123456.wl.sendgrid.net.

_dmarc.example.com.          TXT     "v=DMARC1; p=none; rua=mailto:dmarc-agg@example.com"

SendGrid에서 흔히 걸려드는 것은 “SMTP 인증만 마치고 도메인 인증은 미설정”인 상태입니다. 이 상태에서는 발송 자체는 가능하지만, 수신측에서 보면 From:과 인증된 도메인의 관계가 약해집니다. Domain Authentication을 통과시키고, 그 위에서 필요에 따라 Custom Return Path를 설정하는 것이 기본입니다.

SES의 일반적인 설정

SES는 기본적으로 amazonses.com 서브도메인의 MAIL FROM을 사용하므로 SPF 자체는 암묵적으로 성립합니다. 다만 사이트 도메인과 SPF 얼라인먼트를 맞추고 싶다면 커스텀 MAIL FROM을 사용합니다. 이때 SES는 커스텀 MAIL FROM 도메인에 SPF TXT와 MX를 요구하며, MX는 정확히 1개여야 합니다. 또한 Easy DKIM에서는 3개의 CNAME을 DNS에 추가합니다.

; 예: SES Easy DKIM
abcde12345._domainkey.example.com.   CNAME   abcde12345.dkim.amazonses.com.
fghij67890._domainkey.example.com.   CNAME   fghij67890.dkim.amazonses.com.
klmno54321._domainkey.example.com.   CNAME   klmno54321.dkim.amazonses.com.

; 예: SES custom MAIL FROM
bounce.example.com.                  MX      10 feedback-smtp.ap-northeast-1.amazonses.com.
bounce.example.com.                  TXT     "v=spf1 include:amazonses.com -all"

_dmarc.example.com.                  TXT     "v=DMARC1; p=none; rua=mailto:dmarc-agg@example.com"

SES 설계에서 중요한 것은 MAIL FROM용 도메인은 발송용 From: 도메인 그 자체가 아니라, 바운스 전용 서브도메인으로 하는 것입니다. AWS도 MAIL FROM은 실제로 메일을 보내는 도메인 자체가 아닌 서브도메인으로 하도록 안내하고 있습니다.

Mailgun의 일반적인 설정

Mailgun에서는 발신 도메인 인증 시 SPF용 TXT와 DKIM용 TXT가 필요하고, 추가로 2개의 MX를 추가합니다. 이미 SPF가 있는 경우에는 새로운 SPF 레코드를 추가하는 대신 기존 레코드에 include:mailgun.org를 끼워 넣습니다. DKIM 키가 여러 개 보일 수 있지만, 현재 사용하는 키가 DNS에 올바르게 등록되어 있으면 발송은 가능합니다.

; 예: Mailgun을 서브도메인으로 사용
mg.example.com.                      TXT     "v=spf1 include:mailgun.org ~all"
mx._domainkey.mg.example.com.        TXT     "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ..."
mg.example.com.                      MX      10 mxa.mailgun.org.
mg.example.com.                      MX      10 mxb.mailgun.org.
email.mg.example.com.                CNAME   mailgun.org.

_dmarc.example.com.                  TXT     "v=DMARC1; p=none; rua=mailto:dmarc-agg@example.com"

Mailgun은 서브도메인 운영과 잘 맞기 때문에, mg.example.com처럼 전용 발송 서브도메인을 만들면 폼 알림이나 트랜잭션 알림의 관리가 쉬워집니다.

사이트가 공유 호스팅의 로컬 MTA로 발송하는 경우

공유 호스팅에서 가장 먼저 봐야 할 것은 자신의 애플리케이션이 아니라 호스팅 쪽 발송 기반의 품질입니다. 발신 IP의 PTR, DKIM 지원, 공유 IP의 평판, 발송 로그의 가시성이 약하다면 이 구성은 그것만으로도 불리합니다. Google도 발신 IP의 PTR을 중시하고 있으며, 공유 IP의 평판이 나쁘면 차단 원인이 될 수 있다고 안내하고 있습니다.

실무적으로는 다음 순서로 진행하는 것이 안전합니다.

  1. 호스팅 회사가 SPF/DKIM/PTR을 관리 화면이나 지원을 통해 설정 가능한지 확인한다
  2. From:은 반드시 자사 도메인으로 고정한다
  3. SPF에 호스팅의 발신 IP나 허용된 발신 도메인을 포함시킨다
  4. DKIM을 호스팅 기능으로 활성화한다. 없다면 외부 SMTP로 전환한다
  5. 가능하다면 MAIL FROM용 바운스 주소를 bounce.example.com처럼 분리한다

공유 호스팅에서 직접 DKIM을 만드는 경우의 전형적인 예로, OpenDKIM 계열에서는 opendkim-genkey개인 키와 DNS용 TXT 레코드를 생성할 수 있습니다. DKIM의 공개 키를 셀렉터가 붙은 selector._domainkey.example.com에 두는 구조는 RFC 6376과 일치합니다.

mkdir -p /etc/opendkim/keys/example.com
opendkim-genkey -D /etc/opendkim/keys/example.com -d example.com -s form2026
chown opendkim:opendkim /etc/opendkim/keys/example.com/form2026.private
chmod 600 /etc/opendkim/keys/example.com/form2026.private

생성 후의 이미지는 다음과 같습니다.

form2026._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ..."
# /etc/opendkim/KeyTable
form2026._domainkey.example.com example.com:form2026:/etc/opendkim/keys/example.com/form2026.private

# /etc/opendkim/SigningTable
*@example.com form2026._domainkey.example.com

다만, 공유 호스팅에서 PTR이나 outbound relay를 직접 제어할 수 없다면 외부 SMTP로 옮기는 것이 지름길입니다. 문의 폼 알림처럼 적은 양의 발송이라도, 인증이 약한 로컬 MTA는 Gmail이나 기업 메일에 불리합니다.

사이트가 PHP mail() 또는 SMTP 라이브러리를 사용하는 경우

PHP mail()은 편리하지만, 인증과 전달률 관점에서는 “그 뒤의 MTA가 무엇인가“에 좌우됩니다. PHP 매뉴얼은 메일에는 From 헤더가 필요하며, sendmail_path를 통한 발송에서는 추가 파라미터로 envelope 발신자를 지정할 수 있다고 설명하고 있습니다. 반대로 말하면 mail()을 쓴다고 해서 SPF/DKIM/DMARC가 자동으로 갖춰지는 것은 아닙니다.

먼저 최소한 다음과 같이 설계합니다.

  • From:contact@example.com
  • Reply-To:는 폼 이용자
  • envelope 발신자는 bounce@example.com
  • 본문에도 이용자의 주소를 명기한다
  • 가능하다면 mail()이 아니라 인증된 SMTP를 사용한다

mail()의 최소 구성 예시

헤더에 이용자 입력을 그대로 넣는 것은 위험합니다. $name이나 $email에 CR/LF가 섞이면 공격자가 Bcc: 같은 추가 헤더를 끼워 넣을 수 있어, 폼이 스팸 중계기가 될 수 있습니다. PHP 매뉴얼도 헤더에 사용하는 외부 입력은 반드시 검증/정규화하도록 안내하고 있습니다. 아래 예시에서는 envelope 발신자 인자(additional_params)를 포함해, 헤더에 섞일 값은 사전에 sanitize합니다.

<?php

// 헤더에 사용해도 되는 값만 반환한다. CR/LF/NUL이 포함되면 거부한다.
function sanitize_header_value(string $value): string {
    if (preg_match('/[\r\n\0]/', $value)) {
        throw new InvalidArgumentException('Invalid characters in header value');
    }
    return trim($value);
}

// 이메일 주소를 RFC에 맞게 검증한다.
function sanitize_email(string $email): string {
    $clean = sanitize_header_value($email);
    if (!filter_var($clean, FILTER_VALIDATE_EMAIL)) {
        throw new InvalidArgumentException('Invalid email address');
    }
    return $clean;
}

$to = 'ops@example.com';
$subject = sanitize_header_value('문의 알림');

// $name / $email / $message는 폼 입력. $message는 본문용이므로 CR/LF를 허용하지만,
// 헤더에 사용하는 $name / $email은 CR/LF를 반드시 거부한다.
$safeName  = sanitize_header_value($name);
$safeEmail = sanitize_email($email);

$body = <<<TEXT
Name: {$safeName}
Email: {$safeEmail}

{$message}
TEXT;

$headers = [
    'From' => 'Example Site <contact@example.com>',
    'Reply-To' => sprintf('%s <%s>', $safeName, $safeEmail),
    'Content-Type' => 'text/plain; charset=UTF-8',
];

// additional_params도 셸로 전달되므로, 고정값만 사용하고 동적 입력은 섞지 않는다.
mail($to, $subject, $body, $headers, '-fbounce@example.com');

이 예시의 포인트는 두 가지입니다. 하나는 Return-Path:를 헤더로 작성하지 않고, 5번째 인자인 -f로 envelope 발신자를 전달하고 있다는 것입니다. 다른 하나는 Reply-To: 등의 헤더에 섞일 이용자 입력을, CR/LF를 거부하는 sanitize 함수를 통과시키고 있다는 것입니다. sanitize 없이 헤더를 구성하면 공격자가 \r\nBcc: victim@example.com 같은 문자열을 흘려 넣어 추가 헤더를 끼워 넣을 수 있으므로, PHP 매뉴얼에서도 외부 입력을 헤더에 사용할 때의 검증은 필수로 여겨집니다. additional_params도 결국 셸로 전달되므로, 이용자 입력을 섞지 말고 고정값으로 전달하십시오.

SMTP 라이브러리를 사용하는 예시

<?php
$mail->isSMTP();
$mail->Host = 'smtp.example.com';
$mail->Port = 587;
$mail->SMTPAuth = true;
$mail->SMTPSecure = 'tls';
$mail->Username = getenv('SMTP_USER');
$mail->Password = getenv('SMTP_PASS');

$mail->setFrom('contact@example.com', 'Example Site');
$mail->addAddress('ops@example.com');
$mail->addReplyTo($email, $name);

// 라이브러리에 따라 Sender / return-path를 별도로 설정할 수 있다
$mail->Sender = 'bounce@example.com';

$mail->Subject = '문의 알림';
$mail->Body = $body;

$mail->send();

SMTP 라이브러리의 장점은 헤더 발신자와 envelope 발신자를 나누어 제어하기 쉽다는 것입니다. 문의 폼 용도에서는 From:을 사이트 도메인으로 고정하고, 답장 주소만 Reply-To:에 두는 설계에 가장 적합합니다.

SPF・DKIM・DMARC의 구체적인 예시

SPF의 기본 예시

example.com. TXT "v=spf1 ip4:203.0.113.10 include:sendgrid.net -all"

SPF에서는 실제로 발송하는 발신원을 모두 포함시켜야 합니다. 서드파티 발신자를 사용하는 경우, Google도 그 발신자가 SPF와 DKIM으로 인증되어 있는지 확인하도록 요구하고 있습니다. 또한 SPF는 DNS 조회 횟수에 제한이 있으므로 include를 지나치게 쌓는 것도 주의가 필요합니다.

DKIM의 기본 예시

form2026._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ..."

셀렉터는 키 로테이션을 위해 사용되며, 여러 공개 키를 같은 도메인에 공존시킬 수 있습니다. 운영에서는 default보다 용도나 연월로 구분할 수 있는 이름을 쓰면 나중에 보기 편합니다.

DMARC 도입 예시

먼저 관측 모드입니다.

_dmarc.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc-agg@example.com; ruf=mailto:dmarc-afrf@example.com; adkim=r; aspf=r; fo=1"

다음으로, 일부를 격리하고 싶을 때입니다.

_dmarc.example.com. TXT "v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc-agg@example.com; adkim=r; aspf=r"

마지막으로, 엄격한 운영입니다.

_dmarc.example.com. TXT "v=DMARC1; p=reject; sp=reject; rua=mailto:dmarc-agg@example.com; adkim=s; aspf=s"

p=none은 모니터링만, quarantine은 격리 권장, reject는 SMTP 단계에서의 거부 권장을 의미합니다. adkimaspf는 strict/relaxed를 전환합니다. 처음부터 reject로 가기보다, none으로 유입량과 정상 발송원을 관측한 다음 단계적으로 올리는 것이 안전합니다.

게다가 rua / ruf를 외부의 집계 서비스로 보낼 경우, RFC 7489에서는 제3자 쪽에 추가 DNS 레코드가 필요합니다. 예를 들어 example.com의 리포트를 thirdparty.example.net으로 보내려면, 수신측은 example.com._report._dmarc.thirdparty.example.net TXT "v=DMARC1"을 공개해야 합니다.

트러블슈팅 체크리스트

마지막으로, 현장에서 바로 쓸 수 있는 확인 순서를 정리합니다. 문의 폼의 메일 미도착은 위에서부터 순서대로 없애 나가는 것이 빠릅니다.

먼저 확인할 것

  • From:이 자사 도메인인가
  • Reply-To:에 이용자 주소를 넣었는가
  • Authentication-Results에서 spf=pass 또는 dkim=pass가 있고, 게다가 dmarc=pass인가
  • dmarc=fail이라면 인증 실패인지 alignment failure인지
  • SPF가 하나의 레코드로 정리되어 있는가
  • SPF의 조회 횟수가 너무 많지 않은가
  • DKIM 공개 키가 조회되는가
  • DMARC에 p=가 있는가
  • 발신 IP의 PTR과 역방향 조회가 적절한가
  • 공유 IP를 사용하고 있지 않은가, 또는 레퓨테이션이 나빠지지 않았는가

바운스와 로그에서 볼 위치

바운스 메일이 오고 있다면, message/delivery-status 형식의 DSN 안에 있는 Final-Recipient, Status, Action, Diagnostic-Code가 중요합니다. RFC 3464는 이러한 기계 판독 가능한 전송 실패 정보를 정의하고 있습니다. 예를 들어 Diagnostic-Code: smtp; 550 relay not permitted 같은 줄이 있다면 애플리케이션 계층이 아니라 SMTP 쪽의 거부입니다.

서버 쪽에서는 최소한 MTA의 전송 로그를 봅니다. Postfix라면 전송 성공・실패, 큐 지연, 릴레이 거부, DNS 해석 실패, DKIM milter 경고가 나타납니다. Exim도 마찬가지입니다. 대표적인 확인 명령어를 들어 두겠습니다.

# 예: Postfix
journalctl -u postfix -n 200 --no-pager
postqueue -p

# 예: Exim
exim -bp

호스팅에 따라 로그 경로나 명령 권한이 다르므로, “애플리케이션 로그”가 아니라 “메일 전송 로그”를 볼 수 있는지를 먼저 확인하십시오. 이것이 보이지 않는 환경에서는 외부 SMTP로 옮기는 것이 문제 분석에 더 유리합니다.

Gmail과 Outlook에서 보는 방법

Gmail에서는 “메시지 원본 보기”, Microsoft의 Outlook에서는 “메시지 세부 정보”나 “인터넷 헤더”에서 원본 헤더를 확인할 수 있습니다. 문의 폼 미도착 조사에서는 스크린샷보다 헤더 전문 텍스트를 저장해 비교하는 것이 효과적입니다.

수신측 오류 구분법

Gmail 계열 오류는 코드에서 원인을 읽어내기 쉽습니다.

오류 예시 의미 주요 대처
5.7.27 SPF 불합격 SPF 레코드에 발신원 추가
5.7.30 DKIM 불합격 DKIM 키・서명 설정 수정
4.7.32 From:과 SPF/DKIM의 조직 도메인 불일치 From: 설계 재검토
5.7.25 PTR / 역방향 조회 미비 발신 IP의 역방향 조회 정비

Google의 FAQ에서도 이러한 오류와 대처 방침이 명시되어 있습니다. 문의 폼에서 특히 많은 것은 4.7.32얼라인먼트 불일치입니다.

최종 판단 기준

다음 3가지 조건을 동시에 만족한다면, 문의 폼 알림의 설계는 견고하다고 할 수 있습니다.

  1. From:example.com 하위
  2. Reply-To:가 폼 이용자 주소
  3. Authentication-Results에서 dmarc=pass가 나옴

이 3가지가 갖춰져 있다면, SendGrid・SES・Mailgun・공유 호스팅・SMTP 라이브러리 중 어느 것을 사용해도 설계로서는 논리가 통합니다. 반대로 어느 하나라도 빠진다면 먼저 From: 설계부터 의심하는 것이 가장 빠른 길입니다.

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

문의 폼 이메일이 도착하지 않는 가장 흔한 원인은 무엇인가요?
SMTP 연결 자체의 문제가 아니라, 눈에 보이는 발신자(From:)와 실제로 인증되는 발신자(SPF의 MAIL FROM / DKIM의 d=)가 맞지 않는 것이 원인입니다. 특히 많은 것은 폼 이용자의 Gmail 주소를 그대로 From:에 넣는 설계입니다. 사이트의 SMTP에서 보내면서 From: taro@gmail.com으로 설정하면, 인증되는 것은 사이트 쪽 도메인인데 From:은 Gmail이라는 불일치가 생겨 DMARC의 얼라인먼트에 실패합니다.
문의 폼 알림 메일의 From은 어떻게 설정해야 하나요?
From:은 자사 도메인으로 고정하고, 폼 이용자의 주소는 Reply-To:에 넣는 것이 기본입니다. 이렇게 하면 답장의 편의성을 확보하면서도, 눈에 보이는 발신자와 인증된 발신자가 도메인 단위로 일치하기 때문에 전달률이 안정됩니다. Sender:는 작성자와 실제 발송 주체가 다를 때만 사용하며, Return-Path는 헤더를 직접 작성하는 것이 아니라 MTA나 메일 서비스 쪽에서 envelope 발신자로 설정합니다.
메일 미도착 조사는 먼저 무엇을 해야 하나요?
코드를 보기 전에 실제로 도착한 메일의 원본 헤더를 보는 것입니다. Gmail이라면 '메시지 원본 보기'에서 Authentication-Results, Return-Path, From, DKIM-Signature를 확인합니다. 최우선으로 볼 것은 From:의 도메인, Return-Path:의 도메인, 그리고 spf / dkim / dmarc가 pass하고 있는지입니다. dmarc=fail이라면 인증 실패인지 alignment failure인지를 구분합니다. DNS 쪽은 dig나 nslookup으로 SPF·DKIM·DMARC 세 가지 레코드를 조회해 확인합니다.
DMARC를 처음부터 reject로 설정해도 되나요?
처음부터 reject로 설정하기보다, 먼저 p=none의 관측 모드로 유입량과 정상 발송원을 확인한 다음 quarantine, reject로 단계적으로 올리는 것이 안전합니다. p=none은 모니터링만, quarantine은 격리 권장, reject는 SMTP 단계에서의 거부 권장을 의미합니다. 또한 전달(forwarding)은 SPF를 쉽게 깨뜨리고, 중계 과정에서 본문이나 헤더가 변경되면 DKIM도 깨지기 때문에 SPF에만 의존하지 말고 DKIM을 반드시 활성화해두는 것도 중요합니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기