'Java/JPA'에 해당되는 글 2건

  1. 2024.01.07 JPA 오해와 진실(트랜잭션 관점) 2
  2. 2019.05.29 JPA 잠금(Lock) 이해하기
Java/JPA2024. 1. 7. 16:43
반응형

개요

최근 JPA를 사용하고 있어서 재고가 틀어지는 문제가 자주발생한다라는 이야기를 들었습니다. JPA의 트랜잭션 관점에서관 오해하고 있는 부분이 있는것 같아서 그 부분을 바로잡고자 관련 내용을 정리해 보았습니다. 트랜잭션 관점이라고는 하지만 최대한 기술적인 부분을 배제하고 쉽게 설명해보도록 하겠습니다.

전제

이 글에서 사용되는 예제에서 DB의 격리수준은 일반적으로 많이 사용되는 READ COMMITTED를 전제로 작성되었습니다. 다른 격리수준의 설정에서는 동작이 다를 수 있습니다. 또한 JPA 예제코드는 일반적으로 많이 사용하는 하이버네이트를 기준으로 작성되었습니다.

JPA기본 사용예

아래와 같은 재고 엔터티를 가정해봅시다.(극단적으로 단순화한 형태입니다.)

@Entity
public class Inventory {

    @Id
    @Column(name = "sku_code")
    private String skuCode;

    //재고수량
    @Column(name = "qty", nullable = false)
    private int qty;
    //...
}

주문이 발생해서 재고를 차감할 경우, 재고수량을 꺼내서 차감이 가능한지 확인한 후 가능한 경우에는 재고수량으로 부터 주문할 수량을 차감합니다.

if (inventory.getQty() >= 1) {
    inventory.setQty(inventory.getQty() - 1);
} else {
    //예외처리
}

이 코드는 잠재적으로 위에서 언급한 '재고가 틀어지는 문제'가 발생할 가능이 높습니다. 예를 들어 주문자 A가 주문하는 시점에 읽어들인 재고 엔터티의 재고가 10이고 주문자 B도 동시에 재고데이터를 읽어들인다면 동일하게 10일 것입니다. 상기 코드를 통해서 A는 2개 B는 3개를 차감했다면 결과는 어떻게 될까요? 기대하는 과는 5지만 실제 결과는 처리순서에 따라 8 혹은 7이 될 것입니다. 기본적으로 JPA는 영속컨텍스트에 한번 읽어들인 엔터티를 적재해두고 요청이 끝날때까지 계속 사용하므로 앞선 처리의 결과가 반영되지 않습니다.

UPDATE inventory SET qty = 8 WHERE sku_code = 'SKU1'; 
UPDATE inventory SET qty = 7 WHERE sku_code = 'SKU1'; 

위와같은 쿼리가 실제로 JPA에 의해서 발행되게 되어 최종적으로 7로 업데이트가 될 것 입니다. 이것이 잘 아시는 lost update(잃어버린 업데이트)문제라고 할 수 있습니다.

SQL 사용예

다음으로는 SQL을 직접사용하여 재고 차감하는 코드를 살펴보도록 하겠습니다.

//주문자 A
SELECT sku_code, qty FROM inventory WHERE sku_code = 'SKU1'; 
//주문자 B
SELECT sku_code, qty FROM inventory WHERE sku_code = 'SKU1'; 

쿼리결과

|   sku_code |            qty  |
|------------|-----------------|
|     SKU1   |              10 |

위와같은 쿼리를 작성하여 실행시켜 재고수를 취득하게 되는데 동일한 시점에 쿼리가 실행될 경우 동일하게 10이 취득됩니다. 이것을 주문수량에 맞게 차감하는 쿼리를 작성할 경우 아래와 같습니다.

//주문자 A
UPDATE inventory SET qty = 10 - 2 WHERE sku_code = 'SKU1'; 
//주문자 B
UPDATE inventory SET qty = 10 - 3 WHERE sku_code = 'SKU1'; 

상기의 조회 및 업데이트 쿼리는 위에서 엔터티를 조회 및 재고를 업데이트할때 발생하는 쿼리와 본질적으로 동일한 SQL입니다. 따라서 동일하게 lost update(잃어버린 업데이트)문제가 발생할 가능성이 있습니다.

SQL 사용시 해결방법

상기에 기술한 대로 JPA와 SQL을 직접 사용하는 방법 모두 동일한 문제가 발생할 가능성이 있다는 점을 알 수 있습니다. 각각의 경우에 해결할 수 있는 방법에 대해서 알아보도록 하겠습니다.

SQL 사용시 해결방법1

SQL사용시에는 아래와 같이 UPDATE문에 현재 값을 참조하여 차감 처리를 하도록 간단하게 기술할 수 있습니다.

//주문자 A
UPDATE inventory SET qty = qty - 2 WHERE sku_code = 'SKU1'; 
//주문자 B
UPDATE inventory SET qty = qty - 3 WHERE sku_code = 'SKU1'; 

처리후 결과

|   sku_code |            qty  |
|------------|-----------------|
|     SKU1   |              5  |

이전과는 다르게 예상했던대로 값이 업데이트 되었습니다. 하지만 위의 방법에는 치명적인 문제가 있습니다. 바로 잔여수량을 고려하여 재고의 차감이 일어나고 있지 않다는 점입니다.

SQL 사용시 해결방법2

