지난 글에서는 모의 투자 사이트의 체결 로직을 어떻게 구상했는지 다뤘습니다.
지정가와 시장가 주문의 특성, 호가창 방식과 체결량 방식의 장단점을 비교한 뒤, 최종적으로 실시간 체결량 기반 방식을 선택한 이유를 설명했습니다.
2025.11.19 - [백엔드] - [모의 투자 사이트 개발기] 체결 로직 구상하기
[모의 투자 사이트 개발기] 체결 로직 구상하기
이전 글에서는 WebSocket을 이용해 실시간 시세 데이터를 가져와서 처리하는 방법을 다뤘습니다.하지만 실제와 같은 경험을 위해 또 중요한 것이 있죠. 바로 체결입니다. 이번 글에서는 이 체결 로
hwanheee.tistory.com
이번 글에서는 그 구상을 실제로 설계하고 구현한 내용을 공유합니다. 웹소켓으로 받은 실시간 체결 데이터를 어떻게 처리하고 사용자 주문과 어떻게 매칭하는지, 그리고 동시성 문제와 데이터 정합성을 어떻게 보장했는지 코드와 함께 정리해 보려고 합니다.
금융 도메인과 체결 엔진의 중요성
먼저 금융 도메인에서 체결 엔진은 주식 매수와 매도 주문을 처리하는 핵심 시스템입니다. 체결 엔진은 데이터의 정확성과 신뢰성을 보장해야 합니다. 핵심은 데이터의 무결성과 트랜잭션의 일관성을 유지하는 것입니다.
체결 엔진 설계의 트레이드오프
체결 엔진 설계에서는 다양한 트레이드오프를 고려해야 합니다.
예를 들어, 데이터베이스 중심의 설계는 데이터의 무결성을 보장하지만, 성능상의 한계를 가질 수 있습니다.
반면 레디스 중심의 설계는 빠른 속도로 처리가 가능하지만 메모리 특성상 서버 장애 시 데이터 휘발 가능성이 있고, RDBMS와 함께 사용할 경우 이중 쓰기(Dual-Write)로 인한 데이터 불일치 문제를 해결해야 한다는 복잡성이 따릅니다.
이 글에서는 체결 엔진의 구현과정과 데이터의 신뢰성을 보장하기 위해 어떠한 기술적 의사결정을 내렸고 어떻게 구현했는지 알아보겠습니다.
설계
먼저 실시간 체결량 기반의 체결엔진 설계는 크게 다음과 같이 잡았습니다.
웹소켓 수신(KIS) → 이벤트 발행 → 이벤트 큐(Redis) → 이벤트 처리(주문과 매칭) → 체결 완료
왜 이벤트 큐를 도입했을까?
1. 웹소켓 스레드 블로킹 최소화: 이벤트 발행만 하고 즉시 반환
2. 이벤트 처리 실패가 웹소켓 수신에 영향 없음
3. 여러 이벤트가 동시에 들어와도 큐에 순서대로 쌓임
4. 락 경합 시 이벤트 보존
메세지 큐가 아닌 Redis List인 이유
프로젝트의 현재 단계와 데이터 특성을 고려하여 Redis를 사용했습니다. 메세지 손실에 대한 재시도 매커니즘이 없지만 손실되는 메세지는 실시간 체결 데이터로 끊임없이 들어오기 때문에 빠르게 구현하기 위해 선택했습니다.
물론 시스템의 안정성을 위해 다음 단계에서는 Kafka나 RabbitMQ와 같은 전용 메시지 브로커로 리팩토링하여, 메시지 영속성과 신뢰성을 강화할 계획입니다.
또한 매매 주문들을 순차적으로 체결할 때 데이터베이스가 아닌 레디스를 사용했습니다.
왜 RDBMS가 아니라 Redis였을까?
주식 매칭의 핵심 원칙은 '가격 우선, 시간 우선'입니다. 즉, 들어온 주문들은 항상 가격순 → 시간순으로 정렬되어 있어야 하며, 체결 엔진은 가장 우선순위가 높은 주문을 빨리 조회해야 합니다.
만약 RDBMS로 오더북을 구현한다면 정렬 후 조회하는 쿼리가 매 체결 순간마다 실행되어야 합니다.
인덱스를 잘 설정한다고 해도 매번 들어오는 주문과 체결로 인해 B-Tree를 재정렬하는 비용이 발생합니다.
주문이 쌓이는 상황에서 병목이 발생할 수도 있겠죠.
이 문제를 해결하기 위해 Redis의 Sorted Set (ZSET) 자료구조를 도입했습니다.
ZSET은 데이터를 넣는 순간, 내부적으로 Skip List 알고리즘을 통해 자동으로 정렬됩니다. 별도의 ORDER BY 연산이 필요 없습니다.
- Score: 주문 가격 (가격 우선)
- Value: 주문 정보 (동일 가격 내에서는 시간순 처리를 위한 부가 정보 포함)
ZRANGE 명령어를 통해 최우선 주문을 조회하는 시간 복잡도는 O(log N)입니다. 주문이 쌓여 있어도 즉시 조회가 가능합니다.
또한 디스크를 거치지 않고 모든 연산이 메모리에서 수행되므로, 체결 엔진의 처리 속도를 높일 수 있습니다.
DB + Redis
물론 Redis는 휘발성 메모리라는 단점이 있습니다. 서버가 다운되면 오더북이 날아갈 위험이 있죠. 이를 보완하기 위해 DB와 Redis를 함께 사용하는 전략을 취했습니다.
- 영속성 보장 (DB): 주문이 들어오면 먼저 RDBMS의 Order 테이블에 대기(PENDING) 상태로 저장하여 데이터 유실을 방지합니다.
- 빠른 처리 (Redis): 동시에 Redis 오더북에 등록하여 실제 매칭은 Redis에서 수행합니다.
- 결과 반영: 체결이 완료되면 Redis에서 데이터를 제거하고, DB에 결과를 비동기로 업데이트합니다.
전체적인 아키텍처

