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
Java2019. 9. 1. 01:50
반응형

개발 진행중에 갑자기 알수 없는 컴파일 에러가 발생했습니다. 컴파일 에러가 발생할만한 코드도 없었고 IDE(IntelliJ)도 별다른 문법에러 표기를 하지 않았으므로 의문속에서 살펴보기 시작했습니다.

사용환경

  • macOS Mojave 10.14.6
  • JDK 11.0.2(Zulu JDK)
  • IntelliJ

증상

아래와 같이 컴파일 에러 표시와 메시지가 출력되었습니다.

IntelliJ의 컴파일 에러 표시
Information:java: compiler message file broken: key=compiler.misc.msg.bug arguments=11.0.2, {1}, {2}, {3}, {4}, {5}, {6}, {7}
Information:java: java.lang.NullPointerException
Information:java:     at jdk.compiler/com.sun.tools.javac.comp.Flow$FlowAnalyzer.visitApply(Flow.java:1235)
Information:java:     at jdk.compiler/com.sun.tools.javac.tree.JCTree$JCMethodInvocation.accept(JCTree.java:1634)
Information:java:     at jdk.compiler/com.sun.tools.javac.tree.TreeScanner.scan(TreeScanner.java:49)
Information:java:     at jdk.compiler/com.sun.tools.javac.comp.Flow$BaseAnalyzer.scan(Flow.java:398)
Information:java:     at jdk.compiler/com.sun.tools.javac.comp.Flow$FlowAnalyzer.visitReturn(Flow.java:1210)
Information:java:     at jdk.compiler/com.sun.tools.javac.tree.JCTree$JCReturn.accept(JCTree.java:1546)
Information:java:     at jdk.compiler/com.sun.tools.javac.tree.TreeScanner.scan(TreeScanner.java:57)
Information:java:     at jdk.compiler/com.sun.tools.javac.comp.Flow$FlowAnalyzer.visitBlock(Flow.java:997)
Information:java:     at jdk.compiler/com.sun.tools.javac.tree.JCTree$JCBlock.accept(JCTree.java:1020)
Information:java:     at jdk.compiler/com.sun.tools.javac.comp.Flow$FlowAnalyzer.visitMethodDef(Flow.java:964)
Information:java:     at jdk.compiler/com.sun.tools.javac.tree.JCTree$JCMethodDecl.accept(JCTree.java:866)
Information:java:     at jdk.compiler/com.sun.tools.javac.comp.Flow$FlowAnalyzer.visitClassDef(Flow.java:927)
Information:java:     at jdk.compiler/com.sun.tools.javac.tree.JCTree$JCClassDecl.accept(JCTree.java:774)
Information:java:     at jdk.compiler/com.sun.tools.javac.comp.Flow$FlowAnalyzer.analyzeTree(Flow.java:1327)
Information:java:     at jdk.compiler/com.sun.tools.javac.comp.Flow$FlowAnalyzer.analyzeTree(Flow.java:1317)
Information:java:     at jdk.compiler/com.sun.tools.javac.comp.Flow.analyzeTree(Flow.java:218)
Information:java:     at jdk.compiler/com.sun.tools.javac.main.JavaCompiler.flow(JavaCompiler.java:1401)
Information:java:     at jdk.compiler/com.sun.tools.javac.main.JavaCompiler.flow(JavaCompiler.java:1375)
Information:java:     at jdk.compiler/com.sun.tools.javac.main.JavaCompiler.compile(JavaCompiler.java:973)
Information:java:     at jdk.compiler/com.sun.tools.javac.api.JavacTaskImpl.lambda$doCall$0(JavacTaskImpl.java:104)
Information:java:     at jdk.compiler/com.sun.tools.javac.api.JavacTaskImpl.handleExceptions(JavacTaskImpl.java:147)
Information:java:     at jdk.compiler/com.sun.tools.javac.api.JavacTaskImpl.doCall(JavacTaskImpl.java:100)
Information:java:     at jdk.compiler/com.sun.tools.javac.api.JavacTaskImpl.call(JavacTaskImpl.java:94)

