수신 이메일 · 2026년 9월 22일

Amazon SES로 이메일 수신하기: S3와 Lambda

Amazon SES, S3, Lambda로 프로덕션 수준의 수신 이메일 파이프라인을 구축합니다. MIME 안전성, 테넌트 라우팅, 멱등성, 재시도, 스레드 처리를 다룹니다.

가장 짧고 안정적인 Amazon SES 수신 경로는 다음과 같습니다. 도메인을 검증하고, MX 레코드가 SES 수신 엔드포인트를 가리키게 하고, 수락한 모든 메시지를 S3에 저장한 뒤, Lambda를 비동기로 호출해 파싱하고 저장하는 것입니다. S3 액션을 Lambda 액션보다 먼저 두세요. SES 메시지 ID를 멱등성 키로 취급하고, 라우팅에는 SMTP 엔벨로프 수신자를 사용하며, 헤더나 첨부 파일을 신뢰하는 대신 안전하지 않은 콘텐츠는 격리하세요.

구축할 아키텍처

SES가 루트 도메인의 모든 메일을 받아야 하는 경우가 아니라면 inbound.example.com 같은 전용 서브도메인을 사용하세요. 그러면 애플리케이션 메일이 직원 메일함과 분리되고, 롤백이 메일 마이그레이션이 아닌 DNS 변경으로 끝납니다.

프로덕션 흐름은 다음과 같습니다.

sender -> SES inbound SMTP endpoint -> active SES receipt rule -> S3 raw-message object -> asynchronous Lambda action -> MIME parser and policy checks -> application database and private attachment storage

Amazon SES 수신 규칙은 액션을 순서대로 실행합니다. AWS는 코드에서 메시지 본문이 필요할 때 S3를 먼저, Lambda를 나중에 두는 패턴을 명시적으로 문서화하고 있습니다. Lambda 액션을 직접 사용하면 메타데이터와 일부 헤더만 받으며 전체 본문은 받지 못합니다. 본문은 S3에 있는 원본 MIME 객체로 남습니다(AWS SES 수신 개념).

이런 분리는 유용합니다. SMTP와 맞닿은 단계에서는 원본 메시지를 빠르게 저장하고, 파싱, 인덱싱, 알림, 비즈니스 로직은 수락 이후에 처리합니다. 일시적인 데이터베이스 장애 때문에 발신자의 메일 서버가 SMTP 트랜잭션을 반복하게 만들 필요가 없습니다.

1. 지원되는 리전을 선택하고 도메인 검증하기

SES 이메일 수신은 일부 AWS 리전에서만 사용할 수 있습니다. 최신 SES 수신 엔드포인트 목록에서 하나를 선택하고, 관련 AWS 문서에서 명시적으로 허용하지 않는 한 SES, Lambda, SNS, KMS 리소스를 그 리전에 함께 두세요.

메일을 받을 정확한 루트 도메인 또는 서브도메인에 대해 SES 도메인 ID를 만드세요. 도메인 검증에는 SES가 제공하는 DNS 레코드를 게시해야 합니다. 수신용 검증은 도메인에 대한 소유권을 증명하는 것이며, 수신 트래픽을 SES로 보내는 MX 경로 설정과는 별개입니다(AWS 도메인 검증 가이드).

전용 서브도메인이라면 DNS 레코드는 개념적으로 다음과 같은 모습입니다.

inbound.example.com. MX 10 inbound-smtp.us-east-1.amazonaws.com.

us-east-1을 선택한 리전으로 바꾸세요. AWS는 MX 값을 10 inbound-smtp.<region>.amazonaws.com으로 문서화하고 있습니다(AWS MX 레코드 가이드). 사람들이 Google Workspace, Microsoft 365 또는 다른 메일박스 제공업체로 여전히 메일을 받고 있다면 루트 도메인을 SES로 향하게 하지 마세요.

테스트하기 전에 두 곳 이상의 리졸버에서 게시된 레코드를 확인하세요.

dig MX inbound.example.com +short

DNS가 보인다는 것은 경로가 게시되었다는 것만 증명합니다. 설정이 완료되었다고 하기 전에 테스트 주소로 통제된 메시지를 보내 SES가 저장했는지 확인하세요.

