1. 문제 상황
사용자는 마켓에서 아이템을 구매할 수 있다. 이때 아이템 A를 판매하는 판매자가 n명이라면, 아이템 A의 수량은 n개가 된다. 구매자가 구매를 요청하면, 해당 아이템을 가장 처음에 등록한 판매자의 상품을 구매하게 된다. 그런데 멀티 스레드 환경에서 구매 요청이 동시에 올 경우, 재고 수량 이상의 사용자가 구매에 성공하는 동시성 문제가 발생한다.
2. 해결 방안
2-1. JPA 낙관적 락
JPA의 버전 관리를 통한 방법이다. 다음과 같이 Trade 엔티티에 버전 관리 필드를 추가해주고, JPA 데이터 조회 메서드에 '@Lock(value = LockModeType.OPTIMISTIC)' 를 붙여주면 된다.
그러나 낙관적 락은 현재 상황에 적합하지 않다. 그 이유는 공정하지 않기 때문이다. 예를 들어 재고가 3개가 있고 사용자 5명이 순차적으로 요청을 보낸다고 가정하자.
- user1, 2가 동시에 trade1을 조회한다.
- user1은 구매에 성공하지만 user2는 버전 충돌로 구매에 실패한다.
- 그때 user3이 trade2를 조회하여 구매에 성공한다.
즉, user3보다 user2가 먼저 요청을 보냈지만, trade1 구매에 대해 user2가 버전 충돌이 이뤄지는 동안 user3이 그 다음 재고인 trade2에 대해 구매에 성공하게 된다.
만약 재고 1개에 대해 선착순 1개의 요청만 구매에 성공해야 한다면, 낙관적 락으로 충분히 해결 가능하다. 그러나 재고가 n개 있으며, 요청 순서에 따라 순차적으로 선착순 n개의 요청이 구매에 성공해야 한다면 낙관적 락으로는 공정하게 처리할 수 없다.
2-2. DB 락(SELECT FOR UPDATE)
두 번째로는 DB에 락을 거는 방식이다. JPA 데이터 조회 메서드에 '@Lock(value = LockModeType.PESSIMISTIC_WRITE)' 를 붙여주면 SELECT FOR UPDATE 구문으로 데이터를 조회하게 된다.
@Service
@RequiredArgsConstructor
public class TradeService {
private final UserRepository userRepository;
private final TradeRepository tradeRepository;
@Transactional
public void purchase(User buyer, Item item) {
Trade trade = tradeRepository.findFirstProduct(item); // 판매중인 첫번째 trade 엔티티를 조회하며 락을 건다.
User seller = userRepository.findById(trade.getSellerId())
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_USER));
// ... 구매 처리
}
}
@Repository
@RequiredArgsConstructor
public class TradeEntityRepository implements TradeRepository {
private final TradeJpaRepository tradeJpaRepository;
@Override
public Trade findFirstProduct(Item item) {
return tradeJpaRepository.findFirstByItemAndTradeStatusAndSellerIdNotOrderByCreatedAtAsc(item, TradeStatus.ON_SALE, -1)
.orElseThrow(() -> new BusinessException(ErrorCode.INSUFFICIENT_PRODUCT))
.toTrade();
}
}
public interface TradeJpaRepository extends JpaRepository<TradeEntity, Long> {
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
Optional<TradeEntity> findFirstByItemAndTradeStatusAndSellerIdNotOrderByCreatedAtAsc(Item item, TradeStatus tradeStatus, long userId);
}
그러나 이 방법도 공정하지 않다. 예를 들어 재고가 2개가 있고 사용자 3명이 순차적으로 요청을 보낸다고 가정하자.
- user1, 2가 동시에 판매중인 trade1을 조회한다. 이때 user1이 먼저 락을 걸어 조회하고, user2는 락이 해제될 때까지 대기한다.
- 이어서 user1이 trade1에 대해 구매에 성공하여 trade1의 상태가 판매 완료 상태가 되었다.
- user3이 trade2를 조회한다.
- user2는 user1이 락을 해제하여 다시 레코드를 조회하고자 하는데, trade1의 상태가 판매 완료가 되어, trade2를 조회한다. 그런데 user3이 락을 걸었으므로 또다시 대기한다.
즉, user3보다 user2가 먼저 요청을 보냈지만, trade2에 대한 구매는 user3이 성공한다.
따라서 요청을 순차적으로 처리해야 한다면 DB에 락을 거는 방식이 아닌, 비즈니스 로직 자체를 순차적으로 처리하도록 해야 한다.
2-3. Redis 분산 락
따라서 비즈니스 로직 자체에 락을 거는 가장 간단한 방법으로 synchronized가 있다. 이렇게 하면 서비스 계층으로 요청이 도착한 순서대로 순차적으로 구매에 성공하게 된다. 그러나 멀티 인스턴스 환경에서는 동시성 제어가 되지 않으므로, 외부의 공통된 곳에서 락을 획득하는 분산락을 적용해보자.
Redisson을 통한 pub/sub 방식의 분산 락을 사용했다.
이때 주의할 점은 @Transactional과 함께 사용할 경우 동시성 제어에 실패할 수 있다. 따라서 @Transactional을 제거해줬으며, 변경 감지가 필요한 부분은 리포지토리 계층에 @Transactional을 붙여주었다.
@Service
@RequiredArgsConstructor
public class TradeService {
private final UserRepository userRepository;
private final TradeRepository tradeRepository;
private final RedissonClient redissonClient;
public void purchase(User buyer, Item item) {
RLock lock = redissonClient.getLock("lock");
boolean isLocked = false;
try {
isLocked = lock.tryLock(3, 30, TimeUnit.SECONDS);
if (!isLocked) {
throw new RuntimeException("락 획득 실패!");
} else {
// 락 획득 후 임계 영역 접근
log.info("락 획득 성공! buyer id = " + buyer.getId());
Trade trade = tradeRepository.findFirstProduct(item);
User seller = userRepository.findById(trade.getSellerId())
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_USER));
// ... 구매 처리
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
if (isLocked && lock.isHeldByCurrentThread()) {
lock.unlock(); // 락 해제
}
}
}
아이템 재고가 5개일 때 동시에 구매 요청을 보냈을 때, 다음과 같이 순차적으로 구매에 성공한 것을 알 수 있다.
INFO gachagacha.gachaapi.service.TradeService [731115ac-d490-48ef-a17e-a5b1da335a96] - 락 획득 성공! buyer id = 4
INFO gachagacha.gachaapi.service.TradeService [d48c6f34-5795-4ebe-ab6c-8419a63e6947] - 락 획득 성공! buyer id = 8
INFO gachagacha.gachaapi.service.TradeService [e768347c-ebf1-498d-8a4e-9436c8d3d3d4] - 락 획득 성공! buyer id = 15
INFO gachagacha.gachaapi.service.TradeService [67c70a2d-4ade-4c5b-bdad-13573691ad87] - 락 획득 성공! buyer id = 19
INFO gachagacha.gachaapi.service.TradeService [33ad9235-4386-4e88-a79e-0fca1e8309a9] - 락 획득 성공! buyer id = 5
INFO gachagacha.gachaapi.service.TradeService [69301dc0-26fc-4d90-b31f-9e7b13c7c7ee] - 락 획득 성공! buyer id = 20
INFO gachagacha.gachaapi.service.TradeService [44bed6e7-0c73-4aee-94be-e1185a9d8c10] - 락 획득 성공! buyer id = 10
...

