본문 바로가기
Lecture

동시성 이슈 사례와 해결 방안 탐구 (Synchronized, database, redis)

by Renechoi 2023. 7. 11.

문제점 

 

다음과 같은 재고 감소 로직을 가진 엔티티와 이를 검증하는 테스트 코드를 살펴보자. 

 

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Stock {

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Long id;
   private Long productId;
   private Long quantity;

   public void decrease(Long quantity) {
      if (this.quantity - quantity < 0) {
         throw new RuntimeException("foo");
      }

      this.quantity -= quantity;
   }

   public Stock(Long id, Long quantity) {
      this.id = id;
      this.quantity = quantity;
   }
}

 

 

다음과 같이 테스트 코드를 작성하면 

 

@BeforeEach
public void insert() {
    Stock stock = new Stock(1L, 100L);
    stockRepository.saveAndFlush(stock);
}

@AfterEach
public void delete() {
    stockRepository.deleteAll();
}

@Test
public void decrease_test() {
    stockService.decrease(1L, 1L);

    Stock stock = stockRepository.findById(1L).orElseThrow();
    // 100 - 1 = 99

    assertEquals(99, stock.getQuantity());
}

 

100개를 설정하였다가 1개를 감소하므로 재고 감소 기능을 검증한다. 

 

무리 없이 통과하는 것을 볼 수 있다. 

 

하지만 이 코드의 문제점은 무엇일까? 

 

요청이 동시에 여러 개가 들어오는 테스트 케이스를 작성해보자. 

 

@Test
public void 동시에_100명이_주문() throws InterruptedException {
   int threadCount = 100;
   ExecutorService executorService = Executors.newFixedThreadPool(32);
   CountDownLatch latch = new CountDownLatch(threadCount);

   for (int i = 0; i < threadCount; i++) {
      executorService.submit(() -> {
         try {
            stockService.decrease(1L, 1L);
         } finally {
            latch.countDown();
         }
      });
   }

   latch.await();

   Stock stock = stockRepository.findById(1L).orElseThrow();

   // 100 - (100 * 1) = 0
   assertEquals(0, stock.getQuantity());
}

 

멀티 스레드를 이용하기 위해 ExecutorService를 사용하였다. ExecutorService는 자바의 API로서 비동기로 사용하는 작업을 단순화할 수 있게 도와준다. newFixedThreadPool(32)는 최대 32개의 스레드를 가진 스레드 풀을 생성하는 메서드이다. 이를 사용하여 주문 처리를 동시에 처리한다.

 

for 반복문으로 100개의 요청을 보낸다. 

 

이때 100개 요청이 끝날때까지 기다려야하므로 CountDownLatch를 사용한다. 

 

카운트 다운 래치는 다른 스레드의 작업이 끝날 때까지 기다리도록 도와준다. latch의 대기 기능을 활용해 threadCount의 값만큼의 이벤트가 발생할 때까지 대기하도록 설정한다. 

 

예상되는 바는 100번 감소를 시키니까 0이 될 것이다. 이를 Assertion을 통해 검증하도록 한다. 

 

 

실패하는 것을 볼 수 있다. 

 

 

왜 이런 결과가 나오는 것일까? 

 

이런 경우 Race Condition(경합 조건)이 일어났다고 한다. Race Condition이란  공유된 자원에 동시에 여러 스레드가 접근할 때 발생할 수 있는 상황을 말한다. 이러한 상황에서 스레드 간의 실행 순서나 타이밍에 따라 예기치 않은 결과가 발생한다.

테스트 코드에서는 ExecutorService와 CountDownLatch를 사용하여 100개의 요청을 동시에 처리한다. 각 요청은 stockService.decrease(1L, 1L) 메서드를 호출하여 재고를 1만큼 감소시킨다.

문제는 Stock 엔티티의 decrease() 메서드에서 발생한다. 현재 구현에서는 재고 수량을 감소시키기 전에 수량이 음수가 되는지 확인하고, 음수가 되면 예외를 던진다. 그러나 여러 스레드가 동시에 이 메서드를 호출할 경우, 첫 번째 스레드가 수량을 확인한 후 감소하기 전에 다른 스레드가 수량을 변경할 수 있다. 이로 인해 잘못된 결과가 발생하게 되는 것이다.

 

