낙관적 락과 비관적 락은 갱신 손실 문제를 어떻게 해결하는가

해로(김선호)·2023년 6월 28일
3

동시성 제어

목록 보기
2/2
post-thumbnail

🎯 GOAL

  • Optimistic LockPessimistic Lock의 개념과 필요성을 이해한다.
  • Race Condition을 해결하기 위해, 두 방식을 트레이드 오프를 고려하여 선택할 수 있다.

들어가며

지난 포스팅에서, Java의 synchronized는 요청이 많은 경우 성능 저하가 심하고, 서버가 여러 대일 경우 갱신 손실 문제를 해결할 수 없기때문에 동시성 제어에는 적합하지 않음을 알 수 있었다. 본 포스팅에서는 이를 해결하기 위한 JPA에서 제공하는 락킹 방법인 낙관적 락비관적 락의 동작 원리와 사용 시 주의사항을 이해하고, 두 방법의 트레이드 오프를 고려하여 어떤 방법을 선택해야하는지에 대한 기준을 제시한다.


낙관적 락(Optimistic Lock)

낙관적 락이란, 트랜잭션 대부분이 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법이다. 데이터베이스에서 사용하는 실제 락을 사용하는 것은 아니고, JPA가 제공하는 버전 관리 기능을 사용한다. SQL 쿼리에서 버전 정보를 확인함으로써 실제로 데이터를 조회, 수정할 수 있는지가 결정된다.


어떻게 동작하는가

낙관적 락은 버전 정보를 이용하여 동시성을 제어한다. 예시를 통해 동작 원리를 이해해보자.

image

  • 트랜잭션 T1이 데이터를 읽는다.
  • 트랜잭션 T2가 데이터를 읽는다.
  • T1이 데이터를 수정할 때, version을 1만큼 증가 시킨다.
    • 데이터의 version은 1에서 2로 변경된다.
  • T2가 데이터를 수정하려고 한다.
    • T2가 읽어들인 데이터의 version은 1이므로, WHERE절의 조건 역시 version = 1이다.
    • 이미 T1에 의해 version이 2로 변경되었으므로, 데이터 수정에 실패하고 예외(OptimisticLockException)가 발생한다.

트랜잭션 T1이 데이터 수정을 할 때 버전을 증가시키므로, 트랜잭션 T2가 데이터를 수정할 때는 버전이 이미 달라진 상태이다. 따라서 트랜잭션 T2 커밋 시 버전이 다르므로 데이터 수정에 실패하고 예외를 발생시킴으로써 T1의 갱신 내역을 지키게 되는 것이다. (만약 예외가 발생되지 않고 T2 역시 커밋된다면 T1의 갱신 내역이 손실되는 갱신 손실 문제가 발생했을 것이다.)


어떻게 사용하는가

낙관적 락의 구현 자체는 어렵지 않다. 단, 구현 시 예외 처리를 반드시 해주어야한다. 예외 처리를 해주지 않을 경우에 생길 수 있는 문제와 어떻게 하면 효율적으로 예외 처리할 수 있는지 알아보고, JPA에서 제공하는 락 옵션을 통해 낙관적 락을 구현하는 방법을 설명한다.


낙관적 락 사용 시 주의점: 예외 처리

앞서 낙관적 락은 버전 정보를 이용한다고 했다. 따라서 낙관적 락을 사용할 때는 트랜잭션 커밋 시 버전 정보가 달라 발생하는 예외에 대해 예외처리를 해주고, 예외가 발생했던 로직을 재시도해야한다.

예외 처리를 해주지 않으면, 같은 로직을 사용하는 요청에 대해 비즈니스 예외 상황이 아님에도 불구하고 요청이 실패할 수 있게된다. 이해하기 쉽게 예를 들어보자.

  • 고객이 상품을 주문하는 상황이라고 가정한다.
  • 상품을 주문 시 상품의 재고가 감소한다.
  • 고객 A고객 B로부터 주문 요청이 들어온다.
  • 고객 A의 주문 요청은 정상적으로 처리되고 재고가 감소한다.
  • 재고가 충분히 남아있음에도(재고가 부족한 비즈니스 예외 상황이 아님에도), 버전 정보가 달라 정상적인 고객 B의 요청에 실패하게 된다.

