관리 메뉴

나구리의 개발공부기록

객체지향 쿼리 언어 - 중급 문법, 경로 표현식, 페치 조인(기본/한계), 다형성 쿼리, 엔터티 직접 사용, Named 쿼리, 벌크 연산 본문

인프런 - 스프링부트와 JPA실무 로드맵/자바 ORM 표준 JPA 프로그래밍 - 기본편

객체지향 쿼리 언어 - 중급 문법, 경로 표현식, 페치 조인(기본/한계), 다형성 쿼리, 엔터티 직접 사용, Named 쿼리, 벌크 연산

소소한나구리 2024. 10. 18. 14:40

출처 : 인프런 - 자바 ORM 표준 JPA 프로그래밍 - 기본편(유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


1. 경로 표현식

1) 정의

  • .(점)을 찍어서 객체 그래프를 탐색하는 것
  • 상태필드, 단일 값 연관필드, 컬렉션 값 연관필드가 내부적으로 동작방식이 다르기 때문에 구분해서 이해해야함
select m.username       # 상태 필드
	from Member m
		join m.team t   # 단일 값 연관 필드
		join m.orders o # 컬렉션 값 연관 필드
where t.name = '팀A'

 

2) 용어 정리

(1) 상태 필드(state field)

  • 단순히 값을 저장하기 위한 필드

(2) 연관 필드(association field)

  • 연관관계를 위한 필드

(3) 단일 값 연관 필드

  • 다대일, 일대일 매핑이 되어 연관관계 대상이 엔터티인 필드

(4) 컬렉션 값 연관 필드

  • 일대다, 다대다 매핑이 되어 연관관계 대상이 컬렉션인 필드

3) 특징

(1) 상태 필드

  • 경로 탐색의 끝이기 때문에 해당 경로에서 다른 경로로 더이상 탐색이 불가능함
  • 일반적인 조회쿼리가 나감
// 상태 필드 조회
String joinQuery = "select m.username from Member m";
List<String> resultList = em.createQuery(joinQuery, String.class).getResultList();

for (String s : resultList) {
    System.out.println("m.username = " + s);
}

출력 결과
Hibernate: 
    /* select
        m.username 
    from
        Member m */ select
            m1_0.username 
        from
            Member m1_0
m.username = 관리자
m.username = 다른사람

 

(2) 단일 값 연관 경로

  • 묵시적 내부 조인(inner join)이 발생하고 연관 필드에서 추가적인 탐색이 가능함
  • 편해보이지만 가능하면 실무에서는 이렇게 쿼리를 작성하지 않는 것을 권장함
  • 정말 작은 애플리케이션에서나 적용할만함
// 단일 값 연관필드 조회
// String joinQuery2 = "select m.team from Member m";   // 단일 값 연관필드 조회, join 발생
String joinQuery2 = "select m.team.name from Member m"; // 추가탐색이 가능함
List<String> resultList2 = em.createQuery(joinQuery2, String.class).getResultList();

for (String s : resultList2) {
    System.out.println("m.username = " + s);
}

출력 결과 - inner 조인 발생
Hibernate: 
    /* select
        m.team.name 
    from
        Member m */ select
            t1_0.name 
        from
            Member m1_0 
        join
            Team t1_0 
                on t1_0.id=m1_0.TEAM_ID
m.username = 팀A
m.username = 팀B

 

(3) 컬렉션 값 연관 경로

  • 묵시적 내부 조인이 발생하지만 추가적인 탐색은 불가능함(size는 가능)
  • from 절에서 명시적 조인을 통해 별칭을 얻으면 별칭으로 탐색이 가능함(명시적 조인을 사용)
// 컬렉션 값 연관필드 조회
String joinQuery3 = "select t.members from Team t";
// String joinQuery3 = "select t.members.size from Team t"; // 추가탐색은 size만 가능함
List<Collection> resultList3 = em.createQuery(joinQuery3, Collection.class).getResultList();
System.out.println("resultList3 = " + resultList3);

출력결과 inner join 발생
Hibernate: 
    /* select
        t.members 
    from
        Team t */ select
            m1_0.id,
            m1_0.age,
            t1_0.id,
            t1_0.name,
            m1_0.type,
            m1_0.username 
        from
            Team t1_0 
        join
            Member m1_0 
                on t1_0.id=m1_0.TEAM_ID
