객체지향 쿼리 언어 - 네이티브 SQL, 객체지향 쿼리 심화

10. 객체지향 쿼리 언어

10.5 네이티브 SQL

  • JPQL은 아래와 같은 내용은 지원하지 않는다.
    • 특정 데이터베이스만 지원하는 함수, 문법, SQL 쿼리 힌트
    • 인라인 뷰(From 절에서 사용하는 서브쿼리), UNION, INTERSECT
    • 스토어드 프로시저
  • 특정 데이터베이스에 종속적인 기능을 지원하는 방법은 아래와 같다.
    • 특정 데이터베이스에만 사용하는 함수
      • JPQL에서 네이티브 SQL 함수를 호출할 수 있다.
      • 하이버네이트는 데이터베이스 방언에 각 데이터베이스에 종속적인 함수들을 정의해두었다.
    • 특정 데이터베이스에만 지원하는 SQL 쿼리 힌트
      • 하이버네이트를 포함한 몇몇 JPA 구현체들이 지원한다.
    • 인라인 뷰(From 절에서 사용하는 서브쿼리), UNION, INTERSECT
      • 하이버네이트는 지원하지 않지만 일부 JPA 구현체들이 지원한다.
    • 스토어드 프로시저
      • JPQL에서 스토어드 프로시저를 호출할 수 있다.
    • 특정 데이터베이스만 지원하는 문법
      • 오라클의 CONNECT BY처럼 특정 데이터베이스에 너무 종속적인 SQL 문법은 지원하지 않는다. 네이티브 SQL을 사용해야 한다.
  • JPA는 SQL을 직접 사용할 수 있는 기능을 제공하는데 이를 네이티브 SQL이라 한다.
  • 네이티브 SQL을 사용하면 엔티티를 조회할 수 있고, 영속성 컨텍스트의 기능을 그대로 사용할 수 있다. JDBC API를 직접 사용하는 경우는 JPA의 기능을 사용할 수 없다.

10.5.1 네이티브 SQL 사용

엔티티 조회

  • em.createNativeQuery(SQL, 결과 클래스) 메서드를 사용하여 엔티티를 조회한다.
  • 위치 기반 파라미터만 지원한다. 단, 하이버네이트에서는 이름 기반 파라미터를 지원한다.
@Test
void projectionEntity() {
    // 위치 기반 파라미터
    String sql1 = "select member_id, age, name, team_id from member where age > ?";
    List<Member> result1 = em.createNativeQuery(sql1, Member.class)
            .setParameter(1, 20)
            .getResultList();

    assertThat(result1.size()).isEqualTo(2);
    assertThat(result1).extracting("name").containsExactly("member3", "member4");

    for (Member member : result1) {
        System.out.println("member = " + member);
    }

    // 이름 기반 파라미터
    String sql2 = "select member_id, age, name, team_id from member where age > :age";
    List<Member> result2 = em.createNativeQuery(sql2, Member.class)
            .setParameter("age", 20)
            .getResultList();

    assertThat(result2.size()).isEqualTo(2);
    assertThat(result2).extracting("name").containsExactly("member3", "member4");
    for (Member member : result2) {
        System.out.println("member = " + member);
    }
}

값 조회

  • em.createNativeQuery(SQL) 메서드의 두 번째 파라미터를 사용하지 않으면 값으로 조회한다.
  • 값들은 Object[]로 반환한다.
  • 영속성 컨텍스트가 관리하지 않는다.
@Test
void projectionValue() {
    String sql = "select member_id, age, name, team_id from member where age > ?";
    List<Object[]> result = em.createNativeQuery(sql)
            .setParameter(1, 10)
            .getResultList();

    assertThat(result).size().isEqualTo(3);

    for (Object[] row : result) {
        System.out.println("member_id = " + row[0]);
        System.out.println("age = " + row[1]);
        System.out.println("name = " + row[2]);
        System.out.println("team_id = " + row[3]);
    }
}

결과 매핑 사용

  • 엔티티와 스칼라 값을 함께 조회하는 것처럼 매핑이 복잡해지면 @SqlResultSetMapping을 정의해서 결과 매핑을 사용한다.
  • 아래 예제는 회원 엔티티와 ORDER_COUNT 컬럼을 매핑한다.
  • MEMBER_ID, AGE, NAME, TEAM_ID 컬럼은 Member 엔티티와 매핑하고 ORDER_COUNT는 값으로 매핑한다.
@Test
void resultMapping1() {
    String sql = "select m.member_id, age, name, team_id, i.order_count " +
            "from member m " +
            "left join " +
            "(select im.member_id, count(*) as order_count " +
            "from orders o, member im " +
            "where o.member_id = im.member_id " +
            "group by o.member_id) i " +
            "on m.member_id = i.member_id";

    List<Object[]> result = em.createNativeQuery(sql, "memberWithOrderCount").getResultList();
    for (Object[] row : result) {
        Member member = (Member) row[0];
        BigInteger orderCount = (BigInteger) row[1];

        System.out.println("member = " + member);
        System.out.println("orderCount = " + orderCount);
    }
}
@Setter @Getter
@NoArgsConstructor
@ToString(exclude = "team")
@Entity
@SqlResultSetMapping(name = "memberWithOrderCount",
        entities = {@EntityResult(entityClass = Member.class)},
        columns = {@ColumnResult(name = "order_count")})
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String name;
    private int age;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    public Member(String name, int age, Team team) {
        this.name = name;
        this.age = age;
        this.team = team;
    }
}
  • @FieldResult를 사용해서 컬럼명과 필드명을 직접 매핑한다.
@Test
void resultMapping2() {
    String sql = "select o.order_id as order_id, " +
            "o.quantity as order_quantity, " +
            "o.item_id as order_item, " +
            "o.member_id as member_id, " +
            "i.name as item_name, " +
            "from orders o, item i " +
            "where (o.quantity > 25) and (o.item_id = i.item_id)";

    List<Object[]> result = em.createNativeQuery(sql, "OrderResults").getResultList();
    for (Object[] row : result) {
        Order order = (Order) row[0];
        String itemName = (String) row[1];

        System.out.println("order = " + order);
        System.out.println("itemName = " + itemName);
    }
}
@Getter @Setter
@NoArgsConstructor
@ToString(of = {"id", "quantity"})
@Entity
@Table(name = "orders")
@SqlResultSetMapping(name = "OrderResults",
        entities = {
        @EntityResult(entityClass = Order.class, fields = {
                @FieldResult(name = "id", column = "order_id"),
                @FieldResult(name = "member", column = "member_id"),
                @FieldResult(name = "quantity", column = "order_quantity"),
                @FieldResult(name = "item", column = "order_item")})},
        columns = {@ColumnResult(name = "item_name")}
)
public class Order {

    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    private int quantity;

    public Order(Member member, Item item, int quantity) {
        this.member = member;
        this.item = item;
        this.quantity = quantity;
    }
}

@Getter @Setter
@NoArgsConstructor
@Entity
public class Item {

    @Id @GeneratedValue
    @Column(name = "item_id")
    private Long id;

    private String name;

    public Item(String name) {
        this.name = name;
    }
}

결과 매핑 어노테이션

  • @SqlResultSetMapping
속성 기능
name 결과 매핑 이름
entities @EntityResult를 사용해서 엔티티를 결과로 매핑한다.
columns @ColumnResult를 사용해서 컬럼을 결과로 매핑한다.
  • @EntityResult
속성 기능
entityClass 결과로 사용할 엔티티 클래스를 지정한다.
fields @FieldResult를 사용해서 결과 컬럼을 필드와 매핑한다.
discriminatorColumn 엔티티의 인스턴스 타입을 구분하는 필드(상속에서 사용됨)
  • @FieldResult
속성 기능
name 결과를 받을 필드명
column 결과 컬럼명
  • @ColumnResult
속성 기능
name 결과 컬럼명

10.5.2 Named 네이티브 SQL

@Setter @Getter
@NoArgsConstructor
@ToString(exclude = "team")
@Entity
@NamedNativeQueries({
        // 엔티티 조회
        @NamedNativeQuery(
                name = "Member.memberSQL",
                query = "select member_id, age, name, team_id from member where age > ?",
                resultClass = Member.class
        ),
        // 결과 매핑 사용
        @NamedNativeQuery(
                name = "Member.memberWithOrderCount",
                query = "select m.member_id, age, name, team_id, i.order_count " +
                        "from member m " +
                        "left join " +
                        "(select im.member_id, count(*) as order_count " +
                        "from orders o, member im " +
                        "where o.member_id = im.member_id " +
                        "group by o.member_id) i " +
                        "on m.member_id = i.member_id",
                resultSetMapping = "memberWithOrderCount"
        )
})
@SqlResultSetMapping(name = "memberWithOrderCount",
        entities = {@EntityResult(entityClass = Member.class)},
        columns = {@ColumnResult(name = "order_count")})
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String name;
    private int age;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    public Member(String name, int age, Team team) {
        this.name = name;
        this.age = age;
        this.team = team;
    }
}
  • 엔티티를 조회하는 경우이다.