위 상황을 도표로 나타내면 아래와 같다.

image

고객 B의 요청이 들어올 때, 재고는 충분히 남아있으므로 비즈니스 예외 상황은 아니다. 이 예외는 개발자가 낙관적 락을 사용해서 생긴 예외이므로, 고객 B의 요청이 정상적으로 수행될 수 있도록 예외가 발생했던 로직을 재수행해야하는 것이다.

예외가 발생했던 로직을 재수행하도록 구현할 때는 Facade 객체를 고려해볼 수 있다. 물론 낙관적 락을 사용하는 서비스 객체에서 try-catch를 이용할 수도 있지만,

  1. 프록시 내부 호출 문제를 방지한다.
  2. SRP를 지킬 수 있다.

는 점에서 Facade 객체 사용은 괜찮은 선택이라 볼 수 있다. Facade 객체 구현은 프록시를 구현하는 것과 비슷한데, Facade 객체부터 리포지토리 객체까지 낙관적 락이 사용되는 로직의 흐름은 다음과 같다.

image

(의존성 방향도 로직 호출 방향과 같다.)

@RequiredArgsConstructor
@Service
public class OptimisticStockServiceFacade {

  private final OptimisticStockService optimisticStockService;

  public void decreaseStock(Long id, Long quantity) throws InterruptedException {
    while (true) {
      try {
        // 낙관적 락을 사용하는 비즈니스 로직
        optimisticStockService.decreaseStock(id, quantity);
        break;
      } catch (ObjectOptimisticLockingFailureException e) {
        Thread.sleep(30);
      }
    }
  }

}

Facade 객체는위와 같이 낙관적 락을 사용하는 비즈니스 로직에 대해 예외 처리를 하는 방식으로 구현한다. 예외를 catch할 때는 스프링이 추상화 해둔 org.springframework. orm.ObjectOptimisticLockingFailureException 예외를 catch 하면 된다.

이제 어떻게 예외를 처리할지도 알아봤으니, 본격적으로 JPA에서 낙관적 락을 사용하는 방법을 알아보자.


NONE

별도의 락 옵션을 사용하지 않아도(NONE), @Version(javax.persistence.Version) 어노테이션이 적용된 필드가 존재하면 낙관적 락이 적용된다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Stock {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  private Long quantity;

  // 낙관적 락 적
  @Version
  private Long version;

  public Stock(Long id, Long quantity) {
    this.id = id;
    this.quantity = quantity;
  }

  public void decrease(Long quantity) {
    if (this.quantity - quantity < 0) {
      throw new IllegalArgumentException("재고가 부족합니다.");
    }
    this.quantity -= quantity;
  }

}

도메인 객체에 @Version이 적용된 필드 version을 선언하기만 하면 낙관적 락이 적용된다. 해당 도메인을 사용하는 로직은 다음과 같이 작성했다.

@RequiredArgsConstructor
@Service
public class OptimisticStockService {

  private final StockRepository stockRepository;

  @Transactional
  public void decreaseStock(Long id, Long quantity) {
    Stock findStock = stockRepository.findByIdWithOptimisticLock(id).orElseThrow();
    findStock.decrease(quantity);
  }

}

이제 낙관적 락이 제대로 적용되는지 확인하는 테스트를 작성하자.

@Test
@DisplayName("동시에 100개의 재고 감소 요청")
void decrease_concurrently() throws InterruptedException {
    // given
    Stock stock = new Stock(1L, 100L);
    stockRepository.save(stock);

    int threadCount = 100;
    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch countDownLatch = new CountDownLatch(threadCount);

    // when
    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {
                // 서비스 객체가 아닌 Facade 객체를 통해 로직을 수행해야한다.
                optimisticStockServiceFacade.decreaseStock(1L, 1L);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                countDownLatch.countDown();
            }
        });
    }

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

    // then
    Stock findStock = stockRepository.findById(1L).orElseThrow();
    assertThat(findStock.getQuantity()).isEqualTo(0L);
}