예를 들어 생각해보자. 

 

쓰레드 1이 값을 갱신하고, 그 갱신된 값을 쓰레드 2가 가져가기를 기대한다. 즉, 100 -> 99가 완료되고 그 결과가 반영이 된 뒤, 다시 조회를 하여 해당 값을 99 -> 98로 줄이기를 기대하는 것이다. 

 

그러나 실제 상황에서는 쓰레드 1과 쓰레드 2는 경합하여 로직을 수행하기 때문에 쓰레드 1이 100 -> 99 처리를 전부 마치기를 기다려주는 것이 아니라 100 -> 진행중 -> 변경(99) -> 완료 과정 중 '진행중'과 '변경' 과정 모두에서도 동일한 작업을 수행하게 된다는 것이다. 따라서 쓰레드 1과 쓰레드 2 모두 100 -> 99를 줄이기 때문에 실제적으로 값은 100 -> 99로 업데이트 되어 갱신이 누락된다. 

 

 

 

 

 

해결 

 

1. synchronized 키워드를 사용한 해결 

 

 

@Transactional
public synchronized void decrease(Long id, Long quantity){
   Stock stock = stockRepository.findById(id).orElseThrow();
   stock.decrease(quantity);
   stockRepository.save(stock);
}

 

synchronized를 사용해도 테스트 케이스가 실패한다. 그 이유는 스프링의 @Transactional 동작 방식 때문이다. 스프링은 @Transactional이 붙은 경우 프록시 객체를 만들어서 사용한다. 위의 코드에서 @Transactional 어노테이션은 decrease 메서드에 적용되어 있기 때문에, 실제로 메서드가 실행될 때는 decrease 메서드를 호출한 곳에서 프록시 객체를 통해 실행되게 된다.

 

즉, 메서드 자체에 대해서는 동기화를 할 수 있지만 트랜잭션 컨텍스트 내에서 데이터베이스에 반영하기 전에 다른 쓰레드가 접근한다면 다른 쓰레드는 decrease 변경이 반영되기 전 값을 가져올 수 있다. 

 

간단한 예시로 살펴보자. 

 

decrease 메서드가 10:00에 종료가 되었고 트랜잭션이 10:05에 종료가 된다면, 10:00 ~ 10:05 이내에는 다른 쓰레드가 decrease 메서드를 호출할 수 있게 된다는 것이다. 결과적으로 이전과 동일한 문제가 발생하게 된다. 

 

 

 

 

2. 데이터베이스를 활용한 해결 

 

데이터베이스를 활용해 Lock을 하는 방법을 살펴보자. 

 

첫 번째는 Pessimistic Lock이다. 실제로 데이터에 Lock을 걸어서 정합성을 맞추는 방법이다. exclusive lock을 걸면 다른 트랜잭션에서는 해당 lock이 해제되기 까지 데이터를 가져갈 수 없게 된다. 이 방식은 데드락이 걸릴 수 있기 때문에 주의를 요한다. 

 

예를 들어 다음과 같은 그림을 통해 보면 

 

https://vladmihalcea.com/optimistic-vs-pessimistic-locking/

 

Alice와 Bob은 각각 계정 테이블의 행을 읽을 때 읽기 (공유) lock 을 획득한다.

Alice와 Bob은 둘 다 식별자 값이 1인 계정 레코드에 대해 읽기 lock을 보유하고 있기 때문에, 어느 한 명도 해당 레코드를 변경할 수 없다. 

이러한 이유로 Bob의 UPDATE 작업은 Alice가 이전에 획득한 공유 lock을 해제할 때까지 제한된다.

 

 

 

 

두 번째는 Optimistic Lock이다. 실제 Lock을 이용하지 않고 버전 정보를 이용해서 정합성을 맞춘다. 먼저 데이터를 읽은 후 update를 수행시 현재 내가 읽은 버전이 맞는지를 확인하며 업데이트 한다. 내가 읽은 버전에서 수정사항이 생겼을 경우 application에서 다시 읽은 후에 작업을 수행해야 한다. 

 

 

 

위의 그림을 보면 버전 칼럼을 사용하는 것을 볼 수 있다. 해당 칼럼은 수정이 발생할 때마다 업데이트 되며 조회시에 필요한 정보로서 기능한다. 

 