@Test
void namedNativeQuery1() {
    List<Member> result = em.createNamedQuery("Member.memberSQL", Member.class)
            .setParameter(1, 20)
            .getResultList();

    assertThat(result.size()).isEqualTo(2);
    assertThat(result).extracting("name").containsExactly("member3", "member4");

    for (Member member : result) {
        System.out.println("member = " + member);
    }
}
  • @NamedNativeQuery 어노테이션에서 조회 결과를 매핑할 대상을 지정한 경우이다.
@Test
void namedNativeQuery2() {
    List<Object[]> result = em.createNamedQuery("Member.memberWithOrderCount")
            .getResultList();

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

        System.out.println("member = " + member);
        System.out.println("orderCount = " + orderCount);
    }
}

@NamedNativeQuery

속성 기능
name 네임드 쿼리 이름(필수)
query SQL 쿼리(필수)
hints 벤더 종속적인 힌트
resultClass 결과 클래스
resultSetMapping 결과 매핑 사용

10.5.4 네이티브 SQL 정리

  • 네이티브 SQL도 Query, TypeQuery(Named 네이티브 쿼리의 경우에만)을 반환하므로 JQPL API를 그대로 사용가능하다.
  • 페이징 처리 API는 아래와 같이 사용한다.
@Test
void nativeQueryPaging() {
    List<Member> result = em.createNativeQuery("select member_id, age, name, team_id from member", Member.class)
            .setFirstResult(1)
            .setMaxResults(2)
            .getResultList();

    assertThat(result.size()).isEqualTo(2);
    assertThat(result).extracting("name").containsExactly("member2", "member3");
}
  • 네이티브 SQL은 관리하기 쉽지 않고 특정 데이터베이스에 종속적인 쿼리가 증가한다.
  • 될 수 있으면 표준 JPQL을 사용하고 기능이 부족하면 하이버네이트같은 JPA 구현체가 제공하는 기능을 사용하는게 좋다.
  • 그래도 안되면 마지막 방법으로 네이티브 SQL을 사용하고 그래도 부족하다면 MyBatis나 JdbcTemplate같은 SQL 매퍼와 JPA를 함께 사용하는 것도 고려할만하다.

10.6 객체지향 쿼리 심화

10.6.1 벌크 연산

  • 엔티티를 수정하려면 변경 감지 기능이나 병합을 사용한다.
  • 엔티티를 삭제하려면 EntityManager.remove() 메서드를 사용한다.
  • 여러개의 엔티티를 한 번에 수정하거나 삭제할 때는 벌크 연산을 사용한다.
  • 벌크 연산은 executeUpdate() 메서드를 사용한다.
@Test
void update() {
    int count = em.createQuery("update Member m set m.age = 40 where m.age >= :age")
            .setParameter("age", 30)
            .executeUpdate();

    assertThat(count).isEqualTo(2);
}
@Test
void delete() {
    int count = em.createQuery("delete from Order o")
            .executeUpdate();

    assertThat(count).isEqualTo(5);
}

벌크 연산의 주의점

  • 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 SQL문을 전송한다.
  • 따라서 영속성 컨텍스트에 있는 엔티티의 데이터와 데이터베이스의 데이터가 일치하지 않을 수 있다.
@Test
    void bulk() {
        Member foundMember = em.createQuery("select m from Member m where m.name = :name", Member.class)
                .setParameter("name", "member1")
                .getSingleResult();

        assertThat(foundMember.getAge()).isEqualTo(10);

        int count = em.createQuery("update Member m set m.age = :age")
                .setParameter("age", 40)
                .executeUpdate();

        // 벌크 연산은 영속성 컨텍스트를 통하지 않고 데이터베이스에 직접 SQL문을 전송하므로,
        // 영속성 컨텍스트에 있는 회원과 데이터베이스에 있는 회원의 나이가 다를 수 있다.
        assertThat(foundMember.getAge()).isEqualTo(10);
    }
  • 이러한 불일치를 해결하기 위해 아래와 같은 해결책을 사용할 수 있다.
  • em.refresh() 사용
    • 벌크 연산을 수행한 직후에 엔티티를 사용해야 한다면 em.refresh() 메서드를 사용해서 데이터베이스에서 다시 조회한다.
  • 벌크 연산 먼저 실행
  • 벌크 연산 수행 후 영속성 컨텍스트 초기화
    • 영속성 컨텍스트를 초기화하면 엔티티를 조회할 때 벌크 연산이 적용된 데이터베이스에서 엔티티를 다시 조회한다.

10.6.2 영속성 컨텍스트와 JPQL

쿼리 후 영속 상태인 것과 아닌 것

  • JPQL로 엔티티를 조회하면 영속성 컨텍스트에서 관리되지만 엔티티가 아니면 영속성 컨텍스트에서 관리되지 않는다.
String jpql1 = "select m from Member m" // 엔티티 조회, 영속성 컨텍스트에서 관리된다.
String jpql2 = "select o.address from Order o" // 임베디드 타입 조회, 영속성 컨텍스트에서 관리되지 않는다.
String jpql3 = "select m.username from Member m" // 단순 필드 조회,  영속성 컨텍스트에서 관리되지 않는다.

JPQL로 조회한 엔티티와 영속성 컨텍스트

  • JPQL로 데이터베이스에서 조회한 엔티티가 영속성 컨텍스트에 이미 있으면 JPQL로 데이터베이스에서 조회한 결과를 버리고, 영속성 컨텍스트에 있던 엔티티를 반환한다.

find() vs JPQL

  • find() 메소드는 엔티티를 영속성 컨텍스트에서 먼저 찾고 없으면 데이터베이스에서 찾는다.
  • JPQL은 항상 데이터베이스에 SQL을 실행해서 결과를 조회한다.

10.6.3 JPQL과 플러시 모드

  • 플러시는 영속성 컨텍스트의 변경 내역을 데이터베이스에 동기화 한다. 플러시가 일어날 때 영속성 컨텍스트에 등록, 수정, 삭제한 엔티티를 찾아서 INSERT, UPDATE, DELETE SQL을 만들어 데이터베이스에 반영한다.
  • 플러시는 플러시 모드에 따라 커밋하기 직전 또는 쿼리 실행 직전에 자동으로 호출된다.
  • 플러시 모드는 FlushModeType.AUTO가 기본 값이다. (커밋 직전 또는 쿼리 실행 직전에 자동으로 호출한다.)
  • FlushModeType.COMMIT 옵션은 커밋시에만 플러시를 호출한다. 쿼리시에는 호출하지 않는다.

쿼리와 플러시 모드

  • JPQL은 영속성 컨텍스트에 있는 데이터를 고려하지 않고 데이터베이스에서 데이터를 조회하므로, JPQL을 실행하기 전에 영속성 컨텍스트의 내용을 데이터베이스에 반영해야 한다.
  • 플러시 모드가 AUTO이면 JPQL 쿼리 실행 직전에 플러시되어 영속성 컨텍스트의 내용이 데이터베이스에 동기화된다.
  • 플러시 모드가 COMMIT이면 JPQL 쿼리 실행시 영속성 컨텍스트에 반영된 데이터를 조회할 수 없다.
  • COMMIT 모드에서 쿼리 실행 전에 플러시를 호출하고 싶으면 em.flush() 메서드를 호출하거나, setFlushMode() 메서드로 해당 쿼리에서만 사용 할 플러시 모드를 AUTO로 변경한다.

플러시 모드와 최적화

  • FlushModeType.COMMIT 모드는 트랜잭션을 커밋할 때만 플러시하고 쿼리를 실행할 때는 플러시하지 않는다.
  • 따라서 해당 모드에서 JPA 쿼리를 사용할 때 영속성 컨텍스트에 있지만 데이터베이스에 반영하지 않은 데이터는 조회할 수 없다.
  • 하지만 상황에 따라 플러시가 너무 자주 일어나는 상황에 이 모드를 사용하면 플러시 횟수를 줄여서 최적화 할 수 있다.
  • JDBC를 직접 사용하여 SQL을 실행할 때 플러시 모드를 AUTO로 설정해도 플러시가 일어나지 않는다. 따라서 JDBC로 쿼리를 실행하기 직전에 em.flush()를 호출해서 영속성 컨텍스트의 내용을 데이터베이스에 동기화 하는 것이 안전하다.

Tags:

Categories:

Updated:

Leave a comment