Backend/JPA 프로그래밍

JPA - NativeSQL

elevne 2023. 7. 16. 13:47

JPQL 은 표준 SQL 이 지원하는 대부분의 문법과 SQL 함수들을 지원하기는 하지만, 특정 데이터베이스에 종속적인 기능들은 지원하지 않는다. 예를 들어 UNION, INTERSECT, 스토어드 프로시저 등이 있다. 때로는 이러한 기능들이 사용될 때가 있다. JPA 는 특정 데이터베이스에 종속적인 기능을 사용할 수 있는 다양한 방법을 열어두었다. 다양한 이유들로 JPQL 을 사용할 수 없을 때, Native SQL 을 사용할 수 있다.

 

 

 

우선 가장 기본적으로 엔티티를 조회하는 코드를 작성해본다.

 

 

String sql = "SELECT ID, AGE, NAME, TEAM_ID " +
        "FROM MEMBER WHERE AGE > ?";

Query nativeQuery = em.createNativeQuery(sql, Member.class).setParameter(1, 20);
List<Member> resultList = nativeQuery.getResultList();

 

 

Native SQL 을 사용할 때 다른 점은 SQL 만 직접 사용한다는 점 뿐이고, 나머지는 JPQL 을 사용할 때와 동일하다. 조회한 엔티티도 영속성 컨텍스트에서 관리된다. JPA 는 공식적으로 Native SQL 에서 이름 기반 파라미터를 지원하지 않고 오직 위치 기반 파라미터만 지원한다. (하지만 하이버네이트는 Native SQL 에 이름 기반 파라미터를 사용할 수 있다. 하이버네이트 구현체를 사용한다면 이름 기반 파라미터를 사용할 수 있다) 또 위 코드에서 createNativeQuery 내에 타입을 지정하였음에도 Query 타입의 결과를 반환한다. 이는 API 규약이 단지 이렇게 정의되어 있을 뿐이라고 한다.

 

 

위처럼 리턴타입을 지정하지 않고 값으로만 반환받을 수도 있다.

 

 

Query nativeQuery2 = em.createNativeQuery(sql).setParameter(1, 20);
List<Object[]> resultList2 = nativeQuery2.getResultList();

 

 

 

위와 같이 단순히 한 엔티티만 조회하는 경우가 아니라, 엔티티와 스칼라 값을 함께 조회하는 것처럼 매핑이 복잡해지면 @SqlResultSetMapping 을 정의해서 결과 매핑을 사용해야 한다.

 

 

String sql =
        "SELCT M.ID, AGE, NAME, TEAM_ID, I.ORDER_COUNT " +
        "FROM MEMBER M " +
                "LEFT JOIN " +
                "(SELECT IM.ID, COUNT(*) AS ORDER_COUNT "+
                "FROM ORDERS O, MEMBER IM "+
                "WHERE O.MEMBER_ID = IM.ID) I "+
                "ON M.ID = I.ID";
Query nativeQuery = em.createNativeQuery(sql, "memberWithOrderCount");
List<Object[]> resultList = nativeQuery.getResultList();

for (Object[] row : resultList) {
    Member member = (Member) row[0];
    BigInteger orderCount = (BigInteger) row[1];
}

 

 

@SqlResultSetMapping(name = "memberWithOrderCount",
    entities = {@EntityResult(entityClass = Member.class)},
    columns = {@ColumnResult(name = "ORDER_COUNT")})
public class Member {
...

 

 

em.createNativeQuery(sql, "memberWithOrderCount") 의 두 번째 파라미터에 결과 매핑 정보의 이름을 넘겨준다. 결과 매핑은 위 @SqlResultSetMapping 애노테이션 내에 정의되어 있다. 결과 매핑을 보면 회원 엔티티와 ORDER_COUNT 컬럼을 매핑한다. 위 쿼리 결과에서 ID, AGE, NAME, TEAM_ID 는 Member 엔티티와, ORDER_COUNT 는 단순히 값으로 매핑하는 것이다. (여러 엔티티와 여러 컬럼을 매핑하는 것도 가능하다)

 

 

Query q = em.createNativeQuery(
        "SELECT o.id AS order_id, " +
                "o.quantity AS order_quantity, " +
                "o.item AS order_item, " +
                "i.name AS item_name, " +
                "FROM Order o, Item i " +
                "WHERE (order_quantity > 25) AND " +
                "(order_item = i.id)", "OrderResults"
);

 

 

@SqlResultSetMapping(name = "OrderResults",
        entities = {
                @EntityResult(entityClass = Orders.class, fields = {
                        @FieldResult(name = "id", column = "order_id"),
                        @FieldResult(name = "quantity", column = "order_quantity"),
                        @FieldResult(name = "item", column = "order_item")
                })
        },
        columns = {
            @ColumnResult(name = "item_name")
        }
)
public class Orders {

 

 

이번에는 @EntityResult 내에 @FieldResult 애노테이션이 사용되었다. 이는 컬럼명과 필드명을 직접 매핑해주는 역할을 한다. (@FieldResult 를 사용한다면 전체 필드를 @FieldResult 로 매핑해줘야 한다)

 

 

 

Native SQL 을 사용할 때에도 Named 쿼리를 사용할 수 있다.

 

 

List<Object[]> resultList = em.createNamedQuery("Member.memberWithOrderCount").getResultList();

 

 

@NamedNativeQuery(
        name = "Member.memberWithOrderCount",
        query =
        "SELCT M.ID, AGE, NAME, TEAM_ID, I.ORDER_COUNT " +
                "FROM MEMBER M " +
                "LEFT JOIN " +
                "(SELECT IM.ID, COUNT(*) AS ORDER_COUNT "+
                "FROM ORDERS O, MEMBER IM "+
                "WHERE O.MEMBER_ID = IM.ID) I "+
                "ON M.ID = I.ID",
        resultSetMapping = "memberWithOrderCount"
)

 

 

@NamedNativeQuery 로 Named Native SQL 을 등록할 수 있다. 이를 사용할 때에는 em.createNamedQuery(named_query_이름) 과 같이 작성한다. JPQL Named 쿼리와 같이 createNamedQuery 메소드를 사용하여 TypeQuery 객체를 반환한다. 또, 위 @NamedNativeQuery 내에서는 resultSetMapping = "memberWithOrderCount" 로 조회 결과를 매핑할 대상을 지정한다. 이처럼 @SqlResultSetMapping 으로 정의한 조회 결과 매핑 정보도 사용할 수 있다. 

 

 

Named Native SQL 은 아래와 같이 XML 파일에 적어두고 사용하는 편이 편할 수 있다. (Native SQL 은 보통 JPQL 로 작성하기 어려운 복잡한 쿼리를 작성하거나, SQL 을 최적화해서 데이터베이스 성능을 향상할 때 사용한다. 이러한 쿼리들은 대체로 복잡하고 라인 수가 많기에 XML 을 사용하는 것이 편리할 것이다)

 

 

<named-native-query name="Member.memberWithOrderCountXml"
    result-set-mapping="memberWithOrderCountResultMap">
    <query>
        <![CDATA[
        SELCT M.ID, AGE, NAME, TEAM_ID, I.ORDER_COUNT
            FROM MEMBER M
                    LEFT JOIN
                    (SELECT IM.ID, COUNT(*) AS ORDER_COUNT
                    FROM ORDERS O, MEMBER IM
                    WHERE O.MEMBER_ID = IM.ID) I
                    ON M.ID = I.ID
        ]]>
    </query>
</named-native-query>
<sql-result-set-mapping name="memberWithOrderCountResultMap">
    <entity-result entity-class="com.example.jpastudy.chapter10.Member"/>
    <column-result name="ORDER_COUNT"/>
</sql-result-set-mapping>

 

 

List<Object[]> resultList = em.createNamedQuery("Member.memberWithOrderCountXml").getResultList();

 

 

 

Native SQL 도 JPQL 을 사용할 때와 마찬가지로 Query, TypedQuery 를 반환하기에 JPQL API 를 그대로 사용할 수 있다고 한다. 아래와 같이 페이징 처리를 해볼 수 있다.

 

 

String sql = "SELECT ID, AGE, NAME, TEAM_ID FROM MEMBER";
Query nativeQuery = em.createNativeQuery(sql, Member.class).setFirstResult(10).setMaxResults(20);

 

 

 

JPQ 2.1 부터는 스토어드 프로시저도 사용할 수 있다. 우선 아래와 같은 스토어드 프로시저를 생성한다.

 

 

CREATE PROCEDURE proc_multiply(
INOUT inParam INT,
INOUT outParam INT
)
BEGIN
	SET outParam = inParam * 2;
END

 

 

JPA 에서 위 프로시저는 다음과 같이 호출할 수 있다.

 

 

StoredProcedureQuery spq = em.createStoredProcedureQuery("proc_multiply");
spq.registerStoredProcedureParameter(1, Integer.class, ParameterMode.IN);
spq.registerStoredProcedureParameter(2, Integer.class, ParameterMode.OUT);
spq.setParameter(1, 100);
spq.execute();

Integer out = (Integer) spq.getOutputParameterValue(2);
System.out.println("out = "+out);

 

 

위는 순서 기반 파라미터로 호출한 것이고, 아래와 같이 이름 기반으로 사용할 수도 있다.

 

 

StoredProcedureQuery spq = em.createStoredProcedureQuery("proc_multiply");
spq.registerStoredProcedureParameter("inParam", Integer.class, ParameterMode.IN);
spq.registerStoredProcedureParameter("outParam", Integer.class, ParameterMode.OUT);
spq.setParameter("inParam", 100);
spq.execute();

Integer out = (Integer) spq.getOutputParameterValue("outParam");
System.out.println("out = "+out);

 

 

 

위와 같이 직접 DB 에 등록된 이름으로 호출할 수도 있지만, 스토어드 프로시저 쿼리에 따로 이름을 부여해서 사용할 수 있다. 이를 Named Stored Procedure 이라고 한다.

 

 

@NamedStoredProcedureQuery(
        name = "multiply",
        procedureName = "proc_multiply",
        parameters = {
                @StoredProcedureParameter(name = "inParam", mode = ParameterMode.IN, type = Integer.class),
                @StoredProcedureParameter(name = "outParam", mode = ParameterMode.OUT, type = Integer.class)
        }
)
public class Member {
...

 

 

@NamedStoredProcedureQuery 로 정의하고 name 속성으로 이름을 부여한다. procedureName 에 실제 호출할 프로시저 이름을 적어주고 @StoredProcedureParameter 로 파라미터 정보를 정의한다. (여러 개의 스토어드 프로시저를 사용한다면 @NamedStoredProcedureQueries 를 사용한다)

 

 

 

그 다음으로는 한 번에 여러 데이터를 수정할 수 있는 벌크 연산에 대해 알아본다. 엔티티를 수정하려면 영속성 컨텍스트의 변경 감지 기능이나 병합을 사용하고, 삭제하려면 EntityManager.remove() 메소드를 사용한다. 하지만 이 방법으로 수백 개 이상의 엔티티를 하나씩 처리하기에는 시간이 너무 오래 걸린다. 이럴 때 여러 건을 한 번에 수정하거나 삭제하는 벌크 연산을 사용할 수 있다. 

 

 

String qlString =
        "UPDATE Product p " +
                "SET p.price = p.price * 1.1 " +
                "WHERE p.stockAmount < :stockAmount";
int resultCount = em.createQuery(qlString).setParameter("stockAmount", 10).executeUpdate();

String qlString2 =
        "DELETE FROM Product p " +
                "WHERE p.price < :price";
int resultCount2 = em.createQuery(qlString2).setParameter("price", 100).executeUpdate();

 

 

벌크 연산은 executeUpdate() 메소드를 사용한다. 이 메소드는 벌크 연산으로 영향을 받은 엔티티 건수를 반환한다. (JPA 표준은 아니지만 하이버네이트는 INSERT 벌크 연산도 지원한다)

 

 

벌크 연산을 사용할 때에는 벌크 연산이 영속성 컨텍스트를 무시하고 데이터베이스에 쿼리한다는 점에 주의해야 한다. (JPQL 에서는 조회한 엔티티만 영속성 컨텍스트가 관리한다. 또, JPQL 은 항상 데이터베이스에 SQL 을 실행해서 결과를 조회한다. (하지만 조회한 엔티티가 이미 영속성 컨텍스트 내에 있을 경우 조회한 결과를 버리고 영속성 컨텍스트 내의 엔티티를 반환한다)) 벌크 연산을 수행한 직후에 업데이트된 엔티티를 정확하게 조회하기 위해서는 em.refresh() 를 사용하여 데이터베이스에서 다시 엔티티를 조회한다. 혹은 벌크 연산을 가장 먼저 실행하고 그 다음에 엔티티를 조회하면 이미 업데이트 된 내용의 엔티티를 불러온다. 또 다른 방법으로는, 벌크 연산을 수행한 직후에 바로 영속성 컨텍스트를 초기화하여 영속성 컨텍스트에 남아 있는 엔티티를 제거할 수 있다. (그렇지 않으면 엔티티를 조회할 때 영속성 컨텍스트에 남아있는 엔티티를 조회할 수 있는데, 이 엔티티에는 벌크 연산이 적용되어 있지 않음)

 

 

 

마지막으로 Flush 에 대해 다시 알아본다. Flush 는 영속성 컨텍스트의 변경 내역을 데이터베이스에 동기화하는 것이다. 플러시를 호출하려면 em.flush() 메소드를 직접 사용할 수도 있지만, 보통 FlushMode 에 따라 커밋하기 직전이나 쿼리 실행 직전에 자동으로 호출된다.

 

 

FlushMode 는 FlushMode.AUTO 가 기본값이다. (커밋 또는 쿼리 실행 시 플러시) 따라서 JPA 는 트랜잭션 커밋 직전이나 쿼리 실행 직전에 자동으로 플러시를 호출한다. 다른 옵션으로는 FlushMode.COMMIT 이 있는데, 이는 커밋 시에만 플러시를 호출하고 쿼리 실행 시에는 플러시를 호출하지 않는다. 이 옵션은 성능 최적화를 위해 꼭 필요할 때만 사용해야 한다. (플러시가 너무 자주 일어나는 상황에 이 모드를 사용하면 쿼리 시 발생하는 플러시 횟수를 줄여서 성능을 최적화할 수 있다)

 

 

 

 

 

 

 

 

 

Reference:

자바 ORM 표준 JPA 프로그래밍