위와같은 문제점을 인지한 개발자는 작성할 UPDATE문에 필요한 잔여수량 조건을 추가해서 UPDATE문을 실행하도록 코드를 수정합니다.

//주문자 A
UPDATE inventory SET qty = qty - 2 WHERE sku_code = 'SKU1' AND qty >= 2; 
//주문자 B
UPDATE inventory SET qty = qty - 3 WHERE sku_code = 'SKU1' AND qty >= 3;
//주문자 C
UPDATE inventory SET qty = qty - 6 WHERE sku_code = 'SKU1' AND qty >= 6;

위의 조건에 의해 마지막 업데이트문이 실행되는 시점에 수량이 5이므로 조건을 만족하지 못해서 실행결과는 업데이트 대상이 없다는 결과가 돌아오게 되고 그걸 바탕으로 예외처리를 하게됩니다.

해결방법의 문제점1

위와같이 SQL문에 조건을 추가하는 방법을 이용하여 문제점을 해결한 후 간헐적으로 재고 차감하는 부분에 정체가 있다는 리포트를 접하게 됩니다. 로그를 확인한 결과 문제가 되는 시점에 아래와 같은 업데이트문이 발행되고 있는 것을 확인했습니다.

//주문자 A
UPDATE inventory SET qty = qty - 2 WHERE sku_code = 'SKU1' AND qty >= 2; 
UPDATE inventory SET qty = qty - 3 WHERE sku_code = 'SKU2' AND qty >= 3;

//주문자 B
UPDATE inventory SET qty = qty - 3 WHERE sku_code = 'SKU2' AND qty >= 3;
UPDATE inventory SET qty = qty - 2 WHERE sku_code = 'SKU1' AND qty >= 2;

위와같이 동일한 상품들을 주문자 A와 B가 동시에 주문할려고 했을때 문제가 되는 부분을 확인했습니다. 여기서 문제가 되는 부분은 바로 UPDATE문의 순서에 있습니다. 일반적인 RDB의 경우 UPDATE문을 실행하게 되면 내부적으로 해당 ROW에 락을 취득하는 처리를 한 후에 업데이트 처리를 하게 됩니다. 이때 주문자 A에 의해서 SKU1의 ROW에 락을 취득하고 SKU2의 ROW에 락을 취득하려는 순간 주문자 B는 SKU2의 ROW에 락을 취득하고 SKU1의 ROW에 락을 취득하려고 시도합니다. 이 경우에 주문자 A 와 B 각각 상대가 가진 락을 취득하지 못해서 다음처리를 진행하지 못하는 상태에 빠지게 됩니다. 이것을 데드락(Deadlock)이라고 합니다.

*요즘 RDB는 데드락 감지(deadlock detection) 기능을 가지고 일정시간후에 해당 트랜잭션을 강제로 종료시키기도 합니다.

위의 경우에 일정한 룰을 이용해서 동일한 순서로 락을 취득하도록 수정하여 문제를 해결 할 수 있습니다. 이경우에는 SKU코드를 기준으로 정렬해서 UPDATE문을 발행하는 것으로 해결 할 수 있습니다.

//주문자 A
UPDATE inventory SET qty = qty - 2 WHERE sku_code = 'SKU1' AND qty >= 2; 
UPDATE inventory SET qty = qty - 3 WHERE sku_code = 'SKU2' AND qty >= 3;

//주문자 B
UPDATE inventory SET qty = qty - 2 WHERE sku_code = 'SKU1' AND qty >= 2;
UPDATE inventory SET qty = qty - 3 WHERE sku_code = 'SKU2' AND qty >= 3;

해결방법의 문제점2

문제를 해결하고 안심한 것도 잠시 재고의 변동을 이력으로 보고싶습니다.라는 요구사항이 올라옵니다. 그래서 이력을 저장하려고 코드를 수정하려고 보니 문제점이 있습니다. 바로 실제 잔여수량 조회가 UPDATE문 내부에 있다는 점입니다. UPDATE문이 실행되는 시점에 확인하도록 되어있어 쿼리가 실행될때 내부에서만 확인이 가능하여 프로그램상으로 확인할 방법이 없다는 점이 확인되었습니다. 이경우 보통 프로그램 수정이 힘들다고 생각되어 DB에서 제공하는 트리거등을 이용해서 별도의 테이블에 이력을 남기거나 하는 선택을 하는 경우가 많이 있습니다. 이러한 DB에서 제공하는 기능을 이용하게 되면 사용하는 DB에 의존적이되어 원하는 타이밍에 이력을 남길수 없는 등 유지보수가 힘들어 지거나 혹은 DB가 아닌 단순히 로그파일에 출력하려고 했는데 DB를 다시 읽어서 조회하거나 하는 불편함이 생기게 됩니다.

SQL 사용시 근본적인 해결책

문제점을 인지한 개발자는 프로그램상에서 잔여재고를 확인하는 방안을 강구하기 위해 명시적으로 락을 취득하기로 하고 조회 쿼리를 아래와 같이 수정합니다.

//주문자 A
SELECT sku_code, qty FROM inventory WHERE sku_code = 'SKU1' FOR UPDATE;
//주문자 B
SELECT sku_code, qty FROM inventory WHERE sku_code = 'SKU1' FOR UPDATE;

