JPA Native Query 사용 시 DTO로 매핑하기

이 글은 필자가 미디엄Medium에 쓴 2편의 글을 합하여 개정한 것이다.


JPA Native Query를 사용하다 보면 반환 값으로 엔터티Entity 가 아닌 DTOData Transfer Object로 받고 싶을 때가 있다.

대표적인 경우가 통계이다. 통계 지표를 만들어낼 때 사용하는 SQL은 대부분이 복잡하고 길다. 문제는 SQL 실행 결과Result Set를 기존의 엔터티에 담기에는 형태가 맞지 않는데 있다.[1]

이 글은 JPA Native Query 반환 값으로 DTO로 만드는 여러 방법을 다룬다.

간단한 예제로 시작하기

이해하기 쉽게 간단한 예제로 시작해 보자.  예제는 상품(Product)과 회원(Member), 그리고 주문(Order) 3개의 엔터티로 구성되어 있다.[2]

ERD

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Entity
@Table(name = "product")
public class Product {
    @Id
    @GeneratedValue
    @Column(name = "product_id")
    private Long id;
    private String name;
    private int price;
    @Column(name = "stock_amount")
    private int stockAmount;
    ...
}
@Entity
@Table(name = "member")
public class Member {
    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private String name;}
    private int age;
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<Order>();
    ...
}
@Entity
@Table(name = "order")
public class Order {
    @Id
    @GeneratedValue
    @Column(name = "order_id")
    private Long id;
    @Column(name = "order_amount")
    private int orderAmount;
    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;
    @ManyToOne
    @JoinColumn(name = "product_id")
    private Product product;
    ..
}

각 상품을 구매한 회원을 조회해 보자

JPA NativeQuery로 수행할 SQL과 실행 결과 값을 매핑할 DTO는 아래와 같다.[3]

1
2
3
4
5
6
7
8
SELECT "product"."product_id" AS productId, "product"."name" AS productName, "product"."price",
    "product"."stock_amount" AS stockAmount, "order"."order_id" AS orderId,
    "member"."member_id" AS memberId, "member"."name" AS memberName
FROM "product"
    LEFT JOIN "order"
        ON "product"."product_id" = "order"."PRODUCT_ID"
    LEFT JOIN "member"
        ON "order"."MEMBER_ID" = "member"."member_id"
1
2
3
4
5
6
7
8
9
10
public class ProductOrderedMemberDTO {
    private Long productId;
    private String productName;
    private int price;
    private int stockAmount;
    private Long orderId;
    private Long memberId;
    private String memberName;
    // .. getter, setter
}

ResultClass로 첫 시도

EntityManager는 NativeQuery 사용 시 결과 값을 다양하게 지원하는데 그중 하나가 ResultClass이다.

image2017-10-15_13-29-44

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
public void test_상품별_구매한_회원_목록_조회_by_resultClass() {
    // given
    setUpTestFixture();
    // when
    EntityManager em = emf.createEntityManager();
    String sql = "SELECT \"product\".\"product_id\" AS productId, \"product\".\"name\" AS productName, \"product\".\"price\", \n" +
            "    \"product\".\"stock_amount\" AS stockAmount, \"order\".\"order_id\" AS orderId,\n" +
            "    \"member\".\"member_id\" AS memberId, \"member\".\"name\" AS memberName\n" +
            "FROM \"product\" \n" +
            "    LEFT JOIN \"order\" \n" +
            "        ON \"product\".\"product_id\" = \"order\".\"PRODUCT_ID\"\n" +
            "    LEFT JOIN \"member\" \n" +
            "        ON \"order\".\"MEMBER_ID\" = \"member\".\"member_id\"";
    Query nativeQuery = em.createNativeQuery(sql, ProductOrderedMemberDTO.class);
    List<ProductOrderedMemberDTO> products = nativeQuery.getResultList();
    // then
    // ...
}

테스트 코드를 수행하면 어떻게 될까?

image2017-10-15_13-32-49

ProductOrderedMemberDTO는 엔터티가 아니기 때문에 MappingException 이 발생한다.

Object Array로 해결하기

일단 ProductOrderedMemberDTO는 엔터티가 아니기 때문에 ResultClass는 사용할 수 없다.  ResultClass를 지정하지 않고 반환된 Object Array를 사용해서 DTO로 매핑하는 방법이 있다.

