본문 바로가기

Spring boot

동시성 관리하기 2-1탄) Database 레벨 - 낙관적 Lock, 비관적 Lock

이전 글(https://baeji-develop.tistory.com/127)을 보면 

Spring은 멀티 스레드이기 때문에 Application 레벨에서 해결할 수 없는 동시성 문제가 있습니다.

 

문제 

한 메서드에 대해 Lock을 걸어도 단일 서버에서 서로 다른 두 메서드가 한 자원에 동시에 접근하면 나타나는 문제.

한 메서드에 대해 Lock을 걸어도 분산 서버에서는 각 서버의 동일한 매서드를 실행 중인 스레드가 한 자원에 동시 접근하면 나타나는 문제. 

 

등등 이외 여러 문제가 발생할 수 있다고 생각되어 Database 레벨인 낙관적 Lock과 비관적 Lock에 대해 고민해보려고 합니다.

* 낙관적 Lock을 Application 레벨이라고 하기도 하는데, 필자는 낙관적 Lock에 DB가 사용되기 때문에 Database 레벨로 칭했습니다.

내용이 길어져 User-Level Lock인 Name Lock은 다음편으로 넘기겠습니다!

 

 

 

분산 락(Distributed Lock)

분산 서버에서 각 스레드가 한 자원에 접근하는 경우, 한 개의 스레드에서만 자원에 접근 할 수 있도록 사용하는 Lock이 분산 락입니다.

 

분산 락은 2가지 상황을 명칭하는데, 아래와 같습니다.

1. WAS(웹 애플리케이션 서버)가 여러개인 경우, 이 사이에서 동시성 문제를 해결하기 위해 사용되는 Lock
2. 스케일 아웃된 DB 환경에서 동시성 문제를 해결하기 위해 사용되는 Lock

 

 

 

비관적 Lock

비관적 Lock은 트랜잭션이 시작될 때 Shared Lock(공유, 읽기잠금, s-lock) 또는 Exclusive Lock(배타, 쓰기잠금, x-lock)을 걸고 시작하는 방법입니다. 즉, write 하기위해서는 Exclucive Lock을 얻어야하는데 Shared Lock이 다른 트랜잭션에 의해서 걸려 있으면 해당 Lock을 얻지 못해서 업데이트를 할 수 없습니다. 수정(쓰기)을 하기 위해서는 해당 트랜잭션을 제외한 모든 트랜잭션이 종료(commit)되어야 합니다. 

- Shared Lcok이 걸려있으면 다른 트랜잭션에서 읽기만 가능합니다.

- Exclusive Lock이 걸려있으면 다른 트랜잭션에서 읽기와 쓰기 모두가 불가능합니다.

 

 

아래 도식도를 글로 나타내면 아래와 같습니다.

  1. Transaction_1 에서 table의 Id 2번을 읽음 ( name = Karol )
  2. Transaction_2 에서 table의 Id 2번을 읽음 ( name = Karol )
  3. Transaction_2 에서 table의 Id 2번의 name을 Karol2로 갱신 요청 ( name = Karol )
    • 하지만 Transaction 1에서 이미 shared Lock을 잡고 있기 때문에 Blocking
  4. Transaction_1 에서 트랜잭션 해제 (commit)
  5. Blocking 되어있었던 Transaction_2의 update 요청 정상 처리

출처 : https://sabarada.tistory.com/175

이렇듯 Transaction을 이용하여 충돌을 예방하는 것이 바로 비관적 락(Pessimistic Lock)입니다.

 

 

해결점

하지만 @Lock(LockModeType.PESSIMISTIC_WIRTE) 의 설명을 살펴보면 "동시 업데이트 트랜잭션 중 데드락 또는 실패 가능성이 높다." 라며 데드락 발생 가능성에 대해서 경고하고 있는 것을 확인할 수 있습니다.

예를 들면 아래와 같은 상황이 있습니다.

Transaction 1 : A 정보를 구하고 잠금
Transaction 2 : B 정보를 구하고 잠금
Transaction 1 : B 정보를 구하려고 하지만 잠겨있음
Transaction 2 : A 정보를 구하려고 하지만 잠겨있음

이 상황은 락을 잡을 수 있는 최대 시간을 지정하며 해결할 수 있습니다. (잠금 시간 초과 설정, Setting Lock Timeout)

Spring data jpa @QueryHints와 @QueryHint 어노테이션을 통해 간단하게 지정할 수 있으며 value 속성의 단위는 밀리세컨드입니다.

public interface StockRepository extends JpaRepository<Stock, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "50")})
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithPessimisticLock(Long id);
}

 

 

 

사용법

public interface StockRepository extends JpaRepository<Stock, Long> {

    default Stock getById(Long id) {
        return findById(id).orElseThrow(NoSuchElementException::new);
    }

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Stock s where s.id = :id")
    Stock findByIdWithPessimisticLock(Long id);
}

 

 

위 StockRepository를 작성하고 / 아래에 서비스 코드를 작성해줍니다.

@RequiredArgsConstructor
@Service
public class PessimisticLockStockService {

    private final StockRepository stockRepository;

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

 

 

테스트 코드를 작성해줍니다.

@SpringBootTest
class PessimisticLockStockServiceTest {

    @Autowired
    private PessimisticLockStockService pessimisticLockStockService;

    @Autowired
    private StockRepository stockRepository;

    private Long stockId;

    @BeforeEach
    void setUp() {
        stockId = stockRepository.saveAndFlush(new Stock(1L, 100))
                .getId();
    }

    @AfterEach
    void tearDown() {
        stockRepository.deleteAll();
    }

    @Test
    void decrease_with_100_request() throws InterruptedException {
        // given
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(threadCount);

        // when
        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    pessimisticLockStockService.decrease(stockId);
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        // then
        Stock stock = stockRepository.getById(stockId);
        assertThat(stock.getQuantity()).isEqualTo(0);
    }
}

 

100개 요청에 대해 quantity가 0개가 됨을 확인할 수 있습니다. 

 

비관적 락은 레코드 단위로 Lock을 걸기 때문에 해당 레코드에만 Lock을 걸어 성능을 많이 떨어뜨리지는 않지만 인기 있는 상품, 게시글에는 (한 레코드에) Lock이 몰려 대기가 길어질 수 있기 때문에 이 점에 유의해야 할 것 같습니다.

 

 

 


 

낙관적 Lock

낙관적 Lock은 실제 Lock이 아닌 별도 컬럼 Version 값을 이용합니다.

수정할 때 내가 먼저 이 값을 수정했다는 표시로 Version 값을 명시하여 다른 사람이 값을 수정할 수 없게 합니다.

내가 읽었던 Version 값과 현재 데이터의 Version값이 다르면 다시 값을 조회한 후 수정 작업을 진행해야 합니다.

* JPA를 사용하는 경우에는 @Version 어노테이션을 붙인 필드를 하나 추가하여, 트랜잭션 시작 시점과 커밋 시점의 version 값이 동일한지를 확인합니다.

 

 

아래 도식도를 글로 나타내면 아래와 같습니다.