일단 별다른 문법에러 표시는 없지만 컴파일시에 에러라고 알려주는 부분을 확인해 보았습니다. 위의 코드에서 ParameterizedTypeReference를 익명으로 생성하는 부분을 가르키고 있었습니다.

private ResponseEntity<ApiResponse<PaymentDto>> exchangeApi(String targetUrl) {
    return this.restTemplate.exchange(
                targetUrl,
                HttpMethod.GET,
                new HttpEntity<>(null, getAuthHeader()),
                new ParameterizedTypeReference<>() {
                }
        );
}

해당 메소드의 시그니처를 확인해보았습니다.

public <T> ResponseEntity<T> exchange(String url, HttpMethod method, @Nullable HttpEntity<?> requestEntity,
            ParameterizedTypeReference<T> responseType, Object... uriVariables) throws RestClientException

시그니처상으로도 구현된 부분중 문제가 될만한 것은 보이지 않았습니다.

해결

역시 별다른 문제는 보이지 않습니다. 에러메시지를 통해서 유추가 가능한 것은 컴파일러에서 어떤 에러가 발생했고 메시지를 출력하려다가 NullPointerException이 발생했다는 사실입니다. 즉 없는 메시지를 출력할려고 했다는 것이므로 상정하지 않던 에러가 발생했다고 볼 수 있습니다. 일단 자바의 버그가 등록되어있는 JDK Bug System에 접속하여 검색해보았습니다.

이리저리 검색을 하던중 NullPointerException when compile generic ParameterizedTypeReference이라는 이슈를 발견했습니다. 제목만봐도 같은 문제처럼 보입니다. 내용을 확인해보니 역시나 일치하는 것을 알 수 있었습니다. 해당되는 버전(Affects Version/s:
8, 10.0.2, 11.0.1, 12
)을 보니 제가 사용해서 문제가 된 11.0.2 뿐만 아니라 상당히 많은 버전에서 문제가 되고 있었습니다.

일단 문제 해결을 위해서 해결된 버전을 확인해 보니 12(Fix Version/s: 12)부터 해결되었다고 되어있었습니다. 제가 사용하는 11버전에서는 아직 해결이 안된 상태로 나와있었습니다.