2. 처리하기 전에 원본 메시지 저장하기

퍼블릭 액세스를 차단하고, 수명 주기 정책을 적용하고, 실용적인 범위에서 가장 좁은 IAM 권한을 부여한 프라이빗 S3 버킷을 만드세요. 그런 다음 수신자 조건이 수신 도메인이나 특정 주소와 일치하는 SES 수신 규칙을 만드세요.

첫 번째 액션은 원본 메시지를 S3에 전달하는 것이어야 합니다. inbound/ 같은 객체 접두사를 쓰면 보존 규칙과 접근 정책의 범위를 정하기 쉽습니다. SES는 수정되지 않은 원본 MIME 콘텐츠를 저장합니다. AWS 문서에 따르면 현재 S3에 저장할 때 기본 최대 크기는 40 MB이고, 전체 메시지를 포함하는 SNS 액션의 최대 크기는 훨씬 작은 150 KB입니다(AWS S3 수신 액션). 이 크기 차이가 실제 답장과 첨부 파일에 S3가 더 안전한 기본값인 이유입니다.

수신 규칙 액션에서 선택 사항인 SES KMS 설정을 사용한다면 암호화 세부 사항을 주의 깊게 읽으세요. SES는 이 기능에 일반 S3 서버 측 암호화가 아닌 클라이언트 측 암호화를 사용하므로, 읽는 쪽에서 호환되는 클라이언트로 객체를 복호화해야 합니다. 가볍게 켰다가 인시던트 중에 Node 파서가 저장된 바이트를 읽지 못한다는 사실을 발견하는 일이 없도록 하세요.

SES에는 의도한 버킷과 접두사에만 쓸 수 있는 권한을 부여하세요. Lambda에는 같은 위치에 대해서만 s3:GetObject를 부여하세요. 이 함수에는 버킷 관리 권한이 필요하지 않습니다.

3. 비동기 Lambda 액션 추가하기

수신 규칙에서 Lambda 액션을 S3 액션 뒤에 두고, SES가 규칙 평가를 계속할지 함수가 결정해야 하는 경우가 아니라면 비동기 호출을 사용하세요. AWS는 일반적인 처리에는 비동기 실행을 권장하고, 동기 실행은 메일 흐름 결정에만 사용하도록 남겨 둡니다(AWS Lambda 수신 액션).

접두사를 설정하지 않으면 SES가 부여한 mail.messageId가 S3 객체 키가 됩니다. 접두사가 있으면 그 앞에 붙입니다. 다음 Node.js 골격은 원본 메시지를 가져와 파싱합니다. @aws-sdk/client-s3와 mailparser 같은 유지 관리되는 MIME 파서를 배포 아티팩트에 포함하고 버전을 고정하세요.

