Spring Boot

[Spring] Lock을 이용한 동시성 제어 - 낙관적락/비관적락/분산락 (1)개념

kittity 2024. 12. 26. 18:54

목차

  1. 낙관적락 (s-lock, 공유락, optimistic lock)
  2. 비관적락 (x-lock, 베타락, pessimistic lock) 
  3. 낙관적락과 비관적락 성능/효율성 테스트
  4. 분산락 (Redis)
  5. Redisson 라이브러리 사용시 주의점

 

분산 환경에서 동시성 제어를 위해 사용하는 Lock의 종류와 각각의 특징을 정리해보고자한다.

 

1. 낙관적락 (s-lock, 공유락, optimistic lock)

  • 특징
    • 트랜잭션 충돌이 발생하지 않을 것이라고 낙관적으로 가정.
    • 읽기 잠금 ( select ..from.. where ... for share )으로 한쪽이 커밋 할 때 까지 쓰기 잠금.
    • s-lock을 설정한 트랜잭션은 해당 row를 읽기만 가능하다.
    • 충돌 빈도가 적을 경우 권장되며, 동시 요청 중 한 건만 성공해야하는 케이스에 적합하다.
    • version을 사용하여 충돌을 예방하며, DB Lock 을 설정해 동시성 문제를 제어하는 것이 아니라 읽은 시점과 수정 시점의 데이터 변경 여부를 확인해 제어하는 방식이다.
    • 트랜잭션, Lock 설정 없이 데이터 정합성을 보장할 수 있으므로 비관적 락보다 성능적으로 우위에 있다.
  • 장점
    • 충돌이 자주 발생하지 않는 경우 성능이 좋다.
  • 단점
    • 충돌이 발생하면 롤백에 대한 처리가 필요하다. 반드시 트랜잭션 커밋 시 버전이 달라 발생하는 예외에 대해 예외처리를 해야한다.
  • 구현 복잡도: 낮음

 

2. 비관적락 (x-lock, 베타락, pessimistic lock)

  • 특징
    • 트랜잭션 충돌이 발생할 것이라고 비관적으로 가정
    • 쓰기 잠금 ( select ..from .. where... for update )으로 특정 자원에 대해 Lock 설정으로 선점해 정합성을 보장하여 하나의 트랜잭션만 읽기/쓰기가 가능하고 다른 트랜잭션은 완료될 때까지 대기한다.
    • 충돌 빈도가 많을 경우 권장되며, 동시 요청에서 순차로 진행될 때 성공할 수 있는 요청이라면 성공시키는 케이스에 적합하다.
  • 장점
    • 충돌이 자주 발생할경우 유리하다.
  • 단점
    • 대기가 많아질 수 있어 DB 성능 저하와 서로 필요한 자원에 락이 걸려있으므로 데드락이 발생할 수 있다.
  • 구현 복잡도: 낮음

 

3. 낙관적락과 비관적락 성능/효율성 테스트

상세 코드는 GitHub에 있습니다.

 

포인트 사용 기능에 대해 동일한 조건에서 낙관적락과 비관적락을 각각 적용해보고 동시성 테스트를 해본 결과 다음과 같다.

  • 조건
    • USER_DEFAULT_AMOUNT(포인트 초기값) = 100000
    • THREAD_COUNT(스레드 개수) = 100
    • USE_AMOUNT(포인트 사용금액) = 500

