Programming-[Backend]/JPA

[JPA기본] 13. 객체지향 쿼리 언어 : JPQL-중급문법

컴퓨터 탐험가 찰리 2021. 10. 25. 08:05
728x90
반응형

 

1. 경로 표현식

 

경로 표현식이란 점(.)을 이용하여 객체를 탐색하는 경로를 표현하는 식을 말한다. 경로 표현식으로는 다음 세 가지 경로를 표현할 수 있다.

 

1
2
3
4
5
select m.username -- 상태 필드
  from Member m
    join m.team t -- 단일 값 연관 필드
    join m.orders o -- 컬렉션 값 연관 필드
  where t.name = '팀A'
cs

 

 

상태필드 : 탐색 종료

상태 필드는 어떤 객체의 단순 필드값을 조회하는 것이다. 상태만을 표현하므로 더 이상 경로 표현식을 통해 추가적인 탐색을 할 수 없다.

 

단일 값 연관 필드 : 탐색 계속, 묵시적 내부 조인

단일값 연관필드는 연관관계를 갖는 필드이므로 추가적인 탐색이 가능하다. 예를 들어 m.team.teamName과 같이 상태 필드값까지 계속 탐색이 가능하다.

 

또한 명시적으로 join 문에 써주지 않더라도 자동으로 조인 쿼리가 나가게 된다. 예를 들어

 

1
2
select m.team.teamName
  from Member m
cs

 