위와 같이 명시적으로 락을 취득하게 되면 주문자 A에 의해서 조회후에 주문자 B가 락을 취득하려고 했을때 주문자 A의 트랜잭션이 끝날때까지 대기하게 되고 트랜잭션 종료이후에 조회 결과가 반환되게 됩니다. 따라서 위에서 기술했던 잔여재고 확인하는 코드를 프로그램상으로 다시 가져올 수 있게 됩니다. 예에서는 단순하게 수량만 확인하게 되어있었는데 다른 다양한 조건을 프로그램으로 기술할 수 있게 되는 장점도 있습니다. FOR UPDATE 키워드를 통해 명시적으로 락을 취득하면서 얻은 결과에 있는 재고수를 사용하는 것을 항상 의식해야 하는점에 주의해야합니다.

*락 취득후에 복잡한 처리를 넣게 되면 다음사용자가 락취득할때까지 대기하는 시간이 길어질 수 있으므로 락취득후의 처리는 최대한 간결하게 해야하고 또한 락 취득자체도 해당 트랜잭션의 최대한 늦은타이밍에 이루어질 수 있도록 신중하게 코드를 작성해야합니다. 스프링을 사용하는 경우라면 Before Commit 페이즈까지 지연해서 동작하도록 코드를 작성하는 것을 추천합니다.

주문자 A의 조회결과

|   sku_code |            qty  |
|------------|-----------------|
|     SKU1   |              10 |

주문자 B의 조회결과

|   sku_code |            qty  |
|------------|-----------------|
|     SKU1   |               8 |

따라서 업데이트 문을 아래와 같이 작성할 수 있게 됩니다.

//주문자 A
UPDATE inventory SET qty = 10 - 2 WHERE sku_code = 'SKU1'; 

//주문자 B
UPDATE inventory SET qty = 8 - 2 WHERE sku_code = 'SKU1';

여러건의 품목을 주문할 경우에는 위의 UPDATE문와 같이 명시적인 락취득시 일정한 기준에 의해서 정렬해서 락을 취득하도록 하는 방법을 동일하게 사용해야 하는점에 주의해야합니다.

//주문자 A
SELECT sku_code, qty FROM inventory WHERE sku_code = 'SKU1' FOR UPDATE;
SELECT sku_code, qty FROM inventory WHERE sku_code = 'SKU2' FOR UPDATE;
//주문자 B
SELECT sku_code, qty FROM inventory WHERE sku_code = 'SKU1' FOR UPDATE;
SELECT sku_code, qty FROM inventory WHERE sku_code = 'SKU2' FOR UPDATE;

위와같이 정렬해서 락을 취득하면 데드락 문제를 회피할 수 있습니다. 다만 여기서 조회쿼리를 여러번 발행하게 되면 DB조회 횟수에 따른 라운드트립 이슈가 있습니다. 이를 최적화 하기 위해 아래와 같이 최종적으로 조회쿼리를 작성하여 사용하면 될 것같습니다.

//주문자 A
SELECT sku_code, qty FROM inventory WHERE sku_code IN ('SKU1', 'SKU2') ORDER BY sku_code FOR UPDATE;
//주문자 B
SELECT sku_code, qty FROM inventory WHERE sku_code IN ('SKU1', 'SKU2') ORDER BY sku_code FOR UPDATE;

위와 같이 IN절을 이용해서 쿼리 횟수를 줄일 수 있습니다. 여기서 데드락 문제 회피를 위해서는 ORDER BY절을 이용해서 동일하게 정렬해야하는 점에 주의해야합니다. 물론 일반적으로 조회된 순(DB마다 다룰 수 있지만)으로 락을 취득하게 되고 조회되는 순서는 비슷할 수 있어 문제되지 않을 수 있지만 명시적으로 순서를 지정하는 것이 위험을 회피할 수 있는 방법이라고 생각됩니다.

JPA 사용시 문제해결

위에서 SQL사용시의 문제해결 방법에 대해서 알아 보았습니다. SQL사용시의 문제해결 방법에 대해서 먼저알아본것은 JPA사용시 문제해결의 바탕이 되는 부분은 결국 SQL(DB)관련된 부분에서 찾아야 하므로 먼저 이해하고 JPA관련된 부분을 보는 것이 이해하는데 도움이 될 것 같아서 입니다.

//주문자 A
Inentory inventory1 = entityManager.find(Inventory.class, 'SKU1', LockModeType.PESSIMISTIC_WRITE);
Inentory inventory2 = entityManager.find(Inventory.class, 'SKU2', LockModeType.PESSIMISTIC_WRITE);

//주문자 B
Inentory inventory1 = entityManager.find(Inventory.class, 'SKU1', LockModeType.PESSIMISTIC_WRITE);
Inentory inventory2 = entityManager.find(Inventory.class, 'SKU2', LockModeType.PESSIMISTIC_WRITE);

위의 예제의 코드는 JPA에서 비관적락을 이용해 엔터티(재고) 정보를 취득하는 코드입니다. 상기코드를 실행하면 아래와 같은 SQL조회 쿼리가 실행됩니다.

//주문자 A
SELECT sku_code, qty FROM inventory WHERE sku_code = 'SKU1' FOR UPDATE;
SELECT sku_code, qty FROM inventory WHERE sku_code = 'SKU2' FOR UPDATE;
//주문자 B
SELECT sku_code, qty FROM inventory WHERE sku_code = 'SKU1' FOR UPDATE;
SELECT sku_code, qty FROM inventory WHERE sku_code = 'SKU2' FOR UPDATE;