resultList3 = [Member{id=1, username='관리자', age=10}, Member{id=2, username='다른사람', age=10}]

4) 명시적 조인, 묵시적 조인

(1) 명시적 조인 - 권장 사항

  • from 절에 join 키워드를 직접 사용함
  • SQL과 동일한 문법으로 사용하기에 가독성이 좋아 유지보수하기가 좋음

(2) 묵시적 조인

  • 경로 표현식에 의해 묵시적으로 SQL 조인 발생
  • 항상 내부 조인이 발생됨

** 묵시적 조인 주의점

  • 컬렉션은 경로 탐색의 끝이기 때문에 명시적 조인으로 별칭을 얻어야 추가 탐색이 가능함
  • 경로 탐색은 주로 select, where 절에서 사용하지만 묵시적 조인으로 인해 SQL의 from 절에 영향을 주게 됨

(3) 예제

  • 묵시적 조인과 명시적 조인 예제
  • 실무에서는 명시적 조인을 사용하자
# 성공, 묵시적 조인으로 어떤 쿼리가 나올지 예측이 쉽지 않음
select o.member.team from Order o 
select t.members from Team # 성공, 컬렉션으로 조회

select t.members.username from Team t #실패, 컬렉션은 추가적인 경로탐색이 불가능(size만 가능)

# 성공, 명시적조인으로 어떤 쿼리가 발생될지 예측이 가능함
select m.username from Team t join t.members m

 

** 참고

  • 묵시적 조인 대신에 명시적 조인을 사용해야함
  • 조인은 SQL 튜닝의 중요 포인트이기 때문에 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기가 어려워 최적화 및 유지보수에 어려움이 있음
  • 묵시적 조인은 실무에서 아예 안쓰기로 협의하기도 함

** 실무에서 매우 중요함, 모르면 실무를 못할 정도라고함

2. 페치 조인(fetch join) - 기본

1) 정의

  • SQL 조인의 종류가 아님
  • JPQL에서 성능 최적화를 위해 제공하는 기능이며 연관된 엔터티나 컬렉션을 SQL 한 번에 함께 조회하는 기능
  • join fetch 명령어로 사용하며 [LEFT [OUTER] | INNER] JOIN FETCH 조인경로의 형식으로 작성함

2) 엔터티 페치 조인

  • 회원을 조회하면서 연관된 팀도 SQL한번으로 조회함
  • join fetch로 Member와 m.team을 조회하면 상태 필드를 m으로만 조회해도 멤버와 팀을 함께 조회함
# JPQL
select m from Member m join fetch m.team

# SQL
SELECT M.*, T.* FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID=T.ID

 

(1) 예제 구조

  • 회원은 3, 팀은 3개가 있음
  • 회원1,2는 팀A에 소속, 회원3은 팀B에 소속, 회원4는 소속되어있지않고 팀C는 아무 회원이 없음
  • 페치 조인을 사용하여 팀이 있는 회원을 조회하면 영속성 컨텍스트는 회원 1,2,3과 팀A, B 5개의 엔터티를 만들어 보관하고 반환함

좌) 테이블 구조 / 중) 조인 결과 테이블 / 우) 페치 조인 후 영속성 컨텍스트에 보관된 결과

 

(2) fetch join 미사용일 때 - N+1쿼리 문제 발생

