[Spring]동시성 제어(Redisson)

2025. 6. 16. 13:33·Spring

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
'Spring' 카테고리의 다른 글
  • [Spring]비동기 메시지 큐 (Kafka & RabbitMQ)
  • [Spring] @Casheable 이해하기
  • [Spring Security] 스프링 시큐리티와 JWT로 인증 구현하기
  • [Spring]N+1 문제
코딩로봇
코딩로봇
금융 IT 개발자
  • 코딩로봇
    쟈니의 일지
    코딩로봇
  • 전체
    오늘
    어제
    • 분류 전체보기 (152)
      • JavaScript (8)
      • SQL (11)
      • 코딩테스트 (30)
        • Java (15)
        • SQL (13)
      • Java (10)
      • 프로젝트 (30)
        • 트러블슈팅 (10)
        • 프로젝트 회고 (18)
      • git,Github (2)
      • TIL (38)
      • Spring (20)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    java #arraylist #list #배열
    스파르타 코딩 #부트캠프 #첫ot
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
코딩로봇
[Spring]동시성 제어(Redisson)
상단으로

티스토리툴바