-
Notifications
You must be signed in to change notification settings - Fork 1
Closed
Milestone
Description
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 새로운 구성 요소
- DLX (Dead Letter Exchange):
ai.image.request.dlx- 실패한 메시지를 받아 재시도 또는 최종 처리로 라우팅
- Retry Queue:
ai.image.request.retry.queue- TTL 60초 설정으로 재시도 대기
- 만료 시 원래 exchange로 재전송
- Final DLQ:
ai.image.request.dlq- 최대 재시도 횟수 초과 시 최종 저장소
- 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 모델 서버(메시지 컨슈머)에서 다음과 같은 변경이 필요합니다:
-
메시지 처리 실패 시 대응 방식 변경
# 기존 방식 (잘못됨) - 메시지를 그냥 버림 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로 전송
-
타임아웃 및 네트워크 오류 처리
- 연결 오류, 타임아웃 등 일시적 오류도 nack(requeue=False)로 처리
- 영구적 오류(잘못된 요청 형식 등)와 일시적 오류 구분하지 않고 모두 재시도 대상으로 처리
-
로깅 개선
- 실패 원인을 상세히 로깅하여 디버깅 지원
- requestId 포함한 구조화된 로그 출력
5.2 모니터링 및 알람 설정
-
DLQ 메시지 수 모니터링
ai.image.request.dlq큐의 메시지 수를 주기적으로 모니터링- 임계값 초과 시 개발팀에 알람 발송
-
재시도 패턴 분석
- 재시도가 자주 발생하는 경우 외부 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단계: RabbitMQ 인프라 설정 (Queue, Exchange, Binding)
- 2단계: DlqListener 배포 및 모니터링 설정
- 3단계: 외부 AI 컨슈머 nack 처리 방식 변경
- 4단계: 전체 시스템 검증 및 모니터링
7.2 롤백 계획
- 기존 Queue 설정 백업
- DlqListener 비활성화 스위치
- 외부 AI 컨슈머 기존 방식으로 복원
8. 기대 효과
-
시스템 안정성 향상
- AI 이미지 생성 요청 유실률 0% 달성
- 일시적 오류로 인한 서비스 중단 최소화
-
운영 효율성 개선
- 실패한 요청에 대한 자동 재시도로 수동 개입 감소
- 최종 실패 요청에 대한 명확한 상태 관리
-
사용자 경험 개선
- AI 이미지 생성 성공률 향상
- 실패 상태에 대한 명확한 피드백 제공
-
모니터링 및 디버깅 개선
- 실패 패턴 분석을 통한 시스템 개선 방향 도출
- 외부 의존성(AI 서버) 안정성 모니터링 강화
9. 향후 개선 방안
-
지수 백오프(Exponential Backoff) 도입
- 현재 고정 60초 TTL을 재시도 횟수에 따라 증가하는 방식으로 개선
-
실패 유형별 차별화된 처리
- 일시적 오류(네트워크, 타임아웃)와 영구적 오류(잘못된 요청) 구분
- 영구적 오류는 즉시 FAILED 상태로 처리
-
Circuit Breaker 패턴 도입
- 외부 AI 서버 연속 실패 시 일정 시간 요청 차단
- 시스템 전체 안정성 보장
-
메시지 우선순위 처리
- 사용자 등급별, 요청 유형별 우선순위 큐 분리
- 중요한 요청의 우선 처리 보장
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels