✨ feat: Email 메시지 재시도 및 DLQ 처리 로직 구현 #499
Open
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
🔨 테스크
이메일 전송에 실패했을 때, 로그 외에 어떻게 추적하고 처리할 수 있을까?
제가 생각했던 내용은 3가지입니다.
결정: 옵션 3 (DLQ 기반 접근) 선택
실시간성이 낮고, 데이터 변경이 적기 때문에 DB 조회 오버헤드가 적은 Fat event를 선택했습니다.
그리고 Worker는 이메일 전송만 담당하는 단일 책임을 가져야 합니다.
제가 고민했던 내용은
실패 추적 / 재시도를 위해 DB를 도입하면 Fat event의 이점이 사라지는가?였습니다.3가지 방법 중, DLQ 기반의 3번 접근이 가장 적합하다고 생각했습니다.
이유는 다음과 같습니다:
Permanent Error와 Transient Error
Permanent Error는 쉽게 말해서email 데이터가 누락되었거나, 문자열에 공백이 껴서 파싱이 안되는 것 등의 재시도를 통해서 해결할 수 없는 에러를 의미합니다.
Trasient Error는 트래픽이 갑자기 몰리거나 내부 로직의 버그 등의 요인으로 SMTP 서버가 갑자기 다운된 상태를 가정할 수 있습니다. 이는 1초, 10초, 길면 수 시간 뒤에 요청하면 해결될 수도 있습니다.
Permanent Error는 크게 두 가지 범주로 나뉠 수 있습니다:
이런 Permanent Error는 발생한 에러를 처리하는 것보다 Producer에서 사전에 방지하는 것이 더 좋습니다.
현재 구현에서는 Producer가 TypeORM의 Entity를 사용하고 검증된 데이터만 발행합니다.
이에 따라, Permanent Error의 발생 가능성이 매우 낮습니다.
만약 발생한다면 DLQ로 모두 보내고, 필요할 때 자동 복구 로직을 추가할 예정입니다.
Transient Error 재시도 로직은 어떻게 구현할까?
앞서 설명했던 에러 중 Transient Error에 집중을 하여, 재시도 로직을 구성해야 합니다.
몇 초 간격으로, 어느 구현 레벨에서 재시도를 할 것인가?가 주요 주제입니다.백오프 전략
백오프는 오류 발생 시 재시도를 일시적으로 줄이거나 지연시키는 전략을 말하는데, 쉽게 3가지를 생각할 수 있습니다.
구현 레벨 비교
애플리케이션 레벨
RabbitMQ Delayed Message 플러그인 사용
지연 메시지가 한 노드에만 저장되면 대기 중인 모든 메시지가 손실되는 위험이 발생할 수 있음.
(공식 문서에서는 최소 3개 이상의 노드가 띄워진 클러스터에서 사용하는 것을 권장함.)
RabbitMQ 레벨에서 재시도 및 대기를 위한 queue를 만들어 사용하기
이렇게 3가지 방안을 비교해서 마지막 방법을 선택했습니다.
초기 설정에서만 queue를 여러 개 사용하면 되고, 현재 이메일 전송 조건 등의 로직이 단순해서 관리가 복잡하지는 않습니다.
추후에 서비스 기능이 확장되고 트래픽이 커지면, 클러스터링과 플러그인 도입으로 이관하는 것이 더 좋을 것이라 판단했습니다.
📋 작업 내용
요약
flowchart LR Start([이메일 전송 실패]) --> ErrorType{에러 타입<br/>분석} ErrorType -->|네트워크 에러| NetError["네트워크 에러<br/>• ECONNREFUSED<br/>• ETIMEDOUT<br/>• Unexpected socket close"] ErrorType -->|SMTP 에러| SMTPError[SMTP 에러] ErrorType -->|알 수 없는 에러| UnknownError[알 수 없는 에러] NetError --> NetRetry{재시도 횟수<br/>< 3회?} SMTPError --> SMTPCode{Response Code} SMTPCode -->|500번대| PermanentDLQ["❌ 즉시 DLQ 발행<br/>SMTP_PERMANENT_FAILURE"] SMTPCode -->|400번대| SMTPRetry{재시도 횟수<br/>< 3회?} NetRetry -->|Yes| WaitQueue["⏱️ Wait Queue 발행<br/>retry count++"] SMTPRetry -->|Yes| WaitQueue NetRetry -->|No| MaxRetryDLQ["❌ DLQ 발행<br/>MAX_RETRIES_EXCEEDED"] SMTPRetry -->|No| MaxRetryDLQ UnknownError --> UnknownDLQ["❌ 즉시 DLQ 발행<br/>UNKNOWN_ERROR<br/>+ 에러 스택 저장"] WaitQueue --> End([재시도 대기]) MaxRetryDLQ --> Final([처리 종료]) PermanentDLQ --> Final UnknownDLQ --> Final style Start fill:#fff4e6 style NetError fill:#e3f2fd style SMTPError fill:#e3f2fd style UnknownError fill:#e3f2fd style WaitQueue fill:#c8e6c9 style MaxRetryDLQ fill:#ffcdd2 style PermanentDLQ fill:#ffcdd2 style UnknownDLQ fill:#ffcdd2 style End fill:#e1f5dd style Final fill:#f5f5f5에러 종류
현재 작업에서 에러는 크게 두 가지로 분류할 수 있습니다:
1번은 말 그대로 네트워크는 연결되었으나, SMTP 서버의 부하나 상대 Email의 저장소 용량이 없는 문제 등으로 발생하는 에러를 의미합니다.
2번은 SMTP의 TCP 연결에서 에러가 발생하는 것을 의미합니다.
에러의 종류를 확인하기 위해, 이메일 전송 요청을 보내는 도중 SMTP 서버를 내리는 테스트를 했고 다음과 같은 목록을 확인했습니다:
451 4.3.5 Unable to process mailUnexpected socket closeconnect ECONNREFUSED <SMTP IP>:1025Transient Negative Completion Reply라고 부르며, 재시도하면 성공할 여지가 있는 에러Permanent Negative Completion Reply라고 부르며, 전송 측에서 형식이 잘못되는 등으로 재시도해도 성공할 여지가 없는 에러📷 스크린 샷
전송 도중 Mailpit(SMTP 테스트를 위한 로컬 서버)을 종료했을 때
다시 Mailpit을 실행시켰을 때 결과
Mailpit을 실행시키면서, 대기 큐에 있던 메시지들이 정상적으로 소비되었습니다.

35초의 시간이 지나서, 재시도 횟수가 초과된 메시지는 정상적으로 DLQ에 발행되었습니다.