발행된 쿼리를 보면 위의 SQL 사용시 근본적인 해결책에서 확인한 SQL 조회문과 동일한 쿼리가 발행되는 것을 확인할 수 있습니다. 이후에 JPA엔터티에 수정을 가하면 락취득후에 수정이 일어나므로 문제없이 재고차감 처리를 할 수 있습니다. 여기서도 마찬가지로 조회순서를 지켜야 마찬가지로 데드락을 회피할 수 있습니다.

//Inventory 엔터티 내부 
public void decreaseInventory(int decreaseQty) {
    if (this.qty >= decreaseQty) {
        this.qty - decreaseQty;
    } else {
    //예외처리
    }
}

//주문자 A
inventory1.decreaseInventory(2);
inventory2.decreaseInventory(3);

//주문자 B
inventory1.decreaseInventory(3);
inventory2.decreaseInventory(4);

위와같이 락취득후에 일반적인 JPA사용법에 맞춰서 재고를 차감하게 되면 아래와 같이 예상하는 UPDATE문이 발행되게 됩니다.

//주문자 A
UPDATE inventory SET qty = 8 WHERE sku_code = 'SKU1'; 
UPDATE inventory SET qty = 7 WHERE sku_code = 'SKU2';

//주문자 B
UPDATE inventory SET qty = 5 WHERE sku_code = 'SKU1';
UPDATE inventory SET qty = 3 WHERE sku_code = 'SKU2';

위의 일련의 예제는 상황에 따라서는 문제를 일으킬 소지가 있습니다. 예를 들어 이미 영속컨텍스트에 엔터티가 적재된 이후에 상기의 락과함께 엔터티를 조회하는 코드를 실행할 경우 엔터티의 최신상태가 반영되지 않고 단순히 락만을 취득하는 쿼리가 발행되기도 합니다. SQL 해결방법에서 언급했던 아래부분과 같은 맥락입니다.

FOR UPDATE 키워드를 통해 명시적으로 락을 취득하면서 얻은 결과에 있는 재고수를 사용하는 것을 항상 의식해야 하는점에 주의해야합니다.

SELECT sku_code FROM inventory WHERE sku_code = 'SKU1' FOR UPDATE

이미 영속컨텍스트에 적재된 엔터티를 상기의 락을 취득하면서 조회하는 코드를 실행하면
위와같이 쿼리문에 엔터티의 Id만 포함된 형태로 락만 취득하기 위한 쿼리가 발행됩니다.

이 문제는 락 취득후에 새로 조회를 강제하는 코드를 실행하거나 아래에서 조회쿼리 최적화에 사용하는 QueryDSL코드를 사용해서 해결할 수 있습니다.

//영속컨텍스트에 적재된 엔터티를 강제로 새로조회하는 코드
entityManager.refresh(inventory);

마지막으로 위에서 조회쿼리를 한개로 처리해서 조회시의 라운드트립 감소시키는 최적화하는 부분을 JPA에서 어떻게 처리할 수 있는지 살펴보도록 하겠습니다. JPA표준 기능에서는 엔터티를 복수개를 한번에 취득하는 기능이 없고 쿼리를 직접작성(크리테리아, JPQL, HQL, 네이티브 쿼리, QueryDSL등)하거나 JPA구현체가 제공하는 기능을 이용하여야 합니다. 해당 내용까지 언급하기에는 이 아티클의 취지와는 다르게 기술적인 내용으로 들어가 버리므로 QueryDSL을 이용해서 조회하는 예제코드를 작성해 보도록 하겠습니다.

query
    .select(inventory)
    .from(inventory)
    .where(inventory.skuCode.in("SKU1", "SKU2"))
    .setLockMode(PESSIMISTIC_WRITE)
    .orderBy(inventory.skuCode.asc())
    .fetch();

위와같이 코드를 작성한 후(마찬가지로 단순화시킨 코드입니다.) 해당코드를 실행하게 되면 아래와 같은 조회 쿼리가 실행됨을 알 수 있습니다.

//주문자 A
SELECT sku_code, qty FROM inventory WHERE sku_code IN ('SKU1', 'SKU2') ORDER BY sku_code FOR UPDATE;

위와같이 기대했던 조회쿼리가 실행되어 엔터티가 적재되므로 위에서 사용했던 일반적인 JPA사용법에 의해서 재고를 차감하면 재고수량의 정합성이 틀어지지 않는 상태에서 재고차감이 가능하게 됩니다. JPA를 사용하는 경우에도 SQL을 직접사용하는 경우와 마찬가지로 정합성이 틀어지지 않는 형태로 데이터 갱신이 가능함을 알 수 있었습니다.

혹자는 JPA표준기능으로는 할 수 없는 것이 아니냐라고 말할 수도 있습니다만 JPA자체도 표준 인터페이스만으로는 네이티브 수준의 복잡한 쿼리가 작성이 불가능한 것을 알기 때문에 JPQL이나 Criteria등을 제공하고 네이티브 쿼리를 직접 작성해서 사용할 수 있는 인터페이스도 제공합니다. 많이 사용하는 JPA구현체인 하이버네이트에서는 HQL이라는 질의언어를 제공하기도 합니다. 또한 JPQL 문자열로 기술되어 유지보수에 어려운 한계를 극복하기위해 정적타입을 이용하여 동적으로 쿼리를 작성할 수 있게 도와주는 QueryDSL등도 마찬가지로 JPA와 함께 사용되는 기술이라고 볼 수 있습니다.