실망한 상태로 연관 이슈들을 확인해 보던중 한 연관 이슈에서(javac can't tell during speculative attribution if a diamond expression is creating an anonymous inner class or not) 해당 이슈가 11.0.4에서 해결되었다는 것을 발견하고 JDK버전을 11.0.4로 업그레이드하고 문제없이 컴파일 되는것을 확인할 수 있었습니다.

버그의 내용으로 볼때 익명클래스를 생성할때 다이아몬드 오퍼레이터(<>)를 사용한 경우 타입추론이 되지 않는 문제였습니다. 따라서 위의 경우에는 JDK의 버전을 올리지 않고도 new ParameterizedTypeReference<>() {}의 다이아몬드 오퍼레이터에 타입을 명시하면 간단하게 회피할 수 있습니다.

마치며

별 문제는 아니었지만 컴파일러에도 버그가 있을 수 있다는 사실을 오랜만에 느꼈던 순간이었습니다. 개발중 해결되지 않는 문제를 마주쳤을때 컴파일러 혹은 JVM 자체에도 문제가 있을 수 있다는 사실을 기억해두면 한번씩 도움이 되는 순간이 있지 않을까요?

Posted by Reiphiel
Java/Spring2019. 7. 29. 00:14
반응형

Spring MVC를 이용하여 웹 어플리케이션을 개발할 경우 일반적인 MVC(Model View Controller)패턴과 동일하게 컨트롤러(Controller)를 이용하여 사용자의 입력을 처리하고 결과를 출력할 View로 연결합니다. 이때 컨트롤러의 메소드는 개발자가 직접 호출하지 않고 Dispatcher에 의해서 호출되므로 해당 메소드에 전달할 메소드의 Argument들을 해결(Resolve)해줄 방법이 필요합니다.

Spring에서는 HandlerMethodArgumentResolver를 이용하여 Argument를 해결하는 방법을 제공합니다. HandlerMethodArgumentResolver는 Argument들을 해결하기 위한 일종의 전략패턴으로 생각하시면 될 것 같습니다.

Spring에서는 컨트롤러의 Argument를 해결하기 위한 다양한 Built-in ArgumentResolver를 제공합니다. 다들 아시고 계시는 내용이겠지만 정리하는 차원에서 간단하게 정리해보았습니다.

RequestParamMethodArgumentResolver

Http요청의 파라미터를 해결해주는 ArgumentResolver입니다. @RequestParam 어노테이션을 지정했거나 MultipartFile 타입의 파라미터(MultipartResolver도 필요)를 해결해줍니다.

기본 사용법

@GetMapping("/params/simple")
public String getSimpleMapping(@RequestParam String test) {
    return test;
}

위의 예제와 같이 파라미터(매개변수)에 @RequestParam 어노테이션을 지정하면 파라미터명과 동일한 Http 파라미터를 받을 수 있습니다. 또한 문자열 뿐만 아니라 정수형, 실수형등 다양한 타입의 파라미터를 지정할 수 있습니다.

파라미터명 별도 지정

@GetMapping("/params/specific-parameter-name")
public String getSpecificNameMapping(@RequestParam(name = "test-2") String test) {
    return test;
}

Http의 파라미터명을 컨트롤러의 파라미터명과 별도로 지정해야할 필요성이 있는 경우 예를 들어 java의 변수명에 사용할 수 없거나 컨벤션에 맞지않는 문자를 지정해야 할 경우에 사용할 수 있습니다. 혹은 Spring의 설정에 따라 파라미터명이 Byte Code에 반영되지 않을 경우에 명시적으로 지정해야할 수 있습니다.

선택적 파라미터

@RequestParam을 지정한 파라미터의 경우 기본적으로 해당 파라미터가 Http 요청에 포함되어야 합니다. 지정한 파라미터가 존재하지 않을 경우 아래와 같이 예외가 발생하고 Http상태코드 400을 반환받게 됩니다.

 

지정한 파라미터가 존재하지 않는 경우

 

특정 파라미터를 선택적으로 받아야 할 경우에는 required 속성에 필수여부를 지정할 수 있습니다.

@GetMapping("/params/optional")
public String getOptionalParameter(@RequestParam(required = false) String test) {
    return test;
}

위와 같이 파라미터에 required = false를 지정하면 선택적으로 파라미터를 사용할 수 있습니다.

기본값

특정 파라미터에 값을 지정하지 않거나 파라미터가 존재하지 않을 경우 사용할 기본값을 지정할 수 있습니다.

@GetMapping("/params/default-value")
public String getDefaultValueMapping(@RequestParam(defaultValue = "not found") String test) {
    return test;
}

위와같이 defaultValue 속성에 지정한 값이 기본값으로 적용됩니다. 이 경우 required 속성을 지정하지 않더라도 암묵적으로 required = false와 동일하게 동작하게 됩니다. 이유는 파라미터가 존재하지 않더라도 지정한 기본값을 통해 파라미터에 값을 전달할 수 있기 때문입니다.

@GetMapping("/params/optional2")
public String getOptionalParameterMapping2(@RequestParam Optional<String> test) {
    return test.orElse("Not Found");
}

또한 파라미터의 타입으로 Optional을 지정할 경우 역시 선택적으로 파라미터를 사용할 수 있습니다.

Multi-Value 파라미터

동일한 파라미터를 다중으로 지정하거나 콤마(,)를 구분자로 지정된 파라미터를 Collection을 이용해서 파라미터를 받을 수 있습니다.

@GetMapping("/params/multi-value")
public List<String> getMultiValueMapping(@RequestParam List<String> test) {
    //Something to do 
}

RequestParamMapMethodArgumentResolver

RequestParamMethodArgumentResolver와 동일하게 Http 요청의 파라미터를 해결해주는 ArgumentResolver 입니다. 다른점은 파라미터의 타입이 Map인 경우에만 파라미터를 처리해 준다는 점입니다.

기본 사용법

파라미터의 타입으로 Map 혹은 MultiValueMap을 지정하고 @RequestParamname 속성을 별도로 지정하지 않은 경우 요청에 포함된 파라미터 전체를 한개의 파라미터로 받을 수 있습니다. 다만 Map을 지정한 경우에는 값을 여러개 가질 수 없으므로 요청된 동일한 이름의 파라미터중 HttpServletRequest에서 해당 파라미터명의 첫번째(0번) 인덱스의 값을 받을 수 있습니다.

@GetMapping("/map")
public Map<String, String> getMapMapping(@RequestParam Map<String, String> params) {
    return params;
}

@GetMapping("/multi-value-map")
public String getMultiValueMapMapping(@RequestParam MultiValueMap<String, String> params) {
    return params.toString();
}

파라미터 전체가 대상이므로 requireddefaultValue 속성도 반응하지 않는다는 점에 주의하시기 바랍니다.

PathVariableMethodArgumentResolver

@PathVariable을 파라메터에 지정하면 URI Template Variable(/some/{thing})에서 템플릿의('{', '}':Curly Brace로 감싸진 부분) 값을 파라메터로 받을 수 있습니다.

기본 사용법

@GetMapping("/orders/{orderNo}/lines/{lineNo}")
public void someController(@PathVariable Long orderNo, @PathVariable Long lineNo) {
    //Do Something
}

위와같이 파라메터명과 동일한 이름의 템플릿의 값을 파라미터로 받을 수 있으며 @PathVariablename 속성을 통해 템플릿 이름을 직접 지정할 수 있습니다. 또한 문자열뿐만 아니라 정수형, 실수형을 지정할 수 있습니다.

선택적 PathVariable

@PathVariable을 통해 PathVariable을 받을 경우에는 기본적으로 해당 값이 필수적으로 존재해야합니다. required 속성을 통해 @RequestParam과 동일하게 선택적으로 받도록 지정할 수 있습니다. 다만 PathVariable의 특성상 여러개의 PathVariable중 중간에 있는 값을 선택적으로 사용할 경우에는 사용할 수 없는 uri가 생성될 수 있으므로 주의가 필요합니다. 또한 마지막에 위치한 PathVariable을 선택적으로 사용할 경우에는 마지막 문자가 Path 구분자(/)인 uri 와 혼동될 수 있습니다.(Spring의 경우 런타임에 java.lang.IllegalStateException: Ambiguous handler methods mapped for 와 같은 예외를 발생합니다.)

@GetMapping("/optional/{path}")
public void getOptional(@PathVariable(required = false) String path){
    //Something to do
}

위와 같이 required = false를 지정하여 간단하게 선택적으로 사용할 수 있습니다.

Multi-Value Path Variable

Path Variable에 콤마(,)를 구분자로 지정된 한 값이 전달될 경우 Collection 타입의 파라미터로 받을 수 있습니다.

@GetMapping("/path/{variables}")
public List<String> getMultiValueMapping(@PathVariable List<String> variables) {
    return variables;
}

날짜타입

@PathVariable과 함께 @DateTimeFormat를 지정하면 날짜타입의 파라미터로도 받을 수 있습니다.

@GetMapping("/reservations/{date}")
public void getReservations(@PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) Date date) {
    //TODO
}

