Post

[강의] 자바 ORM 표준 JPA 프로그래밍 - 기본편 11법 객체지향 쿼리 언어2 - 중급 문법

자바 ORM 표준 JPA 프로그래밍 - 기본편 강의

김영한님의 인프런 강의(자바 ORM 표준 JPA 프로그래밍 - 기본편) 을 수강하면서 강의 내용을 일부 발췌해 요약한 글.

섹션 11 객체지향 쿼리 언어2 - 중급 문법

경로 표현식

점(.)을 찍어 객체 그래프를 탐색하는 것을 의미한다. 기본편 10강에서 짧게 소개된 부분이다.

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

위의 예시처럼 크게 상태 필드연관 필드로 나뉘어 진다.

상태 필드(state filed)

단순히 값을 저장하기 위한 필드이다. 경로 탐색의 끝이므로 더이상 탐색할 수 없다.

연관 필드(association field)

연관관계를 위한 필드이다.

@ManyToOne, @OneToOne처럼 타겟 대상이 엔티티인 경우 단일 값 연관 필드 이고, @OneToMany, @ManyToMany 처럼 대상이 컬렉션인 경우 컬렉션 값 연관 필드 이다.

단일 값 연관 경로의 경우 묵시적 내부 조인이 발생 하므로 탐색이 가능하다. 예를 들어 멤버의 팀을 탐색한 후, 팀의 이름이나 아이디 등에 접근이 가능하다. 그러나, 실제 운영 시에는 수백개의 쿼리가 발생하는데 이러한 내부 조인은 성능상 큰 영향을 끼친다. 이러한 방식으로 쿼리를 작성하면 튜닝하기 부적절하므로 가급적 명시해 사용해주어야 한다.

컬렉션 값 연관 경로의 경우에도 묵시적 내부 조인이 발생하나 탐색은 불가하다. FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색이 가능하다.

명시적 조인은 join 키워드를 직접 사용하는 것을 의미한다.

String query = "select m.username from Team t join t.members m";

실무에서는 묵시적 조인이 아닌 명시적 조인을 사용하자! 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵다.

페치 조인(fetch join)

실무에서 사용되는 아주아주 매우매우 중요한 부분이다!!

[ LEFT [OUTER] | INNER ] JOIN FETCH 조인경로 의 방식으로 사용한다.

SQL 조인 종류는 아니고 JPQL에서 성능 최적화를 위해 제공하는 기능으로, join fetch 명령어를 사용해 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능이다.

엔티티 페치 조인과 컬렉션 페치 조인을 나누어 설명한다.

엔티티 페치 조인

멤버1,2는 팀A소속이고 멤버3은 팀B 소속인 경우에 멤버와 팀 정보를 가져오고자 하는 아래의 예시를 살펴보자.

1
2
3
4
5
6
7
8
9
String query = "select m from Member m";


List<Member> result = em.createQuery(query, Member.class)
        .getResultList();

for (Member member : result) {
    System.out.println("member = " + member.getUsername() + ", team =  " + member.getTeam().getName());
}

를 실행해보면

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
30
Hibernate: 
    /* select
        m 
    from
        Member m */ select
            member0_.id as id1_0_,
            member0_.age as age2_0_,
            member0_.TEAM_ID as team_id4_0_,
            member0_.username as username3_0_ 
        from
            Member member0_
Hibernate: 
    select
        team0_.id as id1_3_0_,
        team0_.name as name2_3_0_ 
    from
        Team team0_ 
    where
        team0_.id=?
member = 회원1, team =  A
member = 회원2, team =  A
Hibernate: 
    select
        team0_.id as id1_3_0_,
        team0_.name as name2_3_0_ 
    from
        Team team0_ 
    where
        team0_.id=?
member = 회원3, team =  B

Member와 Team은 ManyToOne이고 지연로딩을 설정해 두었으므로, member.getTeam().getName()을 호출한 시점에 데이터베이스에 쿼리를 날리게 된다. 처음에 멤버 정보를 가져오고, 다음엔 팀을 가져오는데 이때 쿼리가 실행되고 영속성 컨텍스트에 팀A가 들어가게 된다. for문의 처리 과정을 정리하면 이렇다.