image2017-10-15_13-38-44

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Test
public void test_상품별_구매한_회원_목록_조회_by_ObjectArray() {
    // given
    setUpTestFixture();
    // when
    EntityManager em = emf.createEntityManager();
    String sql = "SELECT \"product\".\"product_id\" AS productId, \"product\".\"name\" AS productName, \"product\".\"price\", \n" +
            "    \"product\".\"stock_amount\" AS stockAmount, \"order\".\"order_id\" AS orderId,\n" +
            "    \"member\".\"member_id\" AS memberId, \"member\".\"name\" AS memberName\n" +
            "FROM \"product\" \n" +
            "    LEFT JOIN \"order\" \n" +
            "        ON \"product\".\"product_id\" = \"order\".\"PRODUCT_ID\"\n" +
            "    LEFT JOIN \"member\" \n" +
            "        ON \"order\".\"MEMBER_ID\" = \"member\".\"member_id\"";
    Query nativeQuery = em.createNativeQuery(sql);
    List<Object[]> resultList = nativeQuery.getResultList();
    List<ProductOrderedMemberDTO> products = resultList.stream().map(product -> new ProductOrderedMemberDTO(
            ((BigInteger) product[0]).longValue(),
            (String) product[1],
            (Integer) product[2],
            (Integer) product[3],
            product[4] == null ? null : ((BigInteger)product[4]).longValue(),
            product[5] == null ? null : ((BigInteger)product[5]).longValue(),
            (String) product[6])).collect(Collectors.toList());
    // then
    Assert.assertEquals(5, products.size());
    // ...
    em.close()
}

하지만 이 방법은 Object를 DTO 데이터 타입에 맞게 캐스팅해 주어야 하는 번거로움이 있다.

ResultSetMapping으로 해결하기

EntityManager는 ResultClass 이외에도 ResultSetMapping을 지원한다. 이를 사용하여 앞선 Object Array 방식을 개선해 보자.

image2017-10-15_13-47-18

먼저 SqlResultSetMapping 을 사용하여 DTO와 매핑될 메타정보를 선언해 준다.

필자는 Product Entity에 선언하였으며, ConstructorResult을 사용하였기 때문에 ProductOrderedMemberDTO에 생성자를 추가해 준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@SqlResultSetMapping(
        name="ProductOrderedMemberMapping",
        classes = @ConstructorResult(
                targetClass = ProductOrderedMemberDTO.class,
                columns = {
                        @ColumnResult(name="productId", type = Long.class),
                        @ColumnResult(name="productName", type = String.class),
                        @ColumnResult(name="price", type = Integer.class),
                        @ColumnResult(name="stockAmount", type = Integer.class),
                        @ColumnResult(name="orderId", type = Long.class),
                        @ColumnResult(name="memberId", type = Long.class),
                        @ColumnResult(name="memberName", type = String.class),
                })
)
@Entity
@Table(name = "product")
public class Product {
    // ...
}
public class ProductOrderedMemberDTO {
    // ...
    public ProductOrderedMemberDTO(Long productId, String productName, int price, int stockAmount,
                                   Long orderId, Long memberId, String memberName) {
        this.productId = productId;
        this.productName = productName;
        this.price = price;
        this.stockAmount = stockAmount;
        this.orderId = orderId;
        this.memberId = memberId;
        this.memberName = memberName;
    }
    // ...
}

ResultSetMapping을 사용하기 위한 작업이 끝났다. 이제 테스트해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Test
public void test_상품별_구매한_회원_목록_조회_by_ResultSetMappingg() {
    // given
    setUpTestFixture();
    // when
    EntityManager em = emf.createEntityManager();
    String sql = "SELECT \"product\".\"product_id\" AS productId, \"product\".\"name\" AS productName, \"product\".\"price\", \n" +
            "    \"product\".\"stock_amount\" AS stockAmount, \"order\".\"order_id\" AS orderId,\n" +
            "    \"member\".\"member_id\" AS memberId, \"member\".\"name\" AS memberName\n" +
            "FROM \"product\" \n" +
            "    LEFT JOIN \"order\" \n" +
            "        ON \"product\".\"product_id\" = \"order\".\"PRODUCT_ID\"\n" +
            "    LEFT JOIN \"member\" \n" +
            "        ON \"order\".\"MEMBER_ID\" = \"member\".\"member_id\"";
    Query nativeQuery = em.createNativeQuery(sql, "ProductOrderedMemberMapping");
    List<ProductOrderedMemberDTO> products = nativeQuery.getResultList();
    // then
    Assert.assertEquals(5, products.size());
    // ...
    em.close();
}

Object를 DTO 데이터 타입에 맞게 변환해 주어야 하는 번거로움이 사라졌다.

ResultSetMapping 제약 사항

ResultSetMapping의 경우 엔터티에 SqlResultSetMapping으로 DTO와 매핑될 메타정보를 선언해 주어야 한다. 하지만 엔터티가 수정이 불가한 상황(다른 사람에게 제공받아 개발해야 하는 경우)이거나 엔터티와 DTO의 의존 관계를 제거하고 싶은 경우에는 어떻게 해야 할까?

QLRMQuery Language Result Mapper

QLRM은 JPA를 지원하는 ResultSet Mapper이다. QLRM을 사용하면 ResultSetMapping 제약 사항을 벗어날 수 있다. 즉, QLRM은 JPA Native Query 결과값을 DTO로 매핑해 준다.

1
2
3
JpaResultMapper jpaResultMapper = new JpaResultMapper();
Query q = em.createNativeQuery("SELECT ID, NAME FROM EMPLOYEE");
List<EmployeeTO> list = jpaResultMapper.list(q, EmployeeTO.class);

QLRM 사용해 보기

먼저 QLRM을 사용하기 위해 Maven 의존성 모듈을 추가한다.

1
2
3
4
5
<dependency>
    <groupId>ch.simas.qlrm</groupId>
    <artifactId>qlrm</artifactId>
    <version>1.7.1</version>
</dependency>

그리고 ProductOrderedMemberDTO에 생성자를 만든다.(SELECT 컬럼과 동일한 순서와 타입으로)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ProductOrderedMemberDTO {
    private Long productId;
    private String productName;
    private int price;
    private int stockAmount;
    private Long orderId;
    private Long memberId;
    private String memberName;
    public ProductOrderedMemberDTO(BigInteger productId, String productName,
                                   Integer price, Integer stockAmount,
                                   BigInteger orderId, BigInteger memberId,
                                   String memberName) {
        // ...
    }
    // ...

마지막으로 QLRM JpaResultMapper에 Native Query와 DTO를 넘겨주면 끝이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
public void test_상품별_구매한_회원_목록_조회_by_QLRM() {
    // given
    setUpTestFixture();
    // when
    EntityManager em = emf.createEntityManager();
    String sql = "SELECT \"product\".\"product_id\" AS productId, \"product\".\"name\" AS productName, \"product\".\"price\", \n" +
            "    \"product\".\"stock_amount\" AS stockAmount, \"order\".\"order_id\" AS orderId,\n" +
            "    \"member\".\"member_id\" AS memberId, \"member\".\"name\" AS memberName\n" +
            "FROM \"product\" \n" +
            "    LEFT JOIN \"order\" \n" +
            "        ON \"product\".\"product_id\" = \"order\".\"PRODUCT_ID\"\n" +
            "    LEFT JOIN \"member\" \n" +
            "        ON \"order\".\"MEMBER_ID\" = \"member\".\"member_id\"";
    Query nativeQuery = em.createNativeQuery(sql);
    JpaResultMapper jpaResultMapper = new JpaResultMapper();
    List<ProductOrderedMemberDTO> products = jpaResultMapper.list(nativeQuery, ProductOrderedMemberDTO.class);
    // then
    Assert.assertEquals(5, products.size());
    // ...
    em.close();
}

GitHub

전체 코드는 필자의 Github에서 확인할 수 있다.

주석

[1] 일반적으로 많은 테이블을 조인하여 만들어지기 때문이다.

[2] 예제는 JPA 프로그래밍 책에서 일부를 발췌하였다.

[3] 이 글에서는 DTO 매핑이 주제이기 때문에 SQL 자체에 대해서는 논외로 한다


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.