Spring 7기 프로젝트/모임 플렛폼 프로젝트
Spring Event에서 RabbitMQ로: MSA 전환을 위한 메시징 아키텍처 마이그레이션
JuNo_12
2025. 8. 1. 18:13
들어가며
모놀리틱 아키텍처에서 MSA(Microservices Architecture)로 전환할 때 가장 중요한 고려사항 중 하나는 서비스 간 통신 방식입니다. Spring Event는 모놀리틱 환경에서 도메인 간 느슨한 결합을 위한 훌륭한 도구이지만, MSA 환경에서는 사용할 수 없습니다. 이번 글에서는 Spring Event 기반 통신을 RabbitMQ로 마이그레이션하는 전략과 실제 구현 방법을 다루겠습니다.

1. 현재 아키텍처 분석
기존 Spring Event 기반 통신 구조
현재 프로젝트에서는 다음과 같은 도메인 간 통신이 Spring Event로 구현되어 있습니다.
// 결제 완료 → 모임 참가자 추가
@EventListener
public void handlePaymentSuccessEvent(PaymentCompletedEvent event) {
meetingService.addParticipant(event.getMeetingId(), event.getUserId());
eventPublisher.publishEvent(new MeetingEvents.Join(...));
}
// 모임 참가 → 알림 발송
@EventListener
public void handleMeetingJoinEvent(MeetingEvents.Join event) {
notificationService.sendNotification(...);
}
MSA 전환 시 문제점
Spring Event의 한계
- JVM 내부에서만 동작 (프로세스 간 통신 불가)
- 서비스가 분리되면 이벤트 전달 불가능
- 메시지 유실 시 복구 메커니즘 부재
현재 구조의 모순점
- 이미 WebClient를 통한 서비스 간 통신 구조 존재
- RabbitMQ를 결제/알림 영역에서 사용 중
- MSA 전환을 염두에 둔 설계인데 모놀리틱 통신 방식 혼재
2. RabbitMQ vs Kafka 선택 기준
실제 서비스에서의 RabbitMQ 사용 사례
RabbitMQ를 내부 통신으로 사용하는 주요 서비스들
- Shopify: 주문/결제 도메인 간 통신
- Zalando: 재고/주문 연동 시스템
- 많은 핀테크 스타트업: 결제 도메인의 신뢰성 요구사항
- Spring Cloud 생태계: 공식 권장 메시지 브로커
선택 기준 비교
| 요구사항 | RabbitMQ | Kafka |
| 메시지 처리량 | ~100K msg/sec | 1M+ msg/sec |
| 순서 보장 | Queue 단위 보장 | Partition 단위 보장 |
| 메시지 유실 방지 | 강력한 ACK 메커니즘 | 복제 기반 내구성 |
| 운영 복잡성 | 상대적으로 단순 | 높은 복잡성 |
| 학습 곡선 | 완만 | 가파름 |
| 메모리 사용량 | 적음 | 많음 |
현재 프로젝트에 RabbitMQ가 적합한 이유
1. 적절한 메시지 처리량
- 모임 플랫폼 특성상 초당 수천 건 수준
- Kafka의 높은 처리량이 오버스펙
2. 순서 보장의 중요성
결제 완료 → 참가자 추가 → 알림 발송
모임 취소 → 환불 처리 → 알림 발송
3. 기존 인프라 활용
- 이미 RabbitMQ 사용 중
- 추가 학습 비용 없음
- 운영 복잡성 최소화
3. RabbitMQ 설정 및 구조 설계
Exchange와 Queue 설계
@Configuration
@EnableRabbit
public class RabbitMQConfig {
// 도메인별 Exchange 분리
public static final String PAYMENT_EXCHANGE = "payment.exchange";
public static final String MEETING_EXCHANGE = "meeting.exchange";
public static final String NOTIFICATION_EXCHANGE = "notification.exchange";
// 결제 관련 큐
public static final String PAYMENT_COMPLETED_QUEUE = "payment.completed.queue";
public static final String PAYMENT_REFUNDED_QUEUE = "payment.refunded.queue";
// 모임 관련 큐
public static final String MEETING_PARTICIPANT_ADDED_QUEUE = "meeting.participant.added.queue";
public static final String MEETING_PARTICIPANT_CANCELLED_QUEUE = "meeting.participant.cancelled.queue";
@Bean
public TopicExchange paymentExchange() {
return new TopicExchange(PAYMENT_EXCHANGE);
}
// Dead Letter Queue 설정 (메시지 유실 방지)
@Bean
public Queue paymentCompletedQueue() {
return QueueBuilder.durable(PAYMENT_COMPLETED_QUEUE)
.withArgument("x-dead-letter-exchange", "payment.dlx")
.withArgument("x-dead-letter-routing-key", "payment.failed")
.build();
}
}
메시지 DTO 표준화
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PaymentCompletedMessage {
private Long paymentId;
private Long userId;
private Long meetingId;
private Integer amount;
private LocalDateTime completedAt;
// 메시지 버전 관리를 위한 필드
private String messageVersion = "1.0";
}
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MeetingParticipantMessage {
private Long meetingId;
private Long userId;
private String userNickname;
private Long hostUserId;
private String action; // "JOINED" or "CANCELLED"
private LocalDateTime actionAt;
private String messageVersion = "1.0";
}
4. Publisher 구현
기존 Event Publisher를 RabbitMQ Publisher로 변경
@Service
@RequiredArgsConstructor
public class PaymentEventPublisher {
private final RabbitTemplate rabbitTemplate;
// 기존: eventPublisher.publishEvent(new PaymentCompletedEvent(...))
// 변경: RabbitMQ 메시지 발송
public void publishPaymentCompleted(PaymentCompletedEvent event) {
PaymentCompletedMessage message = PaymentCompletedMessage.builder()
.paymentId(event.getPaymentId())
.userId(event.getUserId())
.meetingId(event.getMeetingId())
.amount(event.getAmount())
.completedAt(event.getPaidAt())
.build();
// 메시지 발송 with 확인
rabbitTemplate.convertAndSend(
RabbitMQConfig.PAYMENT_EXCHANGE,
"payment.completed", // routing key
message
);
log.info("결제 완료 메시지 발송: paymentId={}, userId={}",
event.getPaymentId(), event.getUserId());
}
public void publishPaymentRefunded(PaymentRefundedEvent event) {
PaymentRefundedMessage message = PaymentRefundedMessage.builder()
.paymentId(event.getPaymentId())
.userId(event.getUserId())
.meetingId(event.getMeetingId())
.amount(event.getAmount())
.refundedAt(event.getRefundedAt())
.build();
rabbitTemplate.convertAndSend(
RabbitMQConfig.PAYMENT_EXCHANGE,
"payment.refunded",
message
);
}
}
5. Consumer 구현
기존 EventListener를 RabbitListener로 변경
@Component
@RequiredArgsConstructor
public class MeetingMessageConsumer {
private final MeetingService meetingService;
private final RabbitTemplate rabbitTemplate;
// 기존: @EventListener
// 변경: @RabbitListener
@RabbitListener(queues = RabbitMQConfig.PAYMENT_COMPLETED_QUEUE)
@Retryable(value = {Exception.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000))
public void handlePaymentCompleted(PaymentCompletedMessage message) {
try {
log.info("결제 완료 메시지 수신: paymentId={}, userId={}",
message.getPaymentId(), message.getUserId());
// 참가자 추가 처리 (비즈니스 로직 동일)
ParticipantResponseDto participant = meetingService.addParticipant(
message.getMeetingId(),
message.getUserId()
);
// 다음 단계 메시지 발송
MeetingParticipantMessage participantMessage = MeetingParticipantMessage.builder()
.meetingId(message.getMeetingId())
.userId(message.getUserId())
.action("JOINED")
.actionAt(LocalDateTime.now())
.build();
rabbitTemplate.convertAndSend(
RabbitMQConfig.MEETING_EXCHANGE,
"meeting.participant.joined",
participantMessage
);
} catch (Exception e) {
log.error("결제 완료 처리 실패: {}", e.getMessage(), e);
// 예외 재발생으로 DLQ 이동 처리
throw e;
}
}
}
6. 신뢰성 보장 메커니즘
메시지 유실 방지
1. Publisher Confirm
@Configuration
public class RabbitTemplateConfig {
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
// Publisher Confirm 활성화
template.setConfirmCallback((correlationData, ack, cause) -> {
if (!ack) {
log.error("메시지 발송 실패: {}", cause);
// 실패 처리 로직 (재시도, 알림 등)
}
});
// Return Callback (라우팅 실패 시)
template.setReturnsCallback(returned -> {
log.error("메시지 라우팅 실패: {}", returned.getMessage());
});
return template;
}
}
2. Consumer Acknowledgment
@RabbitListener(
queues = RabbitMQConfig.PAYMENT_COMPLETED_QUEUE,
ackMode = "MANUAL"
)
public void handlePaymentCompleted(
PaymentCompletedMessage message,
Channel channel,
@Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag
) {
try {
// 비즈니스 로직 처리
processPaymentCompleted(message);
// 수동 ACK
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
try {
// NACK with requeue
channel.basicNack(deliveryTag, false, true);
} catch (IOException ioException) {
log.error("NACK 실패", ioException);
}
}
}
순서 보장
단일 큐 내에서 순서 보장
// 같은 사용자의 결제 관련 메시지는 순서 보장
public void publishWithOrdering(Long userId, Object message) {
String routingKey = "payment.user." + userId;
rabbitTemplate.convertAndSend(PAYMENT_EXCHANGE, routingKey, message);
}
7. 모니터링 및 운영
메시지 추적
@Component
public class MessageTracker {
public void trackMessageSent(String messageType, Object message) {
log.info("메시지 발송: type={}, payload={}", messageType, message);
// 메트릭 수집 (Micrometer)
Metrics.counter("rabbitmq.message.sent", "type", messageType).increment();
}
public void trackMessageReceived(String messageType, Object message) {
log.info("메시지 수신: type={}, payload={}", messageType, message);
Metrics.counter("rabbitmq.message.received", "type", messageType).increment();
}
}
Dead Letter Queue 모니터링
@RabbitListener(queues = "payment.dlq")
public void handleDeadLetterMessage(Message message) {
log.error("DLQ 메시지 수신: {}", new String(message.getBody()));
// 실패 메시지 분석 및 알림
alertService.sendFailureAlert(
"결제 메시지 처리 실패",
new String(message.getBody())
);
}
8. 마이그레이션 시 주의사항
점진적 전환 전략
1. 중복 발송 방식
// 초기 단계: Spring Event와 RabbitMQ 동시 발송
public void publishPaymentCompleted(PaymentCompletedEvent event) {
// 기존 방식 유지
eventPublisher.publishEvent(event);
// 새로운 방식 추가
rabbitMQPublisher.publishPaymentCompleted(event);
}
2. Feature Toggle 활용
@Value("${feature.rabbitmq.enabled:false}")
private boolean rabbitMQEnabled;
public void publishEvent(Object event) {
if (rabbitMQEnabled) {
rabbitMQPublisher.publish(event);
} else {
springEventPublisher.publishEvent(event);
}
}
버전 호환성 관리
@RabbitListener(queues = PAYMENT_COMPLETED_QUEUE)
public void handlePaymentCompleted(PaymentCompletedMessage message) {
// 메시지 버전별 처리
switch (message.getMessageVersion()) {
case "1.0":
handleV1Message(message);
break;
case "2.0":
handleV2Message(message);
break;
default:
log.warn("지원하지 않는 메시지 버전: {}", message.getMessageVersion());
}
}
9. 성능 및 확장성 고려사항
Connection Pool 설정
spring:
rabbitmq:
host: localhost
port: 5672
username: admin
password: admin
connection-timeout: 30000
publisher-confirm-type: correlated
publisher-returns: true
template:
mandatory: true
listener:
simple:
acknowledge-mode: manual
concurrency: 5
max-concurrency: 10
prefetch: 10
처리량 최적화
@RabbitListener(
queues = PAYMENT_COMPLETED_QUEUE,
concurrency = "5-10" // 동적 스케일링
)
public void handlePaymentCompleted(PaymentCompletedMessage message) {
// 배치 처리로 성능 향상
if (shouldProcessInBatch()) {
batchProcessor.addMessage(message);
} else {
processIndividually(message);
}
}
결론
RabbitMQ 마이그레이션의 장점
기술적 장점
- MSA 전환 시 서비스 분리 용이성
- 메시지 유실 방지 및 순서 보장
- 강력한 재시도 및 오류 처리 메커니즘
- 기존 인프라 활용으로 운영 복잡성 최소화
비즈니스 장점
- 서비스 안정성 향상
- 확장성 있는 아키텍처 구성
- 운영 비용 최적화 (Kafka 대비)
권장사항
- 점진적 마이그레이션: 한 번에 모든 이벤트를 전환하지 말고 핵심 비즈니스 플로우부터 단계적으로 진행
- 충분한 테스트: 메시지 손실, 중복 처리, 순서 보장 등에 대한 철저한 테스트
- 모니터링 강화: 메시지 처리 현황, 실패율, DLQ 모니터링 체계 구축
- 운영 문서화: 장애 상황별 대응 절차 및 복구 방안 문서화
현재 프로젝트의 구조와 요구사항을 고려할 때, RabbitMQ를 활용한 메시징 아키텍처 전환은 MSA로의 자연스러운 진화 과정이며, 무작정 Kafka를 도입하는 것보다 현실적이고 안정적인 선택입니다. 특히 결제와 알림이라는 신뢰성이 중요한 도메인에서는 RabbitMQ의 강력한 메시지 보장 메커니즘이 더욱 가치 있는 선택이 될 것입니다.