PathVariableMapMethodArgumentResolver

PathVariableMethodArgumentResolver와 동일하게 URI의 Path variable을 해결해주는 ArgumentResolver 입니다. 다른점은 파라미터의 타입이 Map인 경우에만 파라미터를 처리해 준다는 점입니다.

기본 사용법

파라미터의 타입으로 Map을 지정하고 @PathVariablename 속성을 별도로 지정하지 않은 경우 요청에 포함된 URI Path Variable 전체를 한개의 파라미터로 받을 수 있습니다.

@GetMapping("/{path}/{variable}")
public Map<String, String> getMapMapping(@PathVariable Map<String, String> variables) {
    return variables;
}

Path Variable 전체가 대상이므로 required는 속성은 적용되지 않습니다.

RequestHeaderMethodArgumentResolver

Http Request의 Header값을 해결해주는 ArgumentResolver로 @RequestHeader어노테이션을 파라메터에 지정하면 Http Request Header의 값을 파라미터로 받을 수 있습니다.

기본 사용법

@GetMapping("/something")
public String getSomething(@RequestHeader("user-agent") String userAgent) {
    return userAgent;
}

어노테이션의 name 속성에 지정한 이름의 Http Request Header의 값이 지정한 파라메터에 전달됩니다. 지정하지 않을 경우 변수명과 동일한 이름의 헤더의 키값이 지정되지만 변수에 사용할 수 없는 문자는 사용할 수 없으므로 지정하는 것을 추천합니다.(Http요청의 헤더에는 java의 변수명으로 사용할 수 없는 하이픈(-)이 포함되는 경우가 많습니다.)