따라서 JPA라서 한계가 있다라고 단정짓기 보다 부족한 부분은 다른 기술들을 이용해서 보완해서 사용할 수 있습니다. 이를 달성하기 위해서는 SQL(DB), 트랜잭션 등에 대해서 정확하게 파악하고 JPA생태계에 있는 다른 기술들에 대해서 어느정도는 이해하고 있는 것이 선결되어야 할 것같습니다.

*상기 QueryDSL로 작성된 예제는 쿼리에서도 알 수 있듯이 영속컨텍스트 적재여부와 무관하게 항상 엔터티의 내용자체도 갱신되므로 엔터티 적재시점 이슈 문제에서 자유로워집니다.

JPA사용시 해결방법에서는 SQL사용시 해결방법에서와는 다르게 UPDATE문을 이용하는 예제는 작성하지 않았습니다. 이는 UPDATE문을 이용하는 부분이 근본적인 해결책이라고 보기에는 부족한 부분이 있으며 마찬가지로 JPA를 이용하여 쿼리를 직접작성해야하는 부분이 있어 기술적인 내용으로 들어가 버리기 때문입니다.

마치며

JPA라서 SQL을 직접 작성할때는 일어나지 않았던 문제가 일어나는 것이 아니라 SQL을 직접작성 할때도 동일한 문제는 발생할 가능성이 있다는 부분을 확인할 수 있었습니다. 그것은 JPA가 무언가 특별한 것이 아니고 최종적으로는 SQL작성해서 실행해주는 역할을 하므로 궁극적으로는 SQL을 실행할때 발생하는 문제는 동일하게 발생합니다. 이러한 부분을 잘 이해하는 것이 JPA를 잘 사용하는 첫걸음이 아닐까 생각합니다.

'Java > JPA' 카테고리의 다른 글

JPA 잠금(Lock) 이해하기  (0) 2019.05.29
Posted by Reiphiel
Java/JPA2019. 5. 29. 22:11
반응형

JPA(Hibernate:하이버네이트)에 의한 잠금(Lock:락) 사용중에 생각하고 있던바와 동작이 좀 다른 부분이 있어서 전반적으로 정리해 보았습니다.

잠금(Lock)의 종류

낙관적 잠금(Optimisstic Lock)

낙관적 잠금은 현실적으로 데이터 갱신시 경합이 발생하지 않을 것이라고 낙관적으로 보고 잠금을 거는 기법입니다. 예를 들어 회원정보에 대한 갱신은 보통 해당 회원에 의해서 이루어지므로 동시에 여러 요청이 발생할 가능성이 낮습니다. 따라서 동시에 수정이 이루어진 경우를 감지해서 예외를 발생시켜도 실제로 예외가 발생할 가능성이 낮다고 낙관적으로 보는 것입니다. 이는 엄밀한 의미에서 보면 잠금이라기 보다는 일종의 충돌감지(Conflict detection)에 가깝습니다.

비관적 잠금(Pessimistic Lock)

동일한 데이터를 동시에 수정할 가능성이 높다는 비관적인 전제로 잠금을 거는 방식입니다. 예를 들어 상품의 재고는 동시에 같은 상품을 여러명이 주문할 수 있으므로 데이터 수정에 의한 경합이 발생할 가능성이 높다고 비관적으로 보는 것입니다. 이 경우 충돌감지를 통해서 잠금을 발생시키면 충돌발생에 의한 예외가 자주 발생하게 됩니다. 이럴경우 비관적 잠금을 통해서 예외를 발생시키지 않고 정합성을 보장하는 것이 가능합니다. 다만 성능적인 측면은 손실을 감수해야 합니다. 주로 데이터베이스에서 제공하는 배타잠금(Exclusive Lock)을 사용합니다.

암시적 잠금(Implicit Lock)

암시적 잠금은 프로그램 코드상에 명시적으로 지정하지 않아도 잠금이 발생하는 것을 의미합니다. JPA에서는 엔터티에 @Version이 붙은 필드가 존재하거나 @OptimisticLocking 어노테이션이 설정되어 있을 경우 자동적으로 충돌감지를 위한 잠금이 실행됩니다. 그리고 데이터베이스의 경우에는 일반적으로 사용하는 대부분의 데이터베이스가 업데이트, 삭제 쿼리 발행시에 암시적으로 해당 로우에 대한 행 배타잠금(Row Exclusive Lock)이 실행됩니다. JPA의 충돌감지가 역할을 할 수 있는 것도 이와 같은 데이터베이스의 암시적 잠금이 존재하기 때문입니다. 데이터베이스의 암시적 잠금이 없다면 충돌감지를 통과한 후 커밋(Commit)이 실행되는 사이에 틈이 생기므로 충돌감지를 하더라도 정합성을 보증할 수 없을 것입니다.

명시적 잠금(Explicit Lock)

프로그램을 통해 의도적으로 잠금을 실행하는 것이 명시적 잠금입니다. JPA에서 엔터티를 조회할 때 LockMode를 지정하거나 select for update 쿼리를 통해서 직접 잠금을 지정할 수 있습니다.