예를 들어 Bob이 account 잔액을 변경할 때, 버전을 1에서 2로 변경한다. 이후 Alice가 계정 잔액을 변경하려고 하면 WHERE 절의 버전 값이 더 이상 1이 아니라 2이기 때문에 레코드 불일치를 야기하며, 따라서, UPDATE 문의 executeUpdate 메서드는 레코드가 변경되지 않았다는 의미로 0의 값을 반환한다.  결과적으로 Alice의 트랜잭션은  OptimisticLockException과 함께 롤백된다. 

 

 

 

세 번째는 Named Lock이다. 이름을 가진 Lock을 획득한 후 해제할 때까지 다른 세션은 이 lock을 사용하지 못하도록 한다. 주의점으로는 transactional이 종료될 때 자동으로 lock이 해제되지 않기 때문에 별도로 해제를 해주어야 한다. 

 

Pessimistic Lock과 유사하지만 메타데이터에 Locking을 하는 차이가 있다. 이를 테면 다음과 같다. 

 

 

Pessimistic의 경우 Stock에 Lock을 걸었지만 Named Lock은 별도의 공간에 Lock을 건다. 세션 1이 1이라는 이름으로 락을 건다면 이 1이 해제되어야 다음 락이 가능하다. 

 

 

 

 

 

 

 

이와 같은 방식을 사용하여 문제를 해결해보자. 

 

먼저 Pessimistic Lock과 Optimistic Lock이다. 

 

Spring Data Jpa에서는 어노테이션을 사용해 쉽게 Lock을 할 수 있도록 제공한다. 

 

다음과 같은 메서드를 작성한다. 

 

public interface StockRepository extends JpaRepository<Stock, Long> {
   @Lock(value = LockModeType.PESSIMISTIC_WRITE)
   @Query("select s from Stock s where s.id=:id")
   Stock findByIdWithPessimisticLock(Long id);

   @Lock(value = LockModeType.OPTIMISTIC)
   @Query("select s from Stock s where s.id = :id")
   Stock findByIdWithOptimisticLock(Long id);
}

 

 

서비스에서 실행 예시는 다음과 같다. 

 

@Service
@RequiredArgsConstructor
public class PessimisticLockStockService {

    private final StockRepository stockRepository;


    @Transactional
    public Long decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findByIdWithPessimisticLock(id);
        stock.decrease(quantity);
        stockRepository.save(stock);

        return stock.getQuantity();
    }
}

 

Optimistic Lock 예제 역시 다음과 같다. 

 

@Service
@RequiredArgsConstructor
public class OptimisticLockStockService {

   private final StockRepository stockRepository;

   @Transactional
   public void decrease(Long id, Long quantity) {
      Stock stock = stockRepository.findByIdWithOptimisticLock(id);
      stock.decrease(quantity);

      stockRepository.saveAndFlush(stock);
   }
}

 

 

버전 정보를 이용하므로 엔티티에 Version 정보 필드를 작성해주어야 한다. 

 

@Entity
public class Stock {

   @Id
   @GeneratedValue(strategy = GenerationType.IDENTITY)
   private Long id;
   private Long productId;
   private Long quantity;

   @Version
   private Long version;

 

Optimistic Lock의 경우 실패시 재시도를 해야하기 때문에 facade에서 이 기능을 수행해줄 클래스를 작성해준다. 

 

@Service
@RequiredArgsConstructor
public class OptimisticLockStockFacade {

   private final OptimisticLockStockService optimisticLockStockService;

   public void decrease(Long id, Long quantity) throws InterruptedException {
      while (true) {
         try {
            optimisticLockStockService.decrease(id, quantity);

            break;
         } catch (Exception e) {
            Thread.sleep(50);
         }
      }
   }
}

 

 

테스트 코드를 다음과 같이 작성해보자. 

 

@SpringBootTest
class OptimisticLockStockFacadeTest {
   @Autowired
   private OptimisticLockStockFacade optimisticLockStockFacade;

   @Autowired
   private StockRepository stockRepository;

   @BeforeEach
   public void insert() {
      Stock stock = new Stock(1L, 100L);

      stockRepository.saveAndFlush(stock);
   }

   @AfterEach
   public void delete() {
      stockRepository.deleteAll();
   }

