본문 바로가기
Back-End/강의

[재고시스템으로 알아보는 동시성이슈 해결방법] 3. Database Lock

by 7533ymh 2022. 8. 28.

동시성 이슈 해결방법

이번 시간에는 Database Lock을 활용한 해결방법을 알아보자.

해결법2. Database ( MySQL )

데이터베이스의 락을 이용해 동시성 문제를 해결해보자

1. Pessimistic Lock (비관적 락)

자원 요청에 따른 동시성문제가 발생할 것이라고 예상하고 미리 락을 걸어버리는 방법론

  • 트랜잭션의 충돌이 발생한다고 가정합니다.
  • 하나의 트랜잭션이 자원에 접근시 락을 걸고, 다른 트랜잭션은 접근하지 못하도록 한다.
  • 데이터베이스의 Shared Lock(공유, 읽기 잠금)이나 Exclusive Lock(배타, 쓰기잠금)을 사용한다.
    • SELECT c1 FROM WHERE c1 = 10 FOR UPDATE (배타 잠금)
      • 이는 레코드 락으로 인덱스 레코드에 락을 건다고 한다.
  • Shared Lock의 경우, 다른 트랜잭션에서 읽기만 가능하다.
    • Exclusive lock 적용이 불가능하다. ( 읽는 동안 변경하는 것을 막기 위해서 ! )
  • Exclusive lock의 경우, 다른 트랜잭션에서 읽기,쓰기 모두 불가능하다.
    • Shared, Excluisive lock 적용이 추가로 불가능하다. ( 쓰는 동안 읽거나, 다른 쓰기를 막기 위해서 ! )

  • DB에 id가 1이고 name이 Yu인 컬럼이 있다고 가정해보자
  • 트랜잭션 A가 Shared Lock으로 SELECT을 수행할 때 트랜잭션 B가 SELECT로 조회는 가능하다.
  • 하지만 트랜잭션 A가 커밋되기 전에 트랜잭션 B의 update 요청은 Shared Lock에 의해 Blocking 되어 대기하고 있다가 트랜잭션 A가 커밋이 된다면 update를 실행하게 된다.

장점

  • 충돌이 자주 발생하는 환경에 대해서는 롤백의 횟수를 줄일 수 있어 성능에서 유리하다.
  • 데이터 무결성을 보장하는 수준이 높다.단점
  • 데이터 자체에 락을 걸어버리기 때문에 동시성이 떨어져 성능 손해를 보게 된다.
    • 특히, 읽기가 많이 이루어지는 데이터베이스의 경우에는 손해가 크다.
  • 서로 자원이 필요한 경우에, 락으로 인해 데드락이 일어날 가능성이 있다.

2. Optimistic Lock ( 낙관적 락 )

자원에 락을 걸어서 선점하지 말고, 동시성 문제가 발생하면 그때가 가서 처리 하자는 방법론>

  • 트랜잭션의 충돌이 발생하지 않는다고 가정합니다.
  • 충돌이 나는 것을 막지 않고, 충돌이 난것을 감지하면 그때 처리한다.
    • 그렇기 때문에, 트랜잭션을 커밋하기 전까지는 트랜잭션의 충돌을 알 수 없다.
  • 일반적으로 version의 상태를 보고 충돌을 확인하고, 충돌이 확인된 경우 롤백을 진행한다.
    • version말고도 hashcode, timestamp를 이용해서 확인도 가능하다.
  • DB단에서 해결하는 것이 아닌 어플리케이션단에서 처리한다.

  • DB에 id가 1이고 name이 Yu, version이 1인 컬럼이 있다고 가정해보자
  • 트랜잭션 A,B가 id가 1인 row를 SELECT한다. ( name = Yu, version = 1 )
  • 이후 트랜잭션 B가 해당 row 값을 갱신한다. ( name = Hwan, version = 2 )
  • 이 상황에서 만약 트랜잭션 A가 row값을 갱신하려고 한다면 version이 다르기 때문에 해당 row를 갱신하지 못하게 된다. ( 이미 version이 2로 갱신되었기 때문에 )

장점

  • 충돌이 안난다는 가정하에, 동시 요청에 대해서 처리 성능이 좋다.단점
  • 잦은 충돌이 일어나는 경우 롤백처리에 대한 비용이 많이 들어 오히려 성능에서 손해를 볼 수 있다.
  • 롤백 처리를 구현하는 게 복잡할 수 있다.
    • 개발자가 수동으로 해줘야 한다.

3. Named Lock

  • 이름과 함께 lock을 획득한다.
  • 해당 lock은 다른 세션에서 획득 및 해제가 불가능하다.
  • 주의할 점으로는 트랜잭션이 종료될 때 해당 락이 자동으로 해제되지 않기 때문에 수동으로 해제를 하거나 선점시간이 끝나야 해제가 된다.
  • pessimistic lock과 유사하지만 pessimistic lock은 row나 테이블단위로 락을 걸지만 named lock은 이름으로 락을 건다.

정리

  • 낙관적 락은 락을 미리 걸지 않기 때문에 성능적으로 비관적 락보다 더 좋다. ( 충돌이 많이 나지 않는다면 )
  • 하지만 충돌이 많이 예상된다면 비용이 많이 들어가는 단점이 있다.
  • 그렇기 때문에 비관적락은 데이터의 무결성이 중요하고, 충돌이 많이 발생하여 잦은 롤백으로 인한 효율성 문제가 발생하는 것이 예상되는 시나리오에 좋다.
  • 낙관적락은 실제로 데이터 충돌이 자주 일어나지 않을 것이라고 예상되는 시나리오에 좋다.

해결법2 - 적용

1. Pessimistic Lock (비관적 락)

  • JPA에서는 @Lock 어노테이션을 이용해 비관적 락을 쉽게 구현할 수 있다.
    • PESSIMISTIC_READ
      • Shared Lock을 획득하고 데이터가 update, delete 되는 것을 방지한다.
    • PESSIMISTIC_WRITE
      • Exclusive Lock을 획득하고 데이터를 다른 트랜잭션에서 read,update,delete하는 것을 방지한다.
    • PESSIMISTIC_FORCE_INCREMENT
      • PESSIMISTIC_WRITE와 유사하지만 @Version이 지정된 Entity와 협력하기 위해 도입되어 PESSIMISTIC_FORCE_INCREMENT락을 획득할 시 버전이 업데이트 된다.
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Stock s WHERE s.id = :id")
Stock findByIdWithPessimisticLock(@Param("id") Long id);
@Service
public class PessimisticLockStockService {

    private final StockRepository stockRepository;

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

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

        stock.decrease(quantity);

        stockRepository.saveAndFlush(stock);
    }
}

  • 테스트가 통과하는 것을 볼 수 있고 쿼리를 보면 for update 가 붙는 것을 확인할 수 있다.
  • PESSIMISTIC_READ라면? ( Shared Lock )
    • 테스트가 실패하며 for share 가 붙는 것을 확인할 수 있다.
    • 쓰기는 불가능하지만 읽는 것 까진 가능하기 때문에 처음 발생한 문제가 발생하여 테스트가 실패하게 된다.
      • update시 blocking 되지만 조회한 stock의 수량이 update 되기 전 일 수 있기 때문에

결론

  • 잦은 충돌이 일어난다면 낙관적 락보다 성능이 우수하며 데이터 무결성을 보장하지만 별도의 락을 잡기 때문에 성능 감소가 있을 수 있다.

2. Optimistic Lock ( 낙관적 락 )

  • JPA에서 낙관적 락을 사용하기 위해서는 version컬럼을 추가하고 @Version 어노테이션을 붙여야 한다.

  • 비관적 락과 동일하게 @Lock 어노테이션을 사용한다.

    • NONE
      • 락 옵션을 적용하지 않아도 엔티티에 @Version이 적용된 필드가 있다면 낙관적 락이 적용된다.
      • 암시적 잠금
        • @Version이 붙은 필드가 존재하거나 @OptimisticLocking 어노테이션이 설정되어 있을 경우 자동적으로 잠금이 실행된다. ( JPA가 해줌 )
        • 추가로 삭제 쿼리가 발생할 시 암시적으로 해당 row에 대한 Exclusive Lock을 건다.
    • OPTIMISTIC
      • 읽기시에도 낙관적 락이 걸린다.
      • 버전을 체크하고 트랜잭션이 종료될 때까지 다른 트랜잭션에서 변경하지 않음을 보장한다.
      • 이를 통해 dirty read와 non-repeatable read를 방지한다.
    • OPTIMISTIC_FORCE_INCREMENT
      • 낙관적 락을 사용하면서 버전 정보를 강제로 증가시킨다.
      • 논리적인 단위의 엔티티 묶음을 관리할 수 있다.
        • 예를 들어, 양방향 연관관계에서 주인인 엔티티만 변경했을 때 주인이 아닌 엔티티는 변경되지 않았지만 논리적으로 변경되었으므로 버전을 증가시켜준다.
    • READ, WRITE
      • READ는 OPTIMISTIC와 같고 WRITE는 OPTIMISTIC_FORCE_INCREMENT와 같다.
      • JPA 1.0의 호환성을 유지하기 위해 존재한다.
  • 발생하는 예외

    • javax.persistence.OptimisticLockException(JPA 예외)
    • org.hibernate.StaleObjectStateException(하이버네이트 예외)
    • org.springframework.orm.ObjectOptimisticLockingFailureException(스프링 예외 추상화)
@Entity
public class Stock {

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

    private Long productId;

    private Long quantity;

    @Version
    private Long version;

        // ...
}
@Lock(value = LockModeType.OPTIMISTIC)
@Query("SELECT s FROM Stock s WHERE s.id = :id")
Stock findByIdWithOptimisticLock(@Param("id") Long id);
@Service
public class OptimisticLockStockService {

    private final StockRepository stockRepository;

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

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

        stock.decrease(quantity);

        stockRepository.saveAndFlush(stock);
    }
}
  • 서비스 로직까지는 위와 동일하지만 비관적 락은 충돌이 발생했을 때 직접 재시도 로직을 구현해야하기 때문에 Facade 패턴을 사용해서 구현한다.
@Service
public class OptimisticLockStockFacade {

    private final OptimisticLockStockService optimisticLockStockService;

    public OptimisticLockStockFacade(OptimisticLockStockService optimisticLockStockService) {
        this.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);
            }
        }
    }
}
  • optimisticLockStockService의 decrease 메서드를 호출하며 충돌이 일어날 경우 ( 예외가 발생한 경우 ) 50밀리초 이후 재시도 하도록 한다.

  • 테스트가 통과하는 것을 볼 수 있고 update 쿼리를 보면 version이 조건으로 붙는 것을 확인할 수 있다.
  • 또한, ObjectOptimisticLockingFailureException 이 발생하는 것을 확인할 수 있다.
  • @Lock 어노테이션이 없다면?
    • @Version필드가 있기 때문에 자동적으로 낙관적 락이 적용된다.
  • NONE이라면?
    • 마찬가지로 통과한다.

결론

  • 별도의 락을 잡지 않으므로 낙관적 락보다 성능적으로 우수하지만 충돌이 일어날 경우 개발자가 직접 재시도 로직을 구현해야 한다.
  • 또한, 충돌이 빈번하게 일어난다면 낙관적 락을 이용하는 것이 성능상 이점이 있다.
  • 즉, 충돌이 빈번하게 일어나지 않는다면 비관적 락을 사용하고 빈번하게 일어난다면 낙관적 락을 사용하는 것이 좋다.