team 으로 이동한 다음, Team 객체의 상태 필드인 teamName까지 경로 탐색을 할 수 있다. 이럴 경우, SQL 문은 자동으로 join(inner join)문을 추가해준다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
try {
      Team team = new Team();
      team.setName("teamA");
      em.persist(team);
      System.out.println("team = " + team);
 
      Member member = new Member();
      member.setName("member1");
      member.setAge(10);
      member.changeTeam(team);
      em.persist(member);
 
      String query = "select m.team.name from Member m ";
      List<String> result = em.createQuery(query, String.class).getResultList();
 
      System.out.println("result = " + result);
 
      tx.commit();
 
    } catch (Exception e) {
cs

 

 

 

컬렉션 값 연관 필드 : 탐색 종료, 묵시적 내부 조인

컬렉션 값 연관 필드는 단일 값 연관 필드와 마찬가지로 묵시적 내부 조인은 발생하지만, 더 이상의 탐색은 불가능하다. 단지 Team.members.size()를 통해 컬렉션의 크기는 조회할 수 있다.

 

원래 테이블상의 관계에서는 join을 해주면 가능하지만, JPA에서는 이를 원칙적으로 막고 있다. 그러나 만약 꼭 필요하다면, 명시적 join으로 alias 값을 얻은 후에는 추가 탐색이 가능하도록 되어있다. 예를 들어 아래와 같은 명시적 join이 포함된 컬렉션 값 필드 조회에서는 상태 필드까지 추가적인 탐색이 가능하다.

 

1
2
3
select m.username
from Team t
join t.members;
cs

 

 

 

 

묵시적 조인은 사용하지 말자!

JPQL 쿼리문을 작성하는 입장에서는 편할 수 있으나, 명시적이지 않은 상태에서 SQL문이 작성되므로 원하는 결과와 다른 결과가 도출될 수도 있고, 다른 개발자와의 커뮤니케이션 등에 방해가 될 수 있다. 또한 가독성도 떨어지게 되므로 명시적 조인을 이용하여 JPQL 쿼리문을 작성하는 것을 권장한다. join은 SQL 튜닝에 중요한 포인트라는 점도 명시적 조인을 해야하는 이유라고 할 수 있다.

 

 


 

2. 페치 조인(fetch join)

 

페치 조인은 SQL의 문법이 아니라 JPQL에서 성능 최적화를 위해 제공하는 기능이다. 앞서 즉시/지연 로딩 부분에서 배웠듯이 연관관계를 가진 엔티티를 불러올 때 N+1 문제를 막기 위해서 엔티티의 연관관계를 맺을 때 fetchType.LAZY를 적용했었다. 그러나, MEMBER 정보를 가져오면서 TEAM 정보도 SQL 한번에 가져오기 위해서 fetch join을 사용한다. 달리 말하자면 선택적인 EAGER fetch라고 할 수 있다.

 

fetch join 적용방법

join문을 join 후 fetch 한다는 의미로 join fetch 라고 작성하면 된다. 이렇게 하면 join해오는 team 엔티티의 내용을 select 구문에서 굳이 작성하지 않더라도 SQL문에서 알아서 team 엔티티를 조인하여 정보를 들고 오는 것을 확인할 수 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
try {
      Team team = new Team();
      team.setName("teamA");
      em.persist(team);
      System.out.println("team = " + team);
 
      Member member = new Member();
      member.setName("member1");
      member.setAge(10);
      member.changeTeam(team);
      em.persist(member);
 
      String query = "select m from Member m join fetch m.team ";
      List<Member> resultList = em.createQuery(query, Member.class).getResultList();
 
      System.out.println("resultList = " + resultList);
 
      tx.commit();
cs

 

다만 실제로 team 정보를 들고 오는지 확인하기 위해서는 TypeQuery가 아니라 일반 Query로 작성해야한다. 결과가 Object 이므로 값을 보고 싶다면 이에 맞는 DTO를 생성해서 Projection을 해야할 것 같다.

 

 

1
2
3
4
5
6
 String query = "select m, t.name from Member m join fetch m.team ";
      List resultList = em.createQuery(query).getResultList();
 
      for (Object o : resultList) {
        System.out.println("o = " + o.toString());
      }
cs

 

 

 

그리고 연관 관계의 주인인 Member 엔티티가 전체 조회가 되어야 한다. Member를 m alias로 표현하여 m.name, m.team 등을 select문에서 불러오더라도, m 전체가 불러와지지 않으면 아래와 같이 IlligalArgumentException이 발생한다.

 

fetch join은 연관된 엔티티의 정보를 한번에 모두 가져오는 것이 기본이다.

이것은 fetch join의 기본 사상과 연관되어 있다. 예를 들어 teamA를 갖는 member가 5명이 있다면, fetch join을 사용하는 경우 모든 member들을 불러와야한다. where 절 등을 추가하여 특정 member 3명만 가져오는 경우 등은 JPA의 설계방향과 다르다. 특정 member 3명만 가져오고 싶다면, team -> member로 연관관계를 지어서 가져오는 것이 아니라 애초에 Member 엔티티에 접근하여 가져와야 한다.

 

연관관계를 갖는 엔티티 탐색의 기본은 연관관계를 갖는 엔티티를 전부다 불러오는 것이라는 것을 기억하자. 그래서, 연관관계를 갖는 엔티티에 별칭을 쓰는 방식은 가급적 사용하지 말아야한다. 예상하지 못한 문제들이 발생할 수도 있다.

ex) m.team as t -> X

 

1
2
3
4
5
6
String query = "select m.team, m.name, m.age, t.name from Member m join fetch m.team ";
      List resultList = em.createQuery(query).getResultList();
 
      for (Object o : resultList) {
        System.out.println("o = " + o.toString());
      }
cs

 

 

join과 join fetch의 차이점

좀 더 명확히 join과 join fetch의 차이를 확인하기 위해서 다음 관계를 갖는 member, team 객체를 만든다.

member1 - teamA,

member2 - teamA,

member3 - teamB

 

배웠던 내용대로 객체를 만들고, persist를 하면 된다. 다만 데이터가 DB에 저장되어 있는 상태이고 영속성 컨텍스트의 1차 캐시에는 저장되지 않은 상황을 가정하기 위해서 em.flush(), em.clear()를 실행한다.

 

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
try {
      Team team1 = new Team();
      team1.setName("teamA");
      em.persist(team1);
 
      Team team2 = new Team();
      team2.setName("teamB");
      em.persist(team2);
 
      Member member1 = new Member();
      member1.setName("member1");
      member1.setAge(10);
      member1.changeTeam(team1);
      em.persist(member1);
 
      Member member2 = new Member();
      member2.setName("member2");
      member2.setAge(11);
      member2.changeTeam(team1);
      em.persist(member2);
 
      Member member3 = new Member();
      member3.setName("member3");
      member3.setAge(11);
      member3.changeTeam(team2);
      em.persist(member3);
 
      em.flush();
      em.clear();
cs

 

1. join

일반 조인으로 쿼리문을 작성하면, member와 관련된 새로운 team 정보를 불러올 때마다 쿼리문을 날리게 된다. 위에서 가정한 상황의 경우 3번의 쿼리가 전달된다. for문에서, 처음 member1을 불러올 때 해당하는 teamA 정보를 불러오는 SQL문을 DB에 날리고, 이 정보가 1차 캐시에 저장된다. 그리고 member2의 team 정보는 똑같은 teamA의 정보이므로 1차 캐시에 SQL문을 날려서 불려온다. 마지막으로 member3의 teamB 정보는 새롭게 DB에 SQL문을 날리게 되므로 총 3번의 SQL문이 전송된다. 만약 각 member들의 팀이 모두 다르다면, SQL문은 1+3번 호출되는 문제, 즉 1+N 문제를 야기할 것이다. 데이터가 많은 상황이라면, 이러한 1+N의 문제는 성능 저하의 원인이 될 수 있다.

1
2
3
4
5
6
String query = "select m from Member m join m.team t ";
      List<Member> resultList = em.createQuery(query, Member.class).getResultList();
 
      for (Member member : resultList) {
        System.out.println("member.getTeam().getName() = " + member.getTeam().getName());
      }
cs

첫번째 DB 조회 SQL 

두번째 1차 캐시 조회 SQL 및 결과 출력문

세번째 DB 조회 SQL 및 결과 출력문

 

2. join fetch

 

같은 상황에서 join fetch로 쿼리문을 작성하고 실행해보면, 1번의 SQL만 전달되고 모든 데이터가 한번에 불려져 오는 것을 확인할 수 있다.

 

1
2
3
4
5
6
7
String query = "select m from Member m join fetch m.team t ";
      List<Member> resultList = em.createQuery(query, Member.class).getResultList();
 
      for (Member member : resultList) {
        System.out.println("member.getTeam().getName() = " + member.getTeam().getName());
      }
 
cs

 

 


 

3. fetch join의 특징과 한계

 

fetch join을 사용할 때는 다음 3가지에 유의해야한다.

 

1. fetch join의 대상에는 별칭을 줄 수 없다.

위에서 언급한 바대로, fetch join으로 가져오는 엔티티는 전체를 가져오는 방식으로만 사용해야한다. 별칭을 사용하여 where절 등에서 특정 조건을 주어 걸러내기 시작하면, JPA의 기본 설계 원칙과 어긋나게 되어 오동작의 위험을 발생시킨다.

 

2. 둘 이상의 컬렉션은 fetch join 할 수 없다.

team -> members -> orders와 같이 1:N:M 의 관계를 갖는 컬렉션을 fetch join하면 데이터가 갑작스럽게 늘어나면서 데이터가 안맞는 오류를 발생시킬 수 있다. 

 

3. 컬렉션을 fetch join하면 페이징 API(setFirstResult, setMaxResults)가 정상작동 하지 않는다.

일대일, 다대일과 같이 기준이 되는 엔티티가 fetch join 대상보다 숫자가 많은 경우에는 기준을 대상으로 하는 페이징 API가 정상작동한다. 그러나, 일대다와 같이 컬렉션을 fetch join 하는 경우에는 페이징 API를 사용할 수 없다. 1개의 Team에 5명의 member가 있는 경우를 생각해보자. Team을 기준으로하여 페이징 API가 작동하는데, 첫 번째 페이지에 3개 결과만 출력하도록 지정한 경우, 3개의 member만 조회된다. 다음 페이지로 넘어간다고해서 나머지 2개의 member가 조회되지 않는다. team을 기준으로 했기 때문이다. 만약 애초에 members를 기준으로 했다면, 5개의 결과가 1페이지에 3개, 2페이지에 남은 2개가 출력될 수 있을 것이다. 따라서 컬렉션을 fetch join하여 페이징 API를 사용할 수 없다.

 

이에따라 하이버네이트는 경고 로그를 남기고 메모리에서 페이징을 한다. 콘솔 결과를 보면, applying in memory! 라는 에러가 발생한 것을 볼 수 있는데, 이것은 페이징을 사용하지 않고 team과 관련된 모든 member 객체를 다 불러온다는 것을 의미한다. 실제로 아래 SQL문에서도 offset이나 limit과 같은 paging관련 sql이 없다. 이렇게 되면 데이터가 많은 경우 모든 데이터를 불러오게 되면서 장애를 발생시킬 수 있다.

 

1
2
3
4
5
6
7
8
9
String query = "select t from Team t join fetch t.members ";
      List<Team> results = em.createQuery(query, Team.class)
              .setFirstResult(0)
              .setMaxResults(1)
              .getResultList();
 
      for (Team team : results) {
        System.out.println("team = " + team + "member = " + team.getMembers().size());
      }
cs


 


 

4. @BatchSize : 1+N 문제 해결하기

 

위에서 언급한 페이징 문제 등을 해결하기 위해서는 team에서 member를 fetch join 하는 것이 아니라 member에서 team을 fetch join 하도록하여 일대다 조회를 다대일 조회로 바꾸면 해결이 가능하다.

 

 

일반 방식으로 페이징 적용 : 1+N 문제 발생

만약 기존대로 team을 From의 기준으로 삼아서 해결하고 싶다면 적용할 수 있는 방식이 @BatchSize 이다. team을 조회하면서 연관된 member 객체의 정보를 페이징을 적용하여 가져와보면, 우선 teamA, teamB등 team 정보를 모두 가져오는 쿼리 1번, teamA에 소속된 members를 불러오는 쿼리 1번, teamB에 소속된 members를 불러오는 쿼리 1번이 발생하여 총 3번 쿼리가 발생하는 것을 확인할 수 있다.

 

1
2
3
4
5
6
7
8
9
String query = "select t from Team t ";
      List<Team> results = em.createQuery(query, Team.class)
              .setFirstResult(0)
              .setMaxResults(2)
              .getResultList();
 
      for (Team team : results) {
        System.out.println("team = " + team.getName() + "member = " + team.getMembers().size());
      }
cs

 

쿼리#1. Team 엔티티 정보 다 불러오기

쿼리#2. TeamA 관련된 members 불러오기(LAZY Loading)

쿼리#3. TeamB 관련된 members 불러오기(LAZY Loading)

 

 

@BatchSize 적용

성능 개선을 위해 Team 객체를 불러온 후, 컬렉션 데이터를 가져올 때 한번에 몇 개까지 In 절에 포함시켜서 가져올지를 @BatchSize 어노테이션으로 결정할 수 있다.

 

Team.class의 일부

1
2
3
@BatchSize(size = 100)
  @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
  private List<Member> members = new ArrayList<>();
cs

 

보통 size 값은 1000이하의 값을 설정해준다. 이렇게 설정 후 다시 코드를 실행해보면, Team에 대한 쿼리가 나간 후, member 쿼리를 불러올 때 TeamA, TeamB의 id값을 각각 ?로 잡아서 In절에서 실행하여 Team에 해당하는 모든 member들을 한번에 불러오는 것을 확인할 수 있다.

 

쿼리#1. Team 객체 조회

 

쿼리#2. members batch로 조회

 

어노테이션을 사용하지 않고 글로벌 setting으로 적용할 수도 있다. persistence.xml 파일에 다음 옵션을 추가해주면 된다.

 

<property name="hibernate.default_batch_fetch_size" value="100" />

 

 

보통의 경우 : 필요한 DTO 데이터로 변환하여 반환

모든 엔티티의 필드값이 필요한 경우가 아니라면, 각 엔티티에서 필요한 속성값만 DTO로 발췌하여 사용한다. 이럴 경우 fetch join이 아니라 일반 조인을 사용하는 것이 효과적이다.

 

 


 

5. 다형성 쿼리

 

상속 관계에 있는 엔티티를 조회할 때, D.type을 이용하여 특정 타입을 지정할 수 있는 기능을 말한다.

 

TYPE

type문을 통해 특정 DTYPE의 엔티티를 조회하는 조건을 추가할 수 있다.

 

 

1
2
3
4
5
6
7
[JPQL]
select i from Item i
where type(i) IN (BOOK, MOVIE)
 
[SQL]
select i from i
where i.DTYPE in ('BOOK''MOVIE')
cs

 

 

TREAT

treat문은 부모 타입의 엔티티를 자식 타입의 엔티티처럼 다룰 수 있게 해준다.

 

1
2
3
4
5
6
7
[JPQL]
select i from Item i
where treat(i as BOOK).author = 'kim'
 
[SQL]
select i from i
where i.DTYPE = 'BOOK' and i.author = 'kim'
cs

 

 


6. 엔티티 직접 사용

 

JPQL 문에서 굳이 식별자를 사용하지 않고 엔티티 자체를 언급하면 SQL문에서 알아서 엔티티의 식별자 값으로 해석해준다.

 

기본 키 값

엔티티의 별칭인 m만 사용해도, SQL에서 m.id로 변환하여 표시해준다.

1
2
3
4
5
[JPQL]
select count(mfrom Member m
 
[SQL]
select count(m.id) as cnt from Member m
cs

 

파라미터 전달

파라미터로 엔티티 자체를 넘겨도, 식별자가 전달된다.

 

1
2
3
4
5
6
7
8
[JPQL]
String jpql = "select m from Member m where m = :member ";
List results = em.createQuery(jpql)
                .setParameter("member", member)
                .getResultList();
 
[SQL]
select m.* from Member m where m.id = ?
cs

 

 


 

7. Named 쿼리

 

named 쿼리는 쿼리에 이름을 부여하여 반복적으로 재사용할 수 있는 기능이다. named 쿼리는 애플리케이션 로딩 시점에 해당 쿼리를 파싱하여 캐시에 들고 있기 때문에, 애플리케이션 로딩 시점에 쿼리를 검증할 수 있다는 장점이 있다. Member.class에서 query = " " 부분을 작성할 때, 잘못된 부분이 있다면 오류를 잡아주는 것이다.

 

Member.class의 일부

1
2
3
4
5
6
7
8
@Entity
@NamedQuery(
        name = "Member.findByUsername",
        query = "select m from Member m where m.name = :name"
)
@Getter
@Setter
public class Member {
cs

 

실행문

1
2
3
4
5
6
7
 List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
              .setParameter("name""member1")
              .getResultList();
 
      for (Member member : resultList) {
        System.out.println("member = " + member);
      }
cs

 

 

나중에 JPA의 기술을 Spring에서 사용하기 편하도록 해주는 Spring Data JPA를 사용하게 된다. 여기서는 Repository의 메서드 위에 @Query("select ... ") 과 같이 바로 쿼리문을 작성할 수 있는 기능을 제공하는데, 이 부분이 JPA의 NamedQuery 를 활용한 부분이라고 보면 된다. 이 역시 컴파일 전에 문법 에러를 잡아주게 되므로 생산성에 매우 좋은 기능이다.

 

 


8. Bulk 연산

 

JPA는 단건에 대해 SQL을 실행하는 방식이다. 만약 재고가 10개 미만인 모든 상품의 가격을 10% 증가시키려면, 1. 해당 상품을 모두 찾는 쿼리, 2. 조회된 엔티티의 가격을 증가시켜서 Dirty checking, 3. 모든 엔티티에 대해 갯수만큼 update 쿼리가 발생해야한다.

 

이런 여러 엔티티의 값을 한 번에 변경할 수 있는 기능이 Bulk 연산이다. 이를 위해서는 executeUpdate() 구문을 사용하면 된다. 해당 구문의 결과는 쿼리의 영향을 받게되는 엔티티 객체의 갯수를 반환하게 된다.

 

1
2
3
4
int count = em.createQuery("update Member m set m.age = 30")
              .executeUpdate();
 
      System.out.println("count = " + count);
cs

 

 

주의점 : 벌크 연산 후 영속성 컨텍스트 초기화 필요

 

UPDATE, DELETE 문을 모두 지원하지만, 영속성 컨텍스트를 무시하고 DB에 직접 쿼리를 날리는 방식이므로 DB-영속성 컨텍스트간 데이터 불일치에 주의해야 한다. 벌크 연산도 쿼리문을 날리게 되므로 자동으로 em.flush(), 플러시가 발생하지만 영속성 컨텍스트를 초기화하지는 않는다. 위 예제에서 DB 값은 age = 30이지만, member들의 age 값을 코드에서 직접 조회해보면, 30이 아닌 기존에 설정한 값들로 조회된다. 그래서 영속성 컨텍스트에 남아있는 정보와 DB에 반영된 정보가 다를 수 있으므로, 벌크 연산 후 em.clear()를 실행하여 영속성 컨텍스트를 초기화 해주어야 한다.

 

NamedQuery처럼, Spring Data JPA에서의 @Modifying이라는 내용이 있는데, 이 @Modifying에서 바로 이 DB부터 바로 업데이트하고, 자동으로 영속성 컨텍스트를 초기화하는 부분이 있다. 이런 DB-영속성 컨텍스트간 불일치가 발생할 수 있다는 내용을 기억해두자.

 

 


 

참조

 

1. 인프런_자바 ORM 표준 JPA 프로그래밍 - 기본편_김영한 님 강의

https://www.inflearn.com/course/ORM-JPA-Basic/lecture/21735?tab=curriculum

728x90
반응형