Skip to content

AI 이미지 실패 처리 #131

@goalSetter09

Description

@goalSetter09

AI 이미지 생성 요청 DLQ 및 재시도 메커니즘 구현 계획

1. 개요

현재 AI 이미지 생성 요청 처리 과정에서 외부 AI 모델 서버가 메시지 처리에 실패할 경우, 해당 요청이 유실될 수 있는 문제를 해결하기 위해 RabbitMQ의 DLQ(Dead Letter Queue)와 TTL(Time-To-Live)을 활용한 재시도 메커니즘을 도입한다. 이를 통해 시스템의 안정성을 높이고 실패한 요청을 추적 및 관리할 수 있도록 개선한다.

2. 현재 아키텍처 분석

2.1 현재 메시지 흐름

AiImageGenerationService.processImageGeneration()
  → MessageQueueService.sendImageGenerationRequest() 
  → ai.image.request.exchange 
  → ai.image.request.queue 
  → 외부 AI 모델 서버(Consumer)
  → (성공시) ai.image.created.exchange 
  → ai.image.created.queue 
  → AiImageCreatedListener.handleImageCreated()

2.2 현재 구성 요소

  • Queue: ai.image.request.queue - AI 이미지 생성 요청 대기열
  • Exchange: ai.image.request.exchange - 요청 라우팅용 토픽 익스체인지
  • Routing Key: ai.image.request - 요청 메시지 라우팅 키
  • Entity: AiRequestEntity - 요청 상태 관리 (PENDING → DONE)
  • Status Enum: AiImageStatus - PENDING, DONE, FAILED

2.3 문제점

  • 외부 AI 서버에서 메시지 처리 실패 시 메시지가 유실됨
  • 실패한 요청에 대한 재시도 메커니즘 부재
  • 실패한 요청의 최종 상태 관리 불가

3. 신규 DLQ 및 재시도 아키텍처 설계

3.1 메시지 흐름도

Producer → Request Exchange → Request Queue ──(실패)──→ DLX ──→ Retry Queue (TTL) ──(만료)──→ Request Exchange (재시도)
                                  ↑                            │
                                  └──(재시도)───────────────────┘
                                                               │
                                                               ├──(최대 재시도 초과)──→ DLQ → DLQ Consumer → (DB 상태 'FAILED'로 변경)

3.2 새로운 구성 요소

  1. DLX (Dead Letter Exchange): ai.image.request.dlx
    • 실패한 메시지를 받아 재시도 또는 최종 처리로 라우팅
  2. Retry Queue: ai.image.request.retry.queue
    • TTL 60초 설정으로 재시도 대기
    • 만료 시 원래 exchange로 재전송
  3. Final DLQ: ai.image.request.dlq
    • 최대 재시도 횟수 초과 시 최종 저장소
  4. DLQ Listener: 최종 실패 메시지 처리 및 상태 업데이트

3.3 재시도 로직

  • 최대 재시도 횟수: 3회
  • 재시도 간격: 60초 (TTL)
  • 메시지 헤더에 x-retries-count 저장하여 재시도 횟수 추적

4. 파일별 상세 구현 계획

4.1. src/main/java/hanium/modic/backend/common/amqp/config/RabbitMqConfig.java 수정

4.1.1 상수 추가

// 기존 상수 하단에 추가
public static final String AI_IMAGE_REQUEST_DLX = "ai.image.request.dlx";
public static final String AI_IMAGE_REQUEST_DLQ = "ai.image.request.dlq";
public static final String AI_IMAGE_REQUEST_RETRY_EXCHANGE = "ai.image.request.retry.exchange";
public static final String AI_IMAGE_REQUEST_RETRY_QUEUE = "ai.image.request.retry.queue";
public static final String AI_IMAGE_REQUEST_DLQ_ROUTING_KEY = "ai.image.request.dlq";
public static final String AI_IMAGE_REQUEST_RETRY_ROUTING_KEY = "ai.image.request.retry";

4.1.2 DLX 및 DLQ Bean 추가