체결에 필요한 데이터베이스 설계

기본적으로 주문, 체결, 매수 시 보유종목(account_stock)테이블과 order_hold테이블을 만들었습니다.
(사용자가 매수 주문을 하면 계좌에서 돈을 빼는 것이 아니라 해당 금액만큼 돈을 묶도록 함. 총 계좌의 묶여있는 돈을 뺀 금액만큼 다른 종목에서 사용 가능)
주문 플로우와 체결 플로우
주문 플로우

1. 사용자 주문 요청 (시장가 / 지정가)
2. 계좌 및 종목 조회, 권한 확인, 매수/매도 구분 확인
3. Order 엔티티 생성 및 홀딩 금액/수량 계산 및 설정
4. Order 및 OrderHold 영속성 컨텍스트에 반영
5. Redis 오더북에 추가
- Sorted Set에 주문 추가
6. 웹소켓 구독 등록
- 체결 이벤트를 받기 위해 해당 종목 구독 시작
체결 처리 플로우

1. KIS 웹소켓에서 체결 데이터를 수신
2. 수신한 데이터를 이벤트로 변환해 Redis 큐에 추가하고 바로 체결 이벤트를 보냄
3. EventPublisher는 이벤트를 받아서 OrderMatchingService의 체결 함수를 호출 (외부 물리 트랜잭션 시작)
4. OrderMatchingService는 레디스에서 종목에 대한 락을 획득하고 큐에서 체결 데이터를 가져와 사용자가 주문한 오더북에서 매칭 가능한 주문을 조회 (내부 논리 트랜잭션 시작)
- 오더북은 각 종목당 가격, 시간 순으로 정렬되어 있음. 주식 시장의 원칙에 맞게 가격 → 시간 순으로 체결됨
- 이벤트가 매수 체결일 경우: 매도 주문 조회 (가격 ≤ 체결가격)
- 이벤트가 매도 체결일 경우: 매수 주문 조회 (가격 ≥ 체결가격)
5. 반목문으로 체결 이벤트 종목의 주문들에 대해 체결을 실행
- 사용자 현금 / 보유주식 확인
매수 주문: 계좌 현금이 체결 금액 이상인지 확인
→ 부족 시 주문 취소 및 잔여 이벤트 재큐잉
매도 주문: 보유 주식 수량이 체결 수량 이상인지 확인
→ 부족 시 주문 취소 - 주문 체결
이벤트에 잔여 수량이 남으면 재큐잉 - Execution 생성
체결 내역을 Execution 엔티티로 저장
주문 정보, 체결 가격, 체결 수량 기록 - 계좌 반영
매수 주문인 경우:
→ Account.cash 감소 (체결 금액만큼)
→ Account.holdAmount 감소 (OrderHold에서)
→ AccountStock 수량 증가 및 평단가 업데이트
매도 주문인 경우:
→ Account.cash 증가 (체결 금액만큼)
→ AccountStock 수량 감소 및 holdQuantity 감소 - 오더북 업데이트 (Redis)
완전 체결된 주문: Sorted Set에서 제거
부분 체결된 주문: remainingQuantity만 업데이트
6. 모든 주문 처리 완료 후 영속성 컨텍스트에 반영
7. 분산 락 해제 (외부 물리 트랜잭션 종료)
- Lua 스크립트로 자신이 획득한 락만 안전하게 해제
- 다음 이벤트 처리 가능
이러한 체결 플로우에서 중요한 것은 데이터의 무결성과 트랜잭션의 일관성을 유지하는 것입니다.
만약 적절한 제어가 없다면 다음과 같은 상황이 발생합니다.

