Skip to content

Conversation

@CodeVac513
Copy link
Collaborator

@CodeVac513 CodeVac513 commented Dec 3, 2025

🔨 테스크

이메일 전송에 실패했을 때, 로그 외에 어떻게 추적하고 처리할 수 있을까?

제가 생각했던 내용은 3가지입니다.

  1. Email Worker가 RDB에 대해 모르는 경우
1. API 서버가 이메일 메시지 발행
2. API 서버가 이메일 상태 추적 테이블에 저장 (id, email, status 등)
3. Worker가 이메일 전송 후 "성공" 메시지를 RabbitMQ에 발행
4. API 서버(혹은 제 3의 서버)가 성공 메시지를 소비하여 DB 상태 업데이트 또는 삭제
  1. EmailWorker가 RDB에 대해 아는 경우
1. API 서버가 이메일 메시지 발행
2. API 서버가 이메일 상태 추적 테이블에 저장
3. Worker가 이메일 전송
4. Worker가 직접 DB를 조회/업데이트하여 상태 관리
  1. 순수하게 RabbitMQ만 활용해서 추적하기
1. API 서버가 이메일 메시지 발행
2. Worker가 이메일 전송
3. 실패 시 재시도 로직 수행
4. 최종 실패 시 DLQ로 이동

결정: 옵션 3 (DLQ 기반 접근) 선택

실시간성이 낮고, 데이터 변경이 적기 때문에 DB 조회 오버헤드가 적은 Fat event를 선택했습니다.
그리고 Worker는 이메일 전송만 담당하는 단일 책임을 가져야 합니다.
제가 고민했던 내용은 실패 추적 / 재시도를 위해 DB를 도입하면 Fat event의 이점이 사라지는가?였습니다.

3가지 방법 중, DLQ 기반의 3번 접근이 가장 적합하다고 생각했습니다.
이유는 다음과 같습니다:

  1. Fat event 의도 유지
  2. 메시징 시스템의 책임인 전송 보장은 DB가 아닌, RabbitMQ가 담당

Permanent Error와 Transient Error

Permanent Error는 쉽게 말해서email 데이터가 누락되었거나, 문자열에 공백이 껴서 파싱이 안되는 것 등의 재시도를 통해서 해결할 수 없는 에러를 의미합니다.

Trasient Error는 트래픽이 갑자기 몰리거나 내부 로직의 버그 등의 요인으로 SMTP 서버가 갑자기 다운된 상태를 가정할 수 있습니다. 이는 1초, 10초, 길면 수 시간 뒤에 요청하면 해결될 수도 있습니다.

Permanent Error는 크게 두 가지 범주로 나뉠 수 있습니다:

  1. 자동 복구 가능: 데이터 정규화로 해결 (ex: 이메일 공백 제거, 소문자 변환 등등)
  2. 자동 복구 불가능: DLQ로 이동 후 수동 개입이 필요 (ex: email과 같은 필수 필드가 누락되는 경우)

이런 Permanent Error는 발생한 에러를 처리하는 것보다 Producer에서 사전에 방지하는 것이 더 좋습니다.
현재 구현에서는 Producer가 TypeORM의 Entity를 사용하고 검증된 데이터만 발행합니다.
이에 따라, Permanent Error의 발생 가능성이 매우 낮습니다.
만약 발생한다면 DLQ로 모두 보내고, 필요할 때 자동 복구 로직을 추가할 예정입니다.

Transient Error 재시도 로직은 어떻게 구현할까?

앞서 설명했던 에러 중 Transient Error에 집중을 하여, 재시도 로직을 구성해야 합니다.
몇 초 간격으로, 어느 구현 레벨에서 재시도를 할 것인가?가 주요 주제입니다.

백오프 전략

백오프는 오류 발생 시 재시도를 일시적으로 줄이거나 지연시키는 전략을 말하는데, 쉽게 3가지를 생각할 수 있습니다.

방식 설명 예시
고정 백오프 매번 같은 간격 5초, 5초, 5초
지수 백오프 지수적 증가 1초, 2초, 4초, 8초
선형 백오프 선형 증가 5초, 10초, 15초, 20초