// DLX (Dead Letter Exchange) - 실패한 메시지를 받아서 재시도 또는 최종 처리로 라우팅
@Bean
public TopicExchange aiImageRequestDlx() {
    return new TopicExchange(AI_IMAGE_REQUEST_DLX, true, false);
}

// 최종 실패 메시지 저장소
@Bean
public Queue aiImageRequestDlq() {
    return new Queue(AI_IMAGE_REQUEST_DLQ, true);
}

// DLX에서 최종 DLQ로의 바인딩
@Bean
public Binding aiImageRequestDlqBinding(Queue aiImageRequestDlq, TopicExchange aiImageRequestDlx) {
    return BindingBuilder.bind(aiImageRequestDlq)
        .to(aiImageRequestDlx)
        .with(AI_IMAGE_REQUEST_DLQ_ROUTING_KEY);
}

4.1.3 재시도 Exchange 및 Queue Bean 추가

// 재시도용 Exchange
@Bean
public TopicExchange aiImageRequestRetryExchange() {
    return new TopicExchange(AI_IMAGE_REQUEST_RETRY_EXCHANGE, true, false);
}

// 재시도 대기 Queue (TTL 60초 설정, 만료 시 원래 exchange로 재전송)
@Bean
public Queue aiImageRequestRetryQueue() {
    Map<String, Object> args = new HashMap<>();
    args.put("x-message-ttl", 60000); // 60초
    args.put("x-dead-letter-exchange", AI_IMAGE_REQUEST_EXCHANGE); // 만료 시 원래 exchange로
    args.put("x-dead-letter-routing-key", AI_IMAGE_REQUEST_ROUTING_KEY);
    return new Queue(AI_IMAGE_REQUEST_RETRY_QUEUE, true, false, false, args);
}

// 재시도 Exchange와 Queue 바인딩
@Bean
public Binding aiImageRequestRetryBinding(Queue aiImageRequestRetryQueue, TopicExchange aiImageRequestRetryExchange) {
    return BindingBuilder.bind(aiImageRequestRetryQueue)
        .to(aiImageRequestRetryExchange)
        .with(AI_IMAGE_REQUEST_RETRY_ROUTING_KEY);
}

// DLX에서 재시도 Exchange로의 바인딩
@Bean
public Binding aiImageRequestDlxToRetryBinding(TopicExchange aiImageRequestRetryExchange, TopicExchange aiImageRequestDlx) {
    return BindingBuilder.bind(aiImageRequestRetryExchange)
        .to(aiImageRequestDlx)
        .with(AI_IMAGE_REQUEST_RETRY_ROUTING_KEY);
}

4.1.4 기존 Queue 수정

// 기존 aiImageRequestQueue() 메서드를 아래와 같이 수정
@Bean
public Queue aiImageRequestQueue() {
    Map<String, Object> args = new HashMap<>();
    args.put("x-dead-letter-exchange", AI_IMAGE_REQUEST_DLX);
    args.put("x-dead-letter-routing-key", AI_IMAGE_REQUEST_RETRY_ROUTING_KEY); // 기본적으로 재시도로 라우팅
    return new Queue(AI_IMAGE_REQUEST_QUEUE, true, false, false, args);
}

4.2. src/main/java/hanium/modic/backend/domain/ai/enums/AiImageStatus.java 수정

AiImageStatus 열거형은 이미 FAILED 상태가 존재하므로 수정 불필요.

4.3. src/main/java/hanium/modic/backend/domain/ai/listener/DlqListener.java 신규 생성

4.3.1 클래스 구조

package hanium.modic.backend.domain.ai.listener;

import static hanium.modic.backend.common.amqp.config.RabbitMqConfig.*;

import java.util.Map;
import java.util.Optional;

