본문 바로가기
Back-end/Spring

[Spring] AWS SES 이메일 반송률 관리하기

by 며루치꽃 2024. 2. 4.

0. 서론


저희 서비스에서는 이메일을 다양한 결제 완료, 배송, 공지 등의 이벤트 등 여러 이벤트가 일어났을 때 보내게 됩니다.

 

현실세계에선 우체국이 있는 것처럼 이메일 서비스에는 SES가 존재합니다. 

이메일을 보내기 위해 많이 사용하는 SES에서는 현실세계의 발송 처럼 수신관리 개념도 필요합니다.
SES에서는 반송률이라는 개념이 있는데 반송률이란 실제 우편으로 예를 들어보면 실제 우편을 발송했을 때 주소가 다르거나, 수신자가 잘못왔을 경우 우체국으로 반송 요청을 하게 되는데 실제 SES에서도 이와 같은 개념이 존재합니다.

지속적으로 유저가 늘어남에 따라 수치가 올라갔을 것이고, 해당 이벤트 처리를 하지 않을 경우 속도저하, 제일 최악의 케이스는 메일을 전송하는 이메일 도메인이 블락됩니다.

  • AWS 권장 수치: 반송률 5%

이를 해결한 내용을 정리합니다.

 

1. AWS SES란?

먼저 SES에 대해 간단하게 원리를 설명하겠습니다.

1-1. SES란?

“사용자의 이메일 주소와 도메인을 사용해 SMTP방식과 API방식으로 이메일을 보내고 받기 위한 경제적이고 손쉬운 방법을 제공하는 아마존의 이메일 플랫폼입니다” 이라고 합니다.

 

SES의 개념도

 

  1. 이메일 발신자 역할을 담당하는 클라이언트 애플리케이션이 SES에 하나 이상의 수신자에게 이메일을 전송하는 요청을 합니다.
  2. 요청이 유효하면 SES가 이메일을 수락합니다.
  3. SES는 인터넷을 통해 수신자의 수신 장치에 메시지를 보냅니다. SES로 전달된 메시지는 ISP로 전송됩니다.
  4. 이 시점에서 다양한 가능성이 존재합니다. 다음과 같은 시나리오가 있습니다.
    1. ISP가 성공적으로 메시지를 수신자의 받은 편지함으로 전송합니다.
    2. 수신자의 이메일 주소가 존재하지 않습니다. 따라서 ISP가 SES로 반송 메일 알림을 전송합니다. 그러면 SES가 알림을 발신자에게 전달합니다.
    3. 수신자가 메시지를 수신하지만 이를 스팸으로 여겨 ISP에게 수신 거부를 제기합니다. SES와의 피드백 루프가 설정되어 있는 ISP가 SES로 수신 거부를 전송하고 SES가 이를 발신자에게 전달합니다.

1-2. 반송 OR 수신거부 케이스

저희가 알아볼 내용은 이제 반송이나 수신 거부 케이스입니다. 이메일은 다양한 케이스를 통해 반송이 되거나 거부될수 있습니다.

  1. 하드바운스: 영구적 조건으로 인해 ISP가 거부했거나 이메일 주소가 SES 금지 목록에 있어 SES가 거부한 이메일입니다.
  2. 소프트바운스: ISP는 일시적 상태(예: ISP가 처리할 요청이 너무 많거나 수신자의 메일박스가 가득 차 있음)로 인해 수신자에게 이메일을 전송하지 못할 수 있습니다.
  3. 수신거부: ISP가 수락하여 수신자에게 배달되었지만 수신자가 이 이메일을 스팸으로 간주하여 이메일 클라이언트에서 ‘스팸으로 표시’와 같은 버튼을 클릭한 이메일입니다.

2. SES 결과 수신

앞서 언급드린 케이스 외에도 다양한 케이스에서 반송 및 수신 거부가 일어나고, SES는 ISP에서 보내준 응답 결과를 가지고 반송 및 수신 거부를 일반화해주고 그에 대한 반송, 수신 거부를 결과를 알려줍니다.

결과를 알려줄 때는 이메일 또는 AWS SNS 서비스(알림) 서비스를 통해서 알려줍니다.

2-1. SES 결과 분류(필터링)

결과에는 크게 3가지로 분류됩니다. 성공, 반송, 수신거부입니다. 반송 or 수신거부에는 세부 사유가 있는데 aws 가이드 대로 필터링을 해줍니다.

2-2. 필터링 결과 저장

필터링 된 결과를 저장합니다. 필터링된 유형, 블랙리스트 이메일, 세부 사유 등을 DB에 저장해줬습니다.

 

