Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package hanium.modic.backend.common.amqp.config;

import java.util.HashMap;
import java.util.Map;

import org.springframework.amqp.core.AmqpAdmin;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
Expand Down Expand Up @@ -27,11 +30,22 @@ public class RabbitMqConfig {
public static final String AI_IMAGE_CREATED_EXCHANGE = "ai.image.created.exchange";
public static final String AI_IMAGE_CREATED_ROUTING_KEY = "ai.image.created";

// DLQ (Dead Letter Queue) 및 재시도 관련 상수
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";

private final RabbitMqProperties rabbitMqProperties;

@Bean
public Queue aiImageRequestQueue() {
return new Queue(AI_IMAGE_REQUEST_QUEUE, true);
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); // 기본적으로 재시도로 라우팅
Copy link

Copilot AI Aug 9, 2025

Choose a reason for hiding this comment

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

The dead letter routing key configuration may cause infinite retry loops. When a message fails, it goes to DLX with retry routing key, but there's no mechanism to limit retry attempts or eventually route to the final DLQ. Consider adding retry count tracking or max retry limit.

Copilot uses AI. Check for mistakes.
return new Queue(AI_IMAGE_REQUEST_QUEUE, true, false, false, args);
Comment on lines +45 to +48
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

재시도 루프가 무한 반복될 수 있습니다 – 최종 DLQ 라우팅 로직을 명확히 해주세요.

현재 x-dead-letter-exchange 를 DLX로, x-dead-letter-routing-key 를 재시도용 라우팅 키로만 지정해 두어 최초 실패 시에는 무조건 재시도 큐로만 흐르게 됩니다.
메시지가 재시도 큐에서 다시 만료-실패를 반복하면 동일 경로로 계속 순환하여 실제 DLQ(ai.image.request.dlq)에 도달하지 못할 가능성이 있습니다.

  • x-death 헤더를 검사해 재시도 횟수를 초과하면 DLQ 라우팅 키로 재게시하거나,
  • 별도의 정책(예: DLX-policy)으로 max-length, max-delivery-count 등을 설정해 DLQ로 강제 전환

과 같은 명시적 종료 지점을 추가해 주세요.
무한 재시도는 메시지 폭주 및 비용 증가로 이어질 수 있습니다.

🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/common/amqp/config/RabbitMqConfig.java
around lines 45 to 48, the current dead-letter exchange and routing key setup
causes infinite retry loops because messages always route back to the retry
queue. To fix this, implement logic to check the 'x-death' header for retry
count and once a maximum retry threshold is exceeded, route the message to the
final dead-letter queue using its routing key. Alternatively, configure queue
policies like max-length or max-delivery-count to automatically move messages to
the DLQ after retries. This ensures messages do not endlessly cycle and properly
reach the DLQ after retry limits.

}

@Bean
Expand Down Expand Up @@ -63,6 +77,58 @@ public Binding aiImageCreatedBinding(Queue aiImageCreatedQueue, TopicExchange ai
.with(AI_IMAGE_CREATED_ROUTING_KEY);
}

// 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);
}

// 재시도용 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)
Copy link

Copilot AI Aug 9, 2025

Choose a reason for hiding this comment

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

The binding direction is incorrect. This method attempts to bind a TopicExchange to another TopicExchange, but the bind() method expects a Queue as the first parameter. This should bind the retry exchange to the DLX, not the other way around.

Suggested change
return BindingBuilder.bind(aiImageRequestRetryExchange)
public Binding aiImageRequestDlxToRetryBinding(Queue aiImageRequestRetryQueue, TopicExchange aiImageRequestDlx) {
return BindingBuilder.bind(aiImageRequestRetryQueue)

Copilot uses AI. Check for mistakes.
.to(aiImageRequestDlx)
.with(AI_IMAGE_REQUEST_RETRY_ROUTING_KEY);
}

@Bean
public CachingConnectionFactory connectionFactory() {
CachingConnectionFactory connectionFactory = new CachingConnectionFactory();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package hanium.modic.backend.domain.ai.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.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 final AiRequestRepository aiRequestRepository;

@Transactional
@RabbitListener(queues = AI_IMAGE_REQUEST_DLQ)
public void handleFinalFailedMessage(AiImageRequestMessageDto messageDto, Message message) {
log.error("[최종 실패] AI 이미지 생성 요청 최종 실패: requestId={}", messageDto.requestId());
Comment on lines +26 to +29
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

메시지 소비 실패 시 재큐잉/ACK 전략을 명시하세요

@Transactional 내부에서 DB 예외가 발생하면 런타임 예외가 throw 되어 컨테이너가 NACK + requeue false 로 처리할 수 있습니다.
현재 리스너는 수동 ACK 설정이 없으므로, 예외 발생 시 메시지가 즉시 DLX 로 이동해 재시도 없이 종료될 수 있으니

  • ackMode=MANUAL + 성공 시 수동 ACK, 실패 시 channel.basicNack(..., requeue=true)
  • 또는 SimpleRabbitListenerContainerFactorydefaultRequeueRejected=true

등 원하는 재시도·폐기 정책을 명확히 지정해 주세요.

🤖 Prompt for AI Agents
In src/main/java/hanium/modic/backend/domain/ai/listener/DlqListener.java around
lines 26 to 29, the message listener lacks explicit acknowledgment and requeue
strategy, which can cause messages to be lost or prematurely dead-lettered on
exceptions. To fix this, configure the listener with manual acknowledgment mode
by setting ackMode=MANUAL, then in the method, acknowledge the message manually
on success and on failure call channel.basicNack with requeue=true to enable
retries. Alternatively, set defaultRequeueRejected=true in the
SimpleRabbitListenerContainerFactory configuration to control requeue behavior
globally.


// 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());
}
}

}