네임드 락을 위해 try 구문 내에서 Lcok을 얻고 상품 구매로직을 수행한 후, Lock을 반환하는 로직을 구현하면서 동시성 문제가 해결되지 않는 문제가 있었다.
부모의 트랜잭션과 동일한 범위로 묶이게 되면, Synchronized와 트랜잭션을 함께 사용했을 때의 문제처럼 DB에 commit되기 전에 Lock이 풀리고, 이때 동시성 문제가 동일하게 발생할 수 있다고 생각했다. 따라서, 별도의 트랜잭션으로 분리하여 상품 구매가 DB에 정상적으로 커밋된 후에 Lock을 해제하도록 구현했다.
ItemService 클래스 내에서 NamedLock을 획득하고 해제하는 트랜잭션과 상품을 주문하는 로직을 REQUIRES_NEW 옵션으로 내부에 새로운 트랜잭션내에서 동작하도록 구성했다.
Lock을 획득한 커넥션과 같은 커넥션에서만 Lock을 반납할 수 있다. 하지만 DB의 쿼리를 실행하는 커넥션과 Lock을 가지고 있는 커넥션이 같은 상황이라면 쿼리가 종료되고 커넥션이 반납되거나, Lock을 반납할 때와 Lock을 얻을 때의 커넥션이 달라 Lock이 반납되지 않을 수 있다.
이로 인해 Lock을 위한 커넥션과 상품 구매를 위한 로직의 커넥션을 분리하기 위해 트랜잭션 전파레벨을 REQUIRES_NEW로 설정한다.
// 핵심 코드만 작성
public class ItemService {
@Transactional
public void buyItemWithNamedLock(Long id, int timeoutSeconds, Long quantity) {
try {
// NamedLock 획득
lockRepository.getLock(id.toString(), timeoutSeconds);
buyItem(id, quantity);
} finally {
// NamedLock 해제
lockRepository.releaseLock(id.toString());
}
}
// 부모 트랜잭션과 분리
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void buyItem(Long id, Long quantity) {
Item item = itemRepository.findById(id)
.orElseThrow(() -> new ItemNotExistException("아이템이 존재하지 않습니다"));
item.decreaseStock(quantity);
}
}
하지만 예상된 결과와 다르게 동시성 문제를 해결할 수 없었다.
트랜잭션이 분리되어 적용될 것으로 예상했는데 그렇지 않았다. 그 이유는 @Transactional의 동작방식에 있었다.
@Transactional을 통한 선언전 트랜잭션 관리 방식은 기본적으로 스프링의 트랜잭션 AOP가 적용되면서 프록시 방식의 AOP을 사용한다. 프록시 객체가 먼저 요청을 받아 트랜잭션을 처리하고, 실제 객체를 호출하는 것이다.
따라서, 트랜잭션을 적용하기 위해서는 항상 프록시를 통해 대상 객체를 호출해야 하는 것이다.
메서드 앞에 별도의 참조가 없이 this를 사용하게 되면서 내부 메서드를 호출하게 되면서, 실제 대상 객체의 인스턴스를 가리키게 된다. 즉, 하나의 클래스 내에서 트랜잭션 전파 옵션 REQUIRES_NEW로 새로운 트랜잭션을 생성하려 해도 메서드 내부 호출은 프록시를 거치지 않는다!
결과적으로 프록시가 아닌 실제 객체의 메서드를 호출하면서 트랜잭션이 적용되지 않은 것이다.
이를 해결하기 위해 네임드 락을 획득하고 해제하는 로직과 상품을 구매하는 service의 클래스를 분리하면서 트랜잭션을 분리하도록 했다.
ItemService 클래스
// 핵심 코드만 작성
public class ItemService {
// 부모 트랜잭션과 분리
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void buyItem(Long id, Long quantity) {
Item item = itemRepository.findById(id)
.orElseThrow(() -> new ItemNotExistException("아이템이 존재하지 않습니다"));
item.decreaseStock(quantity);
}
}
NamedLockItemService 클래스
// 핵심 코드만 작성
public class NamedLockItemService {
@Transactional
public void namedLockBuyItem(Long id, int timeoutSeconds, Long quantity) {
try {
// NamedLock 획득
lockRepository.getLock(id.toString(), timeoutSeconds);
itemService.buyItem(id, quantity);
} finally {
// NamedLock 해제
lockRepository.releaseLock(id.toString());
}
}
}
트랜잭션이 분리되어 작동하면서 성공적으로 테스트가 수행되는 것을 확인할 수 있다.