// 엔터티 페치 조인 - 페치 조인 미적용
String fetchQuery1 = "select m from Member m";
List<Member> result = em.createQuery(fetchQuery1, Member.class).getResultList();
for (Member m : result) {
    System.out.println("member = " + m.getUsername() + ", team = " + m.getTeam().getName());
}
  • fetch join을 미적용하고 조회된 멤버에서 회원 이름과 팀을 조회하면 아래처럼 쿼리 로그가 보임
  • jpql가 실행되어 회원을 가져오는 쿼리, 최초 반복문에서 회원의 이름과 팀의 이름을 조회하는 쿼리, 마지막 반복문에서 회원의 이름과 팀의 이름을 조회하는 쿼리 총 3개의 select 쿼리가 발생함
  • 첫번째 반복문에서는 팀A에 대한 정보가 없어서 DB에서 team정보를 가져오는 쿼리가 발생하고, 2번째 반복문에서는 동일한 정보를 영속성 컨텍스트에서 관리하고 있기 때문에 캐시에서 정보를 반환함
  • 세번째 반복문에서는 팀B에 대한 정보를 가지고 있지 않아서 다시 DB에서 team 정보를 가져오는 쿼리가 발생하게 되며 N+1 문제가 발생함
  • 만약 조회해야할 값이 100개, 1000개이고 동시 접속자가 다수이면 심각한 성능저하가 발생할 수 밖에 없음
  • 즉 LAZY(지연로딩)에서도 N+1문제는 발생할 수 밖에 없고 이를 해결하기 위해서는 fetch join이 가장 권장되는 해결 방법임

 

(2) fetch join 적용

// join fetch 적용
String fetchQuery1 = "select m from Member m join fetch m.team";
// ... 나머지 코드 동일
  • 쿼리에 fetch join을 적용하고 동일한 코드를 실행하면 N+1문제가 해결되어 join 쿼리 한방으로 모든 데이터가 조회됨
  • 프록시로 실행되는 것이 아니라 실제 DB에서 값을 조회한 값을 영속성 컨텍스트에 보관하고 반환하기 때문에 반복문으로 데이터를 조회해도 영속성 컨텍스트에 보관되어있는 값이 반환됨
  • 실무에서 정말 많이 사용하는 방식이므로 꼭 알고 있어야 함

3) 컬렉션 페치 조인

  • 일대다 관계인 컬렉션을 페치 조인하여 조회

(1) 예제 구조 - 하이버네이트 5 기준 설명이며 하이버네이트 6에서는 해당 문제가 해결됨 (아래 참고를 확인)

  • 동일한 예제구조에서 팀과 회원을 페치 조인하여 조회하는 쿼리를 실행
  • 일대다 관계(컬렉션 구조)에서는 페치 조인을 하게되면 조인이 실행된 DB에서는 팀A에 회원이 2개이므로 2개의 데이터를 반환함
  • JPA는 DB에서 받은 두개의 데이터를 각각 독립적인 엔터티로 취급하여 동작하기 때문에 반환된 각 멤버를 팀A와 각각 객체를 생성하게 되고 팀A의 정보를 담은 객체가 2개가 생성되어버림
  • 그리고 생성된 2개의 팀A정보를 담은 Team객체는 join된 정보로 회원정보를 찾기 때문에 팀A에 속한 회원은 2명을 조회하게됨
  • 결국 최종 출력 결과는 팀A가 2번출력되고 팀B는 1번 출력됨

좌) 테이블 구조 / 중) 조인 결과 테이블(팀A기준) / 우) 페치 조인 결과 관계도

// 컬렉션 페치 조인
String fetchQuery2 = "select t from Team t join fetch t.members";
List<Team> result2 = em.createQuery(fetchQuery2, Team.class).getResultList();
for (Team team : result2) {
    System.out.println("team = " + team.getName() + " | members = " + team.getMembers().size());
    for (Member member : team.getMembers()) {
        System.out.println(" -> member = " + member);
    }
}

 

(2) 페치 조인과 DISTINCT

  • 위의 문제를 해결하기위해 쿼리에 distinct를 붙이면 중복된 결과를 제거하여 팀A도 1번 팀B도 1번 출력되는 결과를 받을 수 있음
  • SQL의 DISTINCT는 완벽하게 컬럼의 값들이 중복된 값만 제거하지만 JPQL에서의 DISTINCT는 추가로 애플리케이션에서 중복 제거를 시도하여 조회되는 엔터티의 같은 식별자를 가진 엔터티를 제거함
  • 여기서는 동일한 ID값을 가진 Team 엔터티를 제거함
// distinct 적용
String fetchQuery2 = "select distinct t from Team t join fetch t.members";

 

