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

LeeKyoungChang·2022년 3월 17일
0
post-thumbnail

자바 ORM 표준 JPA 프로그래밍 - 기본편 수업을 듣고 정리한 내용입니다.

 

📚 1. JPQL - 경로 표현식

경로 표현식 : .을 찍어 객체 그래프를 탐색하는 것이다.

select m.username → 상태 필드 
  from Member m
    join m.team t → 단일 값 연관 필드
    join m.orders o → 컬렉션 값 연관 필드 
where t.name = '팀A'

여기서 m.username, m.team, m.orders, t.name이 모두 경로 표현식을 사용한 예다.

 

✔️ 경로 표현식의 용어 정리

  • 상태 필드(state field) : 단순히 값을 저장하기 위한 필드(필드 or 프로퍼티)
  • 연관 필드(association field) : 연관관계를 위한 필드, 임베디드 타입 포함(필드 or 프로퍼티)
    • 단일 값 연관 필드 : @ManyToOne, @OneToOne, 대상이 엔티티
    • 컬렉션 값 연관 필드 : @OneToMany, @ManyToMany, 대상이 컬렉션

➡️ 상태 필드는 단순히 값을 저장하는 필드이고 연관 필드는 객체 사이의 연관관계를 맺기 위해 사용하는 필드다.

상태 필드와 연관 필드 예제

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    
    @Column(name = "name")
    private String usrename;       // 상태 필드
    private Integer age;           // 상태 필드
    
    @ManyToOne(..)
    private Team team;            // 연관 필드(단일 값 연관 필드)
    
    @OneToMany(..)
    private List<Order> orders;   // 연관 필드(컬렉션 값 연관 필드)
  • 상태 필드 : t.username, t.age
  • 단일 값 연관 필드 : m.team
  • 컬렉션 값 연관 필드 : m.orders

 

✔️ 경로 표현식과 특징

JPQL에서 경로 표현식을 사용해서 경로 탐색을 하려면 다음 3가지 경로에 따라 어떤 특징이 있는지 이해해야 한다.

  • 상태 필드 경로 : 경로 탐색의 끝이다. 더는 탐색할 수 없다.
  • 단일 값 연관 경로 : 묵시적으로 내부 조인이 일어난다. 단일 값 연관 경로는 계속 탐색할 수 있다. (왠만하면 사용하지 말기)
  • 컬렉션 값 연관 경로 : 묵시적으로 내부 조인이 일어난다. 더는 탐색할 수 없다. 단 FROM 절에서 조인을 통해 별칭을 얻으면 별칭으로 탐색할 수 있다.

 

(1) 상태 필드 경로 탐색

JPQL의 m.username, m.age는 상태 필드 경로 탐색이다.

select m.username, m.age from Member m

 

JPQL을 실행한 결과 SQL

select m.name, m.age
from Member m

 

(2) 단일 값 연관 경로 탐색

JPQL

select o.member from Order o
  • JPQL을 보면 o.member를 통해 주문에서 회원으로 단일 값 연관 필드로 경로 탐색을 했다.

 

JPQL을 실행한 결과 SQL

select m.*
from Orders o
    inner join Member m on o.member_id=m.id
  • 묵시적 조인 : 단일 값 연관 필드로 경로 탐색을 할시 SQL에서 내부 조인이 일어난다.
    • 묵시적 조인은 모두 내부 조인이다.
    • 외부 조인은 명시적으로 JOIN 키워드를 사용해야 한다.

 

✔️ 명시적 조인 vs 묵시적 조인
명시적 조인 : JOIN을 직접 적어주는 것

SELECT m FROM Member m JOIN m.team t

묵시적 조인 : 경로 표현식에 의해 묵시적으로 조인이 일어나는 것, 내부 조인 INNER JOIN만 할 수 있다.

SELECT m.team FROM Member m

 

(3) 컬렉션 값 연관 경로 탐색

JPQL을 다루면서 많이 하는 실수 중 하나는 컬렉션 값에서 경로 탐색을 시도하는 것이다.

select t.members from Team t           // 성공
select t.members.username from Team t  // 실패

t.members처럼 컬렉션까지는 경로 탐색이 가능하다.
하지만 t.members.username처럼 컬렉션에서 경로 탐색을 시작하는 것은 허락하지 않는다.
만약 컬렉션에서 경로 탐색을 하고 싶으면 다음 코드처럼 조인을 사용해서 새로운 별칭을 획득해야 한다. (명시적으로)

select m.username from Team t join t.members m

join t.members m으로 컬렉션에 새로운 별칭을 얻었다.
이제 별칭 m부터 다시 경로 탐색을 할 수 있다.

 

💡 참고

  • 컬렉션은 컬렉션의 크기를 구할 수 있는 size라는 특별한 기능을 사용할 수 있다.
  • size를 사용하면 COUNT 함수를 사용하는 SQL로 적절히 변환된다.
select t.members.size from Team t

 

⚠️ 경로 탐색을 사용한 묵시적 조인 시 주의사항

  • 항상 내부 조인이다.
  • 컬렉션은 경로 탐색의 끝이다. 컬렉션에서 경로 탐색을 하려면 명시적으로 조인해서 별칭을 얻어야 한다.
  • 경로 탐색은 주로 SELECT, WHERE (다른 곳에서도 사용됨)에서 사용하지만 묵시적 조인으로 인해 SQL의 FROM (JOIN) 절에 영향을 준다.

경로 탐색을 사용하면 묵시적 조인이 발생해서 SQL에서 내부 조인이 일어날 수 있다. 또한 묵시적 조인은 조인이 일어나는 상황을 한 눈에 파악하기 어렵다.

➡️ 묵시적 조인보다는 명시적 조인을 사용하자!

  • 명시적 조인 : join 키워드를 직접 사용하는 것
  • 묵시적 조인 : 경로 표현식에 의미 묵시적으로 join이 발생하는 것(내부 조인)

 

📚 2. JPQL - 페치 조인(fetch join)

실무에서 굉장히 중요하다!

  • 페치(fetch) 조인은 SQL에서 이야기하는 조인의 종류는 아니고 JPQL에서 성능 최적화를 위해 제공하는 기능이다.
  • 페치 조인은 연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능인데 join fetch 명령어로 사용할 수 있다.

JPA 표준 명세에 정의된 페치 조인 문법

페치 조인 ::= [ LEFT [OUTER] | INNER ] JOIN FETCH 조인경로

 

페치조인 설명 잘되어 있는 곳

 

📖 A. 엔티티 페치 조인

페치 조인을 사용해서 회원 엔티티를 조회하면서 연관된 팀 엔티티도 함께 조회하는 JPQL을 보자!

select m
from Member m join fetch m.team

위 예제를 볼시, join 다음에 fetch라 적었다.
이렇게 하면 연관된 엔티티나 컬렉션을 함께 조회하는데 여기서는 회원(m)과 팀(m.team)을 함께 조회한다.
참고로, 일반적인 JPQL 조인과는 다르게 m.team 다음에 별칭이 없는데 페치 조인은 별칭을 사용할 수 없다.

 

💡 참고
하이버네이트는 페치 조인에도 별칭을 허용한다.

 

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

페치 조인을 사용하면 아래 그림처럼 SQL 조인을 시도한다!

엔티티 페치 조인 시도
스크린샷 2022-03-17 오후 1 02 42

 

SQL에서 조인의 결과
엔티티 페치 조인 결과 테이블

스크린샷 2022-03-17 오후 1 02 48

 

엔티티 페치 조인 결과 객체

스크린샷 2022-03-17 오후 1 02 54

엔티티 페치 조인 JPQL에서 select m으로 회원 엔티티만 선택했는데 실행된 SQL을 보면 SELECT M.*, T.*로 회원과 연관된 팀도 함께 조회된 것을 확인할 수 있다.
그리고 위의 그림을 보면 회원과 팀 객체가 객체 그래프를 유지하면서 조회된 것을 확인할 수 있다.

 