3.1 낙관적락

  • 코드
    • PointTransactionIntegrationTest.java
      @Test
      @DisplayName("동일한 사용자가 동시에 100건의 포인트 사용 요청시 낙관적락 동시성 처리로 대부분 실패")
      void concurrentPointUseWithOptimisticLockFailure() throws InterruptedException {
          long beforeTime = System.currentTimeMillis();
      
          // given
          int THREAD_COUNT = 100;
          int USE_AMOUNT = 500;
          ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);
          CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
      
          // when
          for (int i = 0; i < THREAD_COUNT; i++) {
              executorService.submit(() -> {
                  try {
                      UserPointInfo userPointInfo = pointFacade.pointTransaction(testUser.getUserId(), USE_AMOUNT, TransactionType.USE);
                      System.out.println(userPointInfo);
                      return userPointInfo;
                  } finally {
                      latch.countDown();
                  }
              });
          }
      
          latch.await();
          executorService.shutdown();
      
          long afterTime = System.currentTimeMillis();
          long diffTime = afterTime - beforeTime;
          System.out.println("실행 시간(ms): " + diffTime + "ms");
      
          // then
          // 마지막 포인트 조회
          UserPointInfo finalPoint = pointFacade.getUserPointInfo(testUser.getUserId());
      
          // 예상 총 포인트 = 유저 포인트 - (충전금액 * 스레드 수)
          int expectedTotalPoint = USER_DEFAULT_AMOUNT - (USE_AMOUNT * THREAD_COUNT);
          assertThat(finalPoint.point()).isGreaterThan(expectedTotalPoint);
          assertThat(finalPoint.point()).isGreaterThan(50000);
      }
       
    • PointJpaRepository.java
      @Lock(LockModeType.OPTIMISTIC)
      @Query("SELECT po FROM Point po " +
              "WHERE po.user.userId = :userId")
      Point findByUserPointWithOptimisticLock(@Param("userId") Long userId);
  • 결과
    • 100건 중 14건 성공
    • 100000 포인트 중 잔여 93000 포인트
    • 총 소요 시간 242ms

3.2 비관적락

  • 코드
    • PointTransactionIntegrationTest.java
      @Test
      @DisplayName("동일한 사용자가 동시에 100건의 포인트 사용 요청시 비관적락 동시성 처리로 모두 성공")
      void concurrentPointUseWithPessimisticLockSuccess() throws InterruptedException {
          long beforeTime = System.currentTimeMillis();
      
          // given
          int THREAD_COUNT = 100;
          int USE_AMOUNT = 500;
          ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);
          CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
      
          // when
          for (int i = 0; i < THREAD_COUNT; i++) {
              executorService.submit(() -> {
                  try {
                      UserPointInfo userPointInfo = pointFacade.pointTransaction(testUser.getUserId(), USE_AMOUNT, TransactionType.USE);
                      System.out.println(userPointInfo);
                      return userPointInfo;
                  } finally {
                      latch.countDown();
                  }
              });
          }
      
          latch.await();
          executorService.shutdown();
      
          long afterTime = System.currentTimeMillis();
          long diffTime = afterTime - beforeTime;
          System.out.println("실행 시간(ms): " + diffTime + "ms");
      
          // then
          // 마지막 포인트 조회
          UserPointInfo finalPoint = pointFacade.getUserPointInfo(testUser.getUserId());
      
          // 예상 총 포인트 = 유저 초기 포인트 - (충전금액 * 스레드 수)
          int expectedTotalPoint = USER_DEFAULT_AMOUNT - (USE_AMOUNT * THREAD_COUNT);
          assertThat(finalPoint.point()).isEqualTo(expectedTotalPoint);
          assertThat(finalPoint.point()).isEqualTo(50000);
      }
    •  PointJpaRepository.java
      @Lock(LockModeType.PESSIMISTIC_WRITE)
      @QueryHints(@QueryHint(name = "javax.persistence.lock.timeout", value = "3000"))
      @Query("SELECT po FROM Point po " +
              "WHERE po.user.userId = :userId")
      Point findByUserPointWithPessimisticLock(@Param("userId") Long userId);
  • 결과
    • 100건 중 100건 모두 성공
    • 100000 포인트 중 잔여 50000 포인트
    • 총 소요 시간 442ms

3.3 성능/효율성 정리

위의 결과를 통해 다시 정리해보자.

  • 낙관적락
    • 빠르지만 충돌이 발생해 모든 요청에 성공하지 않는다. (테스트 결과: 100건 중 14건 성공, 242ms)
    • 따라서 충돌이 적을 경우 사용하면 성능이 좋다.
    • 충돌 시 롤백/예외 처리가 필요하다.
  • 비관적락
    • Lock을 걸어 선점하고, 다른 트랜잭션은 락이 풀릴 대까지 대기한다. (테스트 결과: 100건 모두 성공, 442ms)
    • 모든 요청이 성공하고 정합성이 보장되나 대기 시간이 발생한다. 
    • 데드락에 주의해야한다.

 