구현 레벨 비교

  1. 애플리케이션 레벨

    • Spring의 RetryTemplate, JS의 p-retry 등의 모듈, 라이브러리를 활용해서 애플리케이션에서 재시도
    • 장점
      • 라이브러리를 활용하면 쉽게 구현할 수 있음.
    • 단점
      • Consumer에서 장애가 발생하면 메시지가 소실될 수 있음(메모리에만 존재함)
      • 재시도 중 다른 메시지를 블로킹
      • 재시도를 하려고 requeue로 큐에 메시지를 넣었는데, 다른 Email Worker가 그 메시지를 가져가서 실행할 수 있음
  2. RabbitMQ Delayed Message 플러그인 사용

    • RabbitMQ에서 지원하는 플러그인을 설치/사용으로 딜레이를 만들 수 있음
    • 장점
      • 설정이 매우 간편함
    • 단점
      • 공식문서에도 적혀있는 제한 사항이 있음.
        지연 메시지가 한 노드에만 저장되면 대기 중인 모든 메시지가 손실되는 위험이 발생할 수 있음.
        (공식 문서에서는 최소 3개 이상의 노드가 띄워진 클러스터에서 사용하는 것을 권장함.)
      • 대량의 메시지에도 부적합하지만, 우리 서비스 트래픽에서는 오케이
  3. RabbitMQ 레벨에서 재시도 및 대기를 위한 queue를 만들어 사용하기

    • Wait Queue를 생성하여 시간 지연 역할을 한 뒤, 개발자가 consumer에서 컨트롤하는 방식
    • 장점
      • 메시지 손실 가능성이 적고, 3가지 방법 중 가장 안정적
    • 단점
      • 관리가 불편해짐. Wait Queue가 재시도 횟수만큼 생기게 됨

이렇게 3가지 방안을 비교해서 마지막 방법을 선택했습니다.
초기 설정에서만 queue를 여러 개 사용하면 되고, 현재 이메일 전송 조건 등의 로직이 단순해서 관리가 복잡하지는 않습니다.
추후에 서비스 기능이 확장되고 트래픽이 커지면, 클러스터링과 플러그인 도입으로 이관하는 것이 더 좋을 것이라 판단했습니다.

📋 작업 내용

요약

  • Error 종류에 따라 메시지 발행 혹은 재시도 로직 구현
    • Transient Error: 5초/10초/20초 백오프로 최대 3회 재시도
    • Permanent Error: 즉시 DLQ로 이동
    • Wait 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
Loading

에러 종류

현재 작업에서 에러는 크게 두 가지로 분류할 수 있습니다:

  1. SMTP 레벨의 에러
  2. 네트워크 연결 레벨의 에러

1번은 말 그대로 네트워크는 연결되었으나, SMTP 서버의 부하나 상대 Email의 저장소 용량이 없는 문제 등으로 발생하는 에러를 의미합니다.
2번은 SMTP의 TCP 연결에서 에러가 발생하는 것을 의미합니다.

에러의 종류를 확인하기 위해, 이메일 전송 요청을 보내는 도중 SMTP 서버를 내리는 테스트를 했고 다음과 같은 목록을 확인했습니다:

  1. 451 4.3.5 Unable to process mail
  2. Unexpected socket close
  3. connect ECONNREFUSED <SMTP IP>:1025
  4. SMTP의 400번대 에러: Transient Negative Completion Reply라고 부르며, 재시도하면 성공할 여지가 있는 에러
  5. SMTP의 500번대 에러: Permanent Negative Completion Reply라고 부르며, 전송 측에서 형식이 잘못되는 등으로 재시도해도 성공할 여지가 없는 에러

📷 스크린 샷

Postman으로 회원가입 인증 이메일을 계속 보내도록 설정하고 SMTP 서버를 이메일 전송 도중 종료하도록 테스트를 진행했습니다.

전송 도중 Mailpit(SMTP 테스트를 위한 로컬 서버)을 종료했을 때

스크린샷 2025-12-09 162841

다시 Mailpit을 실행시켰을 때 결과

Mailpit을 실행시키면서, 대기 큐에 있던 메시지들이 정상적으로 소비되었습니다.
35초의 시간이 지나서, 재시도 횟수가 초과된 메시지는 정상적으로 DLQ에 발행되었습니다.
스크린샷 2025-12-09 162950

image

@CodeVac513 CodeVac513 self-assigned this Dec 3, 2025
@CodeVac513 CodeVac513 added the ✨ Feature 기능 구현 label Dec 9, 2025
@CodeVac513 CodeVac513 requested review from Jo-Minseok and asn6878 and removed request for Jo-Minseok December 9, 2025 07:59
@CodeVac513 CodeVac513 marked this pull request as ready for review December 10, 2025 10:49
Copy link
Member

@Jo-Minseok Jo-Minseok left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ Feature 기능 구현

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BE] 이메일 전송 기능을 RabbitMQ 기반 비동기 처리로 전환

3 participants