3. 설계 적용

제가 선택한 방법은 SNS서비스를 통해 발송 결과를 인지하게 되면 aws lambda를 통해 수신받은 송신 결과를 SQS에 적재하는 방법을 사용했습니다. 그 후, SQS에서 큐에 쌓인 결과를 소비하여 적재하는 방식으로 진행하였습니다.

 

4. 블랙리스트로 인한 발송 처리 제한

실제 코드로 확인해보겠습니다. SQS를 원리와 소비하는 코드는 생략하였습니다.
아래 링크를 참고해주세요.

링크: https://cheony-y.tistory.com/335

 

[Spring] AWS SQS를 Spring cloud 2.2.X 적용 해보기

0. 서론 특정한 이벤트가 있을 경우 지정한 시간동안 의도적으로 딜레이 후에 이벤트를 처리, 이벤트를 수신한대로 적용을 해야하는 케이스가 생겼습니다. 요구사항을 어떻게 처리할까 고민을

cheony-y.tistory.com

 

 

제가 진행해볼 것은 SQS 쌓인 결과를 소비하여 SES 블랙리스트 처리를 해볼 예정입니다.

SES로 보내기 전, 이제는 블랙리스트 데이터가 쌓였습니다. 쌓인 블랙리스트를 가지고 어떤 유저에게 보낼지 이메일 타켓팅을 할 때, 이때 타겟팅할 때 블랙리스트 유저인지 확인해보고 블랙리스트 유저라면 이메일을 보내지 않도록 처리하였습니다. 해당 예제에서는 간단히 블랙리스트를 DB에 저장해보고 블랙리스트 이메일을 체크하여 이메일을 발송하지 않도록 해보겠습니다.

 

@Getter
@AllArgsConstructor
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class SesCallbackMessage {

    @JsonProperty("notificationType")
    private String notificationType;

    @JsonProperty("mail")
    private MailDetails mail;

    @JsonProperty("bounce")
    private BounceDetails bounce;

    @JsonProperty("complaint")
    private ComplaintDetails complaint;

    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class BounceDetails {

        @JsonProperty("feedbackId")
        private String feedbackId;

        @JsonProperty("bounceType")
        private String bounceType;

        @JsonProperty("bounceSubType")
        private String bounceSubType;

        @JsonProperty("bouncedRecipients")
        private List<BouncedRecipient> bouncedRecipients;

        @JsonProperty("remoteMtaIp")
        private String remoteMtaIp;

        @JsonProperty("reportingMTA")
        private String reportingMTA;
    }

    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class BouncedRecipient {

        @JsonProperty("emailAddress")
        private String emailAddress;

        @JsonProperty("action")
        private String action;

        @JsonProperty("status")
        private String status;

        @JsonProperty("diagnosticCode")
        private String diagnosticCode;
    }

    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    @JsonIgnoreProperties(ignoreUnknown = true)
    @ToString
    public static class MailDetails {

        @JsonProperty("source")
        private String source;

        @JsonProperty("sourceArn")
        private String sourceArn;

        @JsonProperty("sourceIp")
        private String sourceIp;

        @JsonProperty("callerIdentity")
        private String callerIdentity;

        @JsonProperty("sendingAccountId")
        private String sendingAccountId;

        @JsonProperty("messageId")
        private String messageId;

        @JsonProperty("headersTruncated")
        private boolean headersTruncated;
    }

    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class Header {

        @JsonProperty("name")
        private String name;

        @JsonProperty("value")
        private String value;
    }

    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class CommonHeaders {

        @JsonProperty("returnPath")
        private String returnPath;

        @JsonProperty("from")
        private List<String> from;

        @JsonProperty("to")
        private List<String> to;

        @JsonProperty("subject")
        private String subject;
    }

    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class ComplaintDetails {

        @JsonProperty("userAgent")
        private String userAgent;

        @JsonProperty("complainedRecipients")
        private List<ComplainedRecipients> complainedRecipients;

        @JsonProperty("complaintFeedbackType")
        private String complaintFeedbackType;

        @JsonProperty("feedbackId")
        private String feedbackId;
    }

    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    @JsonIgnoreProperties(ignoreUnknown = true)
    public static class ComplainedRecipients{
        private String emailAddress;
    }
}

SES로부터 수신된 메시지 Json 타입입니다. 주목할 변수는 notificationType , BounceDetails , ComplaintDetails 입니다.

  • notificationType : 위에서 SES 결과 분류(필터링) 입니다.
    • 성공, 반송, 수신 거부 입니다.
  • BounceDetails : 이 필드는 notificationType이 Bounce이고 반송 메일에 대한 정보를 담고 있는 JSON 객체를 포함하는 경우에만 존재합니다.
  • ComplaintDetails : 이 필드는 notificationType이 Complaint이고 수신 거부에 대한 정보를 담고 있는 JSON 객체를 포함하는 경우에만 존재합니다.

 

