동시성 문제를 해결해보자 - RDB 분산 락
이전 포스트에서 Java의 synchronized를 이용하여 동시성 문제를 해결해보았는데요
@Transactional 적용이 안된다는 점과, 스케일 아웃 환경에서 동시성 보장이 안된다는 문제가 있었습니다
이 두 문제를 해결할 수 있는 방법으로, 분산 락을 이용하는 방법을 소개해보려 합니다
분산된 환경에서 동일 자원에 접근하는 경우, 동시에 한 개의 프로세스만 접근 가능하도록 하기 위해 사용하는 락을 분산락(Distribution Lock)이라고 합니다.
처음 공부할 때 헷갈린 포인트가 X-lock이니, S-Lock등의 키워드였습니다.
모두 분산된 환경에서의 잠금을 구현하기 위한 방법들이고, 그 구현 방법으로 크게 낙관적 락(Optimistic Lock), 비관적 락(Pessmistic Lock), MySQL의 Named Lock 등이 있던 거였습니다
포스트에서 낙관적 락, 비관적 락, MySQL의 네임드 락에 대해 정리해보겠습니다
1. 비관적 락
비관적 락은 데이터베이스의 S-Lock이나 X-Lock등을 이용한 방법입니다
- S(Shared) Lock
S-Lock은 데이터베이스의 특정 행을 읽을 때 사용하는 락입니다.
S-Lock이 걸린 행에는, 다른 트랜잭션은 S-Lock만이 접근 가능하고, X-Lock은 접근 불가능합니다
-X(eXclusive) Lock
X-Lock은 데이터베이스의 특정 행에 쓰기작업을 할 때 사용하는 락입니다.
X-Lock이 걸린 행은 다른 트랜잭션에서는 S-Lock 혹은 X-Lock을 통해 접근할 수 없습니다.
그럼 JPA에서 비관적 락을 적용해보겠습니다.
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticXLock(Long id);
@Lock(LockModeType.PESSIMISTIC_READ)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithPessimisticSLock(Long id);
}
@Lock 어노테이션을 통해 잠금을 설정할 수 있고, LockModeType을 통해 어떤 종류의 락을 설정할지 고를 수 있습니다.
@Query 메서드를 통해 실제 JPQL을 사용한 모습입니다.
이 메서드를 사용하는 서비스와 이 서비스의 테스트 코드는 다음과 같습니다.
@Service
public class StockPessimisticLockService {
private final StockRepository stockRepository;
public StockPessimisticLockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public void decrease(Long id) {
Stock stock = stockRepository.findByIdWithPessimisticXLock(id);
// Stock stock = stockRepository.findByIdWithPessimisticSLock(id);
stock.decrease();
stockRepository.save(stock);
}
}
테스트는 아래와 같습니다
@SpringBootTest
@SuppressWarnings("NonAsciiCharacters")
class StockPessimisticLockServiceTest {
@Autowired
private StockPessimisticLockService stockPessimisticLockService;
@Autowired
private StockRepository stockRepository;
@BeforeEach
void setUp() {
stockRepository.saveAndFlush(new Stock(1L, 100L));
}
@Test
void 동시에_100개의_요청() 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 {
stockPessimisticLockService.decrease(1L);
} finally {
latch.countDown();
}
});
}
latch.await();
// then
Stock stock = stockRepository.findById(1L).orElseThrow();
assertThat(stock.getQuantity()).isZero();
}
}
그리고 이 테스트를 실행했을 때,
테스트가 성공하는 것을 볼 수 있고, Hibernate 내에서 실제 나가는 쿼리를 보면, for update가 잘 붙는 것을 볼 수 있습니다.
비관적 락(pessimistic lock)의 경우, 충돌이 빈번하지 않다면 낙관적 락(optimistic lock)보다 더 좋은 선택일 수 있습니다.
데이터의 정합성이 완전히 보장되나, 데드락의 위험성이 존재한다는 단점이 있습니다.
2. 낙관적 락
낙관적 락은 데이터베이스에 실제 락을 사용하지 않고, 버전 칼럼을 통해 데이터베이스의 정합성을 맞추는 방법입니다.
읽어온 시점과 트랜잭션이 커밋되는 시점의 데이터의 정합성을 비교하는 방식으로 이루어집니다.
사용하고자 하는 테이블에 @Version 어노테이션을 추가해서 관리할 수 있습니다.
@Entity
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
@Version
private Long version;
protected Stock() {
}
public Stock(Long productId, Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public void decrease() {
if (this.quantity < 0) {
throw new IllegalArgumentException();
}
this.quantity -= 1;
}
}
위와 같이 @Version 어노테이션을 이용할 수 있고, 이제 이걸 사용하는 Pessimistic Lock을 적용해보겠습니다
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("select s from Stock s where s.id = :id")
Stock findByIdWithOptimisticLock(Long id);
}
@Lock 어노테이션을 이용하고, Optimistic 옵션을 추가해줍니다
낙관적 락은 @Version 어노테이션이 달린 컬럼을 관리해야할 버전으로 인식 후, 자동으로 비교 및 업데이트해줍니다
즉 데이터베이스단에 거는 잠금이 아닌, 어플리케이션 레벨에 거는 잠금입니다
낙관적 락은 동시성 감지만 가능합니다. 성공해야한다면, 예외를 감지했을 때 재시도를 하기 위해
해당 Service를 사용하는 파사드를 하나 만들어, 재시도 처리를 이곳에서 처리하도록 합니다.
@Service
public class StockOptimisticLockService {
private final StockRepository stockRepository;
public StockOptimisticLockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public void decrease(Long id) {
Stock stock = stockRepository.findByIdWithOptimisticLock(id);
stock.decrease();
stockRepository.save(stock);
}
}
@Service
public class StockOptimisticLockServiceFacade {
private final StockOptimisticLockService stockOptimisticLockService;
public StockOptimisticLockServiceFacade(StockOptimisticLockService stockOptimisticLockService) {
this.stockOptimisticLockService = stockOptimisticLockService;
}
public void decrease(Long id) {
while (true) {
try {
stockOptimisticLockService.decrease(id);
break;
} catch (Exception e) {
try {
Thread.sleep(50);
} catch (InterruptedException ex) {
throw new RuntimeException(ex);
}
}
}
}
}
기존 테스트에서 사용하는 서비스만 파사드로 변경해준 뒤, 실행해 보면
테스트도 성공적으로 통과하는 것을 볼 수 있고, 쿼리에도 version 행을 이용하는 것을 볼 수 있습니다.
낙관적 락은, 실제로 DB상에서 락을 잡는 것이 아닌 데이터베이스의 버전을 통해 사용하기 때문에 비관적 락보다 성능상 이점이 있습니다.
고로 충돌이 빈번할 것이라 예상된다면, 낙관적 락이 좋은 선택지가 될 수 있겠죠?
3. 네임드 락
네임드 락은 MySQL에서 지원해주는 락 중 하나의 종류인데요,
테이블, 레코드가 아닌 임의의 문자열에 대해 잠금을 획득할 수 있습니다
적용 방법은 간단합니다
실제 MySQL에서 GET_LOCK()을 통해 잠금 획득하는 방법을 그대로 이용하는 건데요
코드와 함께 보시죠!
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)", nativeQuery = true)
void releaseLock(String key);
}
nativeQuery 옵션을 통해, JPQL이 아닌 SQL 문법을 사용함을 명시한 다음,
value 옵션에 쿼리를 작성해줍니다.
사용할 서비스에 다음과 같은 메서드를 작성해줍니다.
@Transactional
public void decrease(Long id) {
try {
lockRepository.getLock(id.toString());
stockService.decrease(id);
} finally {
lockRepository.releaseLock(id.toString());
}
}
락을 획득한 다음 재고를 감소시킨 후 최종적으로 락을 해제해줍니다.
(user-level lock은 자동으로 해제되지 않기 때문에 꼭 명시적으로 해제해줘야 합니다.)
네임드 락 역시, 분산락을 구현할 때 많이 사용하지만
비관적 락은 timeout 설정이 어렵지만, 네임드 락이 timeout을 더 쉽게 구현할 수 있습니다.
하지만 트랜잭션 종료시, 락 해제 관리를 꼭 해줘야합니다.
네임드 락은 MySQL의 임의의 문자열에 대해 잠금을 획득하지만(변수같은 느낌인 것 같습니다)
비관적 락은 레코드 수준에 락을 걸기 때문에, 다른 트랜잭션이 같은 레코드에 접근할 수 없다는 차이가 있는 것 같습니다...
코드는 이 곳 에서 확인할 수 있습니다
레퍼런스
https://dev.mysql.com/doc/refman/8.0/en/locking-functions.html
https://techblog.woowahan.com/2631/