Named Lock + synchronized 사용시 발생 문제

Seob·2025년 2월 21일
post-thumbnail

Named Lock과 synchronized 동시 사용 시 발생하는 데드락 문제

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이 각각 다른 순서로 처리하기 때문에 발생하는 문제이다.

  1. 스레드 A의 실행 흐름:
    • Named Lock 획득
    • synchronized 메소드 진입 대기
  2. 스레드 B의 실행 흐름:
    • synchronized 메소드 진입
    • Named Lock 획득 대기
  3. 결과:
    • 두 스레드가 서로가 보유한 락을 기다리며 무한 대기 상태에 빠짐 -> 데드락

해결 방안

그렇다면 이 데드락 상황을 해결하기 위해서 다음과 같은 방안을 생각해 보았다.

1. 락 획득 순서 통일

데드락을 방지하기 위한 첫 번째 방법은 락 획득 순서를 통일하는 것이다.

@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를 필요로 하지 않는다.

  • 복합 연산 보호: 2개 이상의 네임드락이 필요로 할 때, 순서를 지키기 위해 사용 -> 데드락 방지
  • 로컬 캐시 보호: 네임드 락으로 보호되는 데이터의 로컬 캐시에 대한 접근을 동기화하기 위해 사용
    - 분산 시스템에서 각 어플리케이션 인스턴스가 로컬 캐시를 가지고 있을 때, 캐시 일관성을 위해 사용 -> 로컬 캐시에 대한 Read/Write 작업을 동기화하여 여러 쓰레드의 동시 접근 방지
  • 성능 최적화: 더블 체크 락킹(DCL) 패턴을 구현할 때 사용
    - 객체가 먼저 초기화 되었는지 확인한 후, 초기화되지 않은 경우에만 syncrhonized 블록에 진입하여 다시 한 번 초기화를 수행함
  public void updateIfNeeded(String resource) {
      if (needsUpdate) { // 먼저 객체 초기화 확인 -> 이 방법을 통해 불필요한 락 획득 방지 가능
          synchronized (this) {
              if (needsUpdate) { // 다시 초기화
                  try {
                      lockRepository.getLock(resource);
                      // 업데이트 로직 수행
                      needsUpdate = false;
                  } finally {
                      lockRepository.releaseLock(resource);
                  }
              }
          }
      }
  }

2. synchronized 제거 (권장 방법)

대부분의 경우, 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());
        }
    }
profile
백엔드 개발자 Seob입니다

0개의 댓글