1. 스레드A가 매수/매도 주문을 조회하고 체결
2. 업데이트를 하기 전에 스레드B가 동일한 매수 주문을 조회. 이때 스레드 A의 체결이 업데이트 되기 전이라 주문 체결
3. 사용자의 매수 주문은 하나인데 체결이 2번 되는 문제 발생
이러한 애플리케이션 레벨에서의 동시성 문제를 해결하기 위해 레디스 분산락을 도입했습니다.
왜 Redis 분산락을 사용했을까요?
먼저 자바의 synchronized는 단일 서버 내에서만 동작합니다.
또한 Service코드에 락을 걸기 때문에 종목별로 락이 필요한 저희 체결엔진의 요구사항에 맞지 않았습니다.
Zookeeper나 DB 락이 아닌 Redis인 이유
저희 시스템은 이미 사용자들의 주문 오더북을 Redis로 관리하고 있습니다. 데이터가 있는 곳에서 락을 관리하는 것이 네트워크 오버헤드를 줄이고 아키텍처를 단순화하는 길이라 판단했습니다.
또한 디스크 기반의 DB 락이나 Zookeeper보다, 인메모리 기반인 Redis의 SETNX 명령어가 훨씬 가볍고 빠르기 때문에 선택했습니다.
단순히 락을 잡는 것뿐만 아니라, 데드락(Deadlock)과 오동작을 방지하기 위해 다음과 같은 전략을 사용했습니다.
- SETNX (Set if Not Exists): 락이 없을 때만 값을 세팅하는 원자적 명령어를 사용해 상호 배제(Mutual Exclusion)를 구현했습니다.
- TTL (Time To Live) 설정: 만약 락을 획득한 서버가 로직 처리 중 비정상 종료되더라도, 설정한 시간이 지나면 자동으로 락이 만료되도록 하여 시스템 전체가 멈추는 데드락을 방지했습니다.
- UUID 토큰 검증: 락을 해제할 때, 내가 건 락이 맞는지 확인해야 합니다. 락 획득 시 고유한 UUID를 값으로 저장하고, 해제 시 이 값을 비교하여 다른 프로세스의 락을 실수로 지우는 사고를 막았습니다.
- Lua Script 활용: 락의 조회와 삭제 과정이 원자적으로 이루어지도록 Lua 스크립트를 사용하여 해체 측면에서도 동시성 이슈를 차단했습니다.
이때 Lettuce를 사용했습니다.
Lettuce는 Netty기반의 Redis Client로, 요청을 논블로킹으로 처리하여 높은 성능을 가집니다.
그러나 Lettuce는 스핀락 방식이므로 lock이 해제되었는 지 주기적으로 retry를 해야하므로 이 부분에서 부하가 커질 수 있습니다.
추후에 Pub/Sub구조인 Redission 방식을 고려 할 예정입니다.
구현
락 키 생성
종목별로 독립적인 락을 사용합니다. 키는sim:limit:lock:{stockCode} 형식입니다.
종목별로 락을 분리해 다른 종목은 병렬 처리할 수 있습니다.
private static final String LIMIT_LOCK_KEY_PATTERN = "trading:lock:stock:%s";
private String buildLockKey(String stockCode) {
return LIMIT_LOCK_KEY_PATTERN.formatted(stockCode);
// ex) trading:lock:stock:005930
}
락 획득 (SETNX + TTL)
setIfAbsent(SETNX)와 TTL을 함께 사용해 락을 획득합니다.
@Value("${matching.limit-lock-ttl-seconds:2}")
private long lockTtlSeconds; // ttl 2초
private boolean acquireLock(String lockKey, String lockToken) {
return Boolean.TRUE.equals(
redisTemplate.opsForValue().setIfAbsent(
lockKey,
lockToken, // UUID
Duration.ofSeconds(lockTtlSeconds)
)
);
}
UUID 토큰 생성
각 락 획득 시 고유 토큰을 생성해 저장합니다.
public List<Execution> consumeNextEvent(String stockCode) {
String lockKey = buildLockKey(stockCode);
String lockToken = UUID.randomUUID().toString(); // 고유 토큰 생성
if (!acquireLock(lockKey, lockToken)) { // 위의 락 획득 함수 실행
log.debug("지정가 매칭 락 획득 실패. stockCode={}", stockCode);
return List.of(); // 락 획득 실패 시 처리 안함
}
try {
// 체결 처리 로직 ...
} finally {
releaseLock(lockKey, lockToken); // 토큰과 함께 해제
}
}
락 해제 (Lua 스크립트)
Lua 스크립트로 원자적으로 수행합니다.
조회와 삭제가 한 번에 실행되어 중간에 다른 스레드가 개입하지 않습니다.
private static final DefaultRedisScript<Long> RELEASE_LOCK_SCRIPT = new DefaultRedisScript<>(
"""
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""",
Long.class
);
private void releaseLock(String lockKey, String lockToken) {
redisTemplate.execute(RELEASE_LOCK_SCRIPT, List.of(lockKey), lockToken);
}
이렇게 Lettuce 기반 Redis 분산락을 활용하여 매칭 로직의 동시성 문제를 해결하였습니다.
하지만 분산 락 적용으로 데이터의 정합성이 해결된 것은 아닙니다. 현재는 체결 이벤트와 사용자들의 주문 매칭의 종목간 동시성은 보장하였지만 사용자 자산에 대한 동시 접근 문제가 남아있습니다.
분산락만으로는 막을 수 없는 상황
만약 한 사용자의 삼성전자 매수와 SK하이닉스 매수를 동시에 시도한다고 가정하겠습니다.
- 스레드 1: 삼성전자 Redis 락 획득 → 잔고 조회 (100만 원)
- 스레드 2: SK하이닉스 Redis 락 획득 → 잔고 조회 (100만 원)
이때 종목이 다르므로 Redis 락 키가 달라 동시 진입이 가능합니다. - 결과: 두 서버 모두 100만 원을 기준으로 차감하여 업데이트를 시도합니다. 결국 갱신 손실(Lost Update)이 발생하여 데이터 정합성이 깨지게 됩니다.
이 문제를 해결하기 위해, 체결 로직 내부에서 사용자 계좌를 조회할 때 DB 레벨의 비관적 락(Pessimistic Lock)을 추가로 적용했습니다.
비관적 락 선택 이유
1. 충돌이 잦을 것이라고 판단
주식 체결 시스템은 특정 인기 종목이나 급변하는 시장에서 한 사용자의 계좌에 대해 매수/매도 체결이 빗발칠 수 있는 충돌 빈도가 높은 환경입니다.
- 낙관적 락: 충돌이 발생하면 예외(ObjectOptimisticLockingFailureException)를 던지고 롤백됩니다. 10건이 동시에 들어오면 1건만 성공하고 9건은 실패하여 재시도해야 합니다. 이는 어플리케이션의 CPU 자원 낭비와 불필요한 DB 커넥션 점유로 이어집니다.
- 비관적 락: 충돌 시 에러가 아닌 대기 상태가 됩니다. 여러 스레드가 순서대로 처리되므로, 재시도로 인한 오버헤드 없이 모든 요청을 안정적으로 처리할 수 있습니다.
2. 갱신 손실의 차단
- 낙관적 락은 어플리케이션 레벨에서 버전을 체크하는 논리적인 락인 반면, 비관적 락은 데이터베이스 레벨에서 물리적으로 Row를 잠금으로써 다른 트랜잭션의 접근 자체를 차단합니다. 이를 통해 계좌를 수정하는 동안에는 다른 스레드가 읽거나 쓸 수 없게 하여 강력한 수준의 데이터 정합성을 보장합니다.
3. 짧은 트랜잭션 길이
- 비관적 락의 단점은 락을 오래 쥐고 있을 때 발생하는 성능 저하입니다. 하지만 저희 체결 로직은 계좌 조회 → 메모리 연산 → 업데이트로 이어지는 과정이 짧고 단순합니다. 외부 API 호출과 같은 긴 작업이 없으므로, 락 점유 시간이 짧습니다. 따라서 락 대기로 인한 병목보다 잦은 재시도 로직을 수행하는 비용이 훨씬 크다고 판단했습니다.
4. 순서 보장의 필요성
- 낙관적 락은 재시도 과정에서 먼저 들어온 요청이 나중에 처리되는 등 처리 순서가 뒤바뀔 수 있습니다. 반면 비관적 락은 DB의 Lock 획득 순서에 따라 처리되므로, 체결 이벤트의 발생 순서와 잔고 반영 순서를 최대한 일치시키는 데 유리합니다.
구현
JPA가 제공하는 LockModeType.PESSIMISTIC_WRITE를 사용했습니다.
- @Lock(LockModeType.PESSIMISTIC_WRITE): 비관적 쓰기 락 적용
- SQL 변환: SELECT ... FOR UPDATE 로 실행
- 해당 행에 배타적 락을 걸어 다른 트랜잭션의 읽기/쓰기 차단
public interface AccountRepository extends JpaRepository<Account, Long> {
// 비관적 락(X-Lock)을 걸고 계좌를 조회
// 트랜잭션이 종료될 때까지 다른 트랜잭션은 읽기/쓰기가 불가능하고 대기함
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.accountId = :accountId")
Optional<Account> findByIdWithLock(@Param("accountId") Long accountId);
}
Service 계층 (체결 로직)
체결 로직 중 잔고를 확인하거나 변경하는 부분에 일반 findById가 아닌 findByIdWithLock을 사용합니다.
중복 락 획득 방지
JPA는 영속성 컨텍스트에 엔티티를 캐싱해 같은 트랜잭션 내에서 동일한 엔티티를 재사용할 수 있습니다.
하지만 비관적 락(@Lock(LockModeType.PESSIMISTIC_WRITE))은 이 캐싱을 우회합니다.
결국 락이 필요할 때마다 쿼리를 실행하면 캐싱이 되지 않고 매번 쿼리가 날아갑니다.
해결: 한 번 획득한 락을 재사용
데이터베이스의 비관적 락은 트랜잭션 단위로 생존합니다.
영속성 컨텍스트의 Account 객체는 비관적 락을 얻은 채로 트랜잭션 내에서 일관된 상태를 유지합니다.
이러한 이유로 계좌에 대한 락을 한 번만 획득하고, 조회된 Account 객체를 파라미터로 전달하는 방식으로 구현했습니다.
@Transactional
public List<Execution> distributeEvent(String stockCode, LimitOrderFillEvent event) {
// ... 이벤트 처리 로직 ...
for (Long orderId : filledOrderIds) {
Order order = orderMap.get(orderId);
FillCommand command = fillCommands.get(orderId);
// 주문 검증 로직 ...
// 이때 계좌를 한 번만 조회하고 락을 겁니다 (동일 주문 내 중복 락 획득 방지)
Account account = accountRepository.findByIdWithLock(order.getAccount().getAccountId())
.orElseThrow(() -> new IllegalStateException("계좌를 찾을 수 없습니다. accountId=" + order.getAccount().getAccountId()));
int desiredFillQuantity = command.fillQuantity();
int actualFillQuantity = desiredFillQuantity;
BigDecimal fillPrice = event.price();
if (order.getOrderMethod() == OrderMethod.BUY) {
// 락을 획득한 account 객체를 전달합니다
int affordableQuantity = calculateAffordableQuantity(account, fillPrice, desiredFillQuantity);
if (affordableQuantity <= 0) {
log.warn("계좌 현금 부족으로 주문을 취소합니다. orderId={} accountId={} requiredUnitPrice={} cash={}",
orderId, order.getAccount().getAccountId(), fillPrice, account.getCash());
// 락을 획득한 account 객체를 전달합니다
cancelDueToInsufficientFunds(order, stockCode, account);
updatedOrders.add(order);
cancelledQuantity += desiredFillQuantity;
continue;
}
actualFillQuantity = affordableQuantity;
// 미체결 수량 처리 추가
if (actualFillQuantity < desiredFillQuantity) {
cancelledQuantity += (desiredFillQuantity - actualFillQuantity);
}
}
// ... 체결 처리 로직 ...
try {
order.applyFill(actualFillQuantity);
Execution execution = executionService.record(order, event.price(), actualFillQuantity);
executions.add(execution);
publishExecutionFilledEvent(execution, order, stockCode);
// 락을 획득한 account 객체를 전달합니다
handleAccountOnFill(order, event.price(), actualFillQuantity, account);
updatedOrders.add(order);
if (order.getRemainingQuantity() <= 0) {
// 락을 획득한 account 객체를 전달합니다
finalizeFilledOrder(order, account);
}
} catch (IllegalArgumentException ex) {
log.error("주문 체결 처리 중 오류 발생. orderId={} fillQuantity={}", orderId, actualFillQuantity, ex);
// ... 예외 처리 ...
}
}
return executions;
}
// 주문 시 잔고 확인 (가용 금액 계산)
private int calculateAffordableQuantity(Account account, BigDecimal price, int desiredQuantity) {
if (price == null || price.signum() <= 0) {
return 0;
}
// Account 객체를 파라미터로 받아 사용 (락은 이미 획득됨)
BigDecimal cash = account.getCash();
if (cash.compareTo(price) < 0) {
return 0;
}
BigDecimal affordableRaw = cash.divide(price, 0, RoundingMode.FLOOR);
if (affordableRaw.compareTo(BigDecimal.ZERO) <= 0) {
return 0;
}
int affordable = affordableRaw.min(BigDecimal.valueOf(desiredQuantity)).intValue();
return Math.min(affordable, desiredQuantity);
}
// 체결 후 실제 잔고 반영
private void handleAccountOnFill(Order order, BigDecimal price, int fillQuantity, Account account) {
// Account 객체를 파라미터로 받아 사용 (락은 이미 획득됨)
BigDecimal fillAmount = price.multiply(BigDecimal.valueOf(fillQuantity));
if (order.getOrderMethod() == OrderMethod.BUY) {
account.decreaseCash(fillAmount); // 현금 차감
orderHoldRepository.findById(order.getOrderId())
.ifPresentOrElse(
hold -> {
account.decreaseHoldAmount(fillAmount);
hold.decreaseHoldAmount(fillAmount);
},
() -> log.warn("OrderHold를 찾을 수 없습니다. orderId={}", order.getOrderId())
);
updateAccountStockOnBuy(account, order.getStock(), fillQuantity, price);
return;
}
if (order.getOrderMethod() == OrderMethod.SELL) {
account.increaseCash(fillAmount); // 현금 증가
accountStockRepository.findByAccountAndStock(account, order.getStock())
.ifPresentOrElse(
accountStock -> {
accountStock.decreaseHoldQuantity(fillQuantity);
accountStock.decreaseQuantity(fillQuantity);
},
() -> log.warn("AccountStock을 찾을 수 없습니다. orderId={} accountId={} stockCode={}",
order.getOrderId(), account.getAccountId(), order.getStock().getCode())
);
}
}
// 주문 취소 시 홀딩 금액 해제
private void cancelDueToInsufficientFunds(Order order, String stockCode, Account account) {
order.markCancelled();
redisOrderBookRepository.removeOrder(order.getOrderId(), stockCode, order.getOrderMethod());
orderSubscriptionCoordinator.unregisterLimitOrder(stockCode);
orderHoldRepository.findById(order.getOrderId())
.ifPresent(hold -> {
// Account 객체를 파라미터로 받아 사용 (락은 이미 획득됨)
BigDecimal remaining = hold.getHoldAmount();
if (remaining.signum() > 0) {
account.decreaseHoldAmount(remaining); // 홀딩 금액 해제
}
hold.release();
});
}
// 체결 완료 후 남은 홀딩 금액 해제
private void finalizeFilledOrder(Order order, Account account) {
orderSubscriptionCoordinator.unregisterLimitOrder(order.getStock().getCode());
orderHoldRepository.findById(order.getOrderId())
.ifPresent(hold -> {
// Account 객체를 파라미터로 받아 사용 (락은 이미 획득됨)
BigDecimal remaining = hold.getHoldAmount();
if (remaining.signum() > 0) {
account.decreaseHoldAmount(remaining); // 남은 홀딩 금액 해제
}
hold.release();
});
}
이렇게 비관락까지 도입하여 계좌 조회 시점부터 트랜잭션 종료 시까지 물리적인 Row Lock을 건 결과 갱신 손실을 차단하여 데이터 무결성을 보장했습니다.
분산락과 비관락을 사용하여 동시성 제어는 해결했지만 이중 쓰기 문제가 남았습니다.
현재 체결 처리에서 레디스와 DB를 사용중입니다. 두 개의 저장소는 데이터 정합성을 보장해야합니다.
물론 정상적인 상황에서는 문제가 되지 않습니다. 코드는 순차적으로 실행되고, 두 저장소의 데이터는 일치합니다.
문제는 둘 중 하나만 실패했을 경우입니다.
Redis와 RDBMS는 하나의 트랜잭션으로 묶이지 않기 때문에 트랜잭션의 원자성이 보장되지 않습니다.
- DB는 업데이트되었으나 Redis 갱신이 실패한다면?
- Redis는 갱신되었으나 DB 트랜잭션이 롤백된다면?
체결 시스템에서 이러한 데이터 불일치는 주문 누락이나 돈 복사와 같은 문제로 이어집니다.
Redis와 DB가 동시에 수정되는 로직은 사용자의 주문과 이벤트 체결입니다.
이 문제를 해결하기 위해 주문과 체결 2가지 상황에서 생각해보았습니다.
1. 사용자 주문: 트랜잭션 범위를 활용한 원자성 보장
@Transactional 롤백 메커니즘을 활용하여 DB와 Redis가 일치하도록 합니다.
핵심은 작업의 순서입니다.
- 비즈니스 로직과 DB 업데이트를 먼저 수행합니다. (아직 커밋되지 않은 상태)
- 메서드의 가장 마지막 라인에서 Redis 오더북 업데이트를 수행합니다.
비즈니스로직 또는 DB작업이 실패하면 롤백이 되고 레디스는 아직 업데이트가 안 된 상황이니 데이터가 일치합니다.
Redis작업이 실패하면 에러를 던져 앞선 DB 트랜잭션을 강제로 롤백시킵니다. 데이터가 일치합니다.
해당 방식의 문제점
이 방식의 문제점은 메서드가 다 끝난 후 커밋 시점에 예기치 못한 상황으로 롤백되는 경우입니다. 이때 DB는 롤백되지만 레디스는 롤백되지 않습니다.
만약 매수 주문일 경우 사용자의 자산은 롤백되어서 그대로인데 레디스 주문북은 롤백되지 않아서 체결이 되는 상황이 생깁니다.
해결책
스프링의 TransactionSynchronizationManager.afterCommit을 활용하였습니다.
DB 트랜잭션이 완전히 커밋된 후에만 Redis 오더북을 갱신하도록 하였습니다.
이 방식은 위의 문제점은 막아주지만, 반대 상황인 주문 누락 가능성은 여전히 존재합니다.
DB 커밋은 성공했는데(afterCommit 진입) Redis 업데이트 코드가 에러가 난다면?
매수/매도 주문은 했지만 레디스에 없어서 체결이 안 되는 상황이 발생합니다.
하지만 기존의 방식보다 안전합니다. 누락된 주문은 복구 로직을 실행하여 레디스에 다시 넣어주면 됩니다.
잠깐의 누락보다 데이터가 오염되는 것이 더 위험하다고 판단하여 해당 방식을 선택했습니다.
2. 체결: 트랜잭션 범위만 활용
체결 로직에서는 레디스의 데이터가 삭제되는 과정이기때문에 주문과 반대의 상황입니다.
DB 커밋은 성공했는데(afterCommit 진입) Redis 업데이트 코드가 에러가 난다면?
체결이 성공하여 DB에는 반영되었지만 레디스에 삭제가 안 되어서 또 체결이 되는 문제가 발생합니다.
이때는 afterCommit 대신 트랜잭션 범위의 마지막 메서드에 레디스를 수정하는 방식을 선택했습니다.
이 방식의 문제점은 DB커밋이 실패했을 때 레디스의 주문은 삭제되었지만 사용자의 DB는 변함이 없습니다.
복구 로직을 실행하여 누락된 주문을 레디스에 다시 넣어주면 됩니다.
구현
1. 사용자 주문
트랜잭션 핸들러 유틸리티 클래스를 구현하여 afterCommit 활용
// 트랜잭션 동기화를 위한 유틸리티 클래스
public class TransactionHandler {
// 현재 트랜잭션이 성공적으로 커밋된 직후에 runnable을 실행
public static void afterCommit(Runnable runnable) {
if (TransactionSynchronizationManager.isActualTransactionActive()) {
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
runnable.run();
}
}
);
} else {
// 트랜잭션이 없는 상황이라면 그냥 즉시 실행
runnable.run();
}
}
}
OrderService
주문 생성 로직에 TransactionHandler.afterCommit을 적용
public class OrderService {
// 지정가 주문 생성
@Transactional
public OrderResponse createLimitOrder(LimitOrderCreateRequest request) {
// 비관적 락을 걸어 주문 생성 중 잔고 변경 방지
Account account = accountRepository.findByIdWithLock(request.accountId())
.orElseThrow(() -> new BadRequestException("계좌를 찾을 수 없습니다."));
// ... (검증, 주문 객체 생성, 잔고 차감 로직) ...
Order savedOrder = orderRepository.save(order);
// afterCommit을 사용하여 DB 커밋이 성공한 후에만 Redis 오더북에 등록
TransactionHandler.afterCommit(() ->
addOrderToRedisAfterCommit(savedOrder, stock)
);
return OrderResponse.from(savedOrder);
}
// 시장가 주문 생성
@Transactional
public OrderResponse createMarketOrder(MarketOrderCreateRequest request) {
// 비관적 락 사용
Account account = accountRepository.findByIdWithLock(request.accountId())
.orElseThrow(() -> new BadRequestException("계좌를 찾을 수 없습니다."));
// ... (검증, 홀딩 금액 계산 로직) ...
Order savedOrder = orderRepository.save(order);
// afterCommit을 사용하여 DB 커밋이 성공한 후에만 Redis 오더북에 등록
TransactionHandler.afterCommit(() ->
addOrderToRedisAfterCommit(savedOrder, stock)
);
return OrderResponse.from(savedOrder);
}
// 공통 Redis 등록 로직
private void addOrderToRedisAfterCommit(Order order, Stock stock) {
try {
redisOrderBookRepository.addOrder(order);
// 실시간 체결을 위한 웹소켓 구독
orderSubscriptionCoordinator.registerLimitOrder(stock.getCode());
} catch (Exception e) {
log.error("DB 커밋 후 Redis 업데이트 실패 (주문 누락 발생). orderId={} stockCode={}",
order.getOrderId(), stock.getCode(), e);
// TODO: 실패 로그를 기반으로 배치 프로그램에서 복구 수행 (Self-Healing)
}
}
}
2. 체결
트랜잭션 범위를 활용하여 메서드의 마지막에 레디스 수정
코드 구조: OrderMatchingService → OrderExecutionService
이유: 분산락 획득 부분에서 @Transactional이 있으면 락을 대기하는 동안에도 DB 커넥션을 물고 있는 문제가 발생합니다.
이걸 해결하기 위해 락을 획득하는 메서드에 @Transactional을 제거하고, 같은 클래스 내부에 @Transactional함수를 호출하면 프록시 내부 호출 문제로 인해 @Transactional이 동작하지 않습니다. (Spring AOP의 한계)
해결: 클래스를 분리하여 AOP가 잘 동작되도록 하였습니다.
OrderMatchingService
public List<Execution> consumeNextEvent(String stockCode) {
String lockKey = buildLockKey(stockCode);
String lockToken = UUID.randomUUID().toString(); // 고유 토큰 생성
if (!acquireLock(lockKey, lockToken)) { // 위의 락 획득 함수 실행
log.debug("지정가 매칭 락 획득 실패. stockCode={}", stockCode);
return List.of(); // 락 획득 실패 시 처리 안함
}
try {
// 서비스 호출 (프록시를 통해 @Transactional 동작)
return OrderExecutionService.distributeEvent(stockCode, event);
} finally {
releaseLock(lockKey, lockToken); // 토큰과 함께 해제
}
}
OrderExecutionService
트랜잭션 범위를 활용하여 메서드의 마지막에 레디스를 업데이트합니다.
public class OrderExecutionService {
@Transactional //여기서 트랜잭션 시작 (이미 락 획득 상태)
public List<Execution> distributeEvent(String stockCode, LimitOrderFillEvent event) {
// ... (Redis 오더북 조회 및 매칭 대상 선정) ...
for (Long orderId : filledOrderIds) {
// 비관적 락으로 계좌 조회 (갱신 손실 방지)
Account account = accountRepository.findByIdWithLock(order.getAccount().getAccountId())
.orElseThrow(...);
// 체결 로직 및 잔고 변경
// account 객체를 파라미터로 넘겨 락 상태 유지
handleAccountOnFill(order, event.price(), fillQuantity, account);
// ... (Execution 저장 등) ...
}
// DB 반영 (Flush)
if (!updatedOrders.isEmpty()) {
orderRepository.saveAll(updatedOrders);
}
// 트랜잭션 내부 마지막에 Redis 업데이트
// Redis 실패 시 예외를 던져 DB를 롤백시켜야 중복 체결을 막을 수 있음
try {
redisOrderBookRepository.removeOrders(filledOrderIds);
} catch (Exception e) {
log.error("Redis 업데이트 실패로 DB 롤백 실행: stockCode={}", stockCode, e);
throw e; // 강제 롤백 유발
}
return executions;
}
}
이렇게 설계한 트랜잭션 전략의 핵심은 데이터 정합성의 주체를 DB로 삼는다는 것입니다.
Redis는 고속 처리를 위한 엔진일 뿐, 데이터의 원본은 DB에 안전하게 저장되어 있습니다.
이제 오류가 났을 때 Redis와 DB 간의 불일치가 발생하더라도, DB를 기준으로 Redis를 동기화해주면 됩니다.
장애 복구 구현
주기적으로 DB와 Redis의 상태를 비교하고 복구하는 배치 프로그램을 구현했습니다.
RedisDBSyncService
이 서비스는 1분마다 실행되며, 시스템 부하를 최소화하기 위해 Time Window와 Paging 기법을 적용했습니다.
- Time Window: 전체 데이터를 뒤지는 것은 비효율적입니다. 데이터 불일치는 생성 직후에 발생하므로, 최근 5분 데이터만 검사 범위를 한정했습니다.
- Batch Processing: DB 부하를 막기 위해 한 번에 100건씩 끊어서 조회(Paging)하고, 배치 사이에는 약간의 대기 시간을 두어 리소스를 독점하지 않도록 설계했습니다.
@Service
public class RedisDBSyncService {
// 1분마다 실행
@Scheduled(fixedDelay = 60000)
public void syncRedisWithDB() {
// 검사 범위: 최근 5분
LocalDateTime since = LocalDateTime.now().minusMinutes(5);
// 주문 누락 복구
syncMissingOrders(since);
// 유령 주문 제거
removeGhostOrders();
}
private void syncMissingOrders(LocalDateTime since) {
int offset = 0;
while (true) {
// 페이징 처리로 DB 부하 최소화
List<Order> orders = orderRepository.findPendingOrdersSince(since, ..., PageRequest.of(...));
if (orders.isEmpty()) break;
for (Order order : orders) {
// Redis에 없는 주문 발견 시 복구 수행
if (!redisOrderBookRepository.exists(order.getOrderId(), ...)) {
redisOrderBookRepository.addOrder(order);
}
}
// ... 다음 페이지 조회 ...
}
}
}
RedisOrderBookRepository
SCAN 도입: Cursor 기반의 SCAN 명령어를 사용하여 Non-Blocking 방식으로 구현했습니다.
Connection 재사용: RedisTemplate 내부에서 다시 Template을 호출하면 커넥션 풀이 고갈되어 데드락이 발생할 수 있습니다. 이를 방지하기 위해 파라미터로 받은 connection 객체 하나로 작업을 수행하도록 최적화했습니다.
@Repository
public class RedisOrderBookRepository {
public Map<String, Set<Long>> getAllOrderIdsByStock() {
Map<String, Set<Long>> result = new HashMap<>();
String pattern = "sim:order:book:*";
// Low-Level Connection API 사용
redisTemplate.execute((RedisConnection connection) -> {
ScanOptions options = ScanOptions.scanOptions().match(pattern).count(100).build();
try (Cursor<byte[]> cursor = connection.scan(options)) {
while (cursor.hasNext()) {
byte[] keyBytes = cursor.next();
// ... (Key 파싱) ...
// redisTemplate 호출 대신 현재 connection을 재사용하여 ZRANGE 실행
Set<byte[]> rawValues = connection.zRange(keyBytes, 0, -1);
// ... (데이터 변환 및 결과 수집) ...
}
}
return null;
});
return result;
}
}
이렇게 장애 복구 로직을 구현하여 빠른 성능을 유지하면서도, 장애 발생 시 최대 1분 이내에 데이터 정합성이 자동으로 복구되는 시스템을 만들었습니다.
결론
체결 엔진 설계는 주식 거래 시스템의 성공에 중요한 역할을 합니다. 체결 엔진은 매수와 매도 주문을 매칭하여 거래를 성사시키는 핵심적인 역할을 하며, 높은 성능과 안정성이 요구됩니다.
왜냐하면 체결 엔진의 성능과 안정성이 부족하면 거래 지연이나 데이터 불일치 문제가 발생할 수 있기 때문입니다. 이는 사용자 경험에 직접적인 영향을 미치며, 신뢰도를 저하시킬 수 있습니다.
체결엔진의 중요성을 생각하며, 여러 시나리오에서 구현가능한 전략을 선택한 결과 레디스 분산락, 비관적 락, 이중쓰기문제 해결, 장애복구 로직을 선택하여 안정적이고 신뢰할 수 있는 체결 엔진을 구현할 수 있었습니다.
물론 현재 아키텍처가 끝은 아닙니다. Redis List는 메시지 영속성을 완벽히 보장하지 않으며, 폴링 방식의 복구 로직은 데이터가 폭증할 경우 DB에 부담이 될 수 있습니다.
향후에는 메세지 큐를 도입하여 메시지 유실 없는 이벤트 스트리밍 환경을 구축하고, 여러 방법을 찾아 애플리케이션 부하 없이 DB와 Redis의 정합성을 실시간으로 맞추는 방향으로 시스템을 발전시켜 나갈 계획입니다.
읽어주셔서 감사합니다.
'백엔드' 카테고리의 다른 글
| [모의 투자 사이트 개발기] 체결 로직 구상하기 (0) | 2025.11.19 |
|---|---|
| [모의 투자 사이트 개발기] 실시간 주식 시세 중계 시스템 구축하기 (0) | 2025.11.14 |
| [모의 투자 사이트 개발기] KIS 오픈API사용법, 토큰관리, API 호출 (1) | 2025.11.08 |
| 프로젝트에 Rag 도입하기 (0) | 2025.09.29 |
| [동시성 제어] 좌석 예매 동시성 문제 해결하기 (3) | 2025.08.27 |