** 참고 - 하이버네이트6 부터는 자동으로 DISTINCT를 추가하여 문제가 해결되었음

  • 강의 시점에는 하이버네이트5였기 때문에 DISTINCT를 직접 추가해서 중복을 제거해줘야 했음
  • 하이버네이트6 이후부터는 DISTINCT 명령어를 사용하지 않아도 애플리케이션에서 중복 제거가 자동으로 적용
  • 즉 일대다 연관관계(컬렉션)에서의 페치 조인에서 데이터를 조회해도 중복생성된 엔터티를 제거하기 때문에 원하는 결과를 바로 적용 받을 수 있음 (객체와 관계형 데이터베이스의 간극을 줄여줌)
  • 그러나 과거 버전의 하이버네이트를 사용할 수도 있고, JPA 동작하는 원리와 해결했던 방식을 알고 있어야 미래에 대처를 할 수 있다고 생각하기에 전부 내용을 작성하였음

4) 페치 조인과 일반 조인의 차이

  • JPQL은 결과를 반환할 때 연관관계를 고려하지 않고 단지 select 절에 지정한 엔터티만 조회함
  • 즉, 일반 조인은 실행시 연관된 엔터티를 함께 조회하지 않기 때문에 여기에서는 팀 엔터티만 조회되고 회원 엔터티는 조회되지 않음
  • 페치 조인을 사용하면 연관된 엔터티도 함께 조회(즉시 로딩처럼 동작함)하게됨
  • 객체 그래프를 SQL한번에 조회하는 것처럼 동작함
  • 페치 조인으로 실무에서의 대부분의 N+1쿼리 문제를 해결함

3. 페치 조인(fetch join) - 한계

1) 한계점

(1) 페치 조인의 대상에는 별칭을 사용하는 것을 권장하지 않음

  • 페치 조인 대상에는 원칙적으로 별칭을 사용하여 추가적인 조작을 하지 않는것을 권장하고 있으며 관례적으로도 잘 사용하지 않음
  • 페치 조인으로 쿼리를 하면 연관된 엔터티를 모두 가져오게 되는데 별칭을 사용해서 특정 조건을 주게 된다면 JPA가 의도한 설계 및 동작 메커니즘과 실제 동작이 맞지 않아 예상할 수 없는 쿼리가 발생할 수 있음
  • 특정 대상으로 조회할 때는 전부 데이터를 가져왔는데, 또 어떤 상황에서는 동일 대상을 조회해도 일부만 가져오는 상황 자체를 영속성 컨텍스트가 모두 이해하여 동작하도록 설계되어있지 않기 때문에 데이터의 정합성에 문제가 발생할 가능성이 매우 높아짐
  • 그래서 페치 조인으로 특정 조건을 명시하여 일부 데이터를 가져오고 싶은 상황이 발생할 수 있는데, 페치 조인에서 탐색해서 조회하는 것이 아니라 해당 데이터만 조회하는 쿼리를 별도로 만들어서 사용해야 함
  • 유일하게 사용할 때는 fetch join을 단계적으로 적용하여 추가적인 엔터티를 한번에 가져와야할 때 정도만 사용을 한다고 볼 수 있으나 이 마저도 함부로 사용하면 위험함
  • 하이버네이트도 이러한 상황을 알기에 여러 옵션을 제공하지만 해당 옵션을 사용하는 것은 실무에서 더욱 유지보수나 운영하기가 복잡하기 때문에 별도의 쿼리로 관리하는 것을 추천
  • 하이버네이트로 구현시에는 별칭을 붙혀도 에러가 발생되지는 않지만 위험하니 가급적 사용하지 말것을 권장함
  • 본인이 JPA나 데이터를 잘 다루어 각 엔터티간의 데이터가 일관성이 깨지지 않는 선에서 사용할 수 있을 때에나 간단한 조회시에만 사용하자 - 김영한님은 성능 최적화를 위해 종종 사용한다고함

** 정합성

  • 데이터의 정확성, 일관성, 신뢰성을 의미하는 중요한 개념이며 시스템 내의 모든 데이터가 일관되고 정확하며 유효한 상태를 유지하는 것을 의미함
  • 즉, DB의 데이터가 실제 값을 정확히 반영하고 시스템이나 데이터베이스 간의 데이터 불일치가 없고 필요한 모든 데이터는 누락없이 존재해야하며 정의된 규칙과 제약 조건을 준수하는 것

(2) 둘 이상의 컬렉션은 페치 조인 할 수 없음

  • 일대다대다의 연관관계가 적용되어 데이터의 정합성에 문제가 생길 가능성이 높음
  • 적용은 가능하지만 사용하지 않는 것을 권장함

(3) 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없음

  • 기존의 하이버네이트5에서는 일대다 관계시에서 fetch join시 데이터 뻥튀기(컬렉션 페치 조인 내용 참고)가 발생하기때문에 제대로 값을 가져오지 않는 문제가 있었으나 하이버네이트6에서는 자동으로 문제를 해결하므로 해당 문제는 발생하지 않음
  • 그러나 적용해보면 하이버네이트가 경고 로그를 남기는데 메모리에서 페이징이 실행되기 때문에 장애가 발생하기 좋음
  • 실행 후 쿼리로그를 보면 페이지네이션 관련 문법을 사용해 DB에서 특정 값만 가져오는 것이 아니라 DB데이터를 전부 가져오고 난 뒤에 메모리에서 페이징을 하는 것이므로 절대 사용하지 말아야함
// 실제 발생한 에러정보
WARN: HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

2) 컬렉션 페치 조인시 페이징 문제 해결 방법 소개

(1) 다대일 관계의 쿼리로 변경

  • 조회 쿼리는 일대다가 아닌 다대일로 변경하여 페이지네이션을 적용하여 원하는 값을 가져올 수 있도록 쿼리를 작성하거나 비즈니스 로직 등을 변경

 

(2) @BatchSize를 적용

  • 기존의 일대다 연관관계에서 일쪽인 엔터티의 연관관계 매핑필드에 @BatchSize를 적용하거나 글로벌 세팅으로 적용(실무에서는 글로벌 세팅으로 적용하여 사용함)
  • persistence.xml에 propertis 속성에 아래처럼 지정하면 @BatchSize 애노테이션을 사용하지 않고도 글로벌로 적용할 수 있음
<!-- 배치 사이즈 관련 설정 -->
<property name="hibernate.default_batch_fetch_size" value="100"/>
  • 조회쿼리에 join fetch를 적용하지 않고 글로벌 전략의 지연로딩(LAZY) 로딩만 적용한 상태에서 Team 클래스의 연관관계 매핑 필드에(일대다 관계중 일(1)쪽) @BatchSize(size = 1000이하의 값)을 적용
  • BatchSize는 일반적으로 클 수록 성능 최적화에 좋지만 1000이하의 값 중 적절한 값으로 적용하면 됨(대부분의 DB에서의 IN절의 최대 항목수가 대부분 1000개이기 때문)
  • 위처럼 해결하면 N+1문제가 테이블 수만큼만 추가 쿼리가 발생하는 것으로 완전히 해결하는 것이 아닌 완화하는 방법임
// 페이징 테스트2 - 코드 실행 부분
String pagingQuery2 = "select t from Team t";
List<Team> result = em.createQuery(pagingQuery2, Team.class)
        .setFirstResult(0).setMaxResults(2)
        .getResultList();
for (Team t : result) {
    System.out.println("team = " + t.getName() + " | members = " + t.getMembers().size());
    for (Member member : t.getMembers()) {
        System.out.println(" -> member = " + member);
    }
}

// Team 클래스에 @BatchSize 적용
@BatchSize(size = 300)
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();

 

** 참고

  • 강의에서는 배치사이즈를 늘려도 쿼리 로그의 결과에서 statement의 ?의 개수가 조회된 개수만큼 로그에서 보였는데, 지금은 배치사이즈만큼 log에서 찍히게 변경됨
  • 이는 하이버네이트6.2에서 최적화하는 방식이 변경되었기 때문이며 실제 동작은 생성된 ?에서 필요한 값만 채우고 나머지는 null로 동작하는 방식으로 변경되었으며 최종적으로 호출되는 쿼리의 수는 동일함

 

(3) DTO로 뽑아서 직접 쿼리를 짜는 방법

  • 정제해야할 것이 많아서 쉽게 적용할 수 있지는 않음

3) 페치 조인 정리

  • 페치 조인은 연관된 엔터티들을 SQL한 번으로 조회하여 성능이 최적화되며 객체 그래프를 유지할 때 사용하면 효과적임
  • 엔터티의 연관관계 매핑 시 직접 적용하는 글로벌 로딩 전략(지연로딩, 즉시로딩)보다 우선적으로 동작함
  • 실무에서 글로벌 로딩 전략은 모두 지연로딩으로 하고 최적화가 필요한 곳에 페치 조인을 적용하는 것 방식으로 성능문제 해결을 접근해야 함(대부분의 문제는 N+1문제)
  • 대부분의 문제를 페치 조인으로 해결할 수는 있으나 모든 것을 해결할 수는 없음
  • 여러 테이블을 조인해서 엔터티가 가진 모양이 아니라 전혀 다른 결과가 필요하다면 처음 적용시부터 페치 조인보다는 일반 조인을 사용하여 필요한 데이터만 조회한 뒤 DTO로 반환하는 방법이 효과적일 수 있음

** 참고

  • 페치 조인에 대해서는 매우 중요하기 때문에 100% 이해를 해야함
  • 성능 최적화 문제를 네이티브 SQL로 변환하여 문제를 해결하는 것보다 페치 조인으로 해결하는 것이 수백배는 시간을 아낄 수 있음
  • 성능 튜닝을 과거에는 직접 쿼리를 뜯어고치면서했지만 이렇게 선언적으로 성능 튜닝을 할 수 있게 됨

4. 다형성 쿼리 / 엔터티 직접 사용

1) 다형성 쿼리

(1) 구조 예시

  • 엔터티가 Item의 자손으로 Album, Movie, Book이 있다고 가정

 

(2) TYPE 사용

  • 조회 대상을 특정 자식으로 한정 지을 수 있음
  • 상속관계인 대상 중 자식 엔터티를 입력하여 특정 자식의 데이터만 조회할 수 있음
# JPQL 작성 시
select i from Item i where type(i) IN (Book, Movie)

# SQL 발생 쿼리
select i from i where i.DTYPE in (‘B’, ‘M’)

 

(3) TREAT(JPA2.1 이후)

  • 자바의 타입 캐스팅과 유사하며 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용함
  • from, where, select에서 사용가능
  • 상속관계인 대상 중 부모 타입을 자식타입으로 형변환 하는 것처럼 적용하여 자손의 필드를 접근하여 쿼리를 작성함
# JPQL 작성 시
select i from Item i where treat(i as Book).author = ‘kim’

# SQL 발생 쿼리
select i.* from Item i where i.DTYPE = ‘B’ and i.author = ‘kim’

2) 엔터티 직접 사용 시 동작

(1) 기본 키 값을 사용

  • JPQL에서는 함수에 엔터티를 쿼리문에 직접 사용할 수 있는데 엔터티를 직접 사용하게되면 SQL에서는 작성된 엔터티의 기본 키 값을 사용하여 조회함
  • 파라미터 바인딩으로 엔터티를 직접 넘겨도 SQL에서는 기본 키 값을 사용하여 조회함
  • 엔터티가 아닌 기본키를 직접 입력하는 것도 가능하지만 객체지향적으로 엔터티를 입력하는 것이 좋을 수 있음
String entityQuery = "select m from Member m where m = :member";
Member findMember = em.createQuery(entityQuery, Member.class)
                .setParameter("member", member1)
                .getSingleResult();

System.out.println("findMember = " + findMember);

// 실행결과
Hibernate: 
    /* select
        m 
    from
        Member m 
    where
        m = :member */ select
            m1_0.id,
            m1_0.age,
            m1_0.TEAM_ID,
            m1_0.type,
            m1_0.username 
        from
            Member m1_0 
        where
            m1_0.id=?
findMember = Member{id=1, username='회원1', age=0}

 

(2) 외래 키 값을 사용

  • 연관 관계의 엔터티를 직접 쿼리문에 사용하면 연관된 엔터티의 외래 키 값을 사용함
  • 마찬가지로 직접 외래키의 값을 사용해도 됨
// 직접사용 - 외래 키 값 사용
String entityQuery = "select m from Member m where m.team = :team";
List<Member> resultList = em.createQuery(entityQuery, Member.class)
            .setParameter("team", team1)
            .getResultList();

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