낙관적 잠금 사용법

JPA(Hibernate)에서 낙관적 잠금을 사용하는 방법에 대해서 알아보겠습니다.

@Version

JPA에서 낙관적 잠금을 사용하기 위해서는 @Version어노테이션을 붙인 필드를 추가하면 간단하게 적용할 수 있습니다.

@Entity
public class Member implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long memberNo;
    //...
    @Version
    private int version;
    //...

}

특정 필드에 @Version이 붙은 필드를 추가하면 자동적으로 낙관적 잠금이 적용됩니다. @Version을 적용할 수 있는 타입은 아래와 같습니다.

  • int
  • Integer
  • short
  • Short
  • long
  • Long
  • java.sql.Timestamp

실제로 쿼리를 실행해보면 아래와 같이 업데이트 쿼리 발행시에 조건절에 버전정보가 설정된 것을 볼 수 있습니다. 현재 엔터티가 가지고 있는 버전정보가 조건절에 적용되며 update문에는 +1된 값이 적용됩니다. 다른 트랜잭션에의해서 이미 버전정보가 바뀐상태라고 하면 업데이트 로우(Row)수가 0이 반환되면서 충돌감지가 되어 예외(OptimisticLockException)가 발생하게 됩니다. 일단 update문이 실행되면 위에서 언급한 암시적 잠금이 실행되며 동시에 실행된 동일한 엔터티에 대한 쿼리는 앞선 update쿼리가 커밋될때까지 대기하게되어 정합성을 확실하게 보증할 수 있습니다.

@Version에 의한 낙관적 잠금

@OptimisticLocking

JPA 표준 스펙에 정의되어있는 방법이 아니어서 자주 사용되는 방법은 아니지만 Hibernate에서 제공하는 낙관적 잠금을 설정하는 방법입니다.

잠금종류 설명
NONE 낙관적 잠금을 사용하지 않음.
VERSION @Version 어노테이션이 붙어있는 필드를 조건으로 낙관적 잠금.
DIRTY 변경된 필드에 의해서 낙관적 잠금 사용.
ALL 모든 필드를 충돌감지의 조건으로 사용하는 낙관적 잠금.

DIRTY와 ALL은 버전필드 없이도 낙관적 잠금(Version less optimistic lock)을 사용할 수 있는 방법입니다.

@Entity
@OptimisticLocking(type = OptimisticLockType.ALL)
@DynamicUpdate
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_no")
    private Long memberNo;

    @Column(name = "member_id", nullable = false)
    private String memberId;

    @Column(name = "member_name")
    private String memberName;
    //...
}
@OptimisticLocking ALL에 의한 낙관적 잠금

위의 @Version 필드에 의한 잠김과는 다르게 조건절에 전체 컬럼가 걸려 있는 것을 알 수 있습니다. 조건절에는 업데이트 전의 값이 바인딩되어 있습니다. 이와 같이 컬럼 전체에 대한 업데이트 여부를 확인 함으로서 버전 없는 낙관적 잠금이 가능합니다. 주의할 점은 ALL을 사용할 경우에는 @DynamicUpdate는 같이 사용해야한다는 점입니다.
@DynamicUpdate가 필요한 이유는 필드단위로 Dirty여부를 확인하기 위함입니다.

When using OptimisticLockType.ALL, you should also use @DynamicUpdate because the UPDATE statement must take into consideration all the entity property values.

@Entity
@OptimisticLocking(type = OptimisticLockType.DIRTY)
@DynamicUpdate
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_no")
    private Long memberNo;

    @Column(name = "member_id", nullable = false)
    private String memberId;

    @Column(name = "member_name")
    private String memberName;
    //...
}
@OptimisticLocking DIRTY에 의한 낙관적 잠금

DIRTY로 지정했을 경우에는 위와 같이 갱신될 컬럼의 갱신전 값으로 조건절에 바인딩 됩니다. 특정한 컬럼만 충돌확인에 사용하므로 ALL이나 @Version을 사용했을때에 비해서 충돌할 가능성을 낮출수 있습니다. 다시 말해서 특정 엔터티의 서로 다른 부분을 업데이트하는 프로그램이 있을 경우 충돌하지 않고 수행이 가능합니다.

The main advantage of OptimisticLockType.DIRTY over OptimisticLockType.ALL and the default OptimisticLockType.VERSION used implicitly along with the @Version mapping, is that it allows you to minimize the risk of OptimisticLockException across non-overlapping entity property changes.

When using OptimisticLockType.DIRTY, you should also use @DynamicUpdate because the UPDATE statement must take into consideration all the dirty entity property values, and also the @SelectBeforeUpdate annotation so that detached entities are properly handled by the Session#update(entity) operation.

@DynamicUpdate를 사용하지 않을 경우 org.hibernate.MappingException: optimistic-lock=all|dirty requires dynamic-update="true"와 같은 예외가 발생합니다.

명시적 낙관적 잠금

프로그램에 의해서 명시적으로 낙관적 잠금을 사용할 수 있습니다.

public class SomeService {
    @Transactional
    public void someOperation() {
        Member member = this.entityManager.find(Member.class, memberNo, LockModeType.OPTIMISTIC);
        //do something
    }
}
public class SomeService {
    @Transactional
    public void someOperation() {
        Member member = this.entityManager.find(Member.class, memberNo);
        //do something
        this.entityManager.lock(member, LockModeType.OPTIMISTIC);
    }
}