선택적 Request Header

기본적으로 @RequestHeader를 통해 요청 헤더값을 받는 경우 해당 헤더가 존재하지 않을 경우 Http응답코드 400이 발생합니다. 이 경우 required 속성을 지정하면 요청 헤더값을 선택적으로 받을 수 있습니다.

@GetMapping("/something-optional")
public String getSomethingOptional(@RequestHeader(name = "optional-header", required = false) String header) {
    //Something to do
}

기본값

지정한 요청 헤더가 Http 요청에 포함되지 않는 경우 사용할 기본값을 지정할 수 있습니다.

@GetMapping("/something")
public String getHeaderDefaultValue(@RequestHeader(name = "some-header", defaultValue = "nothing") String header) {
    //Something to do
}

위와같이 defaultValue 속성에 지정한 값이 기본값으로 적용됩니다. 이 경우 required 속성을 지정하지 않더라도 파라미터에 값을 바인딩 할 수 있으므로 암묵적으로 required = false를 지정한 것처럼 예외가 발생하지 않습니다.

날짜타입

@RequestHeader과 함께 @DateTimeFormat를 지정하면 날짜타입의 헤더의 값도 받을 수 있습니다.

@GetMapping("/something")
public String getHeaderDateTime(@RequestHeader("some-header") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) Date header) {
    //Something to do
}

RequestAttributeMethodArgumentResolver

Request Attribute의 속성을 해결해주는 ArgumentResolver 입니다. 서블릿 컨테이너에 의해서 설정된 속성이나 ServletRequest#setAttribute을 통해서 설정된 속성을 받을 수 있습니다.

기본 사용법

@GetMapping("/attribute/something")
public String getSomething(@RequestAttribute("something") String something) {
    //Something to do
}

위와 같이 파라미터에 @RequestAttribute 어노테이션에 지정한 이름 혹은 지정하지 않은 경우는 파라미터명과 동일한 속성값을 받을 수 있습니다.

선택적 Request Attribute

기본적으로 @RequestAttribute를 지정하여 Request Attribute를 받는 경우 해당 속성이 존재하지 않을 경우 예외가 발생하고 Http응답코드 400이 반환됩니다. 이 경우 required 속성을 지정하면 해당 속성값을 선택적으로 받을 수 있습니다.

@GetMapping("/attribute/something")
public String getSomething(@RequestAttribute(name = "something", required = false) String something) {
    //Something to do
}

SessionAttributeMethodArgumentResolver

HttpSession의 속성을 해결해주는 ArgumentResolver입니다. 컨트롤러의 파라미터에 @SessionAttribute 어노테이션을 지정하면 적용 가능합니다.

기본 사용법

@GetMapping("/session")
public void getSession(@SessionAttribute("someAttribute") String someAttribute) {
    //Something to do
}

위와 같이 파라미터에 @SessionAttribute 어노테이션에 지정한 이름 혹은 지정하지 않은 경우는 파라미터명과 동일한 세션의 속성값을 받을 수 있습니다.

선택적 Session Attribute

기본적으로 @SessionAttribute를 지정하여 Session Attribute를 받는 경우 해당 세션 속성이 존재하지 않을 경우 예외 처리되고 Http응답코드 400이 반환됩니다 이 경우 required 속성을 지정하면 해당 세션의 속성값을 선택적으로 받을 수 있습니다.

@GetMapping("/session")
public void getSession(@SessionAttribute(name = "someAttribute", required = false) String someAttribute) {
    //Something to do
}

ServletCookieValueMethodArgumentResolver

