카테고리 없음

Database Locking(Optimistic Lock, Pessimistic Lock)으로 동시성 제어하기

ming412 2024. 5. 19. 15:19

잠금 (Locking)

잠금은 데이터가 읽힌 후 사용될 때까지 데이터가 변경되는 것을 방지하기 위한 조치이다.

잠금 전략으로는 여러 트랜잭션 간 충돌이 드물다고 가정하는 낙관적 잠금, 여러 트랜잭션 간 충돌이 자주 발생할 것이라고 가정하는 비관적 잠금이 있다.

낙관적 잠금 (Optimistic Lock)

낙관적 잠금은 아래와 같이 @Version 어노테이션을 통해 처리할 수 있다.

JPA에서는 별도의 옵션을 사용하지 않아도 Entity에 @Version이 적용된 필드만 있으면 낙관적 잠금이 적용된다. (=암시적 잠금)

@Entity
public class Coupon {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long couponId;

    private int totalAmount; // 쿠폰 수량

    private int reservedAmount; // 예약 수량

    @Version
    private Integer version; // 버전 
	   
	  /***/
}

데이터베이스의 Coupon 테이블에는 아래와 같이 version 컬럼이 추가되었다.

 

 

이제 다시 동시 예매 테스트를 해보자.

실행 결과

 

결과는 여전히 실패이다. 10장이 아닌 3장만 예매되었다.

Reservation 테이블을 조회하면, 잠금을 걸지 않은 코드와의 차이점을 발견할 수 있다.

 

 

한 티켓이 여러 번 발급된 이전과 달리, 1~3번 티켓이 각각 한 장씩만 발급되었다.

이번엔 사용자 수를 100명으로 늘리고 다시 테스트를 실행해보자.

