다양한 이유로 JPQL을 사용할 수 없을 때 JPA는 SQL을 직접 사용할 수 있는 기능을 제공하는데 이것을 네이티브 SQL이라 한다. JPQL을 사용하면 JPA가 SQL을 생성하고 네이티브 SQL은 이 SQL을 개발자가 직접 정의하는 것이다.
네이티브 SQL을 사용하면 엔티티를 조회할 수 있고 JPA가 지원하는 영속성 컨텍스트의 기능을 그대로 사용할 수 있다. 반면에 JDBC API를 직접 사용하면 단순히 데이터의 나열을 조회할 뿐이다.
// 결과 타입 정의
public Query createNativeQuery(String sqlString, Class resultClass);
//결과 타입을 정의할 수 없을 때
public Query createNativeQuery(String sqlString);
public Query createNativeQuery(String sqlString, String resultSetMapping);	//결과 매핑 사용
네이티브 SQL은 em.createNativeQuery(SQL, 결과 클래스)를 사용한다. 첫 번째 파라미터에 네이티브 SQL을 입력하고 두 번째 파라미터에는 조회할 엔티티 클래스의 타입을 입력한다. JPQL를 사용할 때와 거의 비슷하지만 실제 데이터베이스 SQL을 사용한다는 것과 위치기반 파라미터만 지원한다는 차이가 있다.
//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();
네이티브 SQL로 SQL만 직접 사용할 뿐 JPQL을 사용하는 방법과 같다. 조회한 엔티티도 영속성 컨텍스트에서 관리된다.
JPA는 공식적으로 네이티브 SQL에서 위치 기반 파라미터만 지원한다. 하지만 하이버네이트는 네이티브 SQL에 이름 기반 파라미터를 사용할 수 있다. 따라서 하이버네이트 구현체를 사용한다면 예제를 이름 기반 파라미터로 변경해도 동작한다.
//SQL 정의
String sql = "SELECT ID, AGE, NAME, TEAM_ID " + 
			"FROM MEMBER WHERE AGE > ?";
            
Query nativeQuery = em.createNativeQuery(sql)
				.setParameter(1, 10);           
                
List<Object[]> resultList = nativeQuery.getResultList();
이렇게 여러 값으로 조회하려면 em.createNativeQuery(SQL)의 두 번째 파라미터를 사용하지 않으면 된다. JPA는 조회한 값들을 Object[]에 담아서 반환한다. 여기서는 스칼라 값들만 조회했을 뿐이므로 결과를 영속성 컨텍스트가 관리하지 않는다. 마치 JDBC로 데이터를 조회한 것과 비슷하다.
엔티티와 스칼라 값을 함께 조회하는 것처럼 매핑이 복잡해지면 @SqlResultSetMapping을 정의해서 결과 매핑을 사용해야 한다.
회원 엔티티와 회원이 주문한 상품 수를 조회하는 예제를 보자.
//SQL 정의
	String sql = 
        "SELECT 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];
        System.out.println("member = " + member);
        System.out.println("orderCount = " + orderCount);
    }
        
em.creteNativeQuery(sql, "memberWithOrderCount")의 두 번째 파라미터에 결과 매핑 정보의 이름이 사용되었다. 결과 매핑을 정의하는 코드를 보자.
@Entity
@SqlResultSetMapping(name = "memberWithOrderCount",
    entities = {@EntityResult(entityClass = Member.class)},
    columns = {@ColumnResult(name = "ORDER_COUNT")})
public class Member {...}
ID, AGE, NAME, TEAM_ID는 Member 엔티티와 매핑하고 ORDER_COUNT는 단순히 값으로 매핑한다.
@FieldResult를 사용해서 컬럼명과 필드명을 직접 매핑할 수도 있다. 이 어노테이션을 한 번이라도 사용하면 전체 필드를 @FieldResult로 매핑해야 한다.
또한 다음과 같이 두 엔티티를 조회하는데 컬럼명이 중복될 때도 사용해야 한다.
SELECT A.ID, B.ID FROM A, B
A, B 엔티티 둘 다 ID라는 필드를 가지고 있어 컬럼명이 충돌한다. 따라서 다음과 같이 별칭을 적절히 사용하고 @FieldResult로 매핑하면 된다.
SELECT
	A.ID AS A_ID
    B.ID AD B_ID
FROM A, B
네이티브 SQL도 앞서 설명한 @NamedNativeQuery를 이용해서 Named 쿼리를 사용할 수 있다.
@Entity
@NamedNativeQuery(
    name = "Member.memberSQL",
    query = "SELECT ID, AGE, NAME, TEAM_ID " +
        "FROM MEMBER WHERE AGE > ?",
    resultClass = Member.class
)
public class Member {...}
//사용 예제
TypedQuery<Member> nativeQuery =
        em.createNamedQuery("Member.memberSQL", Member.class)
            .setParameter(1, 20);
Named 네이티브 쿼리에서 resultSetMapping 속성을 이용해서 조회 결과를 매핑할 대상도 지정할 수있다.
네이티브 SQL도 JPQL처럼 Query, TypeQuery(Named 네이티브 쿼리의 경우에만)를 반환한다. 따라서 JPQL API를 그대로 사용할 수 있어 페이징 처리 API도 적용할 수 있다.
네이티브 SQL은 특정 데이터베이스에 종속적인 쿼리가 증가해서 이식성이 떨어진다. 그러므로 될수록 표준 JPQL을 사용하고 기능이 부족하면 하이버네이트 같은 JPA 구현체가 제공하는 기능을 사용하자. 그래도 안 될 때 네이티브 SQL을 사용하자.
네이티브 SQL로도 부족하다면 MyBatis나 스프링 프레임워크가 제공하는 JdbcTemplate같은 SQL 매퍼와 JPA를 함께 사용하는 것도 고려해보자.
JPA는 2.1부터 스토어드 프로시저를 지원한다.
스토어드 프로시저(Stored Procedure)는 데이터베이스에서 미리 정의된 SQL 코드 집합을 말합니다. 쉽게 말하면, 자주 사용되는 SQL 쿼리나 연산을 미리 데이터베이스에 저장해두고 필요할 때마다 호출해서 실행할 수 있도록 한 기능입니다.
예를 들어, 고객 정보에 대한 조회를 자주 하는 경우, 이런 작업을 스토어드 프로시저로 만들어두고, 고객 정보 조회가 필요할 때마다 호출할 수 있습니다.
CREATE PROCEDURE GetCustomerInfo(IN customerId INT)
BEGIN
    SELECT * FROM Customers WHERE id = customerId;
END;
이렇게 만들어진 프로시저는 GetCustomerInfo(1)처럼 호출하여 사용할 수 있습니다. 이 프로시저는 customerId 값을 받아 해당 고객 정보를 반환하는 역할을 합니다.
즉, 스토어드 프로시저는 데이터베이스 내에서 효율적이고 안정적으로 데이터를 처리할 수 있는 도구입니다.
입력 값을 두 배로 증가시켜 주는 proc_multiply라는 스토어드 프로시저를 사용해 보자. 이 프로시저는 첫 번째 파라미터로 값을 입력받고 두 번째 파라미터로 결과를 반환한다.
// proc_multiply MySQL 프로시저
DELIMITER //
CREATE PROCEDURE proc_multiply (INOUT inParam INT, INOUT outParam INT)
BEGIN
	SET outParam = inParam * 2;
END	//
JPA로 위 스토어드 프로시저를 호출해보자.
	//순서 기반 파라미터 호출
    StoredProcedureQuery spq = 
        em.createStoredProcedureQuery("proc_multiply");
    spq.registerStoredProcedureParamter(1, Integer.class, ParameterMode.IN);	//1
    spq.registerStoredProcedureParamter(2, Integer.class, ParameterMode.OUT);	//2
    
    spq.setParamter(1,100);	//위에 1
    spq.execute();
    
    Integer out = (Integer) spq.getOutputParameterValue(2);	//위에 2
    System.out.println("out = " + out); //결과 = 200
스토어드 프로시저를 사용하려면 em.createStoredProcedureQuery() 메소드에 사용할 스토어드 프로시저 이름을 이력하면 된다. 그리고 registeStoredProcedureParameter() 메소드를 사용해서 프로시저에서 사용할 파라미터를 순서, 타입, 파라미터 모드 순으로 정의하면 된다.
//사용할 수 있는 ParameterMode
public enum ParameterMode {
	IN,			//INPUT 파라미터
    INOUT,		//INPUT, OUTPUT 파라미터
    OUT,		//OUTPUT 파라미터
    REF_CURSOR	//CURSOR 파라미터
}    
파라미터에 이름을 사용하는 방법도 있는데 아래와 같다.
	// 파라미터에 이름 사용
    StoredProcedureQuery spq = 
        em.createStoredProcedureQuery("proc_multiply");
    spq.registerStoredProcedureParamter("inParam", Integer.class, ParameterMode.IN);	//1
    spq.registerStoredProcedureParamter("outParam", Integer.class, ParameterMode.OUT);	//2
    
    spq.setParamter("inParam",100);	//위에 1
    spq.execute();
    
    Integer out = (Integer) spq.getOutputParameterValue("outParam");	//위에 2
    System.out.println("out = " + out); //결과 = 200
스토어드 프로시저 쿼리에 이름을 부여해서 사용하는 것을 Named 스토어드 프로시저라 한다.
@Entity
@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를 사용하면 된다.
XML에 정의해서 사용하는 방법도 있다.
여러 건을 한 번에 수정하거나 삭제하는 벌크 연산에 대해 알아보자.
재고가 10개 미만인 모든 상품의 가격을 10 % 상승시키는 예제를 살펴보자.
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();
벌크 연산은 위처럼 executeUpdate() 메소드를 사용한다. 이 메소드는 벌크 연산으로 영향 받은 엔티티 건수를 반환한다. 삭제도 같은 메소드를 사용한다.
(JPA 표준은 아니지만 하이버네이트는 INSERT에도 벌크 연산을 지원한다고 함)
벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리한다는 점을 주의해야 한다.
예를 들어 가격이 1000원인 상품 A를 조회한 후 벌크 연산으로 모든 상품의 가격을 10% 인상했다고 해보자. 다시 상품 A를 조회하면 1100원의 가격을 기대하지만 실제로는 1000원이 출력된다.
이는 처음에 조회한 후 상품 A는 영속성 컨텍스트에 관리하기 때문에 가격이 1000원이고 벌크 연산은 직접 데이터베이스에 쿼리를 날리기 때문이다. 따라서 영속성 컨텍스트에 있는 상품 A와 데이터베이스에 있는 상품A의 가격이 다를 수 있다.
em.refresh() 사용
벌크 연산 후 필요하다면 em.refresh()를 이용해서 데이터베이스에서 상품A를 다시 조회하면 된다.
em.refresh(productA);	//데이터베이스에 상품A 재조회
벌크 연산 먼저 실행
가장 실용적인 해결방법으로 벌크 연산을 가장 먼저 실행하는 방법이 있다.
벌크 연산 수행 후 영속성 컨텍스트 초기화
벌크 연산 후 영속성 컨텍스트를 초기화하면 재조회시 데이터베이스에서 엔티티를 조회하기 때문에 이 방법도 좋은 방법이다.
벌크 연산은 영속성 컨텍스트와 2차 캐시를 무시하고 데이터베이스에 직접 실행하므로 주의해서 사용하자.
JPQL의 조회 대상은 엔티티, 임베디드 타입, 값 타입으로 다양하다. 엔티티를 조회하면 영속성 컨텍스트에서 관리되지만 다른 타입은 그렇지 않다. 예를 들어 임베디드 타입은 조회해서 값을 변경해도 영속성 컨텍스트가 관리하지 않으므로 변경 감지에 의한 수정이 발생하지 않는다. 물론 엔티티를 조회하면 해당 엔티티가 가지고 있는 임베디드 타입은 함께 수정된다.
select m from Member m //엔티티 조회 (관리O)
select o.address from Order o //임베디드 타입 조회 (관리 x)
select m.id, m.username from Member m	//단순 필드 조회 (관리x)
JPQL로 데이터베이스에서 조회한 엔티티가 영속성 컨텍스트에 이미 있으면 JPQL로 데이터베이스에서 조회한 결과를 버리고 대신에 영속성 컨텍스트에 있던 엔티티를 반환한다. 이때 식별자 값을 사용해서 비교한다.
영속성 컨텍스트는 영속 상태인 엔티티의 동일성을 보장하기 때문에 영속성 컨텍스트에 이미 엔티티가 존재한다면 기존 엔티티는 그대로 두고 새로 검색한 엔티티를 버린다. em.find()로 조회하든 JPQL을 사용하든 영속성 컨텍스트가 같으면 동일한 엔티티를 반환한다.
em.find() 메소드는 엔티티를 영속성 컨텍스트에서 먼저 찾고 없으면 데이터베이스에서 찾는다. 따라서 엔티티가 영속성 컨텍스트에 있으면 메모리에서 바로 찾으므로 성능상 이점이 있다.(1차캐시)
하지만 JPQL은 항상 데이터베이스에 SQL을 실행해서 결과를 조회한다. 앞서 말한 것처럼 영속성 컨텍스트에 엔티티가 존재하면 기존 엔티티를 반환해서 동일성을 보장하긴 한다.
(플러시가 기억나지 않으면 3장을 다시 보고 오자)
플러시는 영속성 컨텍스트의 변경내역을 데이터베이스에 동기화하는 것이다. 플러시를 호출하려면 em.flush() 메소드를 직접 사용해도 되지만 보통 플러시 모드FlushMode에 따라 커밋하기 직전이나 쿼리 실행 직전에 자동으로 플러시가 호출된다.
em.setFlushMode(FlushModeType.AUTO);	//커밋 또는 쿼리 실행 시 플러시 (기본값)
em.setFlushMode(FlushModeType.COMMIT);	//커밋시에만 플러시
플러시 모드는 AUTO가 기본값이다. 따라서 JPA는 트랜잭션 커밋 직전이나 쿼리 실행 직전에 자동으로 플러시를 호출한다. COMMIT옵션은 커밋 시에만 플러시하고 쿼리 실행 시에는 플러시를 호출하지 않는다. 이 옵션은 성능 최적화를 위해 꼭 필요할 때만 사용해야 한다.
JPQL은 영속성 컨텍스트에 있는 데이터를 고려하지 않고 데이터베이스에서 데이터를 조회한다. 따라서 JPQL을 실행하기 전에 플러시를 하지 않으면 문제가 발생할 수 있다.
//예제
//가격을 1000원에서 2000원으로 변경
product.setPrice(2000);
//가격이 2000원 상품 조회
Product product2 =
		em.createQuery("select p from Product p where p.price = 2000",Product.class)
        .getSingleResult();
예를 들어 상품 가격을 1000원에서 2000원으로 수정했다고 해보자. 이 다음 JPQL로 가격이 2000원인 조회를 했다고 하자. 플러시 모드가 AUTO일 때는 쿼리가 실행하기 전에 영속성 컨텍스트가 플러시 되어 방금 2000원으로 수정한 상품을 조회할 수 있다.
만약 플러시 모드가 COMMIT이였다면 쿼리시에는 플러시를 하지 않으므로 방금 2000원으로 수정한 상품을 조회할 수 없다. 이 때는 JPQL을 사용하기 전에 직접 em.flush()를 호출하거나 setFlushMode()를 설정해 주면 된다.
em.setFlushMode(FlushModeType.COMMIT);	//커밋시에만 플러시
//가격을 1000원에서 2000원으로 변경
product.setPrice(2000);
//1. em.flush() //직접 호출
//가격이 2000원 상품 조회
Product product2 =
		em.createQuery("select p from Product p where p.price = 2000",Product.class)
        .setFlushMode(FlushModeType.AUTO)	//2. setFlushMode() 설정
        .getSingleResult();
위와 같이 쿼리를 실행하기 전에 주석 1을 풀어서 직접 em.flush()를 호출해도 되고 setFlushMode()에서 AUTO로 설정하면 된다. 이렇게 쿼리에 설정하는 플러시 모드는 엔티티 매니저에 설정하는 플러시 모드보다 우선권을 가진다.
FlushModeType.COMMIT모드는 트랜잭션을 커밋할 때만 플러시하고 쿼리를 실행할 때는 플러시하지 않는다. 따라서 JPA 쿼리를 사용할 때 영속성 컨텍스트에 있지만 아직 데이터베이스에 반영하지 않은 데이터를 조회할 수 없어 데이터 무결성에 심각한 피해를 줄 수 있다. 다만 플러시가 너무 자주 일어나 성능 최적화가 필요한 경우에 COMMIT을 사용한다.
JPA를 사용하지 않고 JDBC를 직접 사용해서 SQL을 실행할 때도 플러시 모드를 고민해야 한다. JPA를 통하지 않고 JDBC로 쿼리를 직접 실행하면 JPA는 JDBC가 실행한 쿼리를 인식할 방법이 없다. 따라서 별도의 JDBC 호출은 플러시 모드를 AUTO로 설정해도 플러시가 일어나지 않는다. 이때는 JDBC로 쿼리를 실행하기 직전에 em.flush()를 호출해서 영속성 컨텍스트의 내용을 데이터베이스에 동기화하는 것이 안전하다.
참조 : [자바 ORM 표준 JPA 프로그래밍]