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 서버가 성공 메시지를 소비하여 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. 메시징 시스템의 책임인 전송 보장은 RabbitMQ가 담당
  3. 관심사의 분리 - 이메일 전송과 audit 로그는 다른 문제, 만약 이메일 전송 성공을 추적하고 싶다면 audit 로그를 처리하는 다른 애플리케이션을 생성하는 것이 좋아보임

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로 모두 보내고, 향후 필요하다면 자동 복구 로직을 추가할 예정입니다.

Trasient Error는 재시도 로직만 잘 생각하면 됩니다.
외부 서비스인 SMTP 서버에 의존하기에 네트워크나 SMTP 서버의 상태에 따라 결과가 달라질 수 있습니다.

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

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

백오프 전략

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

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

구현 레벨 비교

  1. 애플리케이션 레벨

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

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

    • 'x-message-ttl'이라는 헤더를 통해서 메시지에 대해 소비 기한을 놔두고, TTL이 만료되면 consumer가 리스닝하고 있는 큐로 재발행함

    • 'x-retry-count'라는 consumer가 설정한 재시도 횟수를 저장하는 헤더를 하나 만들어둬야 함 (공식 지원 헤더 아님)

    • 나중에 retry count를 확인해서, 사용자가 설정한 한계를 넘어서면 DLQ로 던지는 형식

    • 정리: Wait Queue는 단순 시간 지연 역할만 하고, 모든 로직은 개발자가 consumer에서 컨트롤함.

    • 장점

      • 메시지 손실 가능성이 적고, 3가지 방법 중 가장 안정적
    • 단점

      • 관리가 불편해짐. Wait Queue가 재시도 횟수만큼 생기게 됨

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

📋 작업 내용

  • 이전 PR이 병합되어야 합니다.

📷 스크린 샷(선택 사항)

동작 화면 첨부

@CodeVac513 CodeVac513 self-assigned this Dec 3, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants