[최종 프로젝트 - 모의 투자 서비스] RabbitMQ 를 이용한 실시간 지정가 체결
이번 프로젝트에서 모의 투자 서비스의 성능을 크게 좌우하는 핵심 기능은 "주문 체결"이다.
기존에는 스케줄러 기반의 주기적 체결 방식으로 구현되어 있었지만, 실시간 가격 변화에 즉시 반응하지 못해 성능과 트래픽 측면에서 한계가 있었다. 그래서 이전에 작성한 블로그를 참고하여 기술을 적용할려고 한다.
✅ 1. 기존 구조의 문제점
- 모든 종목의 주문을 일정 주기로 전체 스캔(Schedule) → 리소스 낭비
- 조건에 맞지 않는 주문도 매번 평가 → 불필요한 쿼리/계산 발생
- 실시간 체결이 어려움 → 사용자 반응성 저하
- 가격 급등락 시 스케줄러 병목 → 성능 저하
📌기존 코드
- 스케줄러를 이용해 1초마다 주문완료된 주문건과 해당 종목의 가격을 redis 에서 불러와 비교함
- 불필요한 쿼리문 발생
@Scheduled(fixedRate = 1000) // 1초마다 실행
public void settleOrders() {
List<Order> completeOrders = orderRepository.findAllReadyOrdersWithFetchJoin(OrderStatus.COMPLETED);
for (Order order : completeOrders) {
Stock stock = order.getStock();
Object priceObject = redisTemplate.opsForValue().get("stockPrice::" + stock.getStockCode());
BigDecimal currentPrice = new BigDecimal(priceObject.toString());
if (order.getType() == Type.LIMIT_BUY && currentPrice.compareTo(order.getPrice()) > 0) {
continue;
}
if (order.getType() == Type.LIMIT_SELL && currentPrice.compareTo(order.getPrice()) < 0) {
continue;
}
tradeOrder(order, stock, currentPrice);
}
}
✅ 개선 방향: 이벤트 기반 체결
📌동기 방식에서 비동기 방식으로 전환한 이유
- 초기 구조에서는 체결 로직이 스케줄러나 서비스 내부 호출을 통해 동기적으로 실행되었다. 하지만 이 방식은 아래와 같은 한계를 가지고 있었다:
항목 | 동기 처리 방식 | 비동기 메시지 처리 방식 |
실행 흐름 | 호출자가 직접 체결 메서드 호출 | 메시지를 큐에 넣고 별도 Consumer가 처리 |
병목 가능성 | 여러 주문이 몰릴 경우 처리 순서 병목 | 큐에 쌓인 순서대로 분산 처리 가능 |
결합도 | 호출자와 체결 로직이 강하게 결합 | 완전히 분리되어 유지보수 유리 |
장애 전파 | 체결 로직에서 예외 발생 시 전체 로직 실패 | 메시지 재처리/재시도 가능, 시스템 격리 효과 |
확장성 | 스레드 수에 따라 제한 | 소비자 수평 확장으로 무한 확장 가능 |
트랜잭션 부담 | 모든 처리가 한 트랜잭션 내에서 이뤄짐 | 큐를 통해 트랜잭션 범위 최소화 가능 |
📌비동기 방식의 장점
- 실시간 반응성 확보: 메시지 브로커가 주가 변동 이벤트를 즉시 체결 처리로 연결해줌
- 비동기 분산 처리: 주문이 몰려도 순차적으로 큐에 저장되고 백그라운드에서 병렬 처리 가능
- 결합도 최소화: 가격 수신 로직과 체결 로직이 서로 독립 → 유지보수/테스트 용이
- 복원력 강화: RabbitMQ는 메시지를 실패 시 재시도하거나 DLQ에 쌓을 수 있어 내결함성 구조 강화
결론적으로,비동기 구조는 이벤트 중심의 실시간 처리 요구사항을 충족하면서도 안정성·확장성·유지보수성 측면에서 동기 방식 대비 명확한 이점을 제공한다.
그렇지만 비동기 구조에도 다양한 기술들이 존재했고 적용 기술을 선택하기 위해 해당 프로젝트를 기준으로 비교를 하였다.
📌메시지 브로커 선택의 기술적 배경
https://computerreport.tistory.com/157
[Spring]비동기 메시지 큐 (Kafka & RabbitMQ)
이번 프로젝트에서 모의 투자 서비스의 성능을 크게 좌우하는 핵심 기능은 "주문 체결"이다. 기존에는 스케줄러 기반의 주기적 체결 방식으로 구현되어 있었지만, 실시간 가격 변화에 즉시 반응
computerreport.tistory.com
- RabbitMQ를 활용해 가격 변경이 발생한 특정 종목에만 체결 로직을 트리거하도록 구조를 전환하였다.
- 이렇게 하면 불필요한 조회를 줄이고, 특정 종목에 대해서만 빠르게 체결 로직을 실행할 수 있다.
근데 나는 "체결되지 않은 주문만 조회"하게 하면 기존 스케줄러 방식도 충분한거 아닌가? 라는 의문이 들었다.
이 질문에 대해 RabbitMQ 기반 이벤트 처리 방식과 개선된 스케줄러 방식을 비교해보았다.
💡큐란?
큐(Queue)는 선입선출(FIFO, First-In-First-Out) 방식의 자료구조다. 요소(element)들이 저장되는 구조로, 먼저 추가된 요소가 먼저 제거된다.
📌 조건: 두 방식 모두 미체결 주문만 조회한다고 가정
항목 | 스케줄러 | RabbitMQ 이벤트 기반 |
동작 조건 | 주기적으로 실행 | 주가 변경 이벤트 발생 시 |
조회 범위 | 전체 종목 미체결 주문 | 해당 종목 주문만 |
리소스 사용 | 반복적 CPU/DB 부하 | 이벤트 발생 시 최소 처리 |
실시간성 | 주기 기반 반응 | 즉시 반응 |
확장성 | 종목 수 증가 시 성능 저하 | 큐 기반 분산 처리 가능 |
- 스케줄러가 아무리 체결되지 않은 주문만 조회한다 해도, 주기적 반복 호출 구조 자체에서 벗어날 수 없다.
- 주가가 변하지 않아도 계속 DB에 접근해 조건을 확인해야 하며, 종목 수가 많을수록 성능 저하가 발생한다.
- 반면, RabbitMQ는 "이벤트가 발생한 종목에만 반응"하고, 큐를 통해 독립적으로 체결 처리를 수행하므로 시스템 부하를 줄이고 확장성이 뛰어난 구조이다
📌 차이점은 알겠는데,그래서 비동기 + 큐 구조가 왜 효과적인가?
단순히 표를 이용해서 비교하면 정확히 이해할 수 가 없었다. 그래서 RabbitMQ 의 구조를 파악하고 장점을 상세히 알아보았다,
RabbitMQ를 활용한 비동기 메시징 시스템은 단순히 "실시간 트리거"라는 기능적 목적을 넘어, 아키텍처적인 이점도 존재한다.
1. 비동기 처리로 시스템 분리
- 주문 체결 로직이 즉시 실행되지 않고 큐에 저장되므로, 생산자(가격 수신)와 소비자(체결 처리)가 완전히 분리된다.
- 이는 곧 서비스 간 결합도를 낮추고, 각 기능을 독립적으로 유지보수하거나 확장할 수 있게 한다.
2. 처리 병목 감소 및 확장성 확보
- 메시지는 큐에 순서대로 쌓이고, 소비자는 멀티 인스턴스로 수평 확장할 수 있다.
- 주문이 몰리는 순간에도 시스템 전체가 과부하 되지 않고, 부하 분산이 자연스럽게 이루어지는 구조다.
3. 내결함성 구조
- 메시지를 수신하지 못하거나 처리 중 예외가 발생하더라도, 큐에 메시지가 남아 있기 때문에 재처리 가능하다.
- DLQ(Dead Letter Queue)와 재시도 정책을 함께 설정하면 데이터 유실 없이 안정적 운영이 가능하다.
4. 실시간성과 최소 리소스 소비의 조화
- 이전 스케줄러 기반 구조는 "변동이 없는 상태에도 반복적으로 DB를 조회"하는 반면,이벤트 기반 구조는 "변동이 있을 때만 반응"하여 불필요한 연산을 줄이고 실시간성도 확보한다.
5. 트랜잭션 경량화
- 큐에 넣는 동작 자체는 빠르게 끝나고, 실제 체결은 별도의 쓰레드(Consumer)에서 비동기로 처리되므로, 서비스 요청 처리의 응답 속도가 짧아지고, 전체 트랜잭션 범위도 최소화된다.
✅ 적용 방법
기본적으로 RabbitMQ 를 설치 후 프로젝트를 진행하였다.
해당 블로그를 참고하여 설치 완료!
https://cladren123.tistory.com/222
RabbitMQ 설치 (windows 환경)
개요 RabbitMQ는 오픈소스 메세지 브로커로 시스템 간 메세지를 안전하게 전달하는데 사용합니다. AMQP을 기반으로 하며 다양한 메시징 패턴과 다양한 언어 및 플랫폼에서 사용할 수 있습니다. 이
cladren123.tistory.com
application.properties
# RabbitMQ 기본 연결 정보
spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.username=guest
spring.rabbitmq.password=guest
# 큐 자동 생성 (선택)
spring.rabbitmq.template.exchange=stock.exchange
spring.rabbitmq.template.routing-key=stock.price
RabbitConfig.class
- 이 클래스는 Exchange, Queue, Binding을 생성하고 메시지를 JSON으로 변환하기 위한 컨버터 및 RabbitTemplate을 설정하여 메시지 전송과 수신을 위한 기반을 구성한다.
package com.example.mockstalk.common.config;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitConfig {
public static final String EXCHANGE_NAME = "stock.exchange";
public static final String QUEUE_NAME = "stock.price";
public static final String ROUTING_KEY = "stock.price";
@Bean
public TopicExchange exchange() {
return new TopicExchange(EXCHANGE_NAME);
}
@Bean
public Queue queue() {
return new Queue(QUEUE_NAME);
}
@Bean
public Binding binding(Queue queue, TopicExchange exchange) {
return BindingBuilder.bind(queue).to(exchange).with(ROUTING_KEY);
}
@Bean
public MessageConverter messageConverter() {
return new Jackson2JsonMessageConverter();
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
template.setMessageConverter(messageConverter());
return template;
}
}
StockPriceEventDto. java
- 주식 ID와 현재 가격 정보를 담는 메시지 객체로, Listener가 메시지를 파싱해 사용할 수 있도록 구성된다.
package com.example.mockstalk.domain.trade.dto;
import java.math.BigDecimal;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class StockPriceEventDto {
private Long stockId;
private BigDecimal currentPrice;
}
StockPriceListener.java
- 큐에서 메시지를 수신하고 해당 종목의 체결 처리를 담당하는 서비스에 위임하는 역할을 수행한다.
package com.example.mockstalk.domain.trade.listener;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import com.example.mockstalk.common.config.RabbitConfig;
import com.example.mockstalk.domain.trade.dto.StockPriceEventDto;
import com.example.mockstalk.domain.trade.service.TradeService;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class StockPriceListener {
private final TradeService tradeService;
@RabbitListener(queues = RabbitConfig.QUEUE_NAME)
public void onPriceUpdated(StockPriceEventDto event) {
tradeService.onPriceUpdated(event.getStockId(), event.getCurrentPrice());
}
}
TradeService.java
- 가격 조건을 만족하는 주문만 조회하여 체결을 시도한다.
public void handlePriceUpdate(Long stockId, BigDecimal currentPrice) {
List<Order> orders = orderRepository.findAllReadyOrdersByStock(stockId);
for (Order order : orders) {
if (order.isConditionMatched(currentPrice)) {
tradeOrder(order, order.getStock(), currentPrice);
}
}
}
📌동작 흐름
- 이벤트 기반 체결 구조는 다음과 같은 순서로 작동한다
- 가격 변동 감지: 실시간 WebSocket 데이터 수신기가 주가 변동을 감지함
- 메시지 발행: 해당 주식 ID와 가격으로 StockPriceEvent 메시지를 생성하여 RabbitMQ로 발행함
- Exchange → Queue: 메시지는 TopicExchange를 거쳐 라우팅 키 기준으로 지정된 Queue에 들어감
- Listener 수신: @RabbitListener가 큐에서 메시지를 수신함
- 체결 트리거: Listener는 메시지를 파싱 후 TradeService에 전달하고, 조건이 맞는 주문만 체결 처리함
- 분산 락 처리: 각 주문 단위로 Redisson 분산 락을 걸어 중복 체결을 방지함
✅ 테스트
- RabbitMQ 의 Que에 stalkcode 와 price 가 잘 들어가는지 확인해보자.
RabbitMQ의 Queue에 stockId와 price가 잘 들어가는지 확인해보자.
우선, stockId가 1인 데이터가 DB에 존재하고, 해당 주식 가격도 유효한 상태여야 한다. 실제 WebSocket 수신기가 아닌 수동 테스트를 위해 별도의 컨트롤러를 작성해 메시지를 직접 발행해보았다.
@RestController
@RequestMapping("/api/test/price")
@RequiredArgsConstructor
public class PricePublishController {
private final RabbitTemplate rabbitTemplate;
@PostMapping
public ResponseEntity<String> publishPrice(@RequestParam Long stockId,
@RequestParam BigDecimal currentPrice) {
StockPriceEventDto event = new StockPriceEventDto(stockId, currentPrice);
rabbitTemplate.convertAndSend(RabbitConfig.EXCHANGE_NAME, RabbitConfig.ROUTING_KEY, event);
return ResponseEntity.ok("테스트 메시지 발행 완료");
}
}
RabbitMQ 메시징을 위한 스프링 지원의 핵심은 RabbitTemplate이다. RabbitTemplate 의 메서드를 이용하여 메시지를 전송 할 수 있다.여기서 잠깐 코드를 살펴보자.
📌 rabbitTemplate.convertAndSend() 로 메시지 변환 후 전송하기
rabbitTemplate.convertAndSend(RabbitConfig.EXCHANGE_NAME, RabbitConfig.ROUTING_KEY, event);
기본적으로 메시지 변환은 SimpleMessageConverter 로 수행된다
또한 스프링은 RabbitTemplate에서 사용할 수 있는 여러 개의 메시지 변환기를 제공한다.
- Jackson2JsonMessageConverter
- MarshallingMessageConverter
- SerializerMessageConverter
- SimpleMessageConverter
- ContentTypeDelegatingMessageConverter
- MessagingMessageConverter
다음과 같이 Bean 으로 등록하면 스프링 부트 자동-구성에서 이 Bean을 찾아 기본 메시지 변환기 대신 이 Bean을 RabbitTemplate으로 주입한다.
@Bean
public MessageConverter messageConverter() {
new Jackson2JsonMessageConverter();
}
다시 본론으로 돌아와,convertAndSend 메소드를 통해 메시지를 전달하게 되면, 해당 stockId와 currentPrice가 담긴 메시지를 RabbitMQ의 Exchange에 전송하게 된다. 이 메시지는 설정한 routingKey를 기준으로 Queue에 바인딩된 Listener에게 전달된다.
예를 들어, 아래와 같은 설정이 있다면:
public static final String EXCHANGE_NAME = "stock.exchange";
public static final String QUEUE_NAME = "stock.price";
public static final String ROUTING_KEY = "stock.price";
RabbitTemplate은 "stock.exchange"에 메시지를 전송하고, 이 Exchange는 "stock.price"라는 이름의 Queue에 메시지를 전달한다. 이 Queue를 감시하고 있는 @RabbitListener가 해당 메시지를 수신하게 된다.
정상적으로 큐에 들어가는지 확인하기 위해서는 Listener 내부에 Thread.sleep 코드를 넣어야한다.
RabbitMQ는 메시지가 Queue에 들어오면 Listener가 바로 가져가 처리하는 구조이므로, 속도가 너무 빠르면 Queue에 메시지가 쌓이는 'Ready' 상태를 UI에서 확인하기 어렵다.
Postman 을 통해 아래의 URL 로 Send 를 보내보자.
{{base_url}}/api/test/price?stockId=1¤tPrice=10250.00
상태 | 의미 |
Ready = 1 | 아직 소비자가 가져가지 않은 메시지 |
Unacked = 1 | 소비자가 메시지 수신했지만 아직 Ack 안 한 상태 (Sleep 중) |
Total = 1 | 전체 메시지 수 (Ready + Unacked) |
- 메시지를 POSTMAN으로 보냄 → Queue에 쌓임 → Ready = 1
- 거의 즉시 Listener가 가져감 → Ready = 0, Unacked = 1
- 10초 동안 Thread.sleep으로 대기
- 10초 뒤 tradeService.onPriceUpdated(...) 호출 후 처리 완료
- 메시지 ack 처리 완료 → Unacked = 0
정상적으로 RabbitMQ 에 메시지가 전달되는 것을 확인 할 수 있었고,이제 만약 체결조건에 맞는 해당 종목의 코드와 데이터가 들어오게 되면 체결이 될 것이다.
이것 또한 확인해보자.
만약 20350원을 지정가로 잡고 주문해보면 즉시 체결이 될까?
정상적으로 주문이 완료됐고, 수동으로 실시간 가격을 수신받게 했다.
{{base_url}}/api/test/price?stockId=1¤tPrice=20350
주문조회를 해보니 정상적으로 체결된 것을 확인할 수 있다!!
✅ 추가 테스트: 성능 차이 실측
RabbitMQ 기반 구조의 실효성을 검증하기 위해 동일 조건 하에 체결 성능을 비교하였다.
- 테스트 조건:
- stockId = 1
- 주문 수 = 100개
- 지정가 20350원으로 모두 동일
- Redis 가격 정보도 20350원으로 동일하게 설정
⏱ 스케줄러 방식
[INFO] 체결 처리 시간: 1660 ms
⏱ RabbitMQ 비동기 방식
[INFO] 체결 처리 시간: 1175 ms
- 단순 수치만 보면 약 30% 성능 개선이지만, 더 중요한 것은 처리 구조의 효율성과 확장성이다.
- RabbitMQ는 변경된 가격이 발생한 종목에만 반응하므로 전체 조회 부하가 없고, 체결 로직이 소비자 쓰레드로 분리되어 시스템 자원을 더 효율적으로 사용할 수 있다.
- 추후 주문 수가 10,000건, 100,000건 이상으로 증가했을 때 이 구조적 차이는 더 크게 벌어질 것이다.
추후에 프로젝트가 어느정도 완성되면 성능개선에 대한 글을 상세히 설명해볼 예정이다.
✅ 결론
이번 글에서는 스케줄러 기반 체결 구조의 한계를 극복하기 위해 RabbitMQ + 비동기 이벤트 트리거를 도입한 과정을 단계별로 살펴보았다.
📌RabbitMQ 장점
- 실시간 반응성
- 주가 변동 이벤트가 들어오면 평균 수 ms 내 체결 로직 진입
- 낮은 시스템 부하
- 변동이 없을 땐 DB · CPU idle 유지
- 유지보수성
- 가격 수신, 메시지 브로커, 체결 서비스가 완전히 모듈화
- 확장 용이성
- 주문 폭주 시 @RabbitListener 컨슈머 인스턴스만 수평 확장
- 검증 루틴 확보
- Thread.sleep·RabbitMQ UI를 이용한 Ready→Unacked→Ack 흐름 시각화로 동작 확인
API 의 신뢰성를 높일려면 RabbitMQ 가 장애가 발생이 발생하거나 나중에 값을 불러오는 Redis 네트워크에 오류가 발생하는 등 예외발생 및 복구 능력이 필요하기에 몇가지 보완 사항을 정리해보았다.
📌보완 포인트
항목 | 필요성 | 개선 아이디어 |
Idempotent 처리 | 중복 메시지 대비 | messageId 기반 ① DB 유니크 키 or ② Redis Set |
DLQ 모니터링 | 예외 메시지 모음 | Dead-Letter Queue + 지표 알람(Slack, Grafana) |
락 실패 Fallback | Redis·Redisson 장애 대비 | 로컬 KeyLock + 재시도 대기 큐 |
메시지 압축/배치 | 초고빈도 시세 이벤트 | RabbitMQ batch publish + zstd 압축 |
Observability | 장애 근원 추적 | Zipkin(트레이싱) + Prometheus(메트릭) |