동시성 이슈를 해결하기 위해 데이터베이스를 이용할 수 있다.
Pessimistic Lock
실제로 데이터에 Lock을 걸어 정합성을 맞추는 방법으로, exclusive lock을 걸게 되면 다른 트랜잭션에서는 lock이 해제되기 전에는 데이터를 가져갈 수 없습니다. 다만 로직에서 다중 커넥션이 필요하거나 중첩 트랜잭션이 있는 경우 등등.. 다양한 이유로 데드락이 걸릴 수 있습니다.
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticLock(Long id);
jpa를 사용하면 @Lock 어노테이션을 제공함으로 써 쉽게 적용할 수 있습니다.
이후 조회 쿼리를 살펴보면 다음과 같습니다.
select stock0_.id as id1_0_, stock0_.product_id as product_2_0_, stock0_.quantity as quantity3_0_
from stock stock0_ where stock0_.id=? for update
"for update"를 사용하여 데이터에 대한 락을 걸고 있습니다.
pessimitstic lock은 충동이 빈번하게 일어난다면 Optimistic lock보다 성능이 좋을 수 있습니다.
또한 lock을 통해 업데이트를 제어하기 때문에 데이터 정합성이 어느정도 보장됩니다.
다만 별도의 락을 잡기 때문에 성능 감소가 발생할 수 있다.
Optimistic Lock
실제로 Lock을 이용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법입니다.
먼저 데이터를 읽은 후에 update를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하여 업데이트를 합니다. 내가 읽은 버전에서 수정사항이 생긴 경우에는 application에서 다시 읽은 후에 작업을 수행해야 한다.
또한 별도의 버전 컬럼이 필요하다.
@Version
private Long version;
@Version 어노테이션을 사용할 때 JTA와 Spring의 두 개의 패키지가 존재한다.
JTA 같은경우 컨테이너가 관리하는 빈을 CDI-관리 빈(CDI-managed), Java EE 사양에 의해 관리되는 빈으로 정의된 클래스에만 적용됩니다. Spring은 Spring Bean에만 적용됩니다.
javax.persistence
해당 패키지의 어노테이션을 사용해야 합니다.
@Lock(value = LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
추가적으로 Optimistic Lock 같은 경우 실패 시 재시도를 해줘야 하기 때문에 별도의 설정이 필요합니다.
public void decrease(Long id, Long quantity) throws InterruptedException {
while (true) {
try {
optimisticLockStockService.decrease(id, quantity);
break;
} catch (Exception e) {
Thread.sleep(50);
}
}
}
별도의 Lock을 잡지 않기 때문에 성능상 이점이 있습니다.
다만 재시도 로직을 별도로 작성해주어야 합니다. 추가적으로 충돌이 빈번한 경우 Pessimistic Lock을 적용하는 것이 좋다.
Named Lock
이름을 가진 metadata locking 입니다. 이름을 가진 lock을 획득한 후 해제할 때까지 다른 세션은 이 lock을 획득할 수 없도록 합니다. 다만 transaction이 종료될 때 lock을 자동적으로 해제하지 않는다는 점입니다.
별도의 명령어로 해제를 수행해주거나 선점시간이 끝나야 해제됩니다.
전제조건으로 동일 데이터베이스에 lock을 위한 커넥션 풀을 별도로 구성해야 한다.
그렇지 않으면 모든 lock이 해제를 하지 않는다면 하나의 커넥션 풀이 가득 차는 현상이 발생할 수 있다. 특히 named lock은 커넥션을 두 개 사용하기 때문인데, lock 획득을 위한 커넥션과, transaction 로직에 필요한 커넥션을 사용한다.
https://kitty-geno.tistory.com/168
위 블로그에 설정 법에 대한 자세한 설명이 나와있다.
@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);
이는 mysql의 기능을 직접 호출하는 것으로 nativeQuery를 보낸다.
따라서 비지니스 로직 실행 전 데이터베이스 연결 세션에서 getLock을 통해 락을 획득한다.
트랜잭션을 위한 커넥션을 얻고 비즈니스 로직을 실행한다.
트랜잭션 커밋 이후 트랜잭션 커넥션을 풀에 반납한다.
releaseLock을 통해 락 해제하고 커넥션을 반납한다.
추가적으로 비지니스 로직이 실행되기 전 facade계층에 대한 코드가 필요합니다.
@Transactional
public void decrease(Long id, Long quantity) {
try {
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
이후 decrease는 부모 트랜잭션과 별개로 새로운 트랜잭션으로 실행하도록 설정해야 한다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public synchronized void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
}
실제 테스트 코드 실행 시 다음과 같다.
select get_lock(?, 3000)
select stock0_.id as id1_0_0_, stock0_.product_id as product_2_0_0_, stock0_.quantity as quantity3_0_0_, stock0_.version as version4_0_0_ from stock stock0_ where stock0_.id=?
update stock set product_id=?, quantity=?, version=? where id=? and version=?
select release_lock(?)
처음 비동기 요청에 대해 서로 lock 획득을 시도하며 먼저 락을 획득한 쿼리가 실행되며 이후 차례대로 락을 얻고 실행하는 방식으로 진행된다.
'SSR' 카테고리의 다른 글
Redis 활용하여 동시성 문제해결 (0) | 2022.12.31 |
---|---|
제한된 리소스에서 살아남기 (1) | 2022.12.31 |
아파치 지시어 (0) | 2022.11.13 |
Https 적용 (0) | 2022.11.12 |
댓글