// SQL 결과 생략, 외래키인 TEAM_ID필드가 사용됨

5. Named 쿼리

1) 정의

  • 미리 정의를 해두고 이름을 부여하여 사용하는 정적 JPQL 쿼리
  • 애노테이션, XML에 정의할 수 있음
  • 애플리케이션 로딩 시점에 초기화 후 재사용하며 로딩 시점에 쿼리를 검증함(장점)
  • 즉 애플리케이션 로딩 시점에 오류를 확인할 수 있기 때문에 사용자가 애플리케이션을 사용하다가 오류를 발생시킬 확률을 줄여줌

2) 적용

(1) 애노테이션으로 적용

  • 쿼리를 적용할 엔터티에 @NamedQuery를 사용하여 쿼리의 이름과 query를 작성
  • 쿼리의 이름은 관례상으로 엔터티명.쿼리명 처럼 작성한다고 함
  • em.createNamedQuery로 @NamedQuery에서 작성한 쿼리를 이름으로 호출할 수 있음
@Entity
@NamedQuery(
        // 관례상 Entity명.네이밍 으로 많이 씀
        name = "Member.findByUsername",
        query = "select m from Member m where m.username = :username"
)
public class Member {
    // ... 코드 생략
}

// 프로그램 실행
// named 쿼리
List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
            .setParameter("username", member1.getUsername())
            .getResultList();

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

// 실행 결과
// member = Member{id=1, username='회원1', age=0}

 

(2) XML에 정의

  • 코드를 사용하는 방법은 같으며 persistence.xml의 mapping-file 세팅와 매핑할 파일의 위치를 적용하는 것에 주의하면 됨
  • 애노테이션보다 XML이 항상 우선권을 가지며 애플리케이션 운영 환경에 따라 XML을 배포할 수 있고 여러 파일에 쿼리를 분리하여 각각 관리 및 등록할 수 있음
  • persistence.xml에 네임드 쿼리를 작성한 xml파일의 위치를 <mapping-file> 속성을 사용하여 등록
  • 매핑 파일 등록 시 <persistence-unit name="..."> 위치 바로 아래에 등록해야하며 다른곳에 등록하면 오류가 발생함
    <persistence-unit name="jpqltest">
<!-- persistence-unit 바로 밑에 namedQuery를 정의한 xml파일을 매핑해야함, 다른곳에 하면 오류 발생함-->
        <mapping-file>
            META-INF/ormMember.xml
        </mapping-file>

 

  • 네임드 쿼리를 작성할 xml파일은 아래와 같이 작성하면 됨
  • 여러개의 네임드 쿼리를 하나의 xml파일에 관리할 수 있음
<entity-mappings xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm" version="2.1">

    <named-query name="Member.findByUsername">
        <query> <![CDATA[
                select m
                    from Member m
                    where m.username = :username
                ]]>
        </query>
    </named-query>

    <named-query name="Member.count">
        <query>select count(m) from Member m</query>
    </named-query>

</entity-mappings>

 

** 참고

  • 이후에 배울 스프링 데이터 JPA를 사용하면 NamedQuery를 엔터티가 아니라 DAO나 Repository에 @Query 애노테이션을 사용하여 NamedQuery의 기능을 적용할 수 있음
  • 실무에서는 네임드쿼리를 직접사용하지않고 스프링 데이터 JPA를 사용하기 때문에 엔터티나 별도의 XML로 쿼리를 관리하지 않아도 DB와 소통하는 곳에서 쿼리를 한번에 관리할 수 있음
  • 즉 스프링 데이터 JPA도 이 @NamedQuery를 파싱하여 동작하기 때문에 원래의 동작 방식을 이해해둘 필요성은 있다고 봄

6. 벌크 연산

  • 쿼리 한번으로 여러 테이블(엔터티)의 데이터를 변경하는 것

1) 벌크연산 미적용 시 예시

  • 만일 수많은 상품이 데이터베이스에 저장되어있다고 가정
  • 재고가 10개 미만인 모든 상품의 가격을 10% 상승하고자 할때 JPA 변경 감지 기능으로 실행하려면 너무 많은 SQL이 실행되어야 함
  • 재고가 10개 미만인 상품을 리스트로 조회하고, 조회된 상품 엔터티의 가격을 10% 증가하고 트랜잭션 커밋 시점에 변경감지가 동작하여 DB에 반영되어야 함
  • 만약 변경된 데이터가 천건, 만건 이라면 그 횟수만큼의 updateSQL이 발생됨