1
2
3
4
5
회원1은 팀A를 SQL에서 가져온다. 
 
회원2는 팀A를 1차 캐시에서 가져온다.

회원 3은 팀B 소속인데, 이는 영속성 컨텍스트에 없으므로 SQL에서 가져온다.`

팀의 소속이 모두 다른 최악의 경우에는 쿼리가 4번 나가게 되는데, 이처럼 첫번째로 날린 쿼리 하나의 결과가 N번의 쿼리를 날리게 되는 것을 N + 1 문제 라고 부른다.

페치 조인 을 사용해보면 어떨까?

쿼리를 이렇게 바꿔주기만 해도 String query = "select m from Member m join fetch m.team";

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Hibernate: 
    /* select
        m 
    from
        Member m 
    join
        fetch m.team */ select
            member0_.id as id1_0_0_,
            team1_.id as id1_3_1_,
            member0_.age as age2_0_0_,
            member0_.TEAM_ID as team_id4_0_0_,
            member0_.username as username3_0_0_,
            team1_.name as name2_3_1_ 
        from
            Member member0_ 
        inner join
            Team team1_ 
                on member0_.TEAM_ID=team1_.id
member = 회원1, team =  A
member = 회원2, team =  A
member = 회원3, team =  B

쿼리가 한번만 나가게 된다. 이미 result에 담긴 시점부터 팀은 프록시가 아닌 실제 데이터가 들어오게 된다. 데이터가 이미 채워져 있으므로 다시 가져올 필요가 없는 것이다.

컬렉션 페치 조인

일대다 관계나 컬렉션의 경우 페치 조인은 다음과 같다.

Team에서 member목록인 members를 가져오는 다음의 예시를 살펴보자.

1
2
3
4
5
6
7
8
9
String query = "select t from Team t join fetch t.members";


List<Team> result = em.createQuery(query, Team.class)
        .getResultList();

for (Team team : result) {
    System.out.println("team = " + team.getName() + ", member size =  " + team.getMembers().size());
}

이를 실행해보면

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Hibernate: 
    /* select
        t 
    from
        Team t 
    join
        fetch t.members */ select
            team0_.id as id1_3_0_,
            members1_.id as id1_0_1_,
            team0_.name as name2_3_0_,
            members1_.age as age2_0_1_,
            members1_.TEAM_ID as team_id4_0_1_,
            members1_.username as username3_0_1_,
            members1_.TEAM_ID as team_id4_0_0__,
            members1_.id as id1_0_0__ 
        from
            Team team0_ 
        inner join
            Member members1_ 
                on team0_.id=members1_.TEAM_ID
team = A, member size =  2
team = A, member size =  2
team = B, member size =  1

다음과 같은 쿼리가 발생한다.

이때, team = 팀A, member size = 2 가 중복해서 출력되는 것을 볼 수 있다.

이는 아래의 강의자료로 설명된다.

image

같은 팀A가 두번 들어가게 되는데, 이러한 중복을 제거하기 위해서는 SQL에 DISTINCT 를 추가하고, 애플리케이션에서 엔티티 중복을 제거하는 방법이 있다.

위의 예시의 경우 SQL에 distinct를 추가하더라도 회원 ID와 NAME이 각각 다르므로 결과는 그대로일 것이다. 따라서 DISTINCT가 추가로 애플리케이션에서 중복을 시도한다. 즉, 같은 식별자를 가진 엔티티를 제거해준다.

다음의 쿼리를 실행해보면, String query = "select distinct t from Team t join fetch t.members";

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
Hibernate: 
    /* select
        distinct t 
    from
        Team t 
    join
        fetch t.members */ select
            distinct team0_.id as id1_3_0_,
            members1_.id as id1_0_1_,
            team0_.name as name2_3_0_,
            members1_.age as age2_0_1_,
            members1_.TEAM_ID as team_id4_0_1_,
            members1_.username as username3_0_1_,
            members1_.TEAM_ID as team_id4_0_0__,
            members1_.id as id1_0_0__ 
        from
            Team team0_ 
        inner join
            Member members1_ 
                on team0_.id=members1_.TEAM_ID
team = A, member size =  2
-> member = Member{id=3, username='회원1', age=30}
-> member = Member{id=4, username='회원2', age=22}
team = B, member size =  1
-> member = Member{id=5, username='회원3', age=29}

중복이 제거된 것을 확인할 수 있다!

정리하면 일대다의 경우 데이터가 뻥튀기될 수 있어 조심해야 한다. 다대일이나 일대일의 경우에는 뻥튀기 되지 않는다.

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

일반 조인 실행 시에는 연관된 엔티티를 함께 조회하지 않는다. 위의 쿼리를 일반 조인으로 실행하는 경우 데이터가 로딩시점에 로딩되지 않아 쿼리가 한번 더 발생하게 된다.

JPQL은 결과를 반환할 때 연관관계 고려하지 않으며 단지 SELECT 절에 지정한 엔티티만 조회할 뿐이다.

페치 조인을 사용할 때만 연관된 엔티티도 함께 조회(즉시 로딩)하며, 페치 조인은 객체 그래프를 SQL 한번에 조회하는 개념이라고 생각하면 된다.

한계

  • 패치 조인 대상에는 별칭을 줄 수 없다.
    하이버네이트에서는 가능하나 가급적 사용하지 않는 것이 좋다.
    페치 조인은 기본적으로 연관된 모든 것들을 가져오는 것이므로 별칭으로 조건을 제한하는 방식은 매우 위험하며 JPA의 설계 의도와도 어긋난다. 데이터의 정합성이나 객체 그래프의 사상과 맞지 않다! (조인 패치를 여러 단계 사용하는 경우에만 사용한다.)

  • 둘 이상의 컬렉션은 페치 조인할 수 없다. 데이터 정합성에 맞지 않을 수 있다. 다대다 ** 이므로 데이터가 예상하지 못하게 늘어날 수 있다.

  • 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다.

    일대일, 다대일 같은 연관 값 필드들은 페치 조인해도 데이터 뻥튀기가 되지 않아 페이징이 가능하다. 반면 컬렉션의 경우 페치 조인 시 데이터 뻥튀기가 되고 원하는 결과가 나오지 않을 수 있다. 하이버네이트는 경고 로그를 남기고 메모리에서 페이징하는데 이는 매우 위험하다.

    • select t from Team t join fetch t.membersselect m from Member M join m.team t로 방향을 뒤집어 다대일로 만드는 방식이나,

    • @BatchSize 어노테이션을 members에 추가해 지정해준 배치 사이즈 만큼 한번에 쿼리가 나갈 수 있게 하는 방식을 이용하자.

      • 참고로 이 어노테이션은 글로벌 세팅을 해두고 사용하는 것이 좋다.
        persistence.xml 에
        <property name="hibernate.default_batch_fetch_size" value="100"/> 와 같은 식으로 추가해준다.
    • 또는 DTO를 이용한다.

참고로 페치 조인은 직접 적용하는 글로벌 로딩 전략(FetchType.Lazy..) 보다 우선한다.

페치 조인으로 엔티티를 가져오는 방식, 페치 조인으로 가져온 뒤 애플리케이션에서 DTO로 바꾸는 방식, 처음부터 DTO로 스위칭해 가져오는 방식이 있다.

실무에서 글로벌 로딩 전략은 모두 지연 로딩이며, 최적화가 필요한 곳은 페치 조인을 적용한다!

모든 것을 페치 조인으로 해결할 수는 없으며, 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다.
정리하자면 여러 테이블을 조인해 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야하면 일반 조인을 사용하고 필요한 데이터들만 조회해 DTO로 반환하는 것이 효과적이다.

‼️️ 페치 조인은 매우 중요하므로 완벽히 숙지해야 한다 ‼️️

다형성 쿼리

부모 Item과 자식 Movie, Book .. 이 있는 다형적인 경우에도 다음과 같이 쓸 수 있다.

Item 중에 Book, Movie를 조회하는 경우

[JPQL] select i from Item i where type(i) IN (Book, Movie)

[SQL] select i from i where i.DTYPE in (‘B’, ‘M’)

TREAT(JPA 2.1)는 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용한다.

예를 들어 부모인 Item과 자식 Book이 있다고 할 때,

[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’

엔티티 직접 사용

JPQL에서 엔티티를 직접 사용하면 SQL에서 해당 엔티티의 기본 키 값을 사용한다.

[JPQL] select count(m.id) from Member m //엔티티의 아이디를 사용

[JPQL] select count(m) from Member m //엔티티를 직접 사용

두 경우 모두 다음의 SQL을 실행한다.

[SQL]select count(m.id) as cnt from Member m

엔티티를 파라미터로 전달하는 것과 식별자를 직접 전달하는 것 또한 마찬가지이다. 엔티티 자체가 DB로 넘어가면 이를 구분하는 것이 DB의 PK이기 때문이다.

한편 멤버의 팀과 같이 외래 키 값의 경우에는

1
2
3
4
5
Team team = em.find(Team.class, 1L);
String qlString = select m from Member m where m.team = :team;
List resultList = em.createQuery(qlString)
.setParameter("team", team)
.getResultList();

파라미터로 넘긴 :team 은 PK이고, m.team은 FK로 매핑된다.

Named 쿼리

미리 정의해서 이름을 부여해두고 사용하는 JPQL를 Named 쿼리라고 한다.

정적 쿼리만 가능하며, 어노테이션이나 XML에 정의할 수 있다.

애플리케이션 로딩 시점에 초기화 후 재사용하는데, 애플리케이션 로딩 시점에 쿼리를 검증 할 수 있다는 엄청난 장점이 있다.

엔티티에 어노테이션으로 다음과 같이 정의하고,

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

아래와 같이 사용할 수 있다.

1
2
3
4
5
6
7
List<Member> resultList = em.createNamedQuery("Member.findByUsername")
.setParameter("username", "회원1")
.getResultList();

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

XML을 이용한 방식은 강의 자료를 참고하자, XML이 항상 우선권을 가지며 애플리케이션 운영 환경에 따라 다른 XML을 배포할 수 있다.

추가적으로 Spring Data JPA 에서는 메서드 바로 위에 @Query로 네임드 쿼리를 작성할 수 있고, 이름없는 named 쿼리라고도 한다.

벌크 연산

일반적으로 알고 있는 SQL의 UPDATE, DELETE 문이라고 생각하면 된다.

JPA 변경 감지만으로 실행하기엔 너무 많은 SQL을 실행해야 하는 경우가 존재한다. 변경된 데이터가 100건이라면 100번의 UPDATE SQL이 실행될 텐데 이는 매우 비효율적이다.

쿼리 한번으로 여러 테이블 로우를 변경할 수 있게 되는데, 다음의 예시를 살펴보자.

1
2
3
4
int resultCount = em.createQuery("update Member m set m.age = 20")
                        .executeUpdate();

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

이와 같이 쿼리 한 번에 모든 회원의 나이를 20살로 변경하는 것이 가능하다!

executeUpdate()의 결과는 영향 받은 엔티티의 수를 반환하며, UPDATE, DELETE에서 벌크 연산을 지원한다. 하이버네이트에서는 INSERT도 지원한다.

주의해야 할 사항은 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리한다는 것이다. 잘못하면 꼬일 수 있으므로 영속성 컨텍스트에 값을 넣지 않고 벌크 연산을 먼저 실행하거나, 벌크 연산 수행 후 영속성 컨텍스트를 초기화해주는 방법이 있다.


드디어 JPA 기본 강의도 끝났다!

SQL은 현장실습 다닐 때 SQLD도 따고, postgreSQL 다뤄보면서 익숙해졌었는데 안본 지 오래 돼서 사실 좀 가물가물하다.

내일부터는 SQL 첫걸음 책도 읽어보고 SQL 도 다시 한번 공부해봐야 할 것 같다.

This post is licensed under CC BY 4.0 by the author.