Spring

DB 낙관적 락

코징 2024. 8. 12. 23:45

이전에 카프카 동시성 이슈에 대해 작성하였습니다.

글을 작성하면서 찾았던 낙관적 락, 비관적 락, 분산락에 대해 설명하고, 기록하도록 하겠습니다.

동시성 테스트

아래 코드는 동시성 테스트를 위한 코드입니다.

큰 특징은

  1. newFixedThreadPool
    • 해당하는 함수로 Java의 쓰레드를 생성하는 함수로써, 10개 스레드를 생성하였습니다.
  2. CountDownLatch
    • new CountDownLatch(THREAD_COUNT) : 스레드 작업 카운트를 적시하여, 차후에 함수에 사용 합니다.
    • latch.await(10, TimeUnit.SECONDS) : 위에 적시한 스레드 카운트가 0 이 될 때까지 기다리고 이후에 작업을진행 합니다.
package com.webtoonrank.demo.core.domain.product.service;


@SpringBootTest
public class ProductServiceTest {

    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

    private static final int THREAD_COUNT = 10;

    @BeforeEach
    public void setup() {
        ProductEntity product = new ProductEntity("Test Product", 20);
        productRepository.save(product);
    }

    @Test
    public void testConcurrentStockDecrease() throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);
        CountDownLatch latch = new CountDownLatch(THREAD_COUNT);

        ProductEntity productEntity = productRepository.findAll().get(0);
        Long productId = productEntity.getId();

        for (int i = 0; i < THREAD_COUNT; i++) {
            executorService.submit(() -> {
                try {
                    boolean success = productService.decreaseStock(productId, 1);
                    System.out.println("Stock decreased: " + success);
                } catch (RuntimeException runtimeException) {
                    System.out.println(runtimeException.getMessage());
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await(10, TimeUnit.SECONDS);

        ProductEntity product = productRepository.findById(productId).orElseThrow();
        System.out.println("Final stock: " + product.getStock());
        assertThat(product.getStock()).isEqualTo(20 - THREAD_COUNT);
    }
}

상품 ProductEntity로 상품의 재고를 세팅 한 후 decreaseStock을 통하여 재고를 줄이는 로직입니다.

    package com.webtoonrank.demo.storage;

import jakarta.persistence.*;
import lombok.Getter;

@Entity
@Getter
public class ProductEntity {

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

    private String name;
    private int stock;

    public ProductEntity(String name, int stock) {
        this.name = name;
        this.stock = stock;
    }

    public ProductEntity() {

    }

    public boolean decreaseStock(int quantity) {
        if (this.stock >= quantity) {
            this.stock -= quantity;
            return true;
        } else {
            return false;
        }
    }
}

일반적으로 생각했을 때 스레드 개수만큼 productService.decreaseStock(productId, 1); 로 차감하여 [초기 재고 수량 20 - 스레드의 카운트] 만큼 나올 것이라고 기대합니다. 하지만 결과는 아래와 같습니다.

이렇게 나오는 이유는 아래와 같습니다.

  • 즉 데이터가 커밋 되기 전에 조회되고, 재고의 카운트가 20으로 인지하여 동시성 이슈가 발생합니다.

낙관적 락을 사용한 동시성 문제 해결

  1. 낙관적 락(Optimistic Lock)
    • 데이터 충돌 가능성이 낮다고 생각하고, 트랜잭션이 완료될 때까지 잠금을 걸지 않고 데이터를 처리하는 방법입니다.
    • 위 그림을 보면 사용자 1은 update 시점에 version = 1을 찾고 업데이트를 통해서 version = 2로 올린 것을 확인할 수 있습니다. 나머지 유저들은 version = 1을 찾고 2로 업데이트를 하려고 하지만, 사용자 1에서 version을 2로 업데이트했기 때문에 Exception이 발생 되어 롤백이 진행이됩니다.

[정리하자면]

  1. 사용자 1 : update 성공 version = 2로 업데이트
  2. 사용자 2 : update 실패 version =1을 사용자 1에서 업데이트했기 때문에 찾을 수 없어서 Exception 발생
  3. 사용자 3 : update 실패 version =1을 사용자 1에서 업데이트했기 때문에 찾을 수 없어서Exception 발생
  4. 사용자 4 : update 실패 version =1을 사용자 1에서 업데이트했기 때문에 찾을 수 없어서Exception 발생
    이제 사용자 1인 원하는 주문을 성공하였고, 나머지 유저들은 다시 시도하면서 데이터의 일관성을 유지하면서 주문을 진행할 수 있습니다.

그렇다면 낙관적 락은 왜 사용하는 것일까?

  • 버전을 통해서 동시성을 해결하고, 트랜잭션이 완료될 때까지 락을 걸지 않기 때문에, 데이터베이스의 락 경합이 줄어들어 전체 시스템의 성능이 향상됩니다.

낙관적 락은 언제 사용하면 좋은가?

  • 읽기 로직이 많고 쓰기 로직이 적을 때 사용하는 게 좋습니다. 이유는 위에 언급한 것처럼 애플리케이션 레벨에서 version을 통해서 관리하고, update하는 시점에 version을 갱신하여 일치하는 트랜잭션만 처리되고 나머지는 Exception을 통해 롤백이 되기 때문입니다.

[실습]

  • JPA에서 낙관적 락 사용법
    • @Version
      private Integer version; 만 선언하면 JPA에 자동으로 업데이트 시 값을 수정해 줍니다.
@Entity
@Getter
public class ProductEntity {

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

    private String name;
    private int stock;

    @Version
    private Integer version;

    ....

    public boolean decreaseStock(int quantity) {
        if (this.stock >= quantity) {
            this.stock -= quantity;
            return true;
        } else {
            return false;
        }
    }
}

아래 테스트 코드를 보면

  • org.springframework.orm.ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.webtoonrank.demo.storage.ProductEntity#1] 를 통해서 트랜잭션이 실패되고, 롤백되는 것을 확인할 수 있습니다.

오늘은 낙관적 락에 대해 설명하였습니다.

직접 테스트 코드를 작성하면서 확인하였고, 낙관적 락을 언제 사용하면 좋을지 알 수 있었습니다.

한 가지 주의해될 점은 데드락 상황이 다음과 같이 발생할 수 있습니다.

  1. 트랜잭션 ATable1의 행을 수정하면서 Table2의 참조 무결성을 확인하기 위해 Table2의 잠금을 요청합니다.
  2. 트랜잭션 B가 동시에 Table2의 행을 수정하면서 Table1의 참조 무결성을 확인하기 위해 Table1의 잠금을 요청합니다.
  3. 이때 트랜잭션 A와 트랜잭션 B는 각각 상대방이 보유한 락을 기다리게 되어 데드락 상태에 빠질 수 있습니다.

오늘은 낙관적 락에대해 설명하게 되었습니다.

  • 언제 사용하면 좋을지?
  • 왜 사용하는지?
  • 주의해야 될 점
  • 사용방법

에 대해 알아보았으며, 추후에 비관적락, 분산락에 대해서도 설명하도록 하겠습니다.

감사합니다.

'Spring' 카테고리의 다른 글

상반기 회고 및 Spring신규 프로젝트 패키지 구조  (0) 2024.08.10