3. Named Lock

  • 테이블 자체에 Lock을 거는 것이 아닌 별도의 공간에 Lock을 걸게 된다.
  • 한 세션이 Lock을 획득한다면 다른 세션은 해당 세션이 Lock을 해제한 이후 획득할 수 있다.

  • get_lock(str, timeout)
    • A 세션이 1이라는 문자로 1000초동안 잠금 획득을 시도한다.
    • 만약 A 세션이 획득을 성공하면 B세션은 동일한 이름의 잠금을 획득할 수 없다.
    • get_lock을 이용한 잠금은 트랜잭션이 커밋되거나 롤백되어도 해제되지 않는다.
    • get_lock의 결과값은 1, 0, null을 반환한다.
      • 1 : 잠금 획득 성공
      • 0 : timeout 초 동안 잠금 획득 실패
      • null : 잠금 획득 중 에러가 발생했을 때
  • release_lock(str)
    • 이름의 잠금의 해제한다.
    • 결과값으로 1,0,null을 반환한다.
      • 1 : 잠금 해제 성공
      • 0 : 잠금이 해제되지는 않았지만, 현재 쓰레드에서 획득한 잠금이 아닌 경우
      • null : 잠금이 존재하지 않을 때
  • 현재는 예제이니 동일한 DataSource를 사용하지만 실제로 사용할 경우 분리하여 사용해야한다.
    • 같은 DataSource를 사용한다면 ConnectionPool이 부족할 수 있기 때문에 다른 서비스에 영향을 끼칠 수 있기 때문이다.
    • 별도의 JDBC를 사용하거나 등의 방법이 있다.
public interface LockRepository extends JpaRepository<Stock, Long> {

    @Query(value = "SELECT GET_LOCK(:key, 3000)", nativeQuery = true)
    void getLock(@Param("key") String key);

    @Query(value = "SELECT RELEASE_LOCK(:key)", nativeQuery = true)
    void releaseLock(@Param("key") String key);
}
  • lock을 얻기 위해 nativeQuery를 사용한다.
@Component
public class NamedLockStockFacade {

    private final LockRepository lockRepository;
    private final NamedLockStockService namedLockStockService;

    public NamedLockStockFacade(LockRepository lockRepository, NamedLockStockService namedLockStockService) {
        this.lockRepository = lockRepository;
        this.namedLockStockService = namedLockStockService;
    }

    @Transactional
    public void decrease(Long id, Long quantity) {
        try {
            lockRepository.getLock(id.toString());
            namedLockStockService.decrease(id, quantity);
        } finally {
            lockRepository.releaseLock(id.toString());
        }
    }
}
  • 락을 얻고 해제하는 로직을 위해 Facade 패턴을 사용해서 구현한다.
@Service
public class NamedLockStockService {

    private final StockRepository stockRepository;

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

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void decrease(Long id, Long quantity) {
        Stock stock = stockRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("재고가 존재하지 않습니다."));

        stock.decrease(quantity);

        stockRepository.saveAndFlush(stock);
    }
}
  • 여기서 주의해야할 점이 StockService의 경우 부모의 트랜잭션과 별도로 실행되어야 하기 때문에 propagation을 REQUIRES_NEW로 설정해야 한다.
    • GET_LOCK()을 수행하는 쿼리가 실행 되고 트랜잭션이 종료되게 되면 pool에서 얻어온
spring:
  datasource:
        # ...
    hikari:
      maximum-pool-size: 40
  • 또한, 같은 DataSource를 사용하기 때문에 커넥션 풀을 늘려줘야 한다.

  • 테스트가 통과하는 것을 볼 수 있고 get_lock, release_lock 쿼리가 나가는 것을 볼 수 있다.

결론

  • Named Lock은 주로 분산 락을 구현할 때 사용한다.
  • 비관적 락은 timeout을 구현하기 어렵지만 Named Lock은 쉽게 구현이 가능하다.
  • 그외에도 데이터 정합성을 맞춰야 할 때 사용할 수 도 있다.
  • 하지만 이 방법은 트랜잭션 종료 시 락 해제와 세션 관리를 잘 해줘야 하기 때문에 주의해야 하며 실제로 구현방법이 복잡할 수 있다.
  • 데이터베이스 Lock에 대해 어떤 원리로 이루어지고 어떻게 동작하는 지 깊게는 아직 모르겠지만 어느정도 흐름을 알 수 있었다. Lock에 대해 깊게 파보고 정리하는 시간을 가져봐야 겠다.

Reference

댓글