  1. A: table의 Id 2번을 읽음 ( response : name = Karol, version = 1 )
  2. B: table의 Id 2번을 읽음 ( response : name = Karol, version = 1 )
  3. B: table의 Id 2번, version 1인 row의 값 갱신 요청 ( request : name = Karol2, version = 2 ) 성공
  4. A: table의 Id 2번, version 1인 row의 값 갱신 요청 ( request : name = Karol1, version = 2 ) 실패

4번 실패 이유: 3번에서 table Id 2번의 version 값이 이미 2가 되었기 때문에 4번에서 갱신 요청하는 시점에는 table의 Id 2번, version 1인 row는 존재하지 않음. 

 

출처 : https://sabarada.tistory.com/175

 

같은 row에 대해 여러개의 수정요청이 있어도 version 같은 별도 컬럼을 추가하여 다른 업데이트들을 막기 때문에 결국 1개의 요청만 성공할 수 있습니다.

* version 뿐만 아니라 hashcode 또는 timestamp를 이용하기도 합니다.

 

 

해결점

하지만 낙관적 락을 사용하는 경우 충돌이 발생했을 때에 대한 처리를 개발자가 직접 해줘야 합니다.

 

스프링에서는 AOP를 통해서 제공하는 @Retryable 어노테이션을 이용하여 예외가 발생했을 때 해당 메소드를 재시도 할 수 있는 기능을 제공해주지만 롤백에 대한 처리가 모호합니다. @Retryable 기능이 존재하긴 하지만 몇번이나 재시도를 할지에 대한 기준을 세우기 어렵다는 문제가 있습니다.

 

또한 한 번의 트랜잭션에서 두 개의 테이블을 수정시킬 경우, 두번째 테이블을 수정하면서 충돌이 일어나면 첫번째 테이블의 수정된 값을 다시 롤백해줘야 합니다. 이때 낙관적 Lock은 트랜잭션으로 잡히지 않기 때문에 개발자가 직접 수정작업을 해줘야 합니다.

비관적 Lock 롤백 수도 코드 / 낙관적 Lock 롤백 수도 코드

 

 

사용법

import jakarta.persistence.Version;


@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Stock {

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

    private Long productId;

    private int quantity;

    @Version
    private Long version;

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

    public void decrease() {
        if (this.quantity == 0) {
            throw new RuntimeException("재고는 0개 미만이 될 수 없습니다.");
        }
        this.quantity--;
    }
}

 

 

 

StockRepository에 아래 메서드를 추가합니다.

* LockModeType으로 NONE과 OPTIMISTIC이 있습니다. 두 방식 모두 낙관적 Lock을 수행하지만 예외를 발생시키는 시점이 다릅니다. NONE은 엔티티를 수정할 때 버전을 체크하고(update 쿼리 사용) 버전 정보가 다르면 예외를 발생합니다. 

OPTIMISTIC은 트랜잭션을 커밋할 때 버전 정보를 조회해(select 쿼리 사용) 현재 엔티티 버전과 같은지 검증하고 예외를 발생시킵니다. 

결국 OPTIMISTIC은 커밋 시점에 버전 정보를 비교하기 때문에 엔티티를 조회한 시점에서 트랜잭션이 끝나는 시점까지 다른 트랜잭션에 의해서 변경되지 않음을 보장합니다. 이로써, 갱신 손실 문제 뿐만 아니라 DIRTY READ, NON-REPEATABLE READ 까지 방지할 수 있습니다.

public interface StockRepository extends JpaRepository<Stock, Long> {

    default Stock getById(Long id) {
        return findById(id).orElseThrow(NoSuchElementException::new);
    }

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

 

 

이를 이용하는 service 코드입니다.

@RequiredArgsConstructor
@Service
public class OptimisticLockStockService {

    private final StockRepository stockRepository;

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

 

 

위에서 말했듯 낙관적 Lock은 예외 처리를 직접 해주어야 합니다. 따라서 아래와 같이 이를 처리하는 OptimisticLockStockFacade를 작성해 주었습니다.

@RequiredArgsConstructor
@Service
public class OptimisticLockStockFacade {

    private final OptimisticLockStockService optimisticLockStockService;

   @Retryable(
        maxAttempts = 2,
        backoff = Backoff(delay = 50)
    )
    public void decrease(Long id) {
    		optimisticLockStockService.decrease(id);
    }
}

 

 

아래는 테스트 코드입니다. 

@SpringBootTest
class OptimisticLockStockFacadeTest {

    @Autowired
    private OptimisticLockStockFacade optimisticLockStockFacade;

    @Autowired
    private StockRepository stockRepository;

    private Long stockId;

    @BeforeEach
    void setUp() {
        stockId = stockRepository.saveAndFlush(new Stock(1L, 100))
                .getId();
    }

    @AfterEach
    void tearDown() {
        stockRepository.deleteAll();
    }