위와 같이 엔터티 매니저가 제공하는 EntityManager#find(Class<T> entityClass, Object primaryKey, LockModeType lockMode)를 사용하거나 EntityManager#lock(Object entity, LockModeType lockMode)를 사용할 수 있습니다.

find를 사용하는 경우에는 엔터티를 영속성 컨텍스트로 부터 찾거나 없을 경우 select하면서 동시에 잠금을 걸때 사용하고 lock는 이미 영속성 컨텍스트에 담겨있는 엔터티를 대상으로 잠금을 걸때 사용합니다.

OPTIMISTIC

잠금모드를 OPTIMISTIC로 지정해서 잠금을 사용하는 경우에는 버전필드의 갱신여부와 상관없이 커밋 직전에 버전을 확인하는 쿼리를 한번 더 발행합니다.

OPTIMISTIC 잠금모드에 의한 버전 확인

해당 엔터티에 변경사항이 있을 경우에는 update쿼리 의해서 이미 충돌감지가 작동하므로 사실상 불필요한 쿼리가 발행될 수 있습니다. 다만 엔터티에 변경 없이 해당 엔터티에 대한 처리를 수행할 경우에 사용할 수 있습니다. 엔터티에 대한 변경이 없으어 암시적인 배타잠금(Row Exclusive Lock)이 발생하지 않으므로 완벽한 잠금이라고 보기 힘든 측면이 있습니다. 자식 엔터티에 대한 수정을 목적으로 잠금을 사용하는 경우에는 빈틈이 있으므로 사용해선 안됩니다. 그럴 경우에는 자식 엔터티의 수정시에 변경할 필드를 추가하거나(예를 들어 자식 엔터티 수정일자 등) 아래에서 소개할 OPTIMISTIC_FORCE_INCREMENT 잠금모드를 사용해야합니다.

JPA(Hibernate)에서는 자식 엔터티만 수정할 경우 부모엔터티는 변경이 있다고 판정되지 않습니다.

OPTIMISTIC_FORCE_INCREMENT

OPTIMISTIC과 달리 버전을 강제로 증가시키는 잠금입니다. 커밋 직전에 아래처럼 버전만 증가시키는 쿼리가 항상 발행됩니다. 따라서 해당 엔터티에 변경이 있었을 경우에는 변경사항에 대한 업데이트문과 버전을 증가시키는 업데이트문에 의해서 두번 버전이 증가합니다. OPTIMISTIC와 동일하게 엔터티 자체에 변경사항이 있을 경우에는 불필요하게 업데이트 문이 발행되므로 주의할 필요가 있습니다. 그리고 암시적인 행 배타잠금(Row Exclusive Lock)이 발생되어 정합성을 보증할 수는 있으므로 자식 엔터티를 수정할때 자식엔터티 전체에 대한 잠금용도로 사용할 수 있습니다.

OPTIMISTIC_FORCE_INCREMENT에 의한 버전확인

비관적 잠금

PESSIMISTIC_READ

데이터베이스에서 제공하는 공유잠금(여러 트랜잭션에서 동시에 읽을 수 있지만 쓸수없는 잠금, for share)을 이용하여 잠금을 획득합니다. 다만 공유잠금을 제공하지 않는 경우 PESSIMISTIC_WRITE와 동일하게 동작합니다. 사용하는 데이터베이스에 따라 지원여부가 갈리므로 확인 후 사용하시기 바랍니다.

public class SomeService {
    @Transactional
    public void someOperation() {
        Member member = this.entityManager.find(Member.class, memberNo, LockModeType.PESSIMISTIC_READ);
        //do something
    }
}

PESSIMISTIC_WRITE

데이터베이스에서 제공하는 행 배타잠금(Row Exclusive Lock)을 이용하여 잠금을 획득합니다.

public class SomeService {
    @Transactional
    public void someOperation() {
        Member member = this.entityManager.find(Member.class, memberNo, LockModeType.PESSIMISTIC_WRITE);
    }
}

PESSIMISTIC_FORCE_INCREMENT

데이터베이스에서 제공하는 행 배타잠금(Row Exclusive Lock)을 이용한 잠금과 동시에 버전을 증가시킵니다. 해당하는 엔터티에 변경은 없으나 하위엔터티를 갱신을 위해서 잠금이 필요할 경우 사용할 수 있습니다.

public class SomeService {
    @Transactional
    public void someOperation() {
        Member member = this.entityManager.find(Member.class, memberNo, LockModeType.PESSIMISTIC_FORCE_INCREMENT);
    }
}
행 배타잠금과 버전 증가

위와 같이 행 배타잠금과 버전증가가 연이어서 발생하는 것을 볼 수 있다.

주의사항

격리수준

일반적으로 주로 사용되는 데이터베이스는 주로 READ COMMITTED에 해당하는 격리수준을 가지는 경우가 많습니다. 하지만 JPA를 사용할 경우 한번 영속 컨텍스트에 적재된 엔터티를 다시 조회할 경우 데이터베이스를 조회하지 않고 영속 컨텍스트에서 엔터티를 가져오므로 REPEATABLE READ 격리수준과 동일하게 동작하게 됩니다.