페치 조인을 사용하기 전 코드

            Team teamA = new Team();
            teamA.setName("teamA");
            em.persist(teamA);

            Team teamB = new Team();
            teamB.setName("teamB");
            em.persist(teamB);

            Member member = new Member();
            member.setUsername("회원1");
            member.setTeam(teamA);
            em.persist(member);

            Member member2 = new Member();
            member2.setUsername("회원2");
            member2.setTeam(teamA);
            em.persist(member2);

            Member member3 = new Member();
            member3.setUsername("회원3");
            member3.setTeam(teamB);
            em.persist(member3);

            // 영속성 컨텍스트 비우기
            em.flush();
            em.clear();

            String query = "select m From Member m";

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

            for (Member s : result) {
                // 회원1. 팀A(SQL)
                // 회원2. 팀A(1차캐시)
                // 회원3. 팀B(SQL)
                // N 명일 때 : N + 1 (첫 번째 Member 테이블 가져온다. 1)
                System.out.println("Member = " + s.getUsername() + ", " + s.getTeam().getName());
            }

            tx.commit();

 

실행 결과

Hibernate: 
    /* select
        m 
    From
        Member m */ select
            member0_.id as id1_0_,
            member0_.age as age2_0_,
            member0_.TEAM_ID as TEAM_ID5_0_,
            member0_.type as type3_0_,
            member0_.username as username4_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, teamA
Member = 회원2, teamA
Hibernate: 
    select
        team0_.id as id1_3_0_,
        team0_.name as name2_3_0_ 
    from
        Team team0_ 
    where
        team0_.id=?
Member = 회원3, teamB
  • 회원 1을 가져올 때 먼저 SQL에서 가져온다. (회원 3, teamB는 아직 sql에서 가져오지 않았다.)
  • 1차캐시에 있는 회원2, teamA을 가져와서 출력한다.
  • 회원 3, teamB는 이제 sql에서 가져온다.

➡️ 그러므로, Member로부터 team을 호출할 때 비로소 sql에서 데이터를 가져와 1차캐시에 저장한다.

 

JPQL을 사용하는 코드 - 페치 조인을 사용한 코드

            String query = "select m from Member m join fetch m.team";

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

            for (Member s : result) {
                // 페치 조인으로 회원과 팀을 함께 조회해서 지연 로딩 발생 안 함
                System.out.println("Member = " + s.getUsername() + ", " + s.getTeam().getName());
            }

 

출력 결과

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_ID5_0_0_,
            member0_.type as type3_0_0_,
            member0_.username as username4_0_0_,
            team1_.name as name2_3_1_ 
        from
            Member member0_ 
        inner join
            Team team1_ 
                on member0_.TEAM_ID=team1_.id
Member = 회원1, teamA
Member = 회원2, teamA
Member = 회원3, teamB
  • List<Member> members = em.createQuery(jpql, Member.class) .getResultList(); 이때, member에 실제 엔티티들이 담긴다.

회원과 팀을 지연 로딩으로 설정했다고 가정해보자.
회원을 조회할 때 페치 조인을 사용해서 팀도 함께 조회했으므로 연관된 팀 엔티티는 프록시가 아닌 실제 엔티티다.
따라서, 연관된 팀을 사용해도 지연 로딩이 일어나지 않는다.
그리고 프록시가 아닌 실제 엔티티이므로 회원 엔티티가 영속성 컨텍스트에서 분리되어 준영속 상태가 되어도 연관된 팀을 조회할 수 있다.

 

📖 B. 컬렉션 페치 조인

이번에는 일대다 관계인 컬렉션을 페치 조인해보자!
ex)

select t
from Team t join fetch t.members
where t.name = '팀A'

아래 예제는 팀(t)을 조회하면서 페치 조인을 사용해서 연관된 회원 컬렉션(t.members)도 함께 조회한다.

SELECT
    T.*, M.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = '팀A'

컬렉션 페치 조인 시도

스크린샷 2022-03-17 오후 1 14 39

 

컬렉션 페치 조인 결과 테이블

스크린샷 2022-03-17 오후 1 14 42

 

컬렉션 페치 조인 결과 객체

스크린샷 2022-03-17 오후 1 14 46

위의 예제의 컬렉션을 페치 조인한 JPQL에서 select t로 팀만 선택했는데 실행된 SQL을 보면 T.*, M.*로 팀과 연관된 회원도 함께 조회한 것을 확인할 수 있다.
그리고 위의 그림의 TEAM 테이블에서 팀A는 하나지만 MEMBER 테이블과 조인하면서 결과가 증가해서 위의 그림의 조인 결과 테이블을 보면 같은 팀A가 2건 조회되었다.
따라서 위의 그림의 컬렉션 페치 조인 결과 객체에서 teams 결과 예제를 보면 주소가 0x100으로 같은 팀A를 2건 가지게 된다.

 

✔️ 컬렉션 페치 조인을 사용하는 예제

일대다 조인은 결과가 증가할 수 있지만 일대일, 다대일 조인은 결과가 증가하지 않는다.

String jpql = "select t from Team t join fetch t.members where t.name = '팀A'";
List<Team> teams = em.createQuery(jpql, Team.class).getResultList();

for (Team team : teams) {

    System.out.println("teamname = " + team.getName() + ", team = " + team);
    
    for (Member member : team.getMembers()) {
        
        // 페치 조인으로 팀과 회원을 함께 조회해서 지연 로딩 발생 안함
        System.out.println(
            "->username = " + member.getUsername() + ", member = " + member);
    }
}

 

출력 결과

teamname = 팀A, team = Team@0x100
->username = 회원1, member = Member@0x200
->username = 회원2, member = Member@0x300
teamname = 팀A, team = Team@0x100
-> username = 회원1, member = Member@0x200
-> username = 회원2, member = Member@0x300

같은 팀A가 2건 조회된다.

 

📖 C. 페치 조인과 DISTINT

  • SQL의 DISTINCT 명령어 : 중복된 결과를 제거하는 명령어
  • JPQL의 DISTINCT 명령어 : SQL에 DISTINCT를 추가하는 것은 물론이고 애플리케이션에서 한 번 더 중복을 제거한다.

이전 컬렉션 페치 조인은 팀A가 중복으로 조회된다.
DISTINCT을 추가

select distinct t
from Team t join fetch t.members
where t.name = '팀A'

먼저 DISTINCT를 사용하면 SQL에 SELECT DISTINCT가 추가된다.
하지만 지금은 각 로우의 데이터가 다르므로 아래 표처럼 SQL의 DISTINCT는 효과가 없다.

로우 번호회원
1팀A회원1
2팀A회원2

 

다음으로 애플리케이션에서 distinct 명령어를 보고 중복된 데이터를 걸러낸다.
select distinct t : 팀 엔티티의 중복을 제거하라는 것
따라서 중복인 팀A는 아래 그림처럼 하나만 조회된다.

스크린샷 2022-03-17 오후 1 24 51

 

컬렉션 페치 조인 사용 예제에 distinct를 추가하면 출력 결과는 다음과 같다. (팀A 하나가 없어짐)

teamname = 팀A, team = Team@0x100
-> username = 회원1, member = Member@0x200
-> username = 회원2, member = Member@0x300

일대다 일경우 뻥튀기 현상이 일어난다.

 

📖 D. 페치 조인과 일반 조인의 차이

페치 조인을 사용하지 않고 조인만 사용할 시 어떻게 될까?

select t
from Team t join t.members m
where t.name = '팀A'
SELECT
    T.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = '팀A'

위의 예제의 JPQL에서 팀과 회원 컬렉션을 조인했으므로 회원 컬렉션도 함께 조회할 것으로 기대해선 안 된다.
위의 에제에 실행된 SQL의 SELECT 절을 보면 팀만 조회하고 조인했던 회원은 전혀 조회되지 않는다.

JPQL은 결과를 반환할 때 연관관계까지 고려하지 않는다. 단지 SELECT 절에 지정한 엔티티만 조회할 뿐이다.
따라서, 팀 엔티티만 조회하고 연관된 회원 컬렉션은 조회하지 않는다. 만약 회원 컬렉션을 지연 로딩으로 설정하면 프록시나 아직 초기화되지 않은 컬렉션 래퍼를 반환한다.
즉시 로딩으로 설정하면 회원 컬렉션을 즉시 로딩하기 위해 쿼리를 한 번 더 실행한다.

 

반면에 페치 조인을 사용하면 연관된 엔티티도 함께 조회한다.

select t
from Team t join fetch t.members
where t.name = '팀A'

 

SELECT T.*, M.*로 팀과 회원을 함께 조회한 것을 알 수 있다.

SELECT
    T.*, M.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = '팀A'

 

📖 E. 페치 조인의 특징과 한계

페치 조인을 사용하면 SQL 한 번으로 연관된 엔티티들을 함께 조회할 수 있어서 SQL 호출 횟수를 줄여 성능을 최적화할 수 있다.

✔️ 글로벌 로딩 전략

글로벌 로딩 전략 : 엔티티에 직접 적용하는 로딩 전략, 애플리케이션 전체에 영향을 미친다.

@OneToMany(fetch = FetchType.LAZY)  // 글로벌 로딩 전략
  • 페치 조인은 글로벌 로딩 전략보다 우선한다.
  • ex) 글로벌 로딩 전략은 지연 로딩으로 설정해도 JPQL에서 페치 조인을 사용하면 페치 조인을 적용해서 함께 조회한다.
  • 최적화를 위해 글로벌 로딩 전략을 즉시 로딩으로 설정하면 애플리케이션 전체에서 항상 즉시 로딩이 일어난다.
    • 물론 일부는 빠를 수는 있지만 전체로 보면 사용하지 않는 엔티티를 자주 로딩하므로 오히려 성능에 악영향을 미칠 수 있다.
    • 따라서 글로벌 로딩 전략은 될 수 있으면 지연 로딩을 사용하고 최적화가 필요하면 페치 조인을 적용하는 것이 효과적이다.

 

💡 참고

  • 페치 조인을 사용하면 연관된 엔티티를 쿼리 시점에 조회하므로 지연 로딩이 발생하지 않는다.
  • 따라서 준영속 상태에서도 객체 그래프를 탐색할 수 있다.

 

✔️ 페치 조인의 특징과 한계 - 정리

  • 페치 조인 대상에는 별칭을 줄 수 없다. ("select distinct t from Team t join fetch t.members";는 안된다.)
    • 페치 조인은 나와 연결된 것들을 끌고 오겠다.
    • 페치 조인은 모든 것을 한 번에 끌고 오기때문에, 별칭을 주면 나른 데이터에서 오류가 발생할 수 있다.
    • SELECT, WHERE 절, 서브 쿼리에 페치 조인 대상을 사용할 수 없다.
    • 하이버네이트에서는 가능하지만, 가급적 사용하지 말자!
  • 둘 이상의 컬렉션을 페치할 수 없다.
  • 컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.
    • 컬렉션(일대다)이 아닌 단일 값 연관 필드(일대일, 다대일)들은 페치 조인을 사용해도 페이징 API를 사용할 수 있다.
    • 하이버네이트에서 컬렉션을 페치 조인하고 페이징 API를 사용하면 경고 로그를 남기면서 메모리에서 페이징 처리를 한다. 데이터가 적으면 상관없겠지만 데이터가 많으면 성능 이슈와 메모리 초과 예외가 발생할 수 있어서 위험하다.

 

  • 페치 조인은 SQL 한 번으로 연관된 여러 엔티티를 조회할 수 있어서 성능 최적화에 상당히 유용하다.
  • 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선한다.
  • 페치 조인은 실무에서 자주 사용하게 된다. 실무에서 글로벌 로딩 전략은 모두 지연 로딩이다.
  • 최적화가 필요한 곳은 페치 조인을 적용한다.

 

📌 페치 조인 - 정리

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

 


참고

profile
"야, (오류 만났어?) 너두 (해결) 할 수 있어"

0개의 댓글