본문 바로가기
Frameworks/Springboot

[Springboot] Synchronized, Database로 동시성 문제 해결하기

by 젊은오리 2023. 5. 9.
728x90

[최상용님의 재고 시스템으로 알아보는 동시성 이슈 해결방법을 학습 후 정리한 내용입니다.]

순서

  1. 재고 시스템 생성과 문제 발생
  2. Synchronized 사용하기
  3. Database 사용하기 - Pessimistic Lock
  4. Database 사용하기 - Optimistic Lock
  5. Database 사용하기 - Named Lock

 

1. 재고 시스템 생성과 문제 발생

재고 시스템을 만들기 위해서 도메인과 서비스를 만들어보자. 도메인의 경우 id, productId, quantity 필드를 가지며, quantity를 받아서 감소시키는 비즈니스 로직이 도메인 상에 존재한다.

[Domain]

@Entity
public class Stock {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long productId;
    private Long quantity;

    public Stock(){}

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

    public Long getQuantity(){
        return quantity;
    }

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

 

서비스의 경우는 상품 id와 quantity를 받아서 실제로 decrease를 수행하는 함수를 정의했다.

[Service]

@Service
public class StockService {

    private StockRepository stockRepository;

    public StockService(StockRepository stockRepository) {
        this.stockRepository = stockRepository;
    }

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

 

이제 재고를 감소시키는 테스트를 돌려보자. 테스트 수행 전 @BeforeEach을 사용해서 id가 1인 상품의 재고를 100개로 생성했고, 이를 저장했다.

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

    stockRepository.saveAndFlush(stock);
}

 

[Test] - 성공

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

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

stock을 1개 감소시키고, 실제로 100개에서 99개가 됐는지 확인을 한 결과, 테스트는 성공한 모습을 볼 수 있다.

하지만 내가 작성한 코드에는 문제가 있다. 어떤 문제점이 있는지 알아보기 위해 멀티 스레드 환경에서 동시에 100개 요청을 보내는 테스트 코드를 작성해보자.

 

@Test
public void 동시에_100개_요청() throws InterruptedException {
    int threadCount = 100;

    ExecutorService executorService = Executors.newFixedThreadPool(32);//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 -(1*100) = 0을 예상
		assertEquals(0L, stock.getQuantity());
}

우리가 예상했던 0개와는 달리 stock에는 89개의 재고가 아직 남아있다. 왜 이럴까??

*Race Condition(경쟁 상태)가 발생했기 때문이다. 이는 둘 이상의 스레드가 공유 데이터에 엑세스할 수 있고, 동시에 데이터를 변경하려고 할 때 발생하는 문제이다. 우리는 스레드 간의 공유 자원에 대한 경쟁 상태를 없애기 위해 한번에 하나의 스레드만 공유 자원에 엑세스할 수 있도록 해야 한다.

 

2. Syncronized 사용하기

서비스 코드에 다음과 같이 @Transactional 어노테이션을 지운 후 함수 선언 시 Syncronized를 사용한다.

//    @Transactional
    public synchronized void decrease(Long id, Long quantity){

        Stock stock = stockRepository.findById(id).orElseThrow();
        stock.decrease(quantity);
        stockRepository.saveAndFlush(stock);
    }

이렇게 되면, 테스트는 성공적으로 실행되면서, 우리가 원하고자 하는 멀티 스레드 환경에서의 동시성 문제를 해결하게 되었다. 하지만 자바의 Syncronized 키워드에도 문제가 있다.

자바의 Sychronized는 하나의 프로세스 안에서만 보장이 된다. 실제 운영 환경에서 여러 대의 서버를 사용하게 되면, 여러 개의 인스턴스가 존재하기 때문에, 인스턴스 단위로 thread-safe를 보장해주는 Syncronized는 문제가 발생할 수 밖에 없다.

 

3. Pessimistic Lock 사용하기

pessimistic lock은 실제로 데이터에 Lock을 걸어서 정합성을 맞추는 방법이다. 자원 요청에 따른 시성문제가 발생할 것이라고 예상하고 락을 걸어버리는 비관적 락 방식이다.

아래 그림과 같이 Server 1이 데이터를 가져올 때 해당 데이터 레코드에 Lock을 걸면, 다른 서버에서는 Server1의 작업이 끝나 Lock이 풀릴 때 까지 데이터에 접근할 수 없게 된다.

 

아래 예시 코드를 살펴보자. stock에 조회를 할 때 pessimistic lock를 걸어놓게 된다. JPA에서 지원하는 @Lock어노테이션을 이용해서 Lock의 종류를 PESSIMISTIC_WRITE로 설정한다.

[Repository]

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);
}

 

decrease()메서드에서는 findById() 대신 findByIdWithPessimisticLock()로 stock을 불러온다.

[Service]

@Transactional
public void decrease(Long id, Long quantity){
    Stock stock = stockRepository.findByIdWithPessimisticLock(id);
    stock.decrease(quantity);
    stockRepository.saveAndFlush(stock);
}
  • pessimistic lock은 일반적으로 SELECT문장에 사용된다. 예를 들어 SELECT FOR UPDATE 문을 통해서 해당 레코드를 잠금 상태로 만들고, 다른 트랜잭션이 접근하지 못하게 한다.
  • pessimistic lock은 동시성 이슈를 해결할 수 있는 방법이기는 하지만, 트랜잭션을 더 길게 유지하기 때문에 성능 이슈를 발생할 수 있다.

 