public class SomeService {
    @Transactional
    public void someOperation(Long memberNo) {
        Member member = this.entityManager.find(Member.class, memberNo);
        //do something
        this.entityManager.find(Member.class, memberNo, LockModeType.PESSIMISTIC_WRITE);
        //do something
    }
}

위의 예제를 보면 동일한 엔터티를 두번 조회하면서 두번째 조회시에 비관적 잠금을 사용하고 있습니다. 실행 결과는 아래와 같습니다.

영속 컨텍스트에 존재하는 엔터티 잠금

버전필드가 존재하지 않는 엔터티의 경우 위와 같이 첫번째 조회시에 영속 컨텍스트에 적재된 엔터티의 상태는 바뀌지 않고 단순히 select for update에 의한 행 배타잠금이 실행됩니다. 즉 REPEATABLE READ 격리 수준과 동일하게 동작하므로 처음 엔터티가 조회되어 잠금이 실행되기 전에 다른 트랜잭션에 의해서 엔터티가 변경되어 커밋된 상태가 반영되지 않고 현재 트랜잭션의 엔터티의 상태가 유지된다는 점에 주의해야 합니다. 이럴 경우 앞선 트랜잭션에 의해서 변경된 값을 잃어버리는 문제(Lost update problem)가 발생할 수 있습니다.

즉 잠금은 동작하였지만 정합성에 문제가 생길 수 있습니다. 예를 들어 포인트를 사용하는 경우 앞선 트랜잭션에서 차감된 포인트가 반영되지 않으므로서 이중사용 문제가 발생할 수 있습니다. 영속 컨텍스트에 엔터티가 존재하는 것이 확실한 경우에는 EntityManger#refresh나 JPQL을 이용하여 데이터베이스로부터 엔터티를 조회하도록 강제할 필요가 있습니다.

영속 컨텍스트에 존재하는 엔터티 잠금 버전필드가 존재하는 경우

버전 필드가 존재하는 엔터티의 경우에는 배타잠금을 실행하는 조건에 버전 정보가 포함되게 됩니다. 이에 따라 쿼리 실행 후 배타잠금을 획득하기 전에 다른 트랜잭션에 의해서 버전이 증가하게 되어 잠금 획득에 실패하게 됩니다. 따라서 비관적 락을 이용하여 순차적인 처리를 기대한 경우라면 기대대로 동작하지 않으므로 주의해야 할 필요가 있습니다.

쿼리 직접 사용

@Version필드가 존재하는 엔터티에 JPQL이나 네이티브 쿼리를 사용하는 경우 주의할 필요가 있습니다. JPQL이나 네이티브 쿼리 실행시 버전정보를 증가시키는 것을 누락할 경우 낙관적 잠금에 빈틈이 생길 수 있으므로 주의가 필요합니다.

Timeout

비관적 잠금에 의해서 데이터베이스에 행 배타잠금이 발생한 경우 이어서 들어오는 동일한 행에대한 배타잠금 요청을 앞선 요청의 잠금이 해제될때까지 대기하게 됩니다. 이럴 때 잠금을 가진 요청의 처리가 길어지게 되면 커넥션 풀의 커넥션이 부족하게 되어 어플리케이션 전체가 영향을 받게되는 아름다운 상황에 놓일 수 있습니다. 이럴경우 잠금획득 대기시간을 설정하는 Timeout을 사용해서 데이터베이스의 잠금이 어플리케이션 전체의 장애로 확산되는 것을 방지할 수 있습니다.

public class SomeService {
    @Transactional
    public void someOperation(Long memberNo) {
        Member member = this.entityManager.find(
            Member.class, memberNo,
            LockModeType.PESSIMISTIC_WRITE,
            Map.of("javax.persistence.lock.timeout", 0L)
        );
        //do something
    }
}

위와같이 설정하면 select for update 쿼리에 nowait가 추가되어 잠금을 취득할 수 없을 경우 즉시 LockTimeoutException과 같은 예외가 발생합니다. millisecond 단위로 시간을 지정하는 것도 가능합니다. 다만 주의할 것은 데이터베이스에 따라 지원여부가 다르고 지원하지 않는 경우 무시되므로 잘 구분해서 사용해야한다는 점입니다. 예를 들어 H2는 쿼리에 Timeout을 지정할 수 없고 PostgreSQL의 경우에는 NOWAIT(0으로 설정)는 지정 가능하나 시간설정은 무시되므로 주의할 필요가 있습니다. 데이터베이스 별로 동작이 다른 부분이 있으므로 커넥션 단위의 설정을 이용하거나 서킷 브레이커(circuit breaker)등을 이용하는 것이 더 바람직해 보입니다.

마치며

낙관적 잠금, 비관적 잠금, 명시적 잠금 등 어떤 종류의 잠금일 이용할 것인지는 해당 도메인의 비지니스 로직에 따라서 다르고 대부분의 도메인에서는 필요없을 수도 있습니다. 하지만 상황에 맞는 잠금을 선택할 수 있도록 특성에 대해 잘 알아 두는 것은 꼭 필요할 듯 합니다.

참고자료

Hibernate ORM 5.4.2.Final User Guide

'Java > JPA' 카테고리의 다른 글

JPA 오해와 진실(트랜잭션 관점)  (2) 2024.01.07
Posted by Reiphiel