@Test
@DisplayName("10장의 쿠폰을 30명의 사용자가 동시에 예매하는 상황")
void 쿠폰_동시_예매_테스트() throws InterruptedException {
    // given
    int memberCount = 100; // 사용자 수 100명으로 증가 
    int ticketAmount = 10;
    Coupon coupon = couponRepository.save(new Coupon(ticketAmount));

    ExecutorService executorService = Executors.newFixedThreadPool(memberCount);
    CountDownLatch latch = new CountDownLatch(memberCount);

    AtomicInteger successCount = new AtomicInteger();
    AtomicInteger failCount = new AtomicInteger();

    // when
    for (int i = 0; i < memberCount; i++) {
        executorService.submit(() -> {
            try {
                couponService.publishCoupon(coupon.getCouponId());
                successCount.incrementAndGet();
            } catch (Exception e) {
                System.out.println(e.getMessage());
                failCount.incrementAndGet();
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await(); // 모든 스레드의 작업이 끝날 때까지 대기

    System.out.println("successCount = " + successCount);
    System.out.println("failCount = " + failCount);

    // then
    long reservationCount = reservationRepository.count();
    assertThat(reservationCount)
            .isEqualTo(Math.min(memberCount, ticketAmount));
}

실행 결과

 

사용자 수를 100명으로 늘리고 다시 테스트를 실행하니 10장이 다 발급되었다.

왜 위와 같은 상황이 발생한걸까?

낙관적 잠금 동작 과정

  • Thread 1이 쿠폰을 발급 받는다. (version=1 → 2)
  • Thread 2가 version 1인 쿠폰을 발급 받는다. (실패)
    • 이때 쿠폰의 version은 2가 되었기 때문에 버전이 다르기 때문
  • Thread 3이 version 2인 쿠폰을 발급 받는다. (version=2 → 3)
  • Thread 4가 version 2인 쿠폰을 발급 받는다. (실패)
    • 이때 쿠폰의 version은 3이 되었기 때문에 버전이 다르기 때문
  • Thread 5가 version 3인 쿠폰을 발급 받는다. (version=3 → 4)

위 flow와 같이 스레드 1, 3, 5는 버전 충돌이 없어 쿠폰 예매에 성공하였지만, 스레드 2, 4는 이전 스레드가 업데이트한 버전과 충돌이 생겨 수정 요청(update문)이 성공적으로 수행되지 못했다.

이처럼 일부 스레드에서 버전 충돌로 수정 요청이 반영되지 않았기 때문에 10개의 쿠폰 중 3개만 발급된 것이다.

따라서 낙관적 잠금은 쿠폰 예매 요청이 버전 충돌로 인해 실패할 경우, 직접 예외를 처리하여 재시도하는 로직을 구현해야 한다.

이러한 재시도 로직을 AOP를 이용해 구현할 수 있다.

AOP를 이용한 재시도 로직 구현

먼저 @Retry 어노테이션을 정의한다.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Retry { }

아래는 낙관적 잠금 재시도 로직을 구현한 Aspect이다. 최대 1000번까지 0.1초 간격으로 재시도한다.

@Order(Ordered.LOWEST_PRECEDENCE - 1)
@Aspect
@Component
public class OptimisticLockRetryAspect {
    private static final int MAX_RETRIES = 1000;
    private static final int RETRY_DELAY_MS = 100;

    @Pointcut("@annotation(Retry)")
    public void retry() {
    }

    @Around("retry()")
    public Object retryOptimisticLock(ProceedingJoinPoint joinPoint) throws Throwable {
        Exception exceptionHolder = null;
        for (int attempt=0; attempt<MAX_RETRIES; attempt++) {
            try {
                return joinPoint.proceed();
            } catch (OptimisticLockException | ObjectOptimisticLockingFailureException | StaleObjectStateException e) {
                exceptionHolder = e;
                Thread.sleep(RETRY_DELAY_MS);
            }
        }
        throw exceptionHolder;
    }
}

아래와 같이 publishCoupon() 메서드에 @Retry 어노테이션을 적용하면, 낙관적 잠금에서 버전이 맞지 않을 때 재시도한다.

@Retry // 커스텀 어노테이션 적용 
@Transactional
public void publishCoupon(long couponId) {
    Coupon coupon = couponRepository.findById(couponId)
            .orElseThrow(() -> new RuntimeException("존재하지 않는 쿠폰입니다."));

    coupon.increaseReservedAmount();
    couponRepository.saveAndFlush(coupon); 

    int couponNumber = coupon.getReservedAmount();
    reservationRepository.save(new Reservation(coupon, couponNumber));
}

실행 결과

 

테스트 실행 결과 성공적으로 10장의 티켓이 예매되었다.

하지만 주의할 점이 있다! 이는 요청이 처리되는 순서가 원래 요청 순서와 달라질 수 있다는 점이다. 먼저 들어온 요청이 실패하면 재시도 로직이 작동하고, 이 동안 다른 요청들이 처리될 수 있기 때문이다.

이는 요청의 순서에 따라 쿠폰 번호를 할당해야 하는 상황에서는 적절하지 않다.

이를 해결할 수 있는 다른 방법을 살펴보자.

비관적 잠금 (Pessimistic Lock)

비관적 락은 배타 락(exclusive lock)과 공유 락(shared lock)이라는 두 가지 옵션이 있다. 공유 락을 걸면 다른 트랜잭션에서 읽기는 가능하지만 쓰기가 불가능하다. 반면, 배타 락을 걸면 다른 트랜잭션에서 읽기와 쓰기가 모두 불가능하다.

Dirty Read를 발생시키지 않기 위해서는 내가 수정하고 있는 데이터에 대해 쓰기 뿐만 아니라 조회까지 것도 막아야 하므로 배타 락을 사용할 것이다.

CouponRepository.java

/**
 * update를 위한 조회 (비관적 락)
 */
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from Coupon c where c.couponId = :id")
Optional<Coupon> findByIdForUpdate(@Param("id") Long id);

Coupon의 reservedAmount를 갱신하기 위해 먼저 couponId를 사용해 해당 쿠폰을 찾아야 한다. 이를 위해 레포지토리에서 갱신을 위한 쿠폰 검색 로직을 findByIdForUpdate() 메서드로 정의했다.

이 메서드는 쿠폰을 검색할 때 PESSIMISTIC_WRITE 모드의 배타 락을 사용하여, 다른 트랜잭션이 동시에 해당 쿠폰에 접근하지 못하도록 한다. 이를 통해 동시에 여러 트랜잭션이 쿠폰의 reservedAmount를 갱신하려고 할 때 발생할 수 있는 충돌을 방지할 수 있다.

비관적 잠금의 LockModeType

PESSIMISTIC_READ 공유 락(s-lock)을 걸어 데이터가 UPDATE, DELETE 되는 것을 방지
PESSIMISTIC_WRITE 배타 락(x-lock)을 걸어데이터가 READ, UPDATE, DELETE 되는 것을 방지
PESSIMISTIC_FORCE_INCREMENT 배타 락(x-lock)을 거는 것 외에도, 버전 필드를 강제로 증가

 

CouponService.java

 @Transactional
	public void publishCoupon(long couponId) {
	    Coupon coupon = couponRepository.findByIdForUpdate(couponId) // 비관적 잠금 사용
	            .orElseThrow(() -> new RuntimeException("존재하지 않는 쿠폰입니다."));
	
	    coupon.increaseReservedAmount();
	    couponRepository.saveAndFlush(coupon); 
	
	    int couponNumber = coupon.getReservedAmount();
	    reservationRepository.save(new Reservation(coupon, couponNumber));
	}

실행 결과

 

 

테스트 실행 결과 정확히 10장만 발급하는데 성공했다.

또한 출력 쿼리문에서 select for update 문을 통해 DB의 특정 row에 잠금을 거는 것을 확인할 수 있었다.

 

SELECT FOR UPDATE

SEELECT ~ FOR UPDATE 는 비관적 락(Pessimistic Lock) 중 배타 락(exclusiove lock)을 적용하는 쿼리로, ‘UPDATE를 하기 위해 SELECT를 한다.’ = ‘이 데이터는 내가 조회하여 수정 중이기 때문에 다른 사람은 건드릴 수 없다.’는 뜻이다.

 

 

트랜잭션을 시작한 뒤 SELECT ~ FOR UPDATE 문을 사용해 조회하면, 조회한 row에 대해서는 트랜잭션이 종료(commit or rollback)될 때까지 CRUD가 차단된다. 해당 row에 대한 access가 발생할 경우, 해당 request에서는 Lock Wait라는 상황으로 응답하며, 트랜잭션이 종료될 때까지 기다리도록 한다.

 

결론

낙관적 잠금 vs 비관적 잠금

낙관적 잠금

  • 장점
    • 트랜잭션 동안 데이터베이스 락을 잡지 않으므로, 성능적으로 비관적 잠금보다 효율적이다.
  • 단점
    • 충돌이 발생할 경우, 충돌을 해결하기 위해 개발자가 수동으로 롤백 처리를 해야 한다.

비관적 잠금

  • 장점
    • 충돌이 발생할 경우, 트랜잭션을 롤백하여 충돌을 간단히 해결할 수 있다.
  • 단점
    • 트랜잭션 동안 데이터베이스 락을 유지하므로 다른 트랜잭션의 접근을 막아 성능 저하를 초래할 수 있다.
    • 데이터베이스에 실제 락을 걸기 때문에, 잘못된 설계로 인해 데드락(교착상태)이 발생할 수 있다.

언제 어느것을 사용해야 할까?

낙관적 락과 비관적 락을 사용하는 기준은 동시에 수정하는 일이 빈번하게 일어나는가? 이다. 그렇기 때문에 동일한 사례에서도 동시에 수정하는 일이 적다면 낙관적 락을, 동시에 수정하는 일이 많다면 비관적 락을 사용하는 것이 좋다.

 

예를 들어 1000명의 사용자가 동시에 A라는 상품을 구매할 때 “충돌이 빈번하게 일어난다”라고 생각할 수 있다. 반면 1000명의 사용자가 A라는 상품을 구매하지만 구매 시간이 각자 다를 때 “충돌이 비교적 적게 일어난다”라고 생각할 수 있다.