   @Test
   public void 동시에_100개의요청() throws InterruptedException {
      int threadCount = 100;
      ExecutorService executorService = Executors.newFixedThreadPool(32);
      CountDownLatch latch = new CountDownLatch(threadCount);

      for (int i = 0; i < threadCount; i++) {
         executorService.submit(() -> {
            try {
               optimisticLockStockFacade.decrease(1L, 1L);
            } catch (InterruptedException e) {
               throw new RuntimeException(e);
            } finally {
               latch.countDown();
            }
         });
      }

      latch.await();

      Stock stock = stockRepository.findById(1L).orElseThrow();

      // 100 - (100 * 1) = 0
      assertEquals(0, stock.getQuantity());
   }
}

 

별도의 락을 잡지 않으므로 Pessimistic보다 성능상의 장점이 있을 수 있다. 단점으로는 업데이트 실패시 재시도 로직을 작성해주어야 한다. 또한 충돌이 만약 빈번한 경우라면 Pessimistic이 더 나은 성능을 제공한다. 

 

 

이번에는 Named Lock을 살펴보자. 

 

네이티브 쿼리를 활용한 레포지토지를 작성한다. 

 

public interface LockRepository extends JpaRepository<Stock, Long> {
    @Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
    void getLock(String key);

    @Query(value = "select release_lock(:key)", nativeQuery = true)
    void releaseLock(String key);
}

 

다음과 같은 facade를 작성한다. 

 

@Component
@RequiredArgsConstructor
public class NamedLockStockFacade {

   private final LockRepository lockRepository;

   private final StockService stockService;

   @Transactional
   public void decrease(Long id, Long quantity) {
      try {
         lockRepository.getLock(id.toString());
         stockService.decrease(id, quantity);
      } finally {
         lockRepository.releaseLock(id.toString());
      }
   }
}

 

락을 획득한 후에 decrease 메서드를 수행한다. 이때 decrease 메서드는 부모의 트랜잭션과 별도로 수행되도록 propagation을 변경해준다. 

 

@Transactional(propagation = Propagation.REQUIRES_NEW)
public synchronized void decrease(Long id, Long quantity){
   Stock stock = stockRepository.findById(id).orElseThrow();
   stock.decrease(quantity);
   stockRepository.save(stock);
}

 

propagation 설정은 @Transactional 어노테이션의 속성 중 하나로 전파 동작을 지정하는 것을 의미한다. propagation은 메서드가 이미 실행 중인 트랜잭션이 있을 때 해당 트랜잭션을 참여시킬지, 새로운 독립적인 트랜잭션을 생성할지를 결정한다.

위의 코드에서 Propagation.REQUIRES_NEW는 독립적인 새로운 트랜잭션을 생성하여 메서드를 실행하도록 지정하는 옵션으로 메서드가 자체적으로 트랜잭션을 시작하고, 부모 트랜잭션과는 독립적으로 동작한다는 것을 의미한다.

 

테스트 코드에서는 구현한 NamedLockFacade를 사용해 테스트 하면 된다. 

 

 

3. Redis를 활용한 해결 

 

 

1) Lettuce 라이브러리 이용하기 

- setnx 명령어를 활용하여 분산 락 구현 (spin lock 방식) 

- retry 로직을 개발자가 구현해주어야 한다. 

 

SETNX 명령어는 "SET if Not eXists"의 약자로, 키가 존재하지 않을 때만 특정 값을 설정하는 명령어이다.

 

이 방식은 mysql을 사용할 때의 NamedLock과 비슷한 방식이라고 할 수 있다. 

 

 

먼저 다음과 같이 레디스 레포지토리를 만든다. 

 

@Component
public class RedisLockRepository {

   private RedisTemplate<String, String> redisTemplate;

   public RedisLockRepository(RedisTemplate<String, String> redisTemplate) {
      this.redisTemplate = redisTemplate;
   }

   public Boolean lock(Long key) {
      return redisTemplate
         .opsForValue()
         .setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
   }

   public Boolean unlock(Long key) {
      return redisTemplate.delete(generateKey(key));
   }

   private String generateKey(Long key) {
      return key.toString();
   }
}

 

