JAVA/JPA

Pessimisitic Lock과 Optimisitic Lock

realizers 2023. 2. 12. 15:29
728x90
반응형

Pessimisitic Lock : 비관적 잠금


비관적 잠금은 여러 트랜잭션이 동일한 데이터에 동시에 접근하고 수정하는 것을 방지하는 잠금 매커니즘입니다. 이 잠금에서의 트랜잭션은 특정 데이터 항목을 읽거나 수정하기 전에 해당 데이터에 대한 잠금을 요청하고 트랜잭션이 완료될 때 잠금을 해지합니다. 
이렇게 한 번에 하나의 트랜잭션만이 데이터에 접근하고 수정할 수 있으므로 데이터 불일치 및 데이터 충돌 문제를 해소할 수 있습니다. 하지만 교착 상태에 빠질 우려가 있으니 주의가 필요합니다. 

 

💡 비관적 잠금의 과정

비관적 잠금의 과정

💡 비관적 잠금의 교착 상태

아래 순서에 따르면 스레드 A는 영원히 스레드 B에 대한 비관적 잠금을 구할 수 없습니다. 그 이유는 스레드 B가 B 에그리게이트에 대해 잠금을 이미 선점하고 있기 때문입니다. 이렇게 두 스레드는 계속하여 서로를 기다리게 되며, 결국 교착상태에 빠지게 됩니다. 
비관적 잠금의 교착 상태는 상대적으로 사용자 수가 많을 때 발생할 가능성이 높으며, 사용자가 많아지면 교착 상태에 빠지는 스레드는 더 빠르게 증가합니다. 이러한 문제가 발생하지 않도록 하기 위해서는 잠금을 구할 때 최대 대기 시간을 지정해야 합니다.

 

상황

  1. 스레드 A : A 에그리게이트에 대한 비관적 잠금 구함
  2. 스레드 B : B 에그리게이트에 대한 비관적 잠금 구함
  3. 스레드 A : B 에그리게이트에 대한 비관적 잠금 시도
  4. 스레드 B : A 에그리게이트에 대한 비관적 잠금 시도

 

Type


💡 배타적 락(Exclusive Lock, Write Lock)

 

  • 데이터를 변경할 때 사용하는 Lock이며, A라는 사람이 데이터에 접근하고 있다면 다른 사람은 해당 데이터에 대해 접근 및 쓰기, 수정이 불가능 합니다. 
  • 데이터에 공유 락이 하나라도 걸려있으면 배타적 락을 걸 수 없습니다.

💡 공유 락(Shared Lock, Read Lock)

 

  • 데이터를 읽을 때 사용하는 Lock이며, A라는 사람이 데이터에 접근하고 있다면 다른 사람은 해당 데이터에 접근은 할 수 있지만 변경 작업은 불가능 합니다.
  • 데이터에 해당 공유 락이 걸려있으면 배타적 락을 걸 수 없습니다.

 

Mode


💡 PESSIMISTIC_READ

 

  • 공유 락(Shared Lock)을 얻을 수 있으며, 데이터에 대해 읽을 수는 있지만 변경 작업은 불가능 합니다.
  • 쿼리문에 select ... for share 구문이 추가됩니다.

💡 PESSIMISTIC_WRITE

 

  • 배타적 락(Exclusive Lock)을 얻을 수 있으며, 사용중인 데이터에 대해 읽거나 변경 작업을 할 수 없습니다.
  • 쿼리문에 select ... for update 구문이 추가됩니다.

💡 PESSIMISTIC_FORCE_INCREMENT

 

  • 낙관적 락처럼 버전 정보를 사용하며, 버전 정보를 강제로 증가시킵니다. 하이버네이트는 nowait를 지원하는 데이터 베이스에 대해 
    for update nowait 옵션을 적용합니다.

 

Lock Scope


💡 EXTENDED

 

  • 연관된 엔티티 객체들도 Lock이 걸립니다.

💡 NORMAL

 

  • 해당 엔티티만 Lock이 걸립니다.

 

코드로 알아보기