100개의 스레드를 만들어 각 스레드가 낙관적 락을 사용하는 로직을 호출하는 테스트이다. 이 때, 예외 처리 로직을 구현한 Facade 객체를 이용하여 낙관적 락을 사용하는 로직을 호출해야함을 기억하자.

스크린샷 2023-07-04 오후 3 31 04

테스트 결과창에 남은 쿼리 로그를 보면 동작 원리에서 설명한 쿼리와 정확히 일치하는 것을 확인할 수 있다.


JPA 락 옵션: OPTIMISTIC

OPTIMISTIC은 JPA가 제공하는 락 옵션 중 하나다. (OPTIMISTIC을 비롯한 락 옵션들은 javax.persistence.LockModeType에 정의되어있다.) Spring Data JPA를 사용하는 경우 org.springframework.data.jpa.repository.Lock 어노테이션을 이용하여 간단하게 지정 가능하다.

// Repository
public interface StockRepository extends JpaRepository<Stock, Long> {

  @Lock(value = LockModeType.OPTIMISTIC)
  @Query("select s from Stock s where s.id = :id")
  Optional<Stock> findByIdWithOptimisticLock(@Param(value = "id") Long id);

}

비즈니스 로직에서 OPTIMISTIC 옵션을 사용하도록 하고, 이전과 같은 테스트를 수행하면 역시나 PASS 결과를 얻을 수 있다.

// Service
@RequiredArgsConstructor
@Service
public class StockService {

  private final StockRepository stockRepository;

  @Transactional
  public synchronized void decreaseStock(Long id, Long quantity) {
    // 낙관적 락 사용
    Stock findStock = stockRepository.findByIdWithOptimisticLock(id).orElseThrow();
    findStock.decrease(1L);
  }

}
image

NONE Vs. OPTIMISTIC

NONEOPTIMISTIC 옵션 둘 중 아무것이나 사용해도 낙관적 락은 적용되는데, 그렇다면 도대체 그렇다면 NONEOPTIMISTIC 옵션은 어떻게 다른 것인가?

두 방식의 차이점은 예외를 발생시키는 시점이다. 먼저 NONE의 경우, 엔티티를 수정할 때 버전을 체크하고(UPDATE 쿼리 사용) 버전 정보가 다를 때 예외가 발생한다. 그러나 OPTIMISTIC의 경우 트랜잭션 커밋 시 버전 정보를 조회해서(SELECT 쿼리 사용) 현재 엔티티의 버전과 같은지 검증하고 같지 않으면 예외를 발생시킨다.

결국 OPTIMISTIC 엔티티를 조회한 시점에서 트랜잭션이 끝나는 시점까지 다른 트랜잭션에 의해서 변경되지 않음을 보장한다. 이로써, 갱신 손실 문제 뿐만 아니라 DIRTY READ, NON-REPEATABLE READ 까지 방지할 수 있는 것이다.

그렇다면 NONEOPTIMISTIC 중에는 무엇을 사용해야하는가? 물론 DIRTY READ, NON-REPEATABLE READ까지 방지한다는 면에서 OPTIMISTIC을 사용하는 것이 낫지만, 가독성 측면에서도 OPTIMISTIC을 사용하는 것이 낫다고 생각한다. 단순히 @Version 만 도메인 객체에서 사용하는 경우 서비스 객체만 보고서는 낙관적 락이 사용되는 것임을 알아차리지 못할 수도 있다. 반면에 findByIdWithOptimisticLock()처럼 락 옵션을 사용하는 것을 명시적으로 나타내면 제 3자도 낙관적 락이 사용되었음을 쉽게 알 수 있을 것이다.


어떤 단점이 있는가

개발자가 재시도 로직을 직접 작성해줘야 한다

낙관적 락의 단점 중 하나는 개발자가 직접 예외 처리를 하고, 낙관적 락을 사용하는 로직을 재시도 해야한다는 점이다. (우리가 Facade 객체를 구현하여 try-catch 로 예외 처리를 해준 것처럼 말이다.) 재시도 하는 책임을 가진 객체를 어떻게 관리해야할지도 고민이 생긴다. (낙관적 락을 사용하는 외의 로직을 호출할 때도 반드시 Facade 객체를 사용해야하는지 등)


충돌이 많아짐에 따라 비용이 증가한다