2) 벌크 연산 예제

  • update, delete는 기본으로 지원하고, 하이버네이트로 구현시에는 insert도 구현할 수 있음
  • executeUpdate()메서드로 작성한 JPQL을 벌크 연산으로 실행할 수 있으며 연산 결과는 영향을 받은 로우의 수를 반환함
  • 회원의 나이를 모드 20으로 설정하는 쿼리를 작성해보면 정상적으로 update쿼리가 생성되고 결과가 DB에 반영됨
// 벌크 연산 - update
int resultCount = em.createQuery("update Member m set m.age = 20").executeUpdate();
System.out.println("resultCount = " + resultCount);

3) 벌크 연산 주의점

  • 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리를 실행하기 때문에 잘못하면 꼬일 수 있음

(1) 문제 해결 방법

  • 영속성 컨텍스트와 관계없이 벌크 연산을 독립적으로 먼저 실행 후 커밋
  • 벌크 연산을 수행 후 영속성 컨텍스트를 초기화

(2) 주의점 예제

  • 데이터 생성 후 벌크연산으로 쿼리를 실행하면 JPA 기본 동작(commit, 쿼리를 직접 날릴 때, em.flush로 직접 호출할 때)으로 자동으로 플러시가 호출 되기때문에 데이터는 반영이되고, 반영된 데이터에 벌크연산의 쿼리가 수행 됨
  • 그러나 벌크연산으로 수행된 이후 commit()이 되기 전에 코드로 데이터를 조회를 해보면 영속성 컨텍스트에 반영이 되어있지 않으므로 회원의 나이는 변경되기 전의 나이가 출력됨
// 벌크 연산 - update
int resultCount = em.createQuery("update Member m set m.age = 20").executeUpdate();
System.out.println("resultCount = " + resultCount);

// 영속성 컨텍스트의 데이터를 조회 -> 값이 없음
System.out.println("member1.getAge() = " + member1.getAge());
System.out.println("member1.getAge() = " + member2.getAge());
System.out.println("member1.getAge() = " + member3.getAge());

// 실행 결과
resultCount = 4
member1.getAge() = 0
member2.getAge() = 0
member3.getAge() = 0

 

  • 영속성 컨텍스트를 무시하고 DB에 쿼리를 반영해버리기 때문에 벌크연산 이후에 추가적인 쿼리를 작성한다고 해도 모든 연산결과가 동작하지 않음
  • 그래서 벌크 연산 이후에 em.clear()로 영속성 컨텍스트를 초기화 해주면 이전의 처음부터 시작하기 때문에 select쿼리가 수행되면서 값을 가져오는 것을 확인할 수 있음
  • 이때 commit() 되기전에 외부에서 DB를 조회하면 DB에는 해당 값이 저장된 것을 확인할 수 없는데, 이는 트랜잭션의 격리성 때문에 commit이 되기 전까지 외부에서 데이터를 볼 수 없기 때문 - https://nagul2.tistory.com/307 내용 확인
// 기존 예제 코드 동일
em.clear();    // 영속성 컨텍스트 초기화

Member findMember = em.find(Member.class, member1.getId());
System.out.println("findMember.getAge() = " + findMember.getAge());

// 실행 결과
Hibernate: 
    select
        m1_0.id,
        m1_0.age,
        m1_0.TEAM_ID,
        m1_0.type,
        m1_0.username 
    from
        Member m1_0 
    where
        m1_0.id=?
findMember.getAge() = 20

 

** 참고

  • 스프링 데이터 JPA를 사용하면 @Modifying으로 em.clear를 자동으로 수행하여 벌크 연산을 편리하게 사용할 수 있음
  • 실무에서는 스프링 데이터 JPA를 사용할텐데, 구현의 근본은 JPA의 기본 구현을 원리로 구현했기 때문에 JPA의 기본 원리를 알고 실무를 접하는 것이 좋음