키를 활용한 setnx 명령어를 통해 lock을 획득하고 종료시에 delete로 unlock을 해주는 로직을 작성하였다. 

 

@Component
@RequiredArgsConstructor
public class LettuceLockStockFacade {

   private final RedisLockRepository redisLockRepository;

   private final StockService stockService;

   public void decrease(Long key, Long quantity) throws InterruptedException {
      while (!redisLockRepository.lock(key)) {
         Thread.sleep(100);
      }

      try {
         stockService.decrease(key, quantity);
      } finally {
         redisLockRepository.unlock(key);
      }
   }
}

 

장점 : 구현이 간단

단점 : spin lock 방식으로 레디스에 부하를 줄 수 있다. 

 

 

 

 

 

2) Redisson 라이브러리 이용하기 

- pub-sub 기반으로 lock 구현 

- 별도의 retry 로직은 필요 없음 

 

 

먼저 Redisson 라이브러리를 추가한다. 

 

implementation 'org.redisson:redisson-spring-boot-starter:3.17.4'

 

 

레디슨을 사용하는 파사드 예제 코드는 다음과 같다. 

 

@Component
@RequiredArgsConstructor
public class RedissonLockStockFacade {

   private final RedissonClient redissonClient;

   private final StockService stockService;
   
   public void decrease(Long key, Long quantity) {
      RLock lock = redissonClient.getLock(key.toString());

      try {
         boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);

         if (!available) {
            System.out.println("lock 획득 실패");
            return;
         }

         stockService.decrease(key, quantity);
      } catch (InterruptedException e) {
         throw new RuntimeException(e);
      } finally {
         lock.unlock();
      }
   }
}

 

레디슨의 경우 락 관련 클래스를 제공해주기 때문에 별도 레포지토리를 작성할 필요는 업다. 위의 클래스에서처럼 로직 실행 전후로 락 획득, 해제 코드를 작성하면 된다. 

 

 

실무에서는 재시도가 필요하지 않은 lock의 경우 lettuce를, 재시도가 필요한 경우 redisson을 사용하는 경우가 많다. 

 

 

 

 

 

결론 

 

동시에 여러 스레드에서 재고 감소 메서드를 호출할 때 경합 조건(Race Condition)이 발생하는 케이스와 해결 방법을 알아보았다. 

 

 

1) synchronized 키워드를 사용한 해결: decrease 메서드에 synchronized 키워드를 추가하여 메서드 수준에서 동기화를 시도할 수 있다. 그러나 이 방법은 스프링의 @Transactional이 필요한 환경에서는 적절한 해결책이 아닌 것으로 확인했다. 

2) 데이터베이스를 활용한 해결: 데이터베이스의 Lock을 사용하여 동시성 문제를 해결할 수 있다. Pessimistic Locking이나 Optimistic Locking과 같은 방법을 사용할 수 있다. Pessimistic Locking은 데이터에 직접 Lock을 걸어서 일관성을 유지하는 방법이며, Optimistic Locking은 버전 정보를 활용하여 일관성을 유지하는 방법이다. 성능 면에서 Redis보다 좋지는 않다. 

3) Redis를 활용한 해결: Redis를 해결하는 경우에는 Lettuce 라이브러리를 사용하거나 Redisson 라이브러리를 사용하는 방식으로 두 가지 방법을 알아보았다. Redis 해결은 Mysql 해결 방법보다 성능은 좋지만 추가 비용이 들 수 있다는 단점이 있다.

 

Lettuce의 경우 Redis의 SETNX 명령어를 사용하여 분산 Lock을 구현할 수 있다. SETNX 명령어는 키가 존재하지 않을 때만 값을 설정하는 기능을 제공한다. 이를 활용하여 재고 감소 메서드 실행 시 Lock을 획득하고, 작업이 완료되면 Lock을 해제하는 방식으로 동시성 문제를 해결할 수 있다. Redisson의 경우 pub sub 방식으로 작동하여 decrease 메서드 실행 시 Lock을 획득하고, 작업이 완료되면 Lock을 해제하는 방식으로 동시성 문제를 해결할 수 있었다.

 

 

 

 


참고자료 

- https://vladmihalcea.com/optimistic-vs-pessimistic-locking/

- 인프런 재고시스템으로 알아보는 동시성이슈 해결방법

 

 

반응형