낙관적 락을 사용하면 충돌이 많아짐에 따라 쿼리를 재수행해야 되므로 그에 따른 비용이 증가한다. (여기서 충돌이 많아진다라는 것은 Race Condition(둘 이상의 스레드가 공유 데이터에 접근함으로써 생기는 문제)이 빈번하게 발생하는 것을 의미한다.) 쿼리가 단 한 번만 재수행된다고 보장할 수도 없으므로, 이런 상황에서는 낙관적 락 이외의 방법(=비관적 락)이 필요하다.


비관적 락(Pessimistic Lock)

비관적 락트랜잭션의 충돌이 발생한다고 가정하고, 데이터에 우선 락을 검으로써 데이터 정합성을 보장하는 방식이다. 데이터베이스의 락 매커니즘에 의존한다.


어떻게 동작하는가

비관적 락은 데이터베이스의 배타락을 사용한다. 아래 그림으로 이해해보자.

image

  • 트랜잭션 T1이 데이터를 조회할 때, 배타락을 건다.
    • select for update를 사용해서 락을 건다.
  • 트랜잭션 T2가 데이터를 조회하려고 했으나, 배타락이 걸려 있어 조회가 불가능하다.
    • T2는 데이터의 락이 해제될때까지 대기한다.
  • T1이 데이터를 수정하고, 커밋한다.
  • T2가 데이터를 조회할 때, 배타락을 건다.
  • T2가 데이터를 수정하고, 커밋한다.

우리가 아는 배타락의 동작 원리와 같으며, 일반적으로 비관적 락이라고 하면 JPA 락 옵션 중 PESSIMISTIC_WRITE을 사용하는 것을 의미하므로 비관적 락을 통해 DB의 배타락을 건다. 정도로 이해하면 된다.


어떻게 사용하는가

JPA 락 옵션: PESSIMISTIC_WRITE

낙관적 락과 마찬가지로 비관적 락 역시 org.springframework.data.jpa.repository.Lock 어노테이션 에 락 옵션을 지정함으로써 사용가능하다.

@Lock(value = LockModeType.PESSIMISTIC_WRITE) 
@Query("select s from Stock s where s.id = :id")
Optional<Stock> findByIdWithPessimisticLock(@Param(value = "id") Long id);

비관적 락은 낙관적 락처럼 버전 정보가 달라서 예외가 발생하거나 하지는 않으므로, Facade 객체 구현없이 바로 사용하면 된다.

// 비즈니스 로직
@RequiredArgsConstructor
@Service
public class PessimisticStockService {

  private final StockRepository stockRepository;

  @Transactional
  public void decreaseStock(Long id, Long quantity) {
    Stock findStock = stockRepository.findByIdWithPessimisticLock(id).orElseThrow();
    findStock.decrease(1L);
  }

}
// Test Code
@Test
@DisplayName("동시에 100개의 재고 감소 요청")
void decrease_concurrently() throws InterruptedException {
    // given
    Stock stock = new Stock(1L, 100L);
    stockRepository.save(stock);

    int threadCount = 100;
    ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
    CountDownLatch countDownLatch = new CountDownLatch(threadCount);

    // when
    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {
                pessimisticStockService.decreaseStock(1L, 1L);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                countDownLatch.countDown();
            }
        });
    }

    countDownLatch.await();

    // then
    Stock findStock = stockRepository.findById(1L).orElseThrow();
    assertThat(findStock.getQuantity()).isEqualTo(0L);
}
image

(비관적 락을 사용한 로직의 테스트 수행 결과)

같은 로직에서 낙관적 락을 사용했을 때보다 비관적 락을 이용했을 때 테스트 수행시간이 1초나 더 짧은 것을 알 수 있는데, 이를 통해 이렇게 충돌이 많은 경우에는 쿼리를 재수행하는 낙관적 락보다, 락을 획득할 때까지 대기해야하는 비관적 락이 적합함을 간접적으로나마 알 수 있다.


어떤 단점이 있는가

데드락 발생 가능성