💡 Lock을 사용하지 않은 코드

 

  • Lock을 사용하지 않고 상품의 재고를 차감하였을 경우 race condition이 발생하여 상품의 재고가 제대로 감소하지 않는 상황이 발생합니다. 그렇기 때문에 테스트는 실패를 하게 됩니다.
@Getter
@Entity
@Table(name = "product")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {

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

    @Comment(value = "재고")
    @Column(name = "stock", nullable = false)
    private Integer stock;

    public void decreaseStock(Integer productBuyQuantity) {
        if (productBuyQuantity == null || this.stock < productBuyQuantity) {
            throw new IllegalArgumentException();
        }
        this.stock -= productBuyQuantity;
    }
}

@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductDao {

    private final JPAQueryFactory queryFactory;

    public Product findByIdx(Long productIdx) {
        return queryFactory
                .selectFrom(product)
                .where(
                        product.idx.eq(productIdx)
                )
                .fetchOne();
    }
}

@Service
@Transactional
@RequiredArgsConstructor
public class ProductService {

    private final ProductDao productDao;

    public void decreaseStock(int stock) {
        Product product = productDao.findByIdx(1L);
        product.decreaseStock(stock);
    }
}

// 테스트 실행시 실패
@Test
void 동시에_재고_차감() throws InterruptedException {
    int threadCount = 1000;
    CountDownLatch latch = new CountDownLatch(threadCount);
    for (int i = 0; i < threadCount; i++) {
        CompletableFuture.runAsync(() -> {
            try {
                productService.decreaseStock(10);
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();

    Product product = productDao.findByIdx(1L);
    assertThat(product.getStock()).isEqualTo(0);
}

 

💡 PESSIMISTIC_READ LOCK를 사용한 테스트

 

  • RESSIMISTIC_READ 모드로 작동을 하면 해당 데이터에 대해 읽기 작업은 가능하나 변경 작업은 불가능하기에 그럼 각 스레드마다 읽었다가 변경할때는 이전 스레드가 완료되면 기다렸다가 변경 작업이 들어가지 않을까? 라고 생각을 했었는데 실상은 그렇게 동작을
    하지 않았습니다. N개의 스레드가 동시에 접근하므로 함께 읽었다가 몇 개의 스레드만 커밋이 해제된 이후에 우연치 않게 접근을 하게되어 재고가 차감되었습니다. 그렇기 때문에 테스트는 실패하게 되었습니다.
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductDao {

    private final JPAQueryFactory queryFactory;

    public Product findByIdx(Long productIdx) {
        return queryFactory
                .selectFrom(product)
                .where(
                        product.idx.eq(productIdx)
                )
                .setLockMode(LockModeType.PESSIMISTIC_READ) // << 변경된 부분
                .fetchOne();
    }
}

@Test
void 동시에_재고_차감() throws InterruptedException {
    int threadCount = 1000;
    CountDownLatch latch = new CountDownLatch(threadCount);
    for (int i = 0; i < threadCount; i++) {
        CompletableFuture.runAsync(() -> {
            try {
                productService.decreaseStock(10);
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();

    Product product = productDao.findByIdx(1L);
    assertThat(product.getStock()).isEqualTo(0);
}

 

💡 PESSIMISTIC_WRITE LOCK를 사용한 테스트

 

  • PESSIMISITIC_WRITE 모드로 작동을 하면 하나의 스레드가 데이터를 얻으면 잠금이 걸려 다른 스레드는 블로킹 상태가되어 접근을 할 수 없으므로 정상적으로 재고를 차감시킬 수 있습니다.
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductDao {

    private final JPAQueryFactory queryFactory;

    public Product findByIdx(Long productIdx) {
        return queryFactory
                .selectFrom(product)
                .where(
                        product.idx.eq(productIdx)
                )
                .setLockMode(LockModeType.PESSIMISTIC_WRITE) // << 변경된 부분
                .fetchOne();
    }
}

@Test
void 동시에_재고_차감() throws InterruptedException {
    int threadCount = 1000;
    CountDownLatch latch = new CountDownLatch(threadCount);
    for (int i = 0; i < threadCount; i++) {
        CompletableFuture.runAsync(() -> {
            try {
                productService.decreaseStock(10);
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();

    Product product = productDao.findByIdx(1L);
    assertThat(product.getStock()).isEqualTo(0);
}

 

💡 OPTIMISTIC_FORCE_INCREMENT를 사용한 테스트

 

  • 엔티티에 버전 정보를 알려주는 @Version 어노테이션을 추가하였습니다.@Version 어노테이션이 붙은 필드의 엔티티는 자동으로 낙관적 락이 적용된다고 합니다. 
public class Product {

    @Version
    private Long versionNo; // << 변경된 부분
}

@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductDao {

    private final JPAQueryFactory queryFactory;

    public Product findByIdx(Long productIdx) {
        return queryFactory
                .selectFrom(product)
                .where(
                        product.idx.eq(productIdx)
                )
                .setLockMode(LockModeType.OPTIMISTIC_FORCE_INCREMENT) // << 변경된 부분
                .fetchOne();
    }
}

// 테스트 실행시 실패
@Test
void 동시에_재고_차감() throws InterruptedException {
    int threadCount = 1000;
    CountDownLatch latch = new CountDownLatch(threadCount);
    for (int i = 0; i < threadCount; i++) {
        CompletableFuture.runAsync(() -> {
            try {
                productService.decreaseStock(10);
            } catch (ObjectOptimisticLockingFailureException e) {
                System.out.println("충돌 감지"); // << 변경된 부분
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();

    Product product = productDao.findByIdx(1L);
    assertThat(product.getStock()).isEqualTo(0);
}

 

비관적락의 장단점


💡 비관적 락의 장점

 

  • 트랜잭션이 시작된 순간부터 충돌을 방지할 수 있습니다.

💡 비관적 락의 단점

 

  • 잠금 시간이 길면 성능에 대한 문제 및 교착 상태가 발생할 수 있습니다.
  • 애플리케이션의 확장성에 영향을 미칩니다.
  • 모든 데이터 베이스가 지원하지 않으므로 사전에 파악해야 합니다.

 

Optimisitic Lock : 낙관적 잠금


낙관적 잠금이란 데이터에 대해 잠금을 사용하지 않는 방법입니다. version이라는 속성 값을 두어 commit할 때 버전 체크를 하여 검증합니다. JPA에서는 @Version 어노테이션을 사용할 수 있으며 엔티티 클래스에는 하나의 @Version 어노테이션만 있어야하며, 여러 테이블에 매핑된 엔티티의 경우 기본 테이블에 배치되어야 합니다. 또한 버전은 int, Integer, long, Long, short, Short, java.sql.Timestamp 중 하나의 타입이어야 합니다.

 

Mode


💡 NONE

 

  • 락 옵션을 적용하지 않아도 엔티티에 @Version이 적용된 필드만 있으면 낙관적 락이 적용됩니다.

💡 OPTIMISTIC

 

  • @Version만 적용했을 경우 엔티티를 수정해야 버전을 체크하지만 이 옵션을 추가하면 엔티티를 조회만 해도 버전을 체크합니다. 
    이것을 사용하면 한 번 조회한 엔티티는 트랜잭션이 종료될 때까지 다른 트랜잭션에서 변경하지 않음을 보장합니다.

💡 OPTIMISTIC_FORCE_INCREMENT

 

  • 낙관적 락을 사용하면서 버전 정보를 강제로 증가시킵니다.

 

낙관적락의 장단점


💡 낙관적 락의 장점

 

  • 락이 발생하지 않을거라 생각하므로 잠금이 필요하지 않습니다.
  • 락을 사용하지 않으므로 교착상태가 발생하지 않습니다. 그렇기 때문에 성능에 영향을 미치지 않습니다.

💡 낙관적 락의 단점

 

  • 버전 또는 타임스탬프를 유지 관리 해야합니다.
  • 동시성 이슈 발생시 수동적으로 처리해야 합니다.

 

 

참고자료

 

 

 

 

 

 

 

 

728x90
반응형