HttpServletRequest로부터 쿠키(Cookie)의 값을 해결하기 위한 ArgumentResolver입니다.

기본 사용법

@PostMapping
public void someController(@CookieValue(name="cookie_name") String cookie) {
    //Do Something
}

위와 같이 메소드의 파라메터에 @CookieVaule 어노테이션을 지정하면 HttpServletRequest에서 쿠키의 값을 파라미터로 받을 수 있습니다.

선택적 쿠키

@PostMapping
public void someController(@CookieValue(name="cookie_name", required = false) String cookie) {
    //Do Something
}

기본적으로 @CookieValue를 지저하여 쿠키값을 받는 경우 해당 쿠키가 존재하지 않을 경우 예외가 발생하고 Http응답코드 400이 반환됩니다. 이 경우 required 속성을 지정하면 해당 쿠키값을 선택적으로 받을 수 있습니다.

기본값

지정한 쿠키가 존재하지 않을 경우 사용할 기본값을 지정할 수 있습니다.

@GetMapping("/cookie")
public String getCookieDefaultValue(@Cookie(name = "some-cookie", defaultValue = "nothing") String header) {
    //Something to do
}

위와같이 defaultValue 속성에 지정한 값이 기본값으로 적용됩니다. 이 경우 required 속성을 지정하지 않더라도 파라미터에 값을 바인딩 할 수 있으므로 암묵적으로 required = false를 지정한 것처럼 예외가 발생하지 않습니다.

ModelAttributeMethodProcessor

Spring MVC 패턴에서 Model에 관한 속성을 처리해주는 ArgumentResolver입니다. 사용자의 입력 즉 Http요청의 파라미터를 Model에 바인딩해주는 역할을 수행합니다. 위에서 살펴본 RequestParamMethodArgumentResolver(@RequestParam)와 유사한 역할을 수행한다고 보면 되지만 POJO를 지정하여 여러개의 파라미터를 한개의 변수로 받을 수 있으므로 좀더 편리하게 사용하실 수 있습니다.

기본 사용법

@GetMapping("/model")
public void getModelFromParameter(@ModelAttribute("test") String test) {
    //Something to do
}

@GetMapping("/model")
public void getModel(@ModelAttribute TestDto test) {
    //Something to do
}

위와 같이 파라미터에 @ModelAttribute를 지정한 경우 해당 어노테이션에 지정한 이름에 해당하는 Http 파라미터를 받을 수 있습니다. 또한 POJO형태의 타입을 지정한 경우 해당 타입이 가진 프로퍼티에 Http 파라미터가 바인딩됩니다. 설정에 따라서 POJO를 지정한 경우 @ModelAttribute를 생략할 수 있으므로 편리하게 사용할 수 있습니다.

ExpressionValueMethodArgumentResolver

Spring의 표현식을 이용해서 값을 해결해주는 ArgurmentResolver입니다. @Value 어노테이션이 지정되어 있는 파라미터를 통해 값을 받을 수 있습니다. 보통 필드에 해당 어노테이션을 지정하는 방식을 많이 사용하므로 그다지 사용되지는 않지만 간단하게 알아보도록 하겠습니다.

기본 사용법

@GetMapping("/values/someValue")
public void getSomeValue(@Value("${someValue}") String someValue) {
    //Somthing to do
}

위와 같이 @Value 어노테이션에 지정한 표현식을 통해서 파라미터의 값이 바인딩 됩니다. 표현식에 대해서는 여기서는 별도로 다루지는 않겠습니다. 다만 위의 표현식의 경우 Spring의 환경변수 org.springframework.core.env.Enviroment#getProperty("someValue")을 호출한 결과를 얻게됩니다.

마치며

위에서 소개한 바와같이 HandlerMethodArgumentResolver를 이용하면 컨트롤러 내부에 작성해야할 보일러 플레이트 코드를 제거하여 코드를 깔끔하게 유지하여 비지니스 로직에만 집중할 수 있습니다. HandlerMethodArgumentResolver를 이용하여 공통화 가능한 중복코드가 있는지 확인해 보는 것을 추천합니다.

Posted by Reiphiel