비관적 락의 단점은 데이터베이스의 배타락을 사용할 때의 단점과 같다. 바로 데드락이 발생할 수 있다는 것이다. 물론 예제의 경우 하나의 Row에만 비관적 락을 적용하는 경우 데드락의 성립 조건(두 개 이상의 트랜잭션이 각자의 데이터에 대해 락을 얻고 서로의 락이 해제되기를 기다리는 상태)이 되질 않는 단순한 로직이지만, Row를 여러 개 쓰는 로직일 경우 데드락이 발생할 수 있음을 인지해야한다.


둘 중에 무엇을 사용해야하는가

지금까지 낙관적 락과 비관적 락의 동작 원리와 단점까지 살펴봤다. 그렇다면 이제 상황별로 무엇을 선택해야할지 기준을 세울 필요가 있다. 필자가 이와 관련하여 최상용 개발자님께 질문을 드렸고, 다음과 같은 답변을 받을 수 있었다.

image

요약하자면 충돌이 잦은 로직이냐, 아니냐가 선택 기준점이 된다. 충돌이 잦은 경우는 비관적 락, 충돌이 적은 경우는 낙관적 락을 고려하면 된다.

예를 들어 여러 사람이 동시에 상품을 주문하는 경우에는 비관적 락 사용을 고려하고, 여러 사람이 주문할 수는 있으나 주문하는 시간이 각자 다른 경우(ex: 고객 1은 12:01 에 주문, 고객 2는 12:02 에 주문)에는 낙관적 락 사용을 고려해볼 수 있겠다.

그런데, 과연 충돌이 많이 일어날 것인가를 어떻게 판단, 측정할 수 있을까? 물론 공연 티켓팅처럼 정해진 시간에 트래픽이 많이 몰리는 이벤트의 경우 충돌이 많이 일어날 것이라고 보는 것이 타당하다. 그런데 앞서 설명한 주문 로직의 경우 실제 트래픽을 받기전까지는 충돌이 많이 일어날지 판단하기 어렵다. 따라서 이렇게 충돌이 많이 일어날지 잘 모르는 경우에는 우선 낙관적 락을 이용하다가(비관적 락에서 사용하는 데이터베이스 락 자체가 비용이므로), 운영상에서 이로 인한 성능 이슈가 발생할 때 비관적 락이나 다른 방법을 고려해보는 것이 괜찮은 방법이 될 것이다.


마치며

갱신 손실 문제를 해결하기 위한 방법으로 낙관적 락비관적 락의 동작 원리와 선택 기준까지 알아봤다. 요약하자면 낙관적 락은 버전 정보를 이용하는 애플리케이션 레벨의 락이고, 비관적 락은 데이터베이스의 락킹 메커니즘을 이용하는 데이터베이스 레벨의 락이었다. 또, 충돌이 많이 일어날지 잘 모르는 경우에는 비용을 고려하여 낙관적 락을 우선적으로 고려해볼 수 있음을 알 수 있었다.

현재 내게 부족한 것은 정확히 어느 정도로 충돌이 많이 발생해야 비관적 락낙관적 락 보다 (비용상으로) 유리해지는가 에 대한 기준점이다. 이는 현재로서는 파악하기 어려운 부분으로 프로젝트를 해보면서 경험치를 채워나갈 예정이다. 이렇게 하나씩 탐구해 나가다 언젠가 나만의 기준이 정립되기를 기대하며, 본 포스팅은 여기서 마친다.

마침.


추가적으로 공부해볼 것

  • Pessimistic Lock에서의 데드락
  • Named Lock
  • 충돌이 잦다의 기준

Reference


잘못된 내용에 대해 언제든지 댓글로 남겨주시면 감사하겠습니다 :)

profile
Every Run, Learn Counts.

2개의 댓글

comment-user-thumbnail
2023년 8월 25일

안녕하세요! 글 너무 잘 읽었습니다. 멀티스레드 환경 관련해서 글을 읽다가 찾게 되었습니다. 혹시 멀티스레드 환경을 만들기 위해서 Executors를 사용하는데 해당 코드는 테스트 코드에만 사용하고 실제 비즈니스 로직(서비스단)에서는 멀티스레드 환경 구성을 안하는데 상관이 없나요? 아니면 제가 잘못 이해하고 있는건가요?

1개의 답글