1️⃣ 체결 구조 개선 (Scheduler → RabbitMQ)
✅ 문제 정의
기존 모의투자 서비스의 핵심 기능인 "주문 체결"은 스케줄러 기반의 주기적 처리 방식으로 구현
@Scheduled(fixedRate = 1000)으로 1초마다 전체 주문을 스캔
주요 문제점
- 주가 변동이 없어도 반복적으로 전체 주문 탐색
- 실시간 반응 불가 (주기 기반 처리)
- 종목 수 증가 시 병목 및 리소스 낭비 발생
@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);
}
}
✅ 가설
"체결 시스템을 스케줄러 기반에서 이벤트 기반 비동기 처리 방식으로 전환하면, 실시간성과 처리 효율을 동시에 확보할 수 있을 것이다."
✅ 해결 방안
- 실시간 주가 변동 감지 시 StockPriceEventDto를 생성하여 RabbitMQ에 발행
- 메시지는 TopicExchange → Queue로 전달되고, @RabbitListener가 메시지를 수신함
- 해당 종목에 대한 조건 만족 주문만 DB에서 조회하여 체결 진행
📌동기 방식에서 비동기 방식으로 전환한 이유
- 초기 구조에서는 체결 로직이 스케줄러나 서비스 내부 호출을 통해 동기적으로 실행되었다. 하지만 이 방식은 아래와 같은 한계를 가지고 있었다
항목 | 동기 처리 방식 | 비동기 메시지 처리 방식 |
실행 흐름 | 호출자가 직접 체결 메서드 호출 | 메시지를 큐에 넣고 별도 Consumer가 처리 |
병목 가능성 | 여러 주문이 몰릴 경우 처리 순서 병목 | 큐에 쌓인 순서대로 분산 처리 가능 |
결합도 | 호출자와 체결 로직이 강하게 결합 | 완전히 분리되어 유지보수 유리 |
장애 전파 | 체결 로직에서 예외 발생 시 전체 로직 실패 | 메시지 재처리/재시도 가능, 시스템 격리 효과 |
확장성 | 스레드 수에 따라 제한 | 소비자 수평 확장으로 무한 확장 가능 |
트랜잭션 부담 | 모든 처리가 한 트랜잭션 내에서 이뤄짐 | 큐를 통해 트랜잭션 범위 최소화 가능 |
📌비동기 방식의 장점
- 실시간 반응성 확보: 메시지 브로커가 주가 변동 이벤트를 즉시 체결 처리로 연결해줌
- 비동기 분산 처리: 주문이 몰려도 순차적으로 큐에 저장되고 백그라운드에서 병렬 처리 가능
- 결합도 최소화: 가격 수신 로직과 체결 로직이 서로 독립 → 유지보수/테스트 용이
- 복원력 강화: RabbitMQ는 메시지를 실패 시 재시도하거나 DLQ에 쌓을 수 있어 내결함성 구조 강화
결론적으로,비동기 구조는 이벤트 중심의 실시간 처리 요구사항을 충족하면서도 안정성·확장성·유지보수성 측면에서 동기 방식 대비 명확한 이점을 제공
✅ 구현 내용
📌동작 흐름
- 이벤트 기반 체결 구조는 다음과 같은 순서로 작동한다
- 가격 변동 감지: 실시간 WebSocket 데이터 수신기가 주가 변동을 감지함
- 메시지 발행: 해당 주식 ID와 가격으로 StockPriceEvent 메시지를 생성하여 RabbitMQ로 발행함
- Exchange → Queue: 메시지는 TopicExchange를 거쳐 라우팅 키 기준으로 지정된 Queue에 들어감
- Listener 수신: @RabbitListener가 큐에서 메시지를 수신함
- 체결 트리거: Listener는 메시지를 파싱 후 TradeService에 전달하고, 조건이 맞는 주문만 체결 처리함
- 분산 락 처리: 각 주문 단위로 Redisson 분산 락을 걸어 중복 체결을 방지함
@RabbitListener(queues = RabbitConfig.QUEUE_NAME)
public void onPriceUpdated(StockPriceEventDto event) {
tradeService.onPriceUpdated(event.getStockId(), event.getCurrentPrice());
}
✅ 해결 완료
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
✅ 추가 테스트: 성능 차이 실측
- 체결 처리 시간 측정 결과:
- 기존 스케줄러: 1660ms (100건 기준)
- RabbitMQ 방식: 1175ms → 약 30% 성능 개선
- 더 중요한 개선 포인트:
- 주가 변동 없을 시 DB 접근 없음 → 리소스 절약
- 종목별 이벤트 반응 처리 가능 → 수천 종목 확장 시에도 성능 저하 최소화
- 체결 시스템의 유지보수성 향상, 트랜잭션 범위 최소화, 장애 격리
2️⃣ 동시성 제어 개선 (Redisson 분산 락 적용)
✅ 문제 정의
- @DistributedLock + @Transactional 함께 사용할 경우, 트랜잭션 커밋보다 락 해제가 먼저 일어남
- 그 결과, 다른 스레드가 커밋되지 않은 데이터를 참조하는 상황 발생
- 실시간 동시 주문 처리 중 잔고·보유 수량이 꼬이는 심각한 정합성 이슈
✅ 가설
"트랜잭션을 수동으로 제어하여 락 해제를 커밋 이후로 미루면, 데이터 정합성을 확보할 수 있다."
✅ 해결 방안
기존에는 @DistributedLock 어노테이션만 활용해 락은 걸 수 있었지만, 트랜잭션을 별도로 컨트롤하지 못해 락이 먼저 해제되고 트랜잭션이 나중에 커밋되는 문제가 발생했다.
이 문제를 해결하기 위해 트랜잭션을 수동으로 제어하는 방식으로 AOP 코드를 수정하였다.
DistributedLockAspect.class
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {
private final RedissonClient redissonClient;
private final PlatformTransactionManager transactionManager;
@Around("@annotation(distributedLock)")
public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
String key = distributedLock.key();
long waitTime = distributedLock.waitTime();
long leaseTime = distributedLock.leaseTime();
RLock lock = redissonClient.getLock(key);
boolean isLocked = false;
try {
isLocked = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);
if (!isLocked) {
throw new IllegalStateException("락 획득 실패: key = " + key);
}
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
return transactionTemplate.execute(status -> {
try {
return joinPoint.proceed(); // 비즈니스 로직 실행
} catch (Throwable throwable) {
// 트랜잭션 롤백을 명시적으로 설정
status.setRollbackOnly();
throw new RuntimeException(throwable);
}
}); // 비즈니스 로직 실행
} finally {
if (isLocked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
✅ 해결 완료
- 트랜잭션이 커밋된 후 락이 해제됨으로써 데이터 일관성 확보
- 동시 체결 시에도 중복 체결이나 잘못된 잔고 반영 없이 정확히 반영됨
- 커스텀 어노테이션을 통해 도입이 쉬움
✅ 회고 및 개선 아이디어
📌핵심 회고
1️⃣체결 구조 개선 (Scheduler → RabbitMQ)
- 스케줄러 방식은 단순 구현은 쉽지만, 실시간성과 효율성 모두 부족함
- RabbitMQ의 이벤트 기반 설계는 확장성, 결합도 최소화, 복원력, 관찰 가능성(Observability) 등 여러 측면에서 우수함
2️⃣동시성 제어 개선 (Redisson 분산 락 적용)
- @Transactional 은 프록시 기반으로 작동한다.
- AOP는 트랜잭션 시작 전 실행되므로, AOP 내 락 획득 후 트랜잭션이 실행된다.
- 그러나 호출부에서 트랜잭션이 이미 시작되어 있으면, AOP 내부의 트랜잭션 제어는 무의미해지고 락 해제가 트랜잭션 커밋보다 먼저 일어난다.
- 결국, 락 해제 → 트랜잭션 커밋 순서가 되면 다른 스레드가 커밋되지 않은 데이터를 조회해 정합성 문제가 발생한다.
📌보완 포인트 및 개선 아이디어
항목 | 필요성 | 개선 아이디어 |
Idempotent 처리 | 중복 메시지 대비 | messageId 기반 Redis set 저장 or DB PK 제약 |
DLQ 모니터링 | 예외 메시지 추적 | Dead Letter Queue + 슬랙/모니터링 알림 연결 |
락 실패 Fallback | Redis 장애 대비 | 로컬 KeyLock, 재시도 대기 큐 구성 |
메시지 압축/배치 | 시세 급등락 시 트래픽 폭주 대응 | zstd 압축 + batch publish 적용 |
Observability | 장애 근원 추적 | Zipkin, Prometheus 연동 |
✅ 결론
- RabbitMQ 기반의 비동기 구조는 성능과 확장성을, Redisson 기반의 락 적용은 정합성과 안정성을 보장함
- 체결 처리 성능, 시스템 부하 절감, 유지보수성, 장애 회복력 등 모든 측면에서 구조적 개선 달성
- 두 개선 방향은 함께 적용될 때 가장 큰 시너지를 발휘함 → 실시간 대량 주문 체결 서비스의 핵심 기반이 됨