5분 브리핑을 진행하면서 다수의 멀티 스레드로 동시 주문을 실행하면 데이터 정합성 문제가 발생하는 것을 다루었고 본격적으로 성능 개선을 위해 분산 락을 적용해볼려고한다.
✅분산 환경에서 동시성 제어는 왜 필요한가?
- 여러 요청이 공유 자원을 동시에 접근할 때, 분산된 DB나 서버 간 동기화 속도 차이로 인해 데이터 정합성 문제가 발생할 수 있다.
예를 들어, 모의투자 시스템에서 동일한 계좌가 여러 주문을 동시에 처리하는 경우, 잔고 감소 또는 보유 주식 수량 차감이 정확하게 이루어지지 않아 잘못된 체결이나 정합성 오류가 발생할 수 있다.
✅분산락 없이 발생하는 문제점
📌 하나의 계좌가 동시에 두 개의 매도 주문 처리 시도
- 기대 결과: 보유 주식 1개씩 두 번 매도되어 총 2개 차감
- 실제 결과: 보유 주식이 1개만 차감
동시 요청 처리 과정에서 Holdings 수량 차감과 Account 잔고 증가가 충돌하면서 정합성이 깨진다.
❓왜 이런 문제가 발생할까
- 여러 요청들이 한 자원에 대해서 공유할 때, 각 분산 DB의 동기화가 여러 요청의 동기화 속도를 못 따라 가는 상황이 발생한다.
- 예를 들어, 위와 같이 한번에 여러 주문이나 다른 서비스 로직이 실행되면 동시에 수량이라는 보유 종목자원을 사용하기 떄문에 커밋되거나 롤백되는 수량의 동기화가 다른서버가 따라가지 못해서 발생하는 것이다.
✅ 해결 방안
- 공유 자원인 수량을 레디스에 올려놓고 분산락(Distributed Lock)을 활용해서 데이터 동시성 문제를 해결할 수 있다.
- 여러 요청마다 락을 점유하고 데이터 업데이트 하기 때문에 각 서버는 각 DB의 동기화를 기다리지 않아도 되며, 동시성 문제도 해결할 수 있다.
✅ 분산 락 비교 (Lettuce VS Redisson)
Spring에서 주로 사용하는 Lettuce 기반의 Spring Data Redis는 분산 락을 직접 구현해야 한다.
📌 Lettuce 의 스핀락
- Lettuce의 락은 setnx 메서드를 이용해 사용자가 직접 스핀락 형태로 구성하게 된다.
- 락이 점유 시도를 실패했을 경우 계속 락 점유 시도를 하게 된다.이로 인해 레디스는 계속 부하를 받게 되며, 응답시간이 지연된다.
- 추가적으로, 만료시간을 제공하고 있지 않아서 락을 점유한 서버가 장애가 생기면 다른 서버들도 해당 락을 점유할 수 없는 상황이 연출된다.
📌 Redisson 의 분산락
- Redisson은 락 획득 시 스핀 락 방식이 아닌 pub/sub 방식을 이용한다.
- pub/sub 방식은 락이 해제될 때마다 subscribe중인 클라이언트에게 "이제 락 획득을 시도해도 된다." 라는 알림을 보내기 때문에, 클라이언트에서 락 획득을 실패했을 때, redis에 지속적으로 락 획득 요청을 보내는 과정이 사라지고, 이에 따라 부하가 발생하지 않게 된다.
- 또한 Redisson은 RLock이라는 락을 위한 인터페이스를 제공한다. 이 인터페이스를 이용하여 비교적 손쉽게 락을 사용할 수 있다.
✅ Redisson 이란?
Redisson은 Java에서 Redis를 쉽게 사용할 수 있도록 도와주는 고급 클라이언트 라이브러리이다.
기본적인 Redis 기능 외에도 다음과 같은 기능을 제공한다.
- 분산 락, 세마포어, 카운트다운 래치 등 동기화 기능
- Java 객체의 자동 직렬화 및 역직렬화
- Map, List, Set, Queue 등 다양한 자료구조 지원
- Pub/Sub 기반의 효율적인 락 처리 방식
- Redis Cluster, Sentinel, AWS ElastiCache, Valkey 등 다양한 환경 호환성
✅ Redisson의 핵심 기능 요약
1️⃣ 재진입 가능
- 동일 스레드에서 여러 번 락 획득이 가능하므로 데드락을 방지할 수 있다.
2️⃣ TTL 및 락 대기 시간 설정
lock.tryLock(10, 30, TimeUnit.SECONDS);
- 락 획득을 최대 10초까지 시도하고, 락을 30초간 유지한다.
- TTL 설정 덕분에 장애 시에도 락이 자동으로 해제된다.
3️⃣ Watchdog 메커니즘
- TTL을 지정하지 않으면 워치독이 자동 활성화된다.
- 락 소유자의 상태를 감시하며 30초 TTL을 주기적으로 갱신한다.
- 비정상 종료 시 TTL 만료로 락이 해제된다.
✅ Redisson 분산락 적용 예제
- RLock을 이용한 분산락 적용 코드로, 단순하게 tryLock()으로 동기화만 보장하는 형태이다.
RLock lock = redissonClient.getLock("LOCK::TRADE::" + order.getAccount().getId());
try {
if (!lock.tryLock(1, 5, TimeUnit.SECONDS)) return;
// 기존 tradeOrder 로직 수행
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
- 동일한 계좌 ID 기준으로 분산락을 걸어 체결을 직렬화
- tryLock()으로 시도 시간과 TTL을 지정하여 교착 상태 방지
✅ Redisson 분산락 적용
📌 로직 삽입
- 다음과 같은 형태로 Redisson 분산락을 서비스 로직 내부에 직접 삽입하였다
RLock lock = redissonClient.getLock("LOCK::TRADE::" + order.getAccount().getId());
try {
if (!lock.tryLock(1, 5, TimeUnit.SECONDS)) return;
// 기존 tradeOrder 로직 수행
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
전체 코드
@Service
@RequiredArgsConstructor
public class TradeService {
private final RedissonClient redissonClient;
private final OrderRepository orderRepository;
private final AccountRepository accountRepository;
private final HoldingsRepository holdingsRepository;
private final TradeRepository tradeRepository;
private final RedisTemplate<String, Object> redisTemplate;
public void tradeOrder(Order order, Stock stock, BigDecimal currentPrice) {
RLock lock = redissonClient.getLock("LOCK::TRADE::" + order.getAccount().getId());
try {
if (!lock.tryLock(1, 5, TimeUnit.SECONDS))
return;
if (order.getOrderStatus() == OrderStatus.CANCELED) {
throw new CustomRuntimeException(ExceptionCode.ORDER_ALREADY_CANCELED);
}
if (order.getOrderStatus() == OrderStatus.SETTLE) {
throw new CustomRuntimeException(ExceptionCode.ORDER_ALREADY_COMPLETED);
}
Account account = accountRepository.findById(order.getAccount().getId())
.orElseThrow(() -> new CustomRuntimeException(ExceptionCode.ACCOUNT_NOT_FOUND));
BigDecimal totalPrice = currentPrice.multiply(BigDecimal.valueOf(order.getQuantity()));
switch (order.getType()) {
case MARKET_BUY:
case LIMIT_BUY:
Holdings holding = holdingsRepository.findByAccountAndStock(account, stock)
.orElseGet(() -> Holdings.builder()
.account(account)
.stock(stock)
.averagePrice(BigDecimal.ZERO)
.quantity(0L)
.build());
// holding.increaseQuantity(order.getQuantity());
holding.updateAveragePrice(order.getQuantity(), totalPrice);
holdingsRepository.save(holding);
break;
case MARKET_SELL:
case LIMIT_SELL:
Holdings sellHolding = holdingsRepository.findByAccountAndStock(account, stock)
.orElseThrow(() -> new CustomRuntimeException(ExceptionCode.HOLDINGS_NOT_FOUND));
sellHolding.decreaseQuantity(order.getQuantity());
account.increaseCurrentBalance(totalPrice);
holdingsRepository.save(sellHolding);
break;
}
order.updateOrderStatus(OrderStatus.SETTLE);
orderRepository.save(order);
accountRepository.save(account);
Trade trade = Trade.builder()
.orderId(order.getId())
.accountId(account.getId())
.quantity(order.getQuantity())
.price(order.getPrice())
.traderDate(LocalDateTime.now())
.charge(0.0) // 매도시 수수료 차감 (아직 적용전)
.trade(true)
.build();
tradeRepository.save(trade);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
이처럼 직접 코드에 락 처리 로직을 넣을 경우, 중복 코드가 증가하고 가독성이 떨어지는 문제가 있다.
특히 여러 서비스 메서드에서 락을 사용할 경우 매번 try-catch-finally 블록을 작성해야 하며, 락 획득 실패나 해제 누락 등의 실수를 유발할 수 있다.
👉 이러한 문제를 해결하기 위해,커스텀 어노테이션 방식을 도입하였다.
📌 커스텀 어노테이션 방식
- 트랜잭션과 락 해제의 순서를 정밀하게 제어하려면 AOP 기반 처리가 유리하다.
- 아래는 @DistributedLock 어노테이션을 이용한 AOP 방식의 적용 예이다.
DistributedLock.interface
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key(); // 락 키
long waitTime() default 5; // 락 대기 시간 (초)
long leaseTime() default 10; // 락 점유 시간 (초)
}
DistributedLockAspect.class
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {
private final RedissonClient redissonClient;
@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);
}
return joinPoint.proceed(); // 비즈니스 로직 실행
} finally {
if (isLocked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
이제 여기서 해당 비즈니스 로직에 커스텀 어노테이션을 적용하면 Redisson 의 분산락 사용이 가능해진다.
@DistributedLock(key = "'trade:' + #tradeId")
✅ Redisson 분산락의 한계점
- 하지만 이 방식은 트랜잭션과의 정합성이 완전히 보장되지 않는 문제가 있다.
- 예를 들어 락을 획득한 후 트랜잭션 커밋 전에 장애가 발생하면, 락은 풀리지만 데이터는 반영되지 않거나 중복 수행될 수 있다
- 트렌잭션과 락의 순서를 정해줘야하는데 기존에 있는 트렌잭션 어노테이션은 사용할 수 없으므로 수동으로 다뤄줘야한다.
'Spring' 카테고리의 다른 글
[Spring]비동기 메시지 큐 (Kafka & RabbitMQ) (0) | 2025.06.20 |
---|---|
[Spring] @Casheable 이해하기 (2) | 2025.05.21 |
[Spring Security] 스프링 시큐리티와 JWT로 인증 구현하기 (2) | 2025.05.15 |
[Spring]N+1 문제 (2) | 2025.05.08 |
[Spring] 트랜잭션 내부 호출 문제 (2) | 2025.05.01 |