JPA 오해와 진실(트랜잭션 관점)
개요
최근 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를 잘 사용하는 첫걸음이 아닐까 생각합니다.