본문 바로가기

Spring boot

동시성 관리하기 2-2탄) Database 레벨 - User-Level Lock (Named Lock)

 

지난 게시글에서 살펴본 낙관적 Lock과 비관적 Lock은 분산 서버에서도 동시성을 보장했습니다. 

이제 비관적 Lock과 비슷한 Named Lock에 대해 살펴보겠습니다.

 

 

User-Level Lock (Named Lock)

User-Level Lock은 MySQL 데이터베이스에서 제공해주는 분산락입니다. 

데이터베이스를 사용하는 사용자가 특정 문자열에 Lock을 걸 수 있는 기능이며

아래와 같은 함수를 제공합니다.

출처 : https://dev.mysql.com/doc/refman/8.0/en/locking-functions.html

 

 

지금부터 Locking Functions에 대해 알아보겠습니다.

 

GET_LOCK(str, timeout)

주어진 str(문자열)에 timeout동안 Lock 획득을 시도한다. (str의 길이는 최대 64글자)

= 이미 다른 세션에서 Lock을 획득한 경우, Lock 획득을 위해 timeout만큼 대기한다.  (if (timeout<0): 무한대기) 

 

  • 해당 Lock은 배타적으로 동작하기 때문에, 한 세션에서 Lock을 얻으면 다른 세션에서는 Lock을 얻을 수 없다. 
  • 여러 클라이언트에서 잠금 획득을 기다리는 경우, 잠금을 획득하게 되는 순서는 정의되지 않기 때문에 잠금 요청 순서대로 잠금을 획득할 것이라 가정하고 사용해서는 안 된다.
  • GET_LOCK()은 statement-based replication에 대해 안전하지 않다.
  • 반환 값 
    • 1: Lock을 성공적으로 획득한 경우
    • 0: timeout이 지나도록 Lock을 획득하지 못한 경우
    • null: 에러 발생 (out of memory)
  • 한 세션이 다른 이름의 Lock을 가질수도, 동일한 이름의 Lock을 여러개 가질 수도 있다. 
# 세션 1
SELECT GET_LOCK('test', 100);
SELECT GET_LOCK('test', 100); # 같은 이름에 대해 Lock 2개 획득

# 세션 2
SELECT GET_LOCK('test', -1);  # 세션 2는 대기 시작

# 세션 1
SELECT RELEASE_LOCK('test');  # 'test'에 대해 release가 1번 이루어져 세션 2는 계속 대기
SELECT RELEASE_LOCK('test');  # 'test'에 대해 release가 2번 이루어져 세션 2에서 Lock 획득

 

  • 여러 개의 Lock을 획득할 수 있기 때문에, 데드락이 발생할 수 있다.
# 세션 1
SELECT GET_LOCK('test1', -1);

# 세션 2
SELECT GET_LOCK('test2', -1);

# 세션 1
SELECT GET_LOCK('test2', -1);  # 세션 2가 test2 에 대한 Lock을 가지고 있으므로 무한 대기

# 세션 2
SELECT GET_LOCK('test1', -1);  # 세션 1이 test1 에 대한 Lock을 가지고 있으므로 무한 대기

# -> 데드락 발생

이렇게 데드락이 발생하면, MySQL은 ERROR 3058을 발생시키며 Lock 획득 요청을 종료한다. (이로 인해 트랜잭션이 롤백되지는 않음)

 

 

 

RELEASE_LOCK(str)

입력받은 str(문자열)의 잠금을 해제한다.

 

  • 반환값
    • 1 : Lock을 성공적으로 해제한 경우
    • 0 : 해당 스레드에서 획득한 Lock이 아닌 경우 (이 경우 Lock은 해제되지 않음)
    • null : 해당 str(이름)의 Lock이 존재하지 않는 경우

 

  • GET_LOCK()을 통해 획득한 Lock은 RELEASE_LOCK()을 호출하여 명시적으로 해제시킬 수 있다.
    • 세션이 종료되는 경우에는 암시적으로 Lock이 해제된다
    • 트랜잭션 커밋 or 롤백인 경우에는 Lock이 해제되지 않는다.

 

 

 

RELEASE_ALL_LOCKS()

  • 모든 잠금을 해제하고 해제한 잠금의 개수를 리턴한다.

IS_FREE_LOCK(str)

  • 입력받은 문자열로 잠금획득이 가능한지 확인한다.

IS_USED_LOCK(str)

  • 입력받은 문자열의 잠금이 존재하는지 확인한다.

 

 

 

 

 

사용법

LockRepository를 구현했습니다.

public interface LockRepository extends JpaRepository<Stock, Long> {

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

    @Query(value = "SELECT RELEASE_LOCK(:key)", nativeQuery = true)
    Integer releaseLock(String key);
}

 

 

 

 

NamedLockStockFacade를 작성해 재고감소 로직 전후로 Lock를 획득하고 반환해주는 작업을 처리해줍니다.

@RequiredArgsConstructor
@Service
public class NamedLockStockFacade {

    private final LockRepository lockRepository;
    private final StockService stockService;

    @Transactional
    public void decrease(Long id) {
        try {
            Integer acquiredLock = lockRepository.getLock(String.valueOf(id));
            if (acquiredLock != 1) {
                throw new RuntimeException("Lock 획득에 실패했습니다. [id: %d]".formatted(id));
            }
            stockService.decrease(id);
        } finally {
            lockRepository.releaseLock(String.valueOf(id));
        }
    }
}

 

트랜잭션 종료 시 Lock이 자동으로 반환되지 않기 때문에 RELEASE_LOCK()을 호출해 명시적으로 해제해줘야 합니다.

 

 

StockService를 다음과 같이 변경합니다.

Transaction의 propagation을 REQUIRES_NEW로 바꿔, 락 획득과는 다른, 새로운 트랜잭션에서 동작하도록 했습니다. 만약 한 트랜잭션 안에서 락 획득과 재고 감소 로직을 실행하면 동시성 문제가 발생합니다.이 이유에 대해서는 제일 아래 주의점에서 살펴보겠습니다.

@RequiredArgsConstructor
@Service
public class StockService {

    private final StockRepository stockRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void decrease(Long id) {
        Stock stock = stockRepository.getById(id);
        stock.decrease();
        stockRepository.saveAndFlush(stock);
    }
}

 

 

 

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

@SpringBootTest
class NamedLockStockFacadeTest {

    @Autowired
    private NamedLockStockFacade namedLockStockFacade;

    @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_name_lock() 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 {
                    namedLockStockFacade.decrease(stockId);
                } catch (Exception e) {
                    System.out.println(e);
                } finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

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

이 코드를 바로 실행하면 REQUIRES_NEW로 인한 커넥션 소모가 커서 올바르게 동작하지 않습니다.

 

일반적인 애플리케이션이면 Lock 획득 커넥션풀(DataSource)과 재고 소모 로직을 위한 커넥션풀(DataSource)을 분리하는 것이 좋겠지만, 이번엔 간단한 테스트이기 때문에 커넥션풀만 늘려주도록 하겠습니다. 

application.yml 설정에 다음과 같이 커넥션 풀 사이즈를 명시합니다.

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    hikari:
      maximum-pool-size: 40  # 추가

 

이렇게 설정해줄 경우 테스트는 올바르게 동작합니다.

 

 

 

 

주의점

Lock 획득 트랜잭션로직을 수행하는 트랜잭션분리시켜야 합니다.

그리고 이들이 분리된다면 한 요청에 대해 최소 2개의 커넥션(Lock 획득에 필요한 커넥션 1개, 로직에 필요한 커넥션 1개)이 사용되기 때문에, 커넥션 부족으로 인한 문제가 발생할 수 있습니다. 
=> Lock을 얻는데 사용하는 Connection Pool(Datasource)과, 로직을 수행하는데 사용되는 ConnectionPool(Datasource)을 분리해 주는 것이 좋습니다.

 

 

트랜잭션이 분리되지 않은 경우, 아래와 같은 문제가 발생할 수 있습니다.

해당 문제는 Lock이 해제된 후, 트랜잭션이 커밋되는 전, 그 사이에 새로운 요청이 들어오면 동시성 문제가 다시 발생합니다.

 

따라서 Lock을 획득하는 트랜잭션과 로직을 수행하는 트랜잭션을 분리되어야 하고

값이 변경된 재고가 commit 된 후, 다음 요청을 수행해야 하는 것이죠.

바로 아래와 같이 수행되어야 합니다. 

 

 

 

 

User-Level Lock  vs  비관적 Lock

User-Level Lock에는 timeout 기능이 있어 Lock을 획득하기 위해 시도하는 시간이고,
비관적 Lock은 @QueryHint 기능이 있어 Lock을 잡고 있는 최대 시간을 정해줄 수 있습니다.

 

User-Level Lock은 Lock의 대상이 테이블, 레코드, AUTO_INCREMENT 등 단순히 사용자가 지정한 이름에 Lock을 걸 수 있습니다.

비관적 Lock은 Lock의 대상이 레코드이므로, 다른 작업을 처리하기 위한 커넥션들도 Lock이 걸린 레코드에 접근하지 못합니다.

이 문제는 User-Level Lock을 사용하면 발생하지 않습니다.

 

 

 

 

User-Level Lock도 낙관적 Lock, 비관적 Lock과 같이 테이블을 파티셔닝하는 경우나, 다양한 서버에 데이터가 분산 저장되는 경우에 

제약 사항이 존재합니다. 

아래는 테이블을 파티셔닝하는 경우의 제약 사항입니다.

MySQL에서 테이블을 파티셔닝하면, 단일 테이블로 보여지긴 하지만 내부적으로는 여러 개의 테이블로 쪼개져있습니다.

이렇게 물리적인 저장소를 분산 저장하기 위해 가장 중요한 제약 사항이 있는데, 바로 파티셔닝 키 안에 PK(Primary Key가 포함이 되어야 한다는 것입니다. 

또한 PK 외에 추가로 Unique, FK 등 이러한 제약 사항을 둘 수 없다는 것입니다. 

 

 

 

다음 글에서는 Redis의 분산 Lock에 대해 알아보겠습니다.

 

 

 

참고 ) 
https://ttl-blog.tistory.com/1569

https://medium.com/@dori_dori/%EC%9E%AC%EA%B3%A0%EA%B0%80-%ED%95%9C-%EA%B0%9C-%EB%82%A8%EC%9D%80-%EB%AC%BC%EA%B1%B4%EC%9D%84-%EB%8F%99%EC%8B%9C%EC%97%90-%EC%97%AC%EB%9F%AC%EB%AA%85%EC%9D%B4-%EC%9E%A5%EB%B0%94%EA%B5%AC%EB%8B%88%EC%97%90-%EB%8B%B4%EC%9C%BC%EB%A9%B4-feat-mysql-user-level-lock-edbe8f7222c2

https://gywn.net/2013/12/mysql-user-level-lock/