Skip to content

[Refactor] Outbox 패턴 재시도 메커니즘 개선 #13

@rlagnlfo1004

Description

@rlagnlfo1004

🔗 관련 이슈

💡 질문 / 논의 내용

기존 Outbox 패턴 구현에서는 스케줄러가 Outbox 테이블의 미처리 이벤트를 조회하여 Discord/Email 알림을 전송하는 구조였으나, 실질적인 재시도가 이루어지지 않는 문제가 있었다.

📝 추가 설명(선택)

1. 무한 재시도 문제

  • 재시도 횟수 제한이 없어 영구적으로 실패하는 이벤트가 무한 반복됨
  • 외부 서비스(Discord/Email)가 영구 장애 시, 시스템 리소스 낭비
Scheduler (10초마다)
    └─ OutboxEventProcessorModule.processEvent()
            └─ notificationPort.sendProjectNotification()
                    └─ NotificationAdapter.sendProjectXxxNotification()
                            ├─ discordWebhookModule.send()  ← try-catch로 삼킴 ✅/❌
                            └─ emailNotificationModule.send() ← try-catch로 삼킴 ✅/❌
  • NotificationAdapter (핵심 문제)
    • Discord/Email 예외를 내부에서 catch하고 log만 남기기에, 예외가 위로 전파되지 않는 문제 존재
  • OutboxEventProcessorModule
    • 예외가 오면 markAsProcessed() 않고 throw
    • 예외를 받아야 재시도 가능한데 못 받기에 재시도를 하지 않는다.

즉, NotificationAdapter가 예외를 삼켜버려서 Processor는 성공으로 인식 → markAsProcessed() 호출 → processed = true 로 저장된다.

알림이 실패했어도 다시 시도되지 않는 구조

2. 재시도 추적 부재

  • OutboxEventEntity에 재시도 횟수(retryCount) 필드가 없음
  • 몇 번 재시도했는지 알 수 없어 모니터링 및 디버깅 어려움
  • 이는 이메일 주소가 잘못되거나, 특정 문제로 인해 영원히 해결되지 않더라도, 이를 계속해서 재시도 할 수 있습니다.

3. 고정된 재시도 간격

  • 현재 10초마다 고정 간격으로 재시도
  • 지수 백오프(exponential backoff) 전략 미적용
  • 일시적 장애 시 불필요한 재시도로 외부 서비스 부하 증가

📊 현재 코드 분석

OutboxEventProcessorModule (현재)

		/**
     * 프로젝트 요청 생성 이벤트 처리
     */
    private void handleProjectRequestCreated(OutboxEventEntity event) throws Exception {
        ProjectRequestEvent projectEvent = objectMapper.readValue(
                event.getPayload(),
                ProjectRequestEvent.class
        );

        notificationPort.sendProjectNotification(new ProjectRequestCreatedNotification(
                projectEvent.projectRequestId(),
                projectEvent.projectName(),
                projectEvent.projectDescription(),
                projectEvent.requesterId(),
                projectEvent.email()
        ));

        event.markAsProcessed();
        outboxMessageRepositoryPort.save(event);
    }

NotificationAdapter (현재)

		/**
     * 프로젝트 요청 생성 알림
     */
    private void sendProjectRequestCreatedNotification(ProjectRequestCreatedNotification notification) {
        // Discord 알림 전송
        try {
            discordWebhookModule.sendProjectRequestCreatedNotification(
                    notification.requesterName(),
                    notification.requesterEmail(),
                    notification.projectName(),
                    notification.projectRequestId(),
                    notification.projectDescription()
            );
            log.info("Discord notification sent for PROJECT_REQUEST_CREATED: projectRequestId={}", notification.projectRequestId());
        } catch (Exception e) {
            log.error("Failed to send Discord notification for PROJECT_REQUEST_CREATED: projectRequestId={}", notification.projectRequestId(), e);
        }

        // Email 알림 전송
        try {
            emailNotificationModule.sendProjectRequestCreatedNotification(
                    notification.requesterName(),
                    notification.requesterEmail(),
                    notification.projectName(),
                    notification.projectRequestId(),
                    notification.projectDescription()
            );
            log.info("Email notification sent for PROJECT_REQUEST_CREATED: projectRequestId={}", notification.projectRequestId());
        } catch (Exception e) {
            log.error("Failed to send Email notification for PROJECT_REQUEST_CREATED: projectRequestId={}", notification.projectRequestId(), e);
        }
    }

OutboxEventEntity (현재)

@Entity
@Table(name = "outbox_events")
public class OutboxEventEntity {
    @Id @GeneratedValue(strategy = GenerationType.UUID)
    private String eventId;

    private Boolean processed = false;
    private LocalDateTime createdAt;
    private LocalDateTime processedAt;

    // ❌ 재시도 관련 필드 없음
    // - retryCount
    // - maxRetryCount
    // - lastErrorMessage
    // - nextRetryAt
}

OutboxEventSchedulerModule (현재)

@Scheduled(fixedDelay = 10000) // 고정 10초
@SchedulerLock(name = "OutboxEventProcessor_processUnprocessedEvents")
public void processUnprocessedEvents() {
    List<OutboxEventEntity> unprocessedEvents =
        outboxMessageRepositoryPort.findByProcessedFalse();

    for (OutboxEventEntity event : unprocessedEvents) {
        try {
            outboxEventProcessorModule.processEvent(event);
        } catch (Exception e) {
            // ❌ 재시도 횟수 증가 없음
            // ❌ 에러 정보 저장 없음
            // ❌ 최대 재시도 체크 없음
            log.error("Failed to process outbox event", e);
        }
    }
}

📚 참고 자료(선택)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions