Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.annotation.EnableRabbit;
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
import org.springframework.amqp.rabbit.connection.CachingConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
Expand Down Expand Up @@ -38,6 +39,14 @@ public class RabbitMqConfig {
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";

// DLQ (Dead Letter Queue) 및 재시도 관련 상수 - AI Image Created
public static final String AI_IMAGE_CREATED_DLX = "ai.image.created.dlx";
public static final String AI_IMAGE_CREATED_DLQ = "ai.image.created.dlq";
public static final String AI_IMAGE_CREATED_RETRY_EXCHANGE = "ai.image.created.retry.exchange";
public static final String AI_IMAGE_CREATED_RETRY_QUEUE = "ai.image.created.retry.queue";
public static final String AI_IMAGE_CREATED_DLQ_ROUTING_KEY = "ai.image.created.dlq";
public static final String AI_IMAGE_CREATED_RETRY_ROUTING_KEY = "ai.image.created.retry";

// Similarity Check Request
public static final String VOTE_SIMILARITY_REQUEST_QUEUE = "vote.similarity.request.queue";
public static final String VOTE_SIMILARITY_REQUEST_EXCHANGE = "vote.similarity.request.exchange";
Expand Down Expand Up @@ -72,7 +81,10 @@ public Binding aiImageRequestBinding(Queue aiImageRequestQueue, TopicExchange ai

@Bean
public Queue aiImageCreatedQueue() {
return new Queue(AI_IMAGE_CREATED_QUEUE, true);
Map<String, Object> args = new HashMap<>();
args.put("x-dead-letter-exchange", AI_IMAGE_CREATED_DLX);
args.put("x-dead-letter-routing-key", AI_IMAGE_CREATED_RETRY_ROUTING_KEY);
return new Queue(AI_IMAGE_CREATED_QUEUE, true, false, false, args);
}
Comment on lines 82 to 88
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

현재 구성은 무한 재시도 루프가 됩니다 — DLQ로 절대 도달하지 않음

  • aiImageCreatedQueue: DLX 라우팅키를 항상 "…retry"로 지정
  • aiImageCreatedRetryQueue: TTL 만료 시 원래 EX/ROUTING_KEY로 반환
  • 결과적으로 실패 → 재시도큐 → 원래큐 → 실패 … 가 반복되고, DLQ 바인딩("…dlq")은 사용되지 않습니다. DLQ 리스너는 메시지를 받지 못합니다.

두 가지 해결책 중 하나를 권장합니다:

  1. 간단한 설정 기반(1회 지연 후 최종 DLQ로 이동)
@@ public Queue aiImageCreatedRetryQueue() {
- args.put("x-dead-letter-exchange", AI_IMAGE_CREATED_EXCHANGE);
- args.put("x-dead-letter-routing-key", AI_IMAGE_CREATED_ROUTING_KEY);
+ // TTL 만료 시 최종 DLQ로 보냄 (지연 재시도는 1회로 제한)
+ args.put("x-dead-letter-exchange", AI_IMAGE_CREATED_DLX);
+ args.put("x-dead-letter-routing-key", AI_IMAGE_CREATED_DLQ_ROUTING_KEY);
}

이렇게 하면: 원래 큐에서 1회 처리 실패 → 재시도 큐로 이동(60초 대기) → DLQ로 최종 이동(무한 루프 차단).

  1. 권장(정교한) 접근: Spring Retry + RepublishMessageRecoverer
  • 리스너 컨테이너 팩토리에 RetryInterceptor를 추가하여 최대 N회(예: 3) 시도 후 DLQ로 republish.
  • 재시도 간 백오프(지수/고정) 설정 가능. DLQ 라우팅키는 “…dlq”.

예:

+import org.springframework.amqp.rabbit.config.RetryInterceptorBuilder;
+import org.springframework.amqp.rabbit.retry.RepublishMessageRecoverer;
+import org.springframework.retry.interceptor.RetryOperationsInterceptor;

@@ public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
-  return factory;
+  factory.setAdviceChain(
+    RetryInterceptorBuilder.stateless()
+      .maxAttempts(3)
+      .backOffOptions(2000, 2.0, 60000) // 2s 시작, 2배, 최대 60s
+      .recoverer(new RepublishMessageRecoverer(rabbitTemplate(connectionFactory),
+        AI_IMAGE_CREATED_DLX, AI_IMAGE_CREATED_DLQ_ROUTING_KEY))
+      .build()
+  );
+  return factory;

참고: Request 경로도 동일한 패턴(무한 루프)입니다. 동일한 방식으로 정리하시길 권장합니다.

무한 루프가 의도인지, 최종 DLQ로 보내고자 하는 최대 재시도 횟수(또는 지연 전략)를 알려주시면 정확한 설정/코드 패치를 드리겠습니다.

Also applies to: 128-136, 147-154, 102-120


다음 스크립트를 실행하여 RabbitMqConfig.java의 DLX/TTL 및 컨테이너 팩토리 재시도 설정을 확인하겠습니다.


🏁 Script executed:

#!/bin/bash
FILE=src/main/java/hanium/modic/backend/common/amqp/config/RabbitMqConfig.java

echo "=== aiImageCreatedQueue 정의 ==="
grep -n "aiImageCreatedQueue" $FILE

echo "=== aiImageCreatedRetryQueue 정의 ==="
grep -n "aiImageCreatedRetryQueue" $FILE

echo "=== x-dead-letter 설정 ==="
grep -n "x-dead-letter" $FILE

echo "=== Listener 컨테이너 팩토리 설정 ==="
grep -R -n "rabbitListenerContainerFactory" src/main/java/hanium/modic/backend/common/amqp/config

echo "=== setDefaultRequeueRejected 사용 여부 ==="
grep -R -n "setDefaultRequeueRejected" src/main/java/hanium/modic/backend/common/amqp/config

Length of output: 1496


무한 재시도 루ープ 방지 및 DLQ 도달 설정 필요

  • 현재 aiImageCreatedQueue → 재시도큐 → 원큐 반복만 수행되어 최종 DLQ(“…dlq”)에는 메시지가 도달하지 않음

  • 아래 두 가지 중 하나로 수정하세요

    1. 재시도큐의 DLX를 DLQ로 변경해 1회 재시도 후 최종 DLQ로 이동
      @@ public Queue aiImageCreatedRetryQueue() {
      - args.put("x-dead-letter-exchange", AI_IMAGE_CREATED_EXCHANGE);
      - args.put("x-dead-letter-routing-key", AI_IMAGE_CREATED_ROUTING_KEY);
      + // 1회 지연 후 최종 DLQ로 이동
      + args.put("x-dead-letter-exchange", AI_IMAGE_CREATED_DLX);
      + args.put("x-dead-letter-routing-key", AI_IMAGE_CREATED_DLQ_ROUTING_KEY);
      }
    2. Spring Retry + RepublishMessageRecoverer 적용
      • rabbitListenerContainerFactoryRetryInterceptorBuilder.stateless().maxAttempts(3)…recoverer(new RepublishMessageRecoverer(rabbitTemplate, AI_IMAGE_CREATED_DLX, AI_IMAGE_CREATED_DLQ_ROUTING_KEY)) 추가하여 N회 실패 시 DLQ로 발행
  • 동일 패턴이 Request 큐에도 적용되어 있으니 함께 수정 필요

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/common/amqp/config/RabbitMqConfig.java
around lines 82 to 88, the current aiImageCreatedQueue setup causes messages to
cycle between the original queue and the retry queue and never reach the final
DLQ; fix by either (A) changing the retry queue's
dead-letter-exchange/routing-key so that after the retry it is routed to the
final DLQ (i.e. set the retry queue DLX to AI_IMAGE_CREATED_DLX and routing key
to AI_IMAGE_CREATED_DLQ_ROUTING_KEY), or (B) configure Spring Retry on
rabbitListenerContainerFactory using
RetryInterceptorBuilder.stateless().maxAttempts(3)...recoverer(new
RepublishMessageRecoverer(rabbitTemplate, AI_IMAGE_CREATED_DLX,
AI_IMAGE_CREATED_DLQ_ROUTING_KEY)) so that after N failures the message is
republished to the DLQ; apply the same correction for the corresponding Request
queue(s).


@Bean
Expand All @@ -87,6 +99,60 @@ public Binding aiImageCreatedBinding(Queue aiImageCreatedQueue, TopicExchange ai
.with(AI_IMAGE_CREATED_ROUTING_KEY);
}

// DLX (Dead Letter Exchange) - AI Image Created
@Bean
public TopicExchange aiImageCreatedDlx() {
return new TopicExchange(AI_IMAGE_CREATED_DLX, true, false);
}

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

// DLX에서 최종 DLQ로의 바인딩
@Bean
public Binding aiImageCreatedDlqBinding(Queue aiImageCreatedDlq, TopicExchange aiImageCreatedDlx) {
return BindingBuilder.bind(aiImageCreatedDlq)
.to(aiImageCreatedDlx)
.with(AI_IMAGE_CREATED_DLQ_ROUTING_KEY);
}

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

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

// 재시도 Exchange와 Queue 바인딩
@Bean
public Binding aiImageCreatedRetryBinding(Queue aiImageCreatedRetryQueue,
TopicExchange aiImageCreatedRetryExchange) {
return BindingBuilder.bind(aiImageCreatedRetryQueue)
.to(aiImageCreatedRetryExchange)
.with(AI_IMAGE_CREATED_RETRY_ROUTING_KEY);
}

// DLX에서 재시도 Exchange로의 바인딩
@Bean
public Binding aiImageCreatedDlxToRetryBinding(TopicExchange aiImageCreatedRetryExchange,
TopicExchange aiImageCreatedDlx) {
return BindingBuilder.bind(aiImageCreatedRetryExchange)
.to(aiImageCreatedDlx)
.with(AI_IMAGE_CREATED_RETRY_ROUTING_KEY);
}

// DLX (Dead Letter Exchange) - 실패한 메시지를 받아서 재시도 또는 최종 처리로 라우팅
@Bean
public TopicExchange aiImageRequestDlx() {
Expand Down Expand Up @@ -186,14 +252,33 @@ public CachingConnectionFactory connectionFactory() {

connectionFactory.setRequestedHeartBeat(120); // 2분

try {
connectionFactory.getRabbitConnectionFactory().useSslProtocol("TLSv1.2");
} catch (Exception e) {
throw new RuntimeException("RabbitMQ SSL 설정 실패", e);
// AWS AmazonMQ는 포트 5671에서 자동으로 SSL을 사용
// 포트 5671일 경우에만 SSL 프로토콜 활성화
if (rabbitMqProperties.getPort() == 5671) {
try {
connectionFactory.getRabbitConnectionFactory().useSslProtocol();
} catch (Exception e) {
throw new RuntimeException("RabbitMQ SSL 설정 실패", e);
}
}
return connectionFactory;
}

@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
CachingConnectionFactory connectionFactory) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setMessageConverter(jackson2JsonMessageConverter());

// 리스너에서 예외 발생 시 메시지를 다시 큐로 반환하지 않음
// DLQ가 설정된 큐는 x-dead-letter-exchange를 통해 DLX로 라우팅되고
// DLQ에서 실패해도 무한 루프가 발생하지 않음
factory.setDefaultRequeueRejected(false);

return factory;
}

@Bean
public AmqpAdmin amqpAdmin(CachingConnectionFactory connectionFactory) {
return new RabbitAdmin(connectionFactory);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ public enum AiImageStatus {
REQUEST, // 요청 상태 및 요청 완료 상태
REQUEST_PENDING, // AI 요청 대기 상태
REQUEST_FAILED, // AI 요청 실패 상태
RESPONSE // 응답을 의미
RESPONSE, // 응답을 의미
RESPONSE_FAILED // 응답 처리 실패 상태
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package hanium.modic.backend.domain.ai.aiServer.listener;

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

import java.util.Optional;

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

import hanium.modic.backend.domain.ai.aiChat.entity.AiChatMessageEntity;
import hanium.modic.backend.domain.ai.aiChat.repository.AiChatMessageRepository;
import hanium.modic.backend.domain.ai.aiServer.dto.AiImageResponseMessageDto;
import hanium.modic.backend.domain.ai.aiServer.enums.AiImageStatus;
import hanium.modic.backend.domain.ai.aiServer.enums.SenderType;
import hanium.modic.backend.domain.ai.aiServer.service.AiResponseSseService;
import hanium.modic.backend.web.ai.aiChat.dto.response.ChatMessageResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class AiImageCreatedDlqListener {

private final AiChatMessageRepository aiChatMessageRepository;
private final AiResponseSseService aiResponseSseService;

/**
* AI 이미지 생성 응답 처리 최종 실패 메시지 처리
* - 메시지 상태를 RESPONSE_FAILED로 변경
* - SSE 연결이 있으면 클라이언트에 실패 알림
* @param messageDto AI 이미지 응답 메시지
* @param message RabbitMQ 메시지 메타데이터
*/
@Transactional
@RabbitListener(queues = AI_IMAGE_CREATED_DLQ)
public void handleFinalFailedMessage(AiImageResponseMessageDto messageDto, Message message) {
log.error("[최종 실패] AI 이미지 생성 응답 처리 최종 실패: requestId={}", messageDto.requestId());

// 1. 요청 메시지 조회
Optional<AiChatMessageEntity> chatMessageOpt = aiChatMessageRepository
.findByRequestIdAndSenderType(messageDto.requestId(), SenderType.USER);

if (chatMessageOpt.isEmpty()) {
log.error("[데이터 오류] requestId={}에 해당하는 채팅 메시지를 찾을 수 없습니다", messageDto.requestId());
return;
}

AiChatMessageEntity requestChatMessage = chatMessageOpt.get();

// 2. 메시지 상태를 RESPONSE_FAILED로 변경
requestChatMessage.updateStatus(AiImageStatus.RESPONSE_FAILED);
aiChatMessageRepository.save(requestChatMessage);
log.info("[상태 업데이트] requestId={} 메시지 상태를 RESPONSE_FAILED로 변경", messageDto.requestId());

// 3. SSE 연결이 있으면 클라이언트에 실패 알림
try {
ChatMessageResponse errorResponse = ChatMessageResponse.createErrorResponse(
requestChatMessage,
"이미지 생성 응답 처리에 실패했습니다. 잠시 후 다시 시도해주세요."
);
aiResponseSseService.sendToClient(messageDto.requestId(), errorResponse);
log.info("[SSE 알림] requestId={} 클라이언트에 실패 알림 전송", messageDto.requestId());
} catch (Exception e) {
log.warn("[SSE 알림 실패] requestId={} SSE 연결이 없거나 전송 실패: {}",
messageDto.requestId(), e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,17 @@ public static ChatMessageResponse from(AiChatMessageEntity entity, String imageU
public static ChatMessageResponse from(AiChatMessageEntity entity) {
return from(entity, null);
}

public static ChatMessageResponse createErrorResponse(AiChatMessageEntity entity, String errorMessage) {
return new ChatMessageResponse(
entity.getId(),
entity.getMessageOrder(),
SenderType.AI,
errorMessage,
entity.getRequestId(),
null,
entity.getCreateAt(),
AiImageStatus.RESPONSE_FAILED
);
Comment on lines +39 to +49
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

오류 응답의 senderType 하드코딩으로 인한 불일치

USER 요청 엔티티의 id/order를 그대로 쓰면서 senderType을 AI로 고정하면, 클라이언트가 senderType 기반 렌더링/정렬 시 혼선이 생깁니다. 상태 변경 알림이라면 엔티티의 senderType을 그대로 사용하세요. 또한 createdAt을 요청 시각 대신 now로 표기하는 것도 고려해 주세요(선택).

필수 수정(불일치 해소):

-            SenderType.AI,
+            entity.getSenderType(),

선택(타임스탬프 의미 명확화):

-            entity.getCreateAt(),
+            LocalDateTime.now(),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public static ChatMessageResponse createErrorResponse(AiChatMessageEntity entity, String errorMessage) {
return new ChatMessageResponse(
entity.getId(),
entity.getMessageOrder(),
SenderType.AI,
errorMessage,
entity.getRequestId(),
null,
entity.getCreateAt(),
AiImageStatus.RESPONSE_FAILED
);
public static ChatMessageResponse createErrorResponse(AiChatMessageEntity entity, String errorMessage) {
return new ChatMessageResponse(
entity.getId(),
entity.getMessageOrder(),
entity.getSenderType(),
errorMessage,
entity.getRequestId(),
null,
LocalDateTime.now(),
AiImageStatus.RESPONSE_FAILED
);
}

}
}
Loading