4. Optimistic Lock 사용하기

Optimistic Lock은 실제로 Lock을 사용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법이다. 자원에 Lock을 걸어서 선점하지 않고, 동시성 문제가 발생하면 그때 가서 처리하는 낙관적 락 방식이다.

아래 그림과 같이 Server 1에서 version1의 데이터베이스 레코드를 업데이트하고자 한다면, SELECT로 읽은 다음 해당 데이터를 업데이트 한 후에 version을 2로 업데이트시킨다. 그러면 Server2에서는 version1을 동시에 읽었지만, 업데이트를 하고자 할 때 데이터베이스의 해당 레코드가 version2로 바뀌었기 때문에 레코드를 업데이트하지 못한다.

레코드를 업데이트하지 못할 경우(실패)에 무엇을 해야 하는 지에 대한 로직을 개발자가 직접 짜야 되기 때문에 Pessimistic Lock보다 번거롭다는 단점이 있다.

 

아래 예시 코드를 살펴보자. 먼저 Stock 엔티티에 version필드를 @Version 어노테이션과 함께 추가해야 한다.

[Stock]

@Entity
public class Stock {
		...//생략

    @Version
    private Long version;
}

 

[Repository]

이번에는 LockModeType을 Optimistic으로 설정한다.

public interface StockRepository extends JpaRepository<Stock, Long> {

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

}

 

[Service]

decrease()메서드에서는 findById() 대신 findByIdWithOptimisticLock()로 stock을 불러온다.

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

 

[Facade]

Optimistic Lock과 같은 경우에는 실패했을 때 재시도를 해야 하므로 Facade라는 클래스를 따로 정의하여 업데이트에 실패한 스레드에 대해서 일시적으로 대기한 후, 업데이트를 다시 할 수 있도록 만들어준다.

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

            break;
        } catch(Exception e){
            Thread.sleep(50);
        }
    }
}
  • Optimistic Lock의 경우 Pessimistic Lock 과 다르게 별도의 Lock 을 사용하지 않으므로, 성능상의 이점이 있다.
  • 하지만 Update를 실패했을 때 로직을 직접 짜야한다.
  • 충돌이 빈번하게 일어나는 비즈니스 로직의 경우 Pessimistic Lock을 사용하는 것이 더 낫다.

 

5. Named Lock하기

Named Lock은 이름을 가진 metadata locking이다. 이름을 가진 lock을 획득한 후 해제할 때 까지 다른 세션이 lock을 획득하지 못하게 한다. transaction이 종료될 때 lock이 자동으로 해제되지 않기 때문에 별도의 명령어로 lock을 해제해주거나, 선점 시간이 끝나야 한다.

아래 그림과 같이 named lock은 Stock에 락을 걸지 않고 별도의 공간에 락을 건다. Session 1이 ‘1’이라는 이름의 락을 건다면, ‘1’의 락을 해지한 후에 락을 걸 수 있다.

 

아래 예시 코드를 살펴보자. 예시에서는 편의성을 위해 Stock 엔티티를 사용하지만, 실무에서는 별도의 JDBC를 사용해야 한다.

[LockRepository]

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, key)", nativeQuery = true)
    void releaseLock(String key);
}

 

실제 로직 실행 전 후로 getLock 과 releaseLock을 수행해야 하기 때문에 Facade 클래스를 생성한다.

  • lockRepository.getLock(id.toString());을 통해 해당 id의 Named Lock을 획득한다.
  • lockRepository.releaseLock(id.toString());을 통해 락을 해제한다.

[Facade]

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

 

비즈니스 로직이 담긴 StockService.decrease()에는 부모의 트랜잭션과 별도로 실행이 되어야 하기 때문에 다음과 같이 @Transactional(propagation = Propagation.*REQUIRES_NEW*) 으로 Propagation을 변경해준다.

[Service]

@Transactional(propagation = Propagation.REQUIRES_NEW)
public synchronized void decrease(Long id, Long quantity){

    Stock stock = stockRepository.findById(id).orElseThrow();
    stock.decrease(quantity);
    stockRepository.saveAndFlush(stock);
}

 

예제에서는 같은 DataSource를 사용하기 때문에 커넥션 풀 수를 늘려준다.

spring:
  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/stock_example
    username: root
    password: 1234
    hikari:
      maximum-pool-size: 40 //추가
  • NamedLock은 주로 병렬 처리, 분산 시스템 등 과같은 분산 환경에서 사용된다.
  • NamedLock은 애플리케이션 수준에서 Lock을 구현하므로, 데이터베이스에 대한 부담을 줄일 수 있다는 장점이 있다.
  • 하지만 Lock을 구현하기 위해 별도의 데이터 저장소가 필요하며, 코드를 작성해야 하므로 번거로울 수 있다는 단점이 있다.
728x90

댓글