spring / / 2023. 5. 31. 09:33

Spring Data Jpa에서 비관적 락(Pessimistic Lock) 사용법

개요

데이터베이스에서 데이터를 조회할 때 다른 작업이 진행중인 작업에 영향을 미치지 못하도록 락을 걸 필요가 있다. 이럴 경우 트랜잭션 격리 레벨을 설정하거나 필요한 데이터에 락을 거는 두 가지의 방법으로 동시성 이슈를 처리할 수 있다.

격리 레벨은 커넥션이 생성될 때 설정이 되고 모든 실행에 영향을 미친다. 하지만 다른 트랜잭션에서 특정 데이터를 수정 및 삭제하지 못하도록 비관적 락을 사용할 수 있다.

두 가지 유형의 락이 있다. (배타락, 공유락)

공유 락을 가지고 있을 때는 읽을 수는 있지만 쓰지는 못한다. 데이터를 쓰거나 삭제하기 위해서는 배타락을 가지고 있어야 한다.
'SELECT ... FOR UPDATE' 구문을 사용하여 배타락을 가질 수 있다.

Lock 모드

JPA는 3가지 비관적 락 모드를 정의한다.

  • PESSIMISTIC_READ: 공유 락을 얻고 데이터가 수정되지 않게 한다.
  • PESSIMISTIC_WRITE: 배타 락을 얻고 읽기/수정을 못하게 한다.
  • PESSIMISTIC_FORCE_INCREMENT: PESSIMISTIC_WRITE와 같이 동작하고 version 속성을 증가시킨다.

예제

다음은 PESSIMISTIC_WRITE을 사용하여 Lock을 거는 예제이다.

기본적인 시나리오는 아래와 같다.

  1. 하나의 상품을 등록하고 상품의 재고는 100개로 설정한다.
  2. 5명의 사용자가 동시에 해당 상품을 구매한다.
  3. 상품의 재고는 995개가 남아야 한다.

Lock을 사용하지 않는 케이스

위의 시나리오를 만들기 위해서 Spring Boot로 하나의 애플리케이션을 만들 것이다.
DB는 H2를 사용하고 Spring Data Jpa로 저장하며 RestController로 API를 생성할 것이다.

pom.xml 설정

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

Entity 클래스

@Entity
@NoArgsConstructor
@Getter
public class ProductJpo {

    @Id
    private long id;

    private String name;

    private long stock;

    public ProductJpo(Product product) {
        this.id = product.getId();
        this.name = product.getName();
        this.stock = product.getStock();
    }

    public Product toDomain() {
        return new Product(
            this.id,
            this.name,
            this.stock
        );
    }
}

Repository 클래스

@Repository
public interface ProductRepository extends JpaRepository<ProductJpo, Long> {
}

Service 클래스

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    public Product register(Product product) {
        productRepository.save(new ProductJpo(product));

        return product;
    }

    public Product find(long id) {
        return productRepository.findById(id)
            .map(ProductJpo::toDomain)
            .orElseThrow((() -> new IllegalArgumentException("Product not found: " + id)));
    }

    @Transactional // Lock을 사용할 때는 @Transactional을 사용해야 한다.
    public long buy(long id) {
        Product product = find(id);
        product.buy();

        productRepository.save(new ProductJpo(product));

        return product.getStock();
    }
}

도메인 클래스

@NoArgsConstructor
@AllArgsConstructor
@Getter
public class Product {

    private long id;

    private String name;

    private long stock;

    public void buy() {
        if (this.stock <= 0) {
            throw new IllegalArgumentException("재고 없음");
        }
        this.stock = this.stock - 1;
    }
}

컨트롤러 클래스

@RestController
@RequiredArgsConstructor
@RequestMapping("products")
public class ProductResource {

    private final ProductService productService;

    @PostMapping
    public Product register(@RequestBody Product product) {
        return productService.register(product);
    }

    @GetMapping("{id}")
    public Product find(@PathVariable long id) {
        return productService.find(id);
    }

    @PutMapping("{id}")
    public long buy(@PathVariable long id) {
        return productService.buy(id);
    }
}

스프링 애플리케이션

@SpringBootApplication
public class PessimisticLockApplication {

    public static void main(String[] args) {
        SpringApplication.run(PessimisticLockApplication.class, args);
    }
}

위와 같이 소스를 작성하고 스프링 애플리케이션을 시작하자.

1. 기본 테스트 진행

  1. 상품을 100개 등록
  2. 5개를 동시에 구입
  3. 남은 재고가 몇개인지 확인
1. 상품 등록
POST http://localhost:8080/products
content-Type: application/json

{
  "id": 1,
  "name": "아이패드",
  "stock": 100
}
2. 5개를 동시에 구입

5개를 동시에 구입하기 위해 5개의 thread로 실행을 해도 되지만 여기서는 curl을 사용하여 한번에 병렬로 요청한다.

구입하는 API는 아래와 같다.

PUT http://localhost:8080/products/1

5개를 동시에 구입하기 위해서 curl명령어를 &로 연결하여 호출한다.

curl -X PUT http://localhost:8080/products/1 & curl -X PUT http://localhost:8080/products/1 & curl -X PUT http://localhost:8080/products/1 & curl -X PUT http://localhost:8080/products/1 & curl -X PUT http://localhost:8080/products/1
5. 남은 재고 확인
GET http://localhost:8080/products/1

위의 API를 호출해보면 결과는 아래와 같다.

{
  "id": 1,
  "name": "아이패드",
  "stock": 96
}

실행할 때마다 98, 97, 96 등 다른 값이 출력이 된다.

100개에서 5명이 구입했으니 95개가 나와야 정상이지만 비정상적인 값이 나온다.

여러 쓰레드를 비관적인 락을 사용하여 이 문제를 해결해보자.

2. Lock을 사용한 케이스 (비관적 락)

비관적 락을 사용하기 위해서 Repository에 PESSIMISTIC_WRITE를 추가한다.

@Repository
public interface ProductRepository extends JpaRepository<ProductJpo, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select p from ProductJpo p where p.id = :id")
    ProductJpo findForUpdate(long id);

}

PESSIMISTIC_WRITE을 통해서 읽기/쓰기에 대해 락을 건다는 의미이다. 해당 트랜잭션이 종료될 때까지는 읽기조차 허용되지 않는다.

Service 클래스에서 buy 메소드에서 find를 사용해서 조회했던 부분을 findForUpdate로 변경하자.

@Transactional
  public long buy(long id) {
      Product product = findForUpdate(id); // 여기서 락이 걸림
      product.buy();

      productRepository.save(new ProductJpo(product));

      return product.getStock();
  }

이렇게 하면 비관적 락이 다 된것이다. 이제 테스트를 해보자.

테스트 진행

  1. 상품을 100개 등록
  2. 5개를 동시에 구입
  3. 남은 재고가 몇개인지 확인
1. 상품 등록
POST http://localhost:8080/products
content-Type: application/json

{
  "id": 1,
  "name": "아이패드",
  "stock": 100
}
2. 5개를 동시에 구입
curl -X PUT http://localhost:8080/products/1 & curl -X PUT http://localhost:8080/products/1 & curl -X PUT http://localhost:8080/products/1 & curl -X PUT http://localhost:8080/products/1 & curl -X PUT http://localhost:8080/products/1
3. 남은 재고 확인
GET http://localhost:8080/products/1

위의 API를 호출해보면 결과는 아래와 같다.

{
  "id": 1,
  "name": "아이패드",
  "stock": 95
}

몇 번을 실행해도 동일하게 95개로 나온다.

참고

반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유