
Named Lock과 synchronized를 함께 사용할 때 발생할 수 있는 데드락 문제
@Service
public class StockService {
private final StockRepository stockRepository;
public StockService(StockRepository stockRepository) {
this.stockRepository = stockRepository;
}
@Transactional
public synchronized void decrease(Long id, Long quantity){
// Stock 조회
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
@Component
public class NamedLockStockFacade {
private final StockService stockService;
private final LockRepository lockRepository;
public NamedLockStockFacade(StockService stockService, LockRepository lockRepository) {
this.stockService = stockService;
this.lockRepository = lockRepository;
}
@Transactional
public void decrease(Long id, Long quantity){
try{
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
}finally {
lockRepository.releaseLock(id.toString());
}
}
}
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);
}
@Test
public void 동시에_100개의_요청() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for(int i = 0; i < threadCount; i++){
executorService.submit(() -> {
try {
stockService.decrease(1L, 1L);
}
finally {
latch.countDown();
}
});
}
latch.await();
Stock stock = stockRepository.findById(1L).orElseThrow();
assertEquals(0,stock.getQuantity());
}
위 테스트를 실행했을 때, 데드락이 발생한다.

어플리케이션 레벨의 synchronized와 DB 레벨의 락인 Named Lock이 각각 다른 순서로 처리하기 때문에 발생하는 문제이다.
그렇다면 이 데드락 상황을 해결하기 위해서 다음과 같은 방안을 생각해 보았다.
데드락을 방지하기 위한 첫 번째 방법은 락 획득 순서를 통일하는 것이다.
@Transactional
public void decrease(Long id, Long quantity) {
synchronized(this) {
try {
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
}
하지만 복합 연산 보호, 로컬 캐시 보호, 성능 최적화(더블 체크 락킹)와 같은 특수한 경우를 제외하고는 일반적으로 synchronized를 필요로 하지 않는다.
public void updateIfNeeded(String resource) {
if (needsUpdate) { // 먼저 객체 초기화 확인 -> 이 방법을 통해 불필요한 락 획득 방지 가능
synchronized (this) {
if (needsUpdate) { // 다시 초기화
try {
lockRepository.getLock(resource);
// 업데이트 로직 수행
needsUpdate = false;
} finally {
lockRepository.releaseLock(resource);
}
}
}
}
}
대부분의 경우, synchronized를 제거하고 네임드 락만 사용하는 것이 가장 효과적인 해결책이다.
@Transactional(propagation = Propagation.REQUIRES_NEW) // 전파 속성 추가
public void decrease(Long id, Long quantity) {
try {
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}