import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { simpleParser } from "mailparser"; const s3 = new S3Client({}); const bucket = process.env.INBOUND_BUCKET; const prefix = process.env.INBOUND_PREFIX || "inbound/"; export async function handler(event) { for (const record of event.Records || []) { const ses = record.ses; const messageId = ses?.mail?.messageId; const recipients = ses?.receipt?.recipients || []; if (!messageId || !/^[A-Za-z0-9._-]+$/.test(messageId)) { throw new Error("Missing or invalid SES message ID"); } // claimOnce must be an atomic insert with a unique constraint. if (!(await claimOnce(messageId))) continue; try { const object = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: `${prefix}${messageId}`, })); const raw = Buffer.from(await object.Body.transformToByteArray()); const parsed = await simpleParser(raw, { skipHtmlToText: true, skipTextToHtml: true, }); await saveInboundMessage({ providerMessageId: messageId, envelopeRecipients: recipients, envelopeFrom: ses.mail.source, headerMessageId: parsed.messageId || null, inReplyTo: parsed.inReplyTo || null, references: parsed.references || [], subject: parsed.subject || "", text: parsed.text || "", html: parsed.html || null, attachments: parsed.attachments, receivedAt: ses.mail.timestamp, }); await markComplete(messageId); } catch (error) { await releaseOrMarkFailed(messageId, String(error)); throw error; } } }

자리 표시자 함수는 애플리케이션별 저장소를 나타내지만, 이들의 계약은 중요합니다. claimOnce는 제공업체 메시지 ID에 대한 데이터베이스 고유 제약 조건이나 조건부 쓰기를 사용해야 합니다. 읽은 뒤 삽입하는 방식은 경쟁 상태가 생깁니다. 운영자가 processing, complete, quarantined, failed를 구분할 수 있도록 처리 상태를 저장하세요.

To 헤더가 아닌 엔벨로프 수신자로 라우팅하기

눈에 보이는 To와 Cc 필드는 발신자가 제공한 메시지 내용입니다. BCC, 전달, 의도적인 조작 때문에 실제 수신 대상이 빠져 있을 수 있습니다. SES 수신 조건은 SMTP 엔벨로프 수신자를 사용하며, AWS는 메시지가 어디로 전달되었는지 판단할 때 후속 처리기가 SES 알림의 수신자를 사용하도록 안내합니다(AWS 수신 개념).

이 구분이 테넌트 간 버그를 막아 줍니다. reply+tenant-a@inbound.example.com이 받은 메시지의 눈에 보이는 To가 tenant-b@example.com이더라도, 표시 헤더가 아니라 엔벨로프 주소에 대해 인증된 애플리케이션 매핑을 기준으로 라우팅하세요.

주소가 고객이나 대화를 식별하는 경우에는 무작위이고 추측할 수 없는 답장 토큰을 사용하세요. 토큰은 저장할 때 해시하고, 적절할 때 만료시키고, 활성 워크스페이스에 매핑되지 않는 주소는 거부하세요. ticket-42 같은 예측 가능한 로컬 파트는 다른 사용자의 스레드에 메시지를 주입해 달라는 초대장과 같습니다.

MIME을 악의적인 입력으로 파싱하기

이메일은 수십 년 된 중첩 구조의 입력 형식입니다. RFC 5322는 메시지 헤더와 본문을 정의하고, MIME은 멀티파트 콘텐츠와 전송 인코딩을 추가합니다(RFC 5322, RFC 2045). 빈 줄이나 경계 문자열로 직접 분할하지 말고 유지 관리되는 파서를 사용하세요.

콘텐츠를 제품에서 사용할 수 있게 하기 전에 제한을 적용하세요.

  • 디코딩된 전체 바이트 수, 첨부 파일 수, 개별 첨부 파일 크기, MIME 중첩 깊이, 파싱 시간에 상한을 두세요.
  • 첨부 파일은 생성한 객체 이름으로 비공개 저장하세요. 발신자의 파일 이름을 경로로 사용하지 마세요.
  • 선언된 콘텐츠 유형과 파일 이름은 참고용으로만 취급하세요. 가능하면 콘텐츠로 유형을 감지하세요.
  • 첨부 파일 콘텐츠를 절대 실행하지 마세요. 다운로드 전에 첨부 파일을 검사하거나 격리하세요.
  • 엄격한 허용 목록으로 HTML을 정화하고, 원격 이미지는 기본적으로 차단하고, 격리된 컨텍스트에서 렌더링하세요. 자동 분석에는 일반 텍스트를 사용하는 편이 좋습니다.
  • 원본 본문, 주소, 토큰, 첨부 파일 콘텐츠를 일반 애플리케이션 로그에 남기지 마세요.

SES는 SPF, DKIM, DMARC, 스팸, 바이러스 판정을 보고할 수 있지만, AWS는 SES가 이런 결과를 노출할 뿐 비즈니스 정책을 자동으로 적용하지는 않는다고 밝힙니다. 실패한 메시지를 거부할지, 격리할지, 경고와 함께 표시할지 결정하세요. 인증 통과는 특정 메커니즘에서 도메인을 식별해 줄 뿐이며, 콘텐츠가 안전하다는 것이나 사람이 작성했다는 것을 증명하지는 않습니다.

재시도를 평범하게 만들기

비동기 Lambda 호출은 실패한 함수를 재시도할 수 있으며, AWS는 함수가 오류를 반환하지 않더라도 중복 전달이 가능하다고 경고합니다. 실패 시 대상(on-failure destination)이나 데드 레터 큐를 구성하고 처리 실패에 대한 알람을 설정하세요(AWS Lambda 재시도 동작).

멱등성은 모든 후속 부수 효과를 포괄해야 합니다.

  1. SES 메시지 ID를 고유 제약 조건 아래에 삽입합니다.
  2. 가능하면 파싱된 콘텐츠와 스레드 링크를 하나의 트랜잭션에 저장합니다.
  3. 알림, 티켓 생성, 에이전트 작업은 메시지 ID와 액션 유형을 키로 하는 아웃박스에 넣습니다.
  4. 영속적인 쓰기가 성공한 후에만 레코드를 완료로 표시합니다.
  5. 손실이 있는 로그 항목이 아니라 원본 S3 객체에서 다시 처리합니다.

SES Lambda 액션 대신 S3 알림으로 처리를 시작하는 경우에도 같은 규칙이 적용됩니다. Amazon S3 알림은 최소 한 번 전달(at-least-once)을 전제로 설계되었으며 순서대로 도착한다는 보장이 없습니다(AWS S3 이벤트 알림).

제목을 신뢰하지 않고 메시지를 스레드로 묶기

파싱된 Message-ID, In-Reply-To, References 필드로 스레드 일치를 제안하세요. Re:로 시작하는 제목만으로 스레드를 묶지 마세요. 또한 무언가를 연결하기 전에 엔벨로프 주소나 답장 토큰이 같은 워크스페이스와 대화에 속하는지 확인하세요.

자동 응답에는 별도의 정책이 필요합니다. Auto-Submitted 같은 신호를 감지하고 답장 루프가 생기지 않게 하세요. RFC 3834는 자동 응답에 명확한 식별과 보수적인 동작을 권장합니다(RFC 3834). AI 에이전트가 답장 초안을 작성한다면 발송은 명시적이고 멱등적인 부수 효과로 유지하세요. 예상치 못한 수신자, 민감한 콘텐츠, 원래의 지원 또는 제품 워크플로 밖의 동작에는 사용자 승인을 요구하세요. 메시지를 받았다고 해서 관련 없는 마케팅에 대한 포괄적인 동의가 되는 것은 아닙니다.

프로덕션 체크리스트

  • 수신 리전에서 SES 수신 이메일을 지원합니다.
  • 도메인 ID가 검증되었고 MX 레코드가 정상적으로 조회됩니다.
  • 수신 규칙에 좁은 수신자 조건이 있으며 의도한 규칙 세트가 활성화되어 있습니다.
  • 비동기 Lambda 처리 전에 S3 작업이 실행됩니다.
  • 버킷은 비공개이고 액세스는 최소 권한이며 보존 정책이 문서화되어 있습니다.
  • SES 메시지 ID에 데이터베이스 고유성 제약 조건이 있습니다.
  • 라우팅은 표시되는 To 또는 Cc 헤더가 아닌 엔벨로프 수신자를 사용합니다.
  • MIME, HTML, 링크, 첨부 파일은 신뢰할 수 없는 입력으로 처리됩니다.
  • 실패한 이벤트는 모니터링되는 대상에 도달하며 재생할 수 있습니다.
  • 스레드 일치 항목은 워크스페이스 소유권을 강제합니다.
  • 자동 응답에는 루프 방지, 동의 경계, 발송 멱등성이 있습니다.
  • 제어된 테스트에서 일반 텍스트, HTML, BCC, 중복 전달, 대용량 첨부 파일, 잘못된 MIME, 파서 실패를 다룹니다.

팀이 AWS 네이티브 제어를 원하고 DNS, IAM, MIME 파싱, 테넌트 격리, 보존, 재시도 처리, 운영 알림을 직접 책임질 준비가 되어 있다면 SES를 직접 사용하는 것이 잘 맞습니다. 이런 애플리케이션 기본 요소를 더 좁은 범위의 이메일 API 뒤에 두고 싶다면, SendHQ가 발신 트랜잭션 이메일과 함께 수신 주소, 보관된 메시지, 스레드, 워크스페이스 단위 접근을 제공합니다. 어느 쪽이든 원본 메시지는 복구할 수 있게 유지하고 모든 후속 동작을 재실행해도 안전하게 만드세요.