import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import hanium.modic.backend.domain.ai.domain.AiRequestEntity;
import hanium.modic.backend.domain.ai.dto.AiImageRequestMessageDto;
import hanium.modic.backend.domain.ai.enums.AiImageStatus;
import hanium.modic.backend.domain.ai.repository.AiRequestRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class DlqListener {
    
    private static final int MAX_RETRY_COUNT = 3;
    private static final String RETRY_COUNT_HEADER = "x-retries-count";
    
    private final AiRequestRepository aiRequestRepository;
    private final RabbitTemplate rabbitTemplate;
    
    @Transactional
    @RabbitListener(queues = AI_IMAGE_REQUEST_DLX)
    public void handleFailedMessage(AiImageRequestMessageDto messageDto, Message message) {
        log.info("[DLQ 처리] 실패한 메시지 수신: requestId={}", messageDto.requestId());
        
        // 메시지 헤더에서 재시도 횟수 확인
        Map<String, Object> headers = message.getMessageProperties().getHeaders();
        Integer retryCount = (Integer) headers.getOrDefault(RETRY_COUNT_HEADER, 0);
        
        if (retryCount >= MAX_RETRY_COUNT) {
            // 최대 재시도 횟수 초과 - 최종 실패 처리
            handleFinalFailure(messageDto);
        } else {
            // 재시도 수행
            handleRetry(messageDto, retryCount, headers);
        }
    }
    
    private void handleFinalFailure(AiImageRequestMessageDto messageDto) {
        log.error("[최종 실패] AI 이미지 생성 요청 최종 실패: requestId={}", messageDto.requestId());
        
        // AiRequestEntity 상태를 FAILED로 변경
        Optional<AiRequestEntity> aiRequestOpt = aiRequestRepository.findByRequestId(messageDto.requestId());
        if (aiRequestOpt.isPresent()) {
            AiRequestEntity aiRequest = aiRequestOpt.get();
            aiRequest.updateStatus(AiImageStatus.FAILED);
            aiRequestRepository.save(aiRequest);
            log.info("[상태 업데이트] requestId={} 상태를 FAILED로 변경", messageDto.requestId());
        } else {
            log.error("[데이터 오류] requestId={}에 해당하는 AI 요청을 찾을 수 없습니다", messageDto.requestId());
        }
        
        // 최종 DLQ로 메시지 전송 (모니터링 및 디버깅용)
        rabbitTemplate.convertAndSend(
            AI_IMAGE_REQUEST_DLX,
            AI_IMAGE_REQUEST_DLQ_ROUTING_KEY,
            messageDto
        );
    }
    
    private void handleRetry(AiImageRequestMessageDto messageDto, Integer currentRetryCount, Map<String, Object> headers) {
        int nextRetryCount = currentRetryCount + 1;
        log.info("[재시도] requestId={}, 재시도 횟수: {}/{}", 
                messageDto.requestId(), nextRetryCount, MAX_RETRY_COUNT);
        
        // 헤더에 재시도 횟수 업데이트
        headers.put(RETRY_COUNT_HEADER, nextRetryCount);
        
        // 재시도 큐로 메시지 전송 (TTL 후 자동으로 원래 큐로 재전송됨)
        rabbitTemplate.convertAndSend(
            AI_IMAGE_REQUEST_RETRY_EXCHANGE,
            AI_IMAGE_REQUEST_RETRY_ROUTING_KEY,
            messageDto,
            message -> {
                message.getMessageProperties().getHeaders().putAll(headers);
                return message;
            }
        );
    }
}

4.4. src/main/java/hanium/modic/backend/domain/ai/listener/package-info.java 추가 (선택사항)

/**
 * AI 도메인 메시지 리스너 패키지
 * - AiImageCreatedListener: AI 이미지 생성 완료 메시지 처리
 * - DlqListener: 실패한 AI 이미지 생성 요청 재시도 및 최종 실패 처리
 */
package hanium.modic.backend.domain.ai.listener;

5. 필요한 추가 조치

5.1 외부 AI 컨슈머 변경 가이드라인

