AWS in action — AWS SES를 활용한 이메일 전송 시 첨부파일 크기 제약
TL;DR
AWS SES API V1과 V2는 각각 10MB, 40MB 메시지 크기 제한이 있다. SMTP 프로토콜 특성상 첨부파일은 Base64로 인코딩 되며 약 33% ~ 37% 까지 크기가 증가한다는 특징이 있다.
AWS SES API V1
SES API V1에선 메시지당 최대 10MB 크기 제한이 있었다.
따라서 아래와 같이 com.amazonaws.aws-java-sdk-ses로 시작하는 AWS SES SDK V1을 사용했다면 첨부파일을 포함한 이메일 메시지의 최대 크기 제약은 10MB 제한이 된다.
// https://mvnrepository.com/artifact/com.amazonaws/aws-java-sdk-ses
implementation("com.amazonaws:aws-java-sdk-ses:1.12.767")
AWS SES API V2
과거엔 제한을 늘리려면 AWS 측에 직접 연락하여 제한을 해제하는 요청을 했어야 했으나 API V2 부터는 제한이 40MB로 늘어났다.
단, Adjustable이 No이므로 개별 요청으로 제한을 늘리는 것을 불가능하다.
// https://mvnrepository.com/artifact/software.amazon.awssdk/sesv2
implementation("software.amazon.awssdk:sesv2:2.27.3")
주의할 점은 위 설명에도 있듯이 40MB의 기준은 Base64 인코딩 후의 메시지 사이즈라는 점이다. (after base64 encoding.)
SMTP 프로토콜과 MIME 메시지
이메일 전송을 위한 SMTP는 텍스트 기반의 프로토콜이다.
따라서 기본적으로 이메일은 텍스트만 전송할 수 있지만 이미지, 비디오, 오디오. HTML 문서 등 다양한 형태의 컨텐츠를 전송할 수 있도록 MIME(Multipurpose Internet Mail Extensions) 메시지 표준이 존재한다.
MIME 메시지 구조
MIME 메시지는 여러 부분(파트)으로 나뉘며, 각 부분은 MIME 헤더를 통해 특정한 컨텐츠 유형과 인코딩 방식으로 표현되어있다.
- MIME 버전 헤더: 메시지가 MIME 형식임을 나타냅니다.
ex) MIME-Version: 1.0
- Content-Type 헤더: MIME 메시지의 각 부분이 어떤 유형의 컨텐츠인지 지정한다. 복합 메시지일 경우
multipart
를 사용한다 - Content-Transfer-Encoding 헤더: 메시지가 전송될 때 인코딩되는 방식을 지정한다. Base64나 Quoted-Printable 등이 있다.
- Content-Disposition 헤더: 첨부 파일의 표시 방식을 지정한다.
ex)Content-Disposition: attachment; filename="example.jpg"
Quoted-Printable과 Base64 인코딩
Quoted-Printable 인코딩은 주로 이메일 본문에 사용된다. 이메일 본문의 경우 ASCII 문자 외에 특수문자가 포함되어있는 경우가 많은데 Quoted-printable 인코딩을 안전한 전송이 가능하며, 인코딩 후 텍스트가 ASCII로 이루어져있어 크기가 크게 증가하지 않는다는 장점이 있다.
Base64 인코딩은 바이너리 데이터를 ASCII 텍스트로 변환하는 인코딩 방식으로 이메일, URL 등에서 바이너리 데이터를 안전하게 전송하기 위해 사용된다. 8비트 바이너리 데이터를 6비트로 잘라서 ASCII 문자로 변환하는 인코딩 방식 특성상 데이터 크기가 약 33% 증가한다.
3바이트가 4바이트의 텍스트로 변환: Base64는 입력된 데이터를 3바이트(24비트)씩 묶어서 처리한다. 이 24비트를 다시 6비트씩 4개의 그룹으로 나누고, 각각을 Base64의 64가지 문자 중 하나로 변환하여 4개의 문자로 표현한다. 이로 인해 인코딩된 결과는 원본 데이터보다 약 33% 더 깁니다.
패딩 문자
=
사용: Base64 인코딩의 결과는 항상 4의 배수의 길이를 가지도록 설계되어 있기에 입력 데이터의 길이가 3의 배수가 아닐 경우, 결과의 길이를 맞추기 위해 패딩 문자=
를 뒤에 붙인다01001101 01100001 01101110 이런 바이너리 데이터가 있다면 , 010011, 010110, 000101, 101110로 재분배한다.
다음으로 각 청크를 ASCII 문자로 변환하는데 각각 T, W, F, u로 매핑된다.
따라서 바이너리 “01001101 01100001 01101110" 3byte가 “TWFu” 4byte로 인코딩 된다.
Gmail의 이메일 원문보기를 통해 보면 아래와 같이 quoted-printable, base64 등으로 인코딩 된 MIME 메시지를 볼 수 있다.
Message-ID: <010101912ad393ff-347ff038-7d55-428f-80c5-42bf890d17fc-000000@us-west-2.amazonses.com>
Subject: {subject}
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="----=_Part_0_861439313.1723000386201"
Feedback-ID: ::1.us-west-2.5gAic98kFnMkNV62MVyCxPAz7qCDTQwaPBjDkEiX0X0=:AmazonSES
X-SES-Outgoing: 2024.08.07-23.251.236.51
------=_Part_0_861439313.1723000386201
Content-Type: multipart/related; boundary="----=_Part_1_1763105084.1723000386204"
------=_Part_1_1763105084.1723000386204
Content-Type: text/html;charset=UTF-8
Content-Transfer-Encoding: quoted-printable # quoted-printable
{html content}
------=_Part_1_1763105084.1723000386204
Content-Type: image/png; name=bg.png
Content-Transfer-Encoding: base64 # base64
Content-Disposition: inline; filename=bg.png
Content-ID: <bg.png>
------=_Part_0_861439313.1723000386201
Content-Type: application/pdf; name="[우아한테크세미나] 2024-04 Java의 미래, Virtual Thread.pdf"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="[우아한테크세미나] 2024-04 Java의 미래, Virtual Thread.pdf"
------=_Part_0_861439313.1723000386201--
메시지 크기 제한 초과
SES API V2에선 모든 인코딩 후 메시지 크기가 40MB(41943040 byte)를 넘어서면 아래와 같은 메시지를 던지며 요청을 거절한다.
Message length is more than 41943040 bytes long: ‘43293679’. (Service: SesV2, Status Code: 400, Request ID: 16143cce-9…, Extended Request ID: null)
가장 좋은 방법은 큰 파일의 경우 별도의 링크로 이메일 본문에 첨부하는 것이다. 단, 일부 파일이 첨부되지 않더라도 이메일이 전송되는 것이 중요한 비즈니스라면 첨부파일을 추가하는 로직에서 적절히 계산하도록 하는 것을 고려해볼만 하다.
단, 첨부파일의 경우 Base64 인코딩으로 인해 약 33%가 증가하는 것을 고려해야 하며 테스트 환경에서 파일의 유형에 따라 최대 37%까지 크기가 증가하는 것을 확인할 수 있었다.
아래에선 최대한 많은 첨부파일을 첨부하기 위해 크기순으로 정렬하는 로직의 일부이다.
fun <T: Attachable> resolveAttachableFiles(
attachmentFileMap: List<Pair<T, FileMetadata>>, maxBytes: Long,
): Pair<Long, List<Pair<T, FileMetadata>>> {
val attachableFiles: MutableList<Pair<T, FileMetadata>> = mutableListOf()
var totalAttachmentLength = 0L
attachmentFileMap
.sortedBy { it.second.contentLength } // 최대한 많은 첨부파일을 보낼 수 있도록 작은 파일부터 첨부
.forEach { (attachment, metadata) ->
val originalLength = metadata.contentLength
/*
Base64 인코딩 시 알려진 바 33% 증가이나
테스트 시 31051438 -> 42582836로 약 37% 증가하여 방어적으로 40%로 계산
*/
val base64IncreaseRate = 1.40
val base64EncodedLength = ceil(originalLength * base64IncreaseRate).toLong()
val expectedTotalAttachment = totalAttachmentLength + base64EncodedLength
log.debug(
"""|[${attachment.attachmentName}]
|original: $originalLength
|base64EncodedLength: $base64EncodedLength""".trimIndent()
)
if (expectedTotalAttachment < maxBytes) {
attachableFiles += (attachment to metadata)
totalAttachmentLength += base64EncodedLength
} else {
log.warn(
"""|[${attachment.attachmentName}]
|첨부파일의 크기 [$originalLength / $base64EncodedLength]가
|허용 크기를 넘어 첨부하지 않습니다.
|Current total: $totalAttachmentLength
|Expected: $expectedTotalAttachment
|Max Total: $maxBytes
|""".trimMargin()
)
}
}
return totalAttachmentLength to attachableFiles.toList()
}
기타 — AWS SES와 ISP의 협약
Gmail의 경우 메시지의 최대 크기 제한은 25MB이다.
SMTP를 통해 25MB를 초과하는 메시지를 보내면 아래와 같은 오류와 함께 요청을 거절한다.
그러나 SES를 통해 Gmail로 메일을 전송하는 경우 잘 전달이 된다.