    @Test
    void decrease_with_100_request() throws InterruptedException {
        // given
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(threadCount);

        // when
        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    optimisticLockStockFacade.decrease(stockId);
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        // then
        Stock stock = stockRepository.getById(stockId);
        assertThat(stock.getQuantity()).isEqualTo(0);
    }
}

 

두개의 서버에서 동시에 총 100개의 요청을 보내면 quantity가 0이 됨을 확인할 수 있습니다. 

다만, 추가 필드(Version)가 필요해 DB 자리를 차지한다는 것에 유의해야 합니다. 

 

 

 

 

 

그럼 언제 어떤 Lock을 사용하는게 좋을까?

1.

낙관적 Lock은 JPA를 사용할 경우, 쉽게 구현할 수 있어 실무에서 많이 사용한다고 합니다.

 

하지만 충돌이 발생하면 이를 해결하기 위해 개발자가 직접 롤백처리를 해줘야합니다. 수동 롤백을 구현하기는 까다롭습니다. 심지어 두 개의 데이터를 update 시켜야 하는 경우, 첫 데이터 update 성공 후 두번째 update에서 실패한 상황이라면, 첫번째 데이터에 update문을 한번 더 날려 이전 데이터로 돌려놔야 하기 때문에 성능측면에서도 좋지 않습니다. 반면에 비관적 Lock은 트랜잭션을 롤백하면 끝납니다.

 

2.

낙관적 Lock의 경우, 충돌이 발생하지 않을 것으로 가정하기 때문에 Lock을 걸지 않는다는 것 자체가 성능상 이점을 가질 수 있습니다. 

 

하지만 충돌이 빈번하게 발생하면 모든 요청이 완료될 때까지 재시도를 수행하며 DB에 수많은 요청을 보내게 될 것입니다. 반면에 비관적 Lock은 Lock을 걸고 수행하기 때문에 요청들이 순차적으로 진행하게 됩니다. 이 경우, 비관적 Lock이 더 좋을 수 있습니다. 

 

 

따라서 충돌이 빈번하게 일어날 것으로 예측되거나 무결성이 매우 중요한 경우에는 비관적 Lock을 사용합니다. 

 

여기서 충돌이 빈번하게 일어날 것인가는 어떻게 알 수 있을까요? 

물론, 정해진 시간에 쿠폰 받기, 공연 티켓팅 같은 이벤트는 충돌이 많이 일어날 것으로 예측하기 쉽습니다.

하지만 충돌이 빈번하게 일어날지 모르겠는 경우에는 비관적 Lock은 항상 Lock이 걸려 있어 대기를 해야 하므로 우선 낙관적 Lock을 이용하다가, 운영상 성능 이슈가 발생할 때 비관적 Lock으로 교체하는 것이 타당할 것 같습니다. 

 

 

낙관적 Lock : 변경 사항이 DB에 커밋될 때만 레코드가 잠기는 경우.
비관적 Lock : 편집하는 동안 레코드가 잠기는 경우.

 

 

 

 

결론 

** 비관적 Lock**

- 실제 Lock을 걸기 때문에 Shared Lcok이 걸려있으면 다른 트랜잭션에서 읽기만 가능하고,  Exclusive Lock이 걸려있으면 다른 트랜잭션에서 읽기와 쓰기 모두가 불가능합니다.

- 데드락 가능성이 있으니 락을 잡을 수 있는 최대 시간을 정해줘야 합니다.

 

** 낙관적 Lock **

- 실제 Lock이 아닌 추가 컬럼(Version)을 생성해, 조회 시의 Version 값과 수정 시의 Version 값이 일치할 때만 수정이 가능하도록 합니다.

- 낙관적 락을 사용하는 경우 충돌이 발생했을 때에 대한 처리를 개발자가 직접 해줘야 합니다.

 

 

낙관적 Lock, 비관적 Lock, Name Lock을 사용하는 경우, 단일 서버 환경 뿐만 아니라 분산 서버 환경에서도 동시성 문제를 방지할 수 있습니다. 하지만 분산 DB 환경에서는 동시성 제어를 할 수 없다는 문제점이 있습니다. 이렇게 분산 DB 상황이라면 Redis 혹은 ZooKeeper등을 통해 분산락을 구현해야 합니다.

 

2탄의 글이 길어져 2-2탄에서는 Named Lock에 대해 작성하고, 분산 DB 상황에 대해서는 3탄에서 작성하겠습니다!

 

 

 

 

 

 

 

 

 

참고 ) 

https://velog.io/@balparang/%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BD%EA%B3%BC-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD%EC%9D%80-Race-Condition%EC%9D%84-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%B4%EA%B2%B0%ED%95%98%EB%8A%94%EA%B0%80

낙관적 lock, 비관적 lock: https://sabarada.tistory.com/175, https://ttl-blog.tistory.com/1568

@Retryable 사용법: https://yeon-kr.tistory.com/213

@Retryable, Recover 차이: https://velog.io/@chas369/Retryable-Recover