중요: 외부 AI 모델 서버(메시지 컨슈머)에서 다음과 같은 변경이 필요합니다:

  1. 메시지 처리 실패 시 대응 방식 변경

    # 기존 방식 (잘못됨) - 메시지를 그냥 버림
    try:
        process_ai_image_generation(message)
        channel.basic_ack(delivery_tag=method.delivery_tag)
    except Exception as e:
        logger.error(f"AI 이미지 생성 실패: {e}")
        channel.basic_ack(delivery_tag=method.delivery_tag)  # 잘못된 방식
    
    # 새로운 방식 (올바름) - 메시지를 DLX로 전송
    try:
        process_ai_image_generation(message)
        channel.basic_ack(delivery_tag=method.delivery_tag)
    except Exception as e:
        logger.error(f"AI 이미지 생성 실패: {e}")
        channel.basic_nack(delivery_tag=method.delivery_tag, requeue=False)  # DLX로 전송
  2. 타임아웃 및 네트워크 오류 처리

    • 연결 오류, 타임아웃 등 일시적 오류도 nack(requeue=False)로 처리
    • 영구적 오류(잘못된 요청 형식 등)와 일시적 오류 구분하지 않고 모두 재시도 대상으로 처리
  3. 로깅 개선

    • 실패 원인을 상세히 로깅하여 디버깅 지원
    • requestId 포함한 구조화된 로그 출력

5.2 모니터링 및 알람 설정

  1. DLQ 메시지 수 모니터링

    • ai.image.request.dlq 큐의 메시지 수를 주기적으로 모니터링
    • 임계값 초과 시 개발팀에 알람 발송
  2. 재시도 패턴 분석

    • 재시도가 자주 발생하는 경우 외부 AI 서버의 문제 여부 확인
    • 특정 요청 유형에서 실패율이 높은지 분석

5.3 데이터베이스 인덱스 최적화

AiRequestEntity에서 FAILED 상태 조회를 위한 인덱스가 이미 존재함:

-- 기존 인덱스 활용 가능
idx_ai_request_user_id_status_request_id (user_id, status, request_id DESC)

6. 테스트 계획

6.1 단위 테스트

  • DlqListener.handleFailedMessage() 메서드 테스트
  • 재시도 횟수별 분기 로직 테스트
  • 최종 실패 시 DB 상태 업데이트 테스트

6.2 통합 테스트

  • RabbitMQ 설정 정상 작동 확인
  • 메시지 흐름 end-to-end 테스트
  • 외부 AI 서버 오류 시나리오 시뮬레이션

6.3 성능 테스트

  • 대량 실패 메시지 처리 성능 확인
  • TTL 기반 재시도 지연 시간 검증

7. 배포 및 롤아웃 계획

7.1 단계별 배포

  1. 1단계: RabbitMQ 인프라 설정 (Queue, Exchange, Binding)
  2. 2단계: DlqListener 배포 및 모니터링 설정
  3. 3단계: 외부 AI 컨슈머 nack 처리 방식 변경
  4. 4단계: 전체 시스템 검증 및 모니터링

7.2 롤백 계획

  • 기존 Queue 설정 백업
  • DlqListener 비활성화 스위치
  • 외부 AI 컨슈머 기존 방식으로 복원

8. 기대 효과

  1. 시스템 안정성 향상

    • AI 이미지 생성 요청 유실률 0% 달성
    • 일시적 오류로 인한 서비스 중단 최소화
  2. 운영 효율성 개선

    • 실패한 요청에 대한 자동 재시도로 수동 개입 감소
    • 최종 실패 요청에 대한 명확한 상태 관리
  3. 사용자 경험 개선

    • AI 이미지 생성 성공률 향상
    • 실패 상태에 대한 명확한 피드백 제공
  4. 모니터링 및 디버깅 개선

    • 실패 패턴 분석을 통한 시스템 개선 방향 도출
    • 외부 의존성(AI 서버) 안정성 모니터링 강화

9. 향후 개선 방안

  1. 지수 백오프(Exponential Backoff) 도입

    • 현재 고정 60초 TTL을 재시도 횟수에 따라 증가하는 방식으로 개선
  2. 실패 유형별 차별화된 처리

    • 일시적 오류(네트워크, 타임아웃)와 영구적 오류(잘못된 요청) 구분
    • 영구적 오류는 즉시 FAILED 상태로 처리
  3. Circuit Breaker 패턴 도입

    • 외부 AI 서버 연속 실패 시 일정 시간 요청 차단
    • 시스템 전체 안정성 보장
  4. 메시지 우선순위 처리

    • 사용자 등급별, 요청 유형별 우선순위 큐 분리
    • 중요한 요청의 우선 처리 보장

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions