[JPA 기본편] 10. 객체 지향 쿼리( JPQL ) - 중급 문법

HJ·2024년 2월 26일
0

JPA 기본편

목록 보기
10/10
post-thumbnail

김영한 님의 자바 ORM 표준 JPA 프로그래밍 - 기본편 강의를 보고 작성한 내용입니다.


1. 경로 표현식

1-1. 상태필드, 연관필드

select m.username   -- 상태 필드
from Member m
join m.team t       -- 단일 값 연관 필드
join m.orders o     -- 컬렉션 값 연관 필드

경로 표현식이란 . 으로 객체 그래프를 탐색하는 것을 의미하고, 어떤 필드로 가느냐에 따라서 내부의 동작이 달라지게 됩니다.

상태 필드란 단순히 값을 저장하기 위한 필드를 의미합니다. 상태 필드는 이후에 . 을 통해 탐색할 수 있는 것이 없기 때문에 경로 탐색의 끝이며, 이후 탐색이 발생하지 않습니다.

연관 필드란 연관관계를 위한 필드를 의미하는데 단일 값 연관 필드, 컬렉션 값 연관 필드로 나뉩니다.


1-2. 단일 값 연관 필드

select m.team.name
from Member m

단일 값 연관 필드는 @xToOne 처럼 대상이 엔티티인 것을 의미합니다. 그래서 select 절에 m.team.name 과 같이 m.team 이후에 . 을 통해 탐색을 더 할 수 있습니다. 이러한 경우 묵시적 내부 조인이 발생합니다. ( 내부 조인만 가능 )


String jpql = "select m.team from Member m";
List<Team> resultList = em.createQuery(jpql, Team.class).getResultList();
select
    t1_0.id,
    t1_0.name 
from
    Member m1_0 
join 
    Team t1_0 
        on t1_0.id=m1_0.TEAM_ID

JPQL 에서 m.team 을 통해 Team 을 가져오려 했는데, 실행되는 로그를 보면 Member 와 Team 을 join 을 하고, 프로젝션에 team 의 필드들을 나열합니다.

객체 입장에서는 . 을 통해 갈 수 있지만, DB 에서는 이처럼 사용하려면 조인이 필요하기 때문에 조인이 발생하고, 이러한 것을 묵시적 내부 조인이라고 합니다.

묵시적 조인이 되면 어디서 조인이 발생했는지 모르기 때문에 join 키워드를 직접 사용하는 명시적 조인을 사용하는 것이 좋습니다.


1-3. 컬렉션 값 연관 필드

String jpql = "select t.members from Team t";
List<Collection> result = em.createQuery(jpql, Collection.class).getResultList();

컬렉션 값 연관 필드는 @xToMany 처럼 대상이 컬렉션인 것을 의미합니다. 단일 값 연관 필드처럼 묵시적 내부 조인이 발생하지만, 이후 탐색은 불가능합니다.

@OneToMany 와 같은 것은 컬렉션이여서 데이터가 여러 개 존재합니다. 그렇게 때문에 어떤 데이터의 어떤 필드를 가져올지 선택할 수 없기 때문에 이후 탐색은 불가능합니다.


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

위처럼 명시적 조인을 통해 별칭을 얻으면 이후 탐색이 가능합니다.




2. 엔티티 패치 조인

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

엔티티 패치 조인은 @ManyToOne 으로 이루어진 연관관계를 조인하는 것입니다.

2-1. 일반 조인

List<Member> members = em.createQuery(
                        "select m from Member m join m.team", Member.class)
                        .getResultList();

for (Member findMember : members) {
    System.out.println("---------------findMember.getUsername() = " 
                        + findMember.getUsername());
    System.out.println("---------------findMember.getTeam().getName() = " 
                        + findMember.getTeam().getName());
}
select
    m1_0.id,
    m1_0.age,
    m1_0.TEAM_ID,
    m1_0.username 
from Member m1_0 
join Team t1_0 
    on t1_0.id=m1_0.TEAM_ID
---------------findMember.getUsername() = memberA
select
    t1_0.id,
    t1_0.name 
from Team t1_0 
where
    t1_0.id=?
---------------findMember.getTeam().getName() = teamA

join 키워드만 사용했을 때는 Team 과 조인하지만 Member 에 관한 컬럼들만 가져옵니다. 그래서 Team 의 이름을 출력할 때 지연로딩에 의해 다시 Team 에 대한 쿼리가 실행됩니다.

만약 Memebr 의 Team 이 전부 다른 데이터라면 Team 을 조회하는 쿼리가 계속 실행되고, N + 1 문제가 발생합니다.

쉽게 말해서 일반 조인 실행 시, 지연 로딩에 의해 연관된 엔티티를 함께 조회하지 않습니다.


2-2. 패치 조인

List<Member> members = em.createQuery(
                        "select m from Member m join fetch m.team", Member.class)
                        .getResultList();
for (Member findMember : members) {
    System.out.println("---------------findMember.getUsername() = " 
                        + findMember.getUsername());
    System.out.println("---------------findMember.getTeam().getName() = " 
                        + findMember.getTeam().getName());
}                        
select
    m1_0.id,
    m1_0.age,
    t1_0.id,
    t1_0.name,
    m1_0.username 
from Member m1_0 
join Team t1_0 
    on t1_0.id=m1_0.TEAM_ID
---------------findMember.getUsername() = memberA
---------------findMember.getTeam().getName() = teamA

패치조인은 Member 와 Team 의 컬럼들을 모두 가져오게 됩니다. 그래서 Team 의 이름을 출력할 때 다시 쿼리가 나가지 않고 바로 출력됩니다. 즉, 패치조인으로 Member 와 Team 을 함께 조회해서 지연 로딩이 이루어지지 않았습니다.

해당 로그는 즉시로딩으로 가져오는 것과 비슷하게 출력되는데 즉시로딩은 연관된 모든 것을 여러 번의 쿼리로 가져옵니다.

하지만 패치조인은 원하는 것만을 한 번의 쿼리로 가져오도록 설정할 수 있습니다. 그래서 일반조인과 달리 N + 1 문제가 발생하지 않습니다.

지연로딩보다 패치조인이 우선이기 때문에 패치조인이 이루어지고, 이 시점에 지연로딩과는 다르게 Team 은 프록시가 아닌 실제 엔티티입니다.


2-3. 참고> 즉시로딩

-- MEMBER
select
    m1_0.id,
    m1_0.age,
    m1_0.TEAM_ID,
    m1_0.username 
from Member m1_0 
join Team t1_0 
    on t1_0.id=m1_0.TEAM_ID
-- TEAM    
select
    t1_0.id,
    t1_0.name 
from Team t1_0 
where t1_0.id=?
---------------findMember.getUsername() = memberA
---------------findMember.getTeam().getName() = teamA

연관된 엔티티를 모두 가져온 다음에 실행됩니다. 이때 조인은 join 키워드만 사용합니다.




3. 컬렉션 패치 조인

컬렉션 패치조인은 @OneToMany 의 관계를 패치조인 하는 것을 말합니다.
현재 데이터는 member1 과 member2 가 teamA 에 속해있는 상태입니다.

List<Team> teams = em.createQuery(
                    "select t from Team t join fetch t.members tm", Team.class)
                    .getResultList();

for (Team team : teams) {
    System.out.println("-----------------------team.getName() = " + 
                        team.getName());
    System.out.println("-----------------------team.getMembers().size() = " + 
                        team.getMembers().size());
}                    
select
    t1_0.id,
    m1_0.Team_id,
    m1_1.id,
    m1_1.age,
    m1_1.TEAM_ID,
    m1_1.username,
    t1_0.name 
from Team t1_0 
join Team_Member m1_0 
    on t1_0.id=m1_0.Team_id 
join Member m1_1 
    on m1_1.id=m1_0.members_id 
where t1_0.name='teamA'
-----------------------team.getName() = teamA
-----------------------team.getMembers().size() = 2

일대다 조인의 경우, 데이터가 늘어나서 teamA 가 두 번 출력되어야 하는데 한 번만 출력됩니다. 이것은 Hibernate 6 부터 distinct 를 자동으로 해주기 때문입니다.

JPQL 의 distinct 는 SQL 에 distinct 를 추가함과 동시에 애플리케이션에서 같은 식별자를 가진 엔티티의 중복을 제거합니다.

SQL 의 distinct 는 완전히 데이터가 동일해야 제거되는데 Member 의 PK 가 다르기 때문에 SQL 의 distinct 로는 제거가 안되고, 애플리케이션의 중복 제거를 통해 제거됩니다.




4. 패치조인 정리

4-1. 일반조인 vs 패치조인

JPQL 은 결과를 반환할 때 연관관계 고려하지 않고 SELECT 절에 지정한 엔티티만 조회합니다. 그래서 일반조인은 실행 시 연관된 엔티티를 함께 조회하지 않게 됩니다.

패치조인을 사용할 때만 연관된 엔티티도 함께 조회하게 됩니다. 즉, 패치조인은 객체 그래프를 SQL 한 번에 조회하는 개념입니다.


4-2. 패치조인 한계

1. 패치조인 대상에는 별칭을 줄 수 없습니다. ( 하이버네이트는 가능, 권장 X )

왜냐하면 패치조인은 연관된 모든 것들을 가지고 오는 것입니다. 중간에 가져오고 싶지 않은 것들을 위해 사용한다면 차라리 따로 조회를 하는 것이 더 좋습니다. from 절에 있는 엔티티에 대해 조건을 거는 것은 가능합니다.

2. 둘 이상의 컬렉션은 패치조인할 수 없습니다.

만약 Team 에 List 로 members 와 orders 가 있다고 했을 때 둘 중 하나만 패치조인을 할 수 있습니다.

3. 컬렉션을 패치조인하면 페이징을 사용할 수 없습니다.

@xToOne 과 같은 연관관계는 패치조인해도 페이징이 가능합니다. 다대일과 같은 관계는 조인을 해도 데이터가 늘어나지 않기 때문입니다.

하지만 @xToMany 와 같은 관계의 컬렉션 패치조인은 페이징이 불가능합니다. 이를 해결할 수 있는 방법이 있는데 API 2편 게시글에 설명되어 있습니다.


4-3. 패치조인 특징

  1. 연관된 엔티티들을 SQL 한 번으로 조회하기 때문에 성능 최적화에 좋습니다.

  2. 엔티티에 직접 적용하는 지연 로딩과 같은 글로벌 로딩 전략보다 우선 시 됩니다.

  3. 모든 것을 패치조인으로 해결할 수는 없고 패치조인은 객체 그래프를 유지해서 .을 통해 찾아가는 것이 필요할 때 사용하면 효과적입니다.

  4. 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하면, 패치조인 보다는 일반 조인을 사용하고 필요한 데이터들만 조회해서 DTO 로 반환하는 것이 효과적입니다.




5. NamedQuery

Named Query 는 미리 정의해서 이름을 부여해두고 사용하는 JPQL 로, 정적 쿼리만 가능합니다.

어노테이션이나 XML 에 정의해서 사용할 수 있는데 애플리케이션 로딩 시점에 JPA 나 Hibernate 가 쿼리를 SQL 로 파싱하고 캐싱합니다.

JPQL 은 SQL 로 파싱돼서 실행되어야 하는데 미리 파싱하고 캐싱을 하기 때문에 실행할 때마다 파싱하는 코스트를 줄일 수 있습니다.

또 JPQL 을 문자로 작성하면 쿼리가 실행되는 런타임에 오류가 발생할 수 있는데 Named Query 를 사용하면 애플리케이션 로딩 시점에 쿼리를 검증할 수 있습니다.


[ 사용 예시 ]

@Entity
@NamedQuery(name = "Member.findByUsername",
            query="select m from Member m where m.username = :username")
public class Member {
    ...
}
// ----------------------------------------------------------------------
List<Member> result = 
em.createNamedQuery("Member.findByUsername", Member.class)
    .setParameter("username", "회원1")
    .getResultList();

@NamedQuery 어노테이션을 통해 쿼리를 생성하면서 이름을 부여하고, 사용할 때도 이름으로 쿼리를 사용할 수 있습니다. 이름을 작성할 때 관례로 엔티티명.쿼리명 으로 많이 사용합니다.




6. 벌크 연산

영속성 컨텍스트에 의해 엔티티가 변경되면 자동으로 update 쿼리가 나간다고 했습니다. 하지만 이런 변경 감지 기능으로는 대량의 데이터를 수정하기도 어렵고 많은 SQL 이 실행되어야 합니다. 이때 사용할 수 있는게 벌크 연산입니다.

executeUpdate() 로 UPDATE, DELETE 를 수행할 수 있으며, 쿼리 한 번으로 여러 엔티티를 변경할 수 있습니다. 실행 결과로 영향 받은 엔티티 수가 반환됩니다.

참고로 하이버네이트는 INSERT INTO ... SELECT 문도 지원합니다.


[ 사용 예시 ]

String qlString = "update Product p " + 
                  "set p.price = p.price * 1.1 " +  
                  "where p.stockAmount < :stockAmount";  

int resultCount = em.createQuery(qlString) 
                    .setParameter("stockAmount", 10)  
                    .executeUpdate();  

[ 주의점 ]

commit 을 하거나, query 가 나가거나, 강제로 flush 를 호출하면 FLUSH 되는데 벌크 연산은 영속성 컨텍스트를 무시하고 DB 에 직접 쿼리가 날라가게 됩니다.

셋 중에 벌크 연산은 Query 를 날리는거에 해당하므로 FLUSH 후에 벌크 연산이 실행되기 때문에 영속성 컨텍스트에 있는 엔티티들은 걱정하지 않아도 됩니다.

Member member = new Member();
member.setAge(10);
em.persist(member);

String qlString = "update Product p " + 
                  "set p.price = p.price * 1.1 " +  
                  "where p.stockAmount < :stockAmount";  
// FLUSH -> DB 에 반영                  
int resultCount = em.createQuery(qlString) 
                    .setParameter("stockAmount", 10)  
                    .executeUpdate();  

Member findMember = em.find(Member.class, member.getId());
System.out.println("member age = " + findMember.getAge());   // 10

하지만 위의 경우 문제가 생기는데 executeUpdate() 로 인해 DB 에서 Member 의 나이는 20살이지만, 위의 출력 결과는 10 이 됩니다.

왜냐하면 FLUSH 를 한다고 영속성 컨텍스트가 지워지는 것이 아닌 em.clear() 를 해야 지워지기 때문입니다. 그래서 영속성 컨텍스트에 존재하는 10 이 출력되는 것입니다.

이로 인한 문제를 방지하기 위해 벌크 연산을 먼저 수행하거나, 벌크 연산 수행 후에 em.clear() 를 통해 영속성 컨텍스트를 초기화해야 합니다.

profile
공부한 내용을 정리해서 기록하고 다시 보기 위한 공간

0개의 댓글