2-4. Redis에서 재고 관리
다음 방법으로는 레디스에 아이템 재고를 관리하는 방식이다. 레디스에 list 형태로 아이템에 대한 재고를 관리한다. 즉 key는 item:stock:{itemId}가 되고, value는 trade id list가 된다. 그리고 아이템 구매시 레디스 list에서 pop을 한다. 이때 pop 연산은 원자적이기 때문에 경합이 발생하지 않는다.
코드를 보면 다음과 같다. 우선 상품이 등록될 때 RDB에 trade 데이터를 INSERT하면서 레디스의 list에서 trade id를 저장해야 한다. 따라서 RDB에 INSERT하는 로직과 레디스에 저장하는 로직은 원자적이어야 한다. 따라서 레디스 트랜잭션 설정을 해주자.
@Bean
public RedisTemplate<String, Long> longRedisTemplate() {
RedisTemplate<String, Long> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Long.class));
redisTemplate.setEnableTransactionSupport(true); // 트랜잭션 설정
return redisTemplate;
}
103번 아이템을 상품으로 등록했으나 롤백되었다. 이때 레디스에서 확인해보면 값이 저장되지 않은 것을 알 수 있다.

그리고 아이템 구매시 구매에 성공하면 lpop으로 레디스에서 해당 trade id가 제거된다. 그러나 구매 실패시(롤백시)에도 lpop으로 레디스 list에서 해당 trade id가 제거된다. 따라서 롤백되는 경우 레디스에 다시 trade id를 저장하도록 했다. (참고로 앞서 레디스 트랜잭션을 설정해주었기 때문에 레디스 list에서 trade id를 조회하는 부분의 코드('tradeRedisRepository.getTradeId(...)')의 반환 값이 항상 null이다. 따라서 해당 부분은 트랜잭션 범위 밖으로 빼고, 그 뒤의 로직들을 transactionTemplate을 통해 트랜잭션으로 묶어주었다.)
@Service
@RequiredArgsConstructor
public class TradeService {
private final UserRepository userRepository;
private final TradeRepository tradeRepository;
private final TradeRedisRepository tradeRedisRepository;
private final TransactionTemplate transactionTemplate;
public void purchase(User buyer, Item item) {
Long tradeId = tradeRedisRepository.getTradeId(item.getItemId());
if (tradeId == null) {
throw new BusinessException(ErrorCode.INSUFFICIENT_PRODUCT);
}
try {
transactionTemplate.execute(status -> {
Trade trade = tradeRepository.findById(tradeId)
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_PRODUCT));
User seller = userRepository.findById(trade.getSellerId())
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_USER));
// ... 구매 처리
});
} catch (RuntimeException e) {
tradeRedisRepository.saveTradeId(item.getItemId(), tradeId);
throw e;
}
}
}
2. 테스트 및 결론
분산 락 방식과 레디스에서 재고 관리를 하는 방식의 성능을 비교해보자. 테스트를 위해 아이템의 재고를 100개 INSERT 해두었고, 1,000개의 구매 요청을 보내도록 했다.
테스트 결과, 분산 락 방식은 111.1/sec, 레디스 재고 관리 방식은 235.9/sec으로, 레디스 재고 관리 방식이 약 2배 더 빠르다.


그 이유는 순차 처리 범위가 레디스 재고 관리 방식이 더 작기 때문이라고 할 수 있다. 분산 락 방식은 구매 요청 처리의 비즈니스 로직 전체가 순차 처리 범위가 된다. 그러나 레디스 재고 관리 방식은 재고 조회 구간만 레디스 싱글 스레드를 통해 순차 처리가 되고, 이후 구매 로직을 처리하는 부분은 동시성을 허용하며 멀티 스레드의 이점을 누릴 수 있다(각 스레드마다 각기 조회한 tradeId에 대해 구매를 처리).