4-1. SNS에 대한 SES 결과 SQS로 수신받기

@Slf4j
@RequiredArgsConstructor
@Component
public class SesCallbackSqsListener {

    private final SesCallbackService sesCallbackService;

    @SqsListener(value = "${cloud.aws.sqs.queue.ses-callback}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS)
    public void receiveMessage(@Headers Map<String, String> header, @Payload String message){

        log.info("[이메일 SQS] 이메일 SES -> SQS 수신 Message: {}", message);
        SesCallbackNotification receiveSesCallbackMessage = objectSerialize(message, SesCallbackNotification.class);

        SesCallbackMessage sesCallbackMessage = 
        	objectSerialize(receiveSesCallbackMessage.getMessage(), SesCallbackMessage.class);

        sesCallbackService.handleSesCallback(sesCallbackMessage);
    }


    public static <T> T objectSerialize(String jsonString, Class<T> valueType){
        try {
            ObjectMapper mapper = new ObjectMapper();
            mapper.registerModule(new JavaTimeModule());
            mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
            return mapper.readValue(jsonString, valueType);
        } catch (JsonProcessingException e){
            throw new ObjectMapperException("JSON 직렬화 에러", e);
        }
    }

}

SNS를 통해 SQS에 메시지를 쌓고 SQS에 쌓인 메시지를 소비하는 리스너입니다.

  • SesCallbackMessage 의 메시지 내용을 객체로 만들어줍니다.
public void insertBounceEmailBlackList(EmailBlacklistInsertDto emailBlacklistInsertDto){
    emailBlacklistUpdateRepository.create(emailBlacklistInsertDto);
}

 

@Repository
public interface EmailBlackListJpaRepository extends JpaRepository<BounceEntity, String> {
}

 

@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class BounceEntity {

    @Id
    private Long id;

    private String bounceEmailAddress;

    private String bounceSubType;

    private String bounceReason;

    private String bounceStatus;

    private LocalDateTime createdAt;

    private LocalDateTime updateAt;

}

Spring data JPA를 통해 간단히 반송 관련된 Entity를 선언해주고, 블랙리스트가 저장되도록 이메일 주소, 반송 하위 타입, 반송 이유 등으로 정의해줬습니다.

 

4-2. 이벤트 타입에 따른 이벤트 처리

@Slf4j
@Service
@RequiredArgsConstructor
public class SesCallbackService {

    private final EmailBlacklistUpdateRepository emailBlacklistUpdateRepository;

    public void handleSesCallback(SesCallbackMessage sesCallbackMessage){
        try{
            String notificationType = sesCallbackMessage.getNotificationType();

            switch (notificationType){
                case "Bounce" ->
                        handleDebounceEmail(sesCallbackMessage);
                case "Complaint" ->
                        handleComplaintEmail(sesCallbackMessage);
                case "Delivery" -> {}
                default -> log.error("[이메일 SES] SES 타입이 없습니다. SES 타입을 확인해주세요");
            }
        } catch (RuntimeException e){
            log.error("[이메일 SES] SES 결과 처리 도중 에러 / messageId: {}", 
            	sesCallbackMessage.getMail().getMessageId());
        }
    }
}

notificationType 을 3가지 종류가 있습니다. 각각의 이벤트에 따라 처리를 해야하는 방법은 AWS SES에 대한 SNS에서 정의해주고 있습니다. 이에 따라 각각 다른 처리를 해줄수 있도록 처리했습니다.