4. 분산락 (Redis)

  • 특징
    • 분산 시스템에서 일관된 락을 제공하기 위한 것.
    • 트랜잭션 밖에서 락을 적용하며,  DB 부하를 최소화할 수 있다.
  • “레디스”를 활용한 락에서 락 획득과 트랜잭션의 순서의 중요성
    • 락과 트랜잭션은 데이터의 무결성을 보장하기 위해 아래 순서에 맞게 수행됨을 보장해야 한다.

      락 획득 → 트랜잭션 시작 → 비지니스 로직 수행 → 트랜잭션 종료 → 락 해제
  • 종류
    • simple lock
      • Lock 획득 실패 시 요청에 대한 비즈니스 로직을 수행하지 않는다.
      • 실패 시 재시도 로직에 대해 고려해야하며 요청의 case 에 따라 실패 빈도가 높다.
    • spin lock
      • Lock 획득 실패 시, 일정 시간/횟수 동안 지속적인 재시도를 하며 이로 인한 네트워크 비용 발생한다.
      • 재시도에 지속적으로 실패할 시, 스레드 점유 등의 문제가 발생한다.
      • Lettuce 라이브러리 사용
        • setnx, setex 등으로 분산락을 직접 구현해야하고, retry, timeout 기능도 구현해야한다.
        • 재시도를 하기 때문에 레디스에 부하를 줄 수 있다.
    • pub/sub
      • 레디스 Pub/Sub 기능을 활용해 락 획득을 실패 했을 시에, “구독” 하고 차례가 될 때까지 이벤트를 기다리는 방식을 이용해 효율적인 Lock 관리가 가능하다.
      • “구독” 한 subscriber 들 중 먼저 선점한 작업만 Lock 해제가 가능하므로 안정적으로 원자적 처리가 가능하다.
      • 직접 구현, 혹은 라이브러리를 이용할 때 해당 방식의 구현이 달라질 수 있으므로 주의해서 사용해야 한다.
      • Redisson 라이브러리 사용
        • subscribe 하는 클라이언트는 락이 해제되었다는 신호를 받고 락 획득을 시도한다.
  • 구현 복잡도: 높음

 

5. Redisson 라이브러리 사용시 주의점

Redis 공식 문서를 보면 분산락을 위해 RedLock 알고리즘을 사용한다. RedLock을 Java에서 사용할 수 있도록 구현한 것이 Redisson 라이브러리다.

 

RedLock 알고리즘은 분산 환경에서 다음과 같다고 가정한다.

  • 단일 Redis 인스턴스 대신 N개의 독립적인 Redis 마스터를 사용 (일반적으로 N=5)
  • 과반수(N/2+1) 이상의 Redis 인스턴스에서 락을 획득해야 유효한 것으로 간주

 

Redis 공식 문서에서는 다음과 같은 문제가 발생할 수 있다고 한다.

 

1. 시스템 클록에 의한 clock drift 현상

  • Redis는 TTL 만료를 위해 monotonic clock 를 사용하지 않는다.
  • 서버의 시간이 갑자기 변경될 경우, 여러 프로세스가 동시에 락을 획득할 수 있다.

2. 서버 다운, 네트워크 단절, 프로세스 실행 시간 초과시 동시성 문제

  • 다음과 같은 경우 안전성 보장이 불가능하다.
    • Redis 서버가 다운 되었다가 복구 되었을 경우
    • Redis 서버는 정상이나, 네트워크 문제로 일부 클라이언트와 통신이 끊겨 락을 보유한 클라이언트가 락을 해제하지 못하는 경우
    • 락을 보유한 프로세스가 TTL보다 오래 실행될 경우
  • 이를 위한 해결책으로 fencing token 구현이 필요하다.
    • 동시성 문제


    • fencing token 사용시


참고자료
- https://redis.io/docs/latest/develop/use/patterns/distributed-locks/
- https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

 

 

관련글

[Spring] Lock을 이용한 동시성 제어 - 낙관적락/비관적락/분산락 (1)개념

[Spring] Lock을 이용한 동시성 제어 - 낙관적락/비관적락/분산락 (2)적용

 

관련 코드는 아래 GitHub를 참고해주세요.

 

GitHub - leehanna602/hhplus-server-construction

Contribute to leehanna602/hhplus-server-construction development by creating an account on GitHub.

github.com

 

728x90