  • Bounce : 반송에 따른 처리
  • Complaint : 수신 거부에 따른 처리
  • Delivery : 성공에 따른 처리(해당 예제에선 스킵)

 

반송(Debounce)의 경우

public void handleDebounceEmail(SesCallbackMessage sesCallbackMessage){
  String notificationType = sesCallbackMessage.getNotificationType();

  SesCallbackMessage.BounceDetails bounce = sesCallbackMessage.getBounce();
  String bounceSubType = bounce.getBounceSubType();

  switch (bounceSubType){
      case "General", "NoEmail", "Suppressed", "OnAccountSuppressionList" -> {
          insertBounceEmailBlackList(bounce);
      }
  }
}

public void insertBounceEmailBlackList(SesCallbackMessage.BounceDetails bounce) {
  String bounceSubType = bounce.getBounceSubType();

  List<SesCallbackMessage.BouncedRecipient> bouncedRecipients = bounce.getBouncedRecipients();
  for (SesCallbackMessage.BouncedRecipient bouncedRecipient : bouncedRecipients) {
      String bounceEmailAddress = bouncedRecipient.getEmailAddress();
      String diagnosticCode = bouncedRecipient.getDiagnosticCode();

      EmailBlacklistInsertDto emailBlacklistInsertDto = 
      	buildEmailBlackListInsertDto(bounceEmailAddress, diagnosticCode, bounceSubType);
      
      insertBounceEmailBlackList(emailBlacklistInsertDto);

  }
}

반송(Debounce)의 경우 입니다. BounceDetails 은  notificationType이 Bounce이고 반송 메일에 대한 정보를 담고 있는 JSON 객체인데 해당 부분에서 bounceSubType 이 있습니다.

bounceSubType 는 Amazon SES가 결정한 반송 메일의 하위 유형입니다. Amazon SES가 결정한 반송 메일의 아래와 같을 경우 블랙리스트에 저장하도록 하였습니다.

  • "General", "NoEmail", "Suppressed", "OnAccountSuppressionList"

 

수신 거부(Complaint)의 경우

public void handleComplaintEmail(SesCallbackMessage sesCallbackMessage){
  String notificationType = sesCallbackMessage.getNotificationType();


  SesCallbackMessage.ComplaintDetails complaint = sesCallbackMessage.getComplaint();
  String complaintFeedbackType = complaint.getComplaintFeedbackType();

  List<SesCallbackMessage.ComplainedRecipients> complainedRecipients = complaint.getComplainedRecipients();

  if(!complainedRecipients.isEmpty()){
      complainedRecipients.stream()
              .map(SesCallbackMessage.ComplainedRecipients::getEmailAddress)
              .filter(emailAddress -> emailAddress != null)
              .forEach(complainEmailAddress -> insertComplaintEmailBlackList(complainEmailAddress, complaintFeedbackType));
  }

}

public void insertComplaintEmailBlackList(String complainEmailAddress, String complaintFeedbackType){
    final String BOUNCE_REASON = "complaint";

    EmailBlacklistInsertDto emailBlacklistInsertDto = buildEmailBlackListInsertDto(complainEmailAddress, BOUNCE_REASON, complaintFeedbackType);
    insertBounceEmailBlackList(emailBlacklistInsertDto);
}

반송 메일 알림은 단일 수신자 또는 여러 수신자와 관련이 있을 수 있습니다. bouncedRecipients 필드는 객체(반송 메일 알림이 관련된 수신자당 1개)의 목록을 포함하고 있습니다.

 

수신거부는 무조건 "이메일을 받지 않겠다" 라고 메일의 수신자가 지정해놓은 것이기 때문에 반송의 경우와 달리 무조건 블랙리스트 블랙리스트 처리를 해줘야합니다.

 

5. 발송

이제 발송단계에서 블랙리스트 BounceEntity 를 체크해보고 해당 이메일 주소가 있다면 이메일을 발송하지 않는식으로 발송을 막음으로써 반송률, 수신거부를 떨어뜨릴 수 있습니다.

 

6. 정리

위의 과정을 정리해보면 다음과 같습니다.

  1. 이메일 전송 결과(SES)를 수신하여 결과를 자동으로 필터링한다.
  2. 필터링된 결과에 따라 블랙리스트에 추가
  3. 추후, 블랙리스트에 추가된 이메일에는 이메일이 전송이 되지 않게 막는다.

 

이메일을 잘 보내지도록 하는 부분에서만 집중했었는데, 이메일을 발송하는 유저 수가 늘어나면 늘어날 수록 발송률이 올라갈 것인 만큼 이메일이 블록되지 않으려면 수신 관리가 반드시 필요할 것 같습니다.
감사합니다.

 

참고

https://docs.aws.amazon.com/ko_kr/ses/latest/dg/send-email-concepts-process.html

 

Amazon SES를 통한 이메일 전송 작동 방식 - Amazon Simple Email Service

SES가 발신자의 요청을 수락한 다음 메시지에 바이러스가 포함된 것을 확인할 경우 SES는 해당 메시지 처리를 중지하고 해당 메시지를 수신자의 메일 서버로 전송하지 않습니다.

docs.aws.amazon.com

 

'Back-end > Spring' 카테고리의 다른 글

[Spring] Spring 캐시 사용하기  (0) 2024.02.18
[Spring] AWS SQS를 Spring cloud 2.2.X 적용 해보기  (2) 2024.01.07

댓글