본문 바로가기

Web Sever 개발과 CS 기초/스프링

JPA JPQL, fetch join 중심으로 이해하기

관련 내용

<JPA 영속성 컨텍스트부터 다양한 매핑까지 완전히 이해하기>

  • JPA 기본 동작과 영속성 원리 이해하기

JPA 기본 동작과 영속성 원리 이해하기

  • JPA 엔티티 매핑 - 테이블, 컬럼, 기본키

JPA 엔티티 매핑 - 테이블, 컬럼, 기본키

  • JPA 연관 관계 매핑과 고급 매핑

JPA 연관 관계 매핑과 고급 매핑

  • JPA지연 로딩, 즉시 로딩 이해와 사용법 영속성전이, 고아 객체 알아보기

JPA지연 로딩, 즉시 로딩 이해와 사용법 영속성전이, 고아 객체 알아보기

  • JPA 값 타입 이해하기

JPA 값 타입 이해하기

  • (현재 글)JPQL, fetch join 중심으로 이해하기

JPA JPQL, fetch join 중심으로 이해하기

개요 목적

이번 시간에는 JPQL 기본적 개념과 간단한 사용법에 대해서 알아본다.

왜냐하면 실무에서 잘 사용하지 않기 때문이다. 그 이유도 아래 글에서 밝힌다.

그리고 JPQL에서 제일 중요한 부분인 fetch join을 자세하게 알아본다.

JPQL이란

JPQL은 복잡한 조건의 검색 조건을 사용해서 테이블 대상이 아니라, 객체 대상으로 조회 쿼리를 날릴 수 있다.

문법은 SQL과 유사하다. 결과적으로 JPQL의 쿼리도 JPA를 거쳐서 SQL로 변환되어 DB에 날라간다.

또한 JPQL은 SQL을 추상화해서 특정 데이터베이스에 의존하지 않는다는 특징이 있다.

JPQL을 실무에서 쓰지 않는 이유

이것을 개요 목적

JPQL 문자열로, 모든 검색 조건을 표현하는 단점이 있다. 그래서 문자열을 잘 못 작성할 확률도 있고, 문제가 컴파일 시점이 아니라 런타임에서 발견되는 문제도 있다. 문자열을 사용해서 타입을 판별할 수 없다는 단점도 있다.

또한 동적 조건 사용할 때, 문자열을 +(더하는)식으로 하기 때문에, if문 같은 코드 활용성이 떨어진다.

JPQL 기본 문법

SQL과 비슷한 문법 구조를 지닌다.

select_문 :: =
        select_절
        from_절
        [where_절]
        [groupby_절]
        [having_절]
        [orderby_절]
SELECT m FROM Member AS m WHERE m.username = 'hwang'

다만, 문법 안에 들어가는 엔티티 값은 대소문자를 구분해야 한다. 또한 m 같은 별칭을 반드시 사용해야 한다.

TypeQuery, Query로 실행

JPQL을 실행하려면 쿼리 객체를 만들어야 한다. 쿼리 객체로는 TypeQuery와 Query가 있는데 반환할 타입을 명확하게 지정할 수 있으면 TypeQuery 객체를, 명확하게 지정할 수 없으면 Query 객체를 사용하면 된다

TypedQuery<Member> query =
    em.createQuery("select m from Member m", Member.class);
List<Member> resultList = query.getResultList();
for (Member member resultList) {
    System.out.println("member : " + member);
}

파라미터 바인딩

파라미터 바인딩 하는 방법에는 이름 기준으로 하는 방법과, 위치 기준으로 하는 방법이 있는데 이름 기준 방법이 더 명확하게 바인딩을 확인 할 수 있다.

TypedQuery<Member> query =
    em.createQuery("select m from Member m where m.username = :username", Member.class);
query.setParameter("username", 'hwang');
List<Member> resultList = query.getResultList();

프로젝션

SELECT 절에 조회할 대상을 지정하는 것을 프로젝션이라 한다. 프로젝션 대상에는 엔티티, 임베디드 타입, 스칼라 타입(기본형타입), 새로지정 객체 타입이 있다.

//지정 객체 타입 조회 대상 설정
public class UserDTO{
    private String username;
    private int age;
    public UserDTO(String username, int age){
        this.username = username;
        this.age = age;
    }
}

TypedQuery<UserDTO> query =
    em.createQuery("select new jpabook.jpql.UserDTO(m.username, m.age) from Member m", UserDTO.class);
List<UserDTO> resultList = query.getResultList();

페이징

JPA는 페이징을 다음 두 API로 추상화했다. (데이터베이스마다 다른 페이징 처리를 같은 API로 처리할 수 있는 것은 데이터베이스 방언을 설정해두었기 때문이다.)

  • setFirstResult (int startPosition) : 조회 시작 위치 (0부터 시작한다.)
  • setMaxResults (int maxResult) : 조회할 데이터 수

아래의 예시에서는 FirstResult의 시작이 10이므로 11번째부터 시작해서 총 20건의 데이터를 조회한다. 따라서 11~30번 데이터를 조회한다.

TypedQuery<Member> query =
    em.createQuery("select m from Member m order by m.username DESC", Member.class);
    
query.setFirstResult(10);
query.setMaxResults(20);

List<Member> resultList = query.getResultList();

조인

//내부 조인(동일한 값이 있는 행만 반환)
SELECT m FROM Member m [INNER]JOIN m.team t
//외부 조인(동일하지 않는 값이 있는 행도 반환)
SELECT m FROM Member m LEFT [OUTER] JOIN m.team t
//세타 조인 (연관관계가 없는 것을 조인하기)
SELECT m, t FROM Member m, Team t WHERE m.userName=t.name

실무에서 정말 중요한 fetch join

fetch join의 기본과 다대일 연관관계

SQL 조인 종류가 아니라, JPQL 성능 최적화를 위한 특별 기능이다. 페치 조인을 쓰면, 연관된 엔티티나, 컬랙션 데이터도 한번에 가져온다.

일반 조인의 경우 “SELECT m FROM Member m JOIN m.team t”하면 멤버 정보만 가져오지만,

fetch join을 설정하면 멤버와 연관된 팀 정보도 한번에 가져온다.

//fetch join을 하면,
select m from Member m join fetch m.team
//멤버와 연관된 팀 정보까지도 같이 가져온다.
SELECT M.*, T.* FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID=T.ID
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
//LAZY 지연 로딩 설정으로
//팀의 정보를 실제 조회 할 때만 연관관계인 팀 관련 쿼리가 날아가지만,
member.getTeam.getName()

//fetch join하면 회원과 팀을 함께 조회해서 지연 로딩이 발생하지 않는다.
String jpql = "select m from Member m join fetch m.team"; 

컬랙션 fetch join 일대다 연관관계

이번엔 팀에서 다 관계인 멤버리스트를 한번에 가져오는 fetch join을 해보자.

방법은 다대일 페치 조인과 같다.

@OneToMany(mappedBy = "team")
List<Member> memberList = new ArrayList<>();

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) { 
		//페치 조인으로 팀과 회원을 함께 조회해서 지연 로딩 발생 안함
		//team안에 멤버리스트가 포함되어 있다.
		for (Member member : team.getMembers()) { 
		System.out.println(“-> username = " + member.getUsername()+ ", member = " + member); 
 } 
}

일대다 fetch Join에서는 다대일과 다르게 데이터 뻥튀기 문제가 있을 수 있다.

Distinct로 뻥튀기 된 row의 엔티티 수를 줄일 수 있다.

Distinct의 역할은 두 개이다.

  • SQL에 DISTINCT를 추가
  • SQL에 DISTINCT를 추가하지만 데이터가 다르므로 SQL 결과에서 중복 제거가 실패 팀A하나는 회원1을 들고 있고, 다른 하나는 회원2을 들고 있는 다른 데이터이다.
  • 애플리케이션에서 엔티티 중복 제거
    • 다만 어플리케이션 차원에서는 같은 식별자를 가진 Team 엔티티 제거가 가능하다.

//적용 전
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

//적용 후
select distinct t from Team t join fetch t.members where t.name = ‘팀A’
->
teamname = 팀A, team = Team@0x100 
-> username = 회원1, member = Member@0x200 
-> username = 회원2, member = Member@0x300

fetch join 한계

1. 패치 조인 대상에는 별칭을 줄 수 없다.

//별칭 - 거르기 작업을 하면 잘못 동작할 수 있기 때문에, 쓰지 않는다
//fetch join은 기본적으로 fetchjoin한 엔티티 데이터 다 가져온다는 가정하에 작동한다.
"select t from Team t join fetch t.member where t.member.age =10"

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

일대다도 데이터 row수가 1+N 뻥튀기가 되는데,

둘 이상의 컬랙션을 페치 조인하면, 가져오는 데이터가 지나치게 켜저서 원하는 데이터를 못 가져올 수 있다. 컬랙션은 하나만 페치 조인을 하자.

3. 컬렉션을 페치 조인하면 페이징 API(setFirstResult,setMaxResults)를 사용할 수 없다.

일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 페이징이 가능하다. 데이터 row 수가 뻥튀기가 되지 않기 때문이다.

반면엔, 컬랙션 페치 조인을 하게 되면, 실제 엔티티에 맞는 수보다 row수가 더 많기 때문에 페이징 처리를 제대로 할 수 없다. 페이징은 DB row수 기반으로 작동하는 것이기 때문에

3-1. 컬랙션 페치 조인 페이징 문제를 해결하는 방법, batch

첫 번째 방법은 다대일로 변경하기

//다대일로 페치 조인해서 페이징 처리하기
"SELECT t FROM TEAM t JOIN FETCH t.members m"
->
"SELECT m FROM Member m JOIN FETCH m.team t"

두 번째 방법은 batch size를 설정하는 것이다.

팀A와 컬랙션(회원1회원2)과 팀B와 컬랙션(회원3)을 함께 가져오면 페이징하는 예시에 대해서 알아보겠다.

//먼저 팀만 조회를한다 페이징 사이즈에 맞게.
String query = "select t from Team t";
List<Team> results = em.createQuery(query, Team.class).setFirstResult(0)
    .setMaxResults(2).getResultList();

//그 이후에 지연로딩을 아에 사용하여, 따로 컬랙션을 가져오면 
//페이징에 맞게 팀도 가져오고, (회원들)컬랙션도 포함된 팀도 가져올 수 잇다.
for (Team team : results) { 
    for (Member member : team.getMembers()) {
        System.out.println("->member= "+member);
    }
}

그러나 이렇게 되면 팀A, 팀B 총 두개의 엔티티에서 컬랙션을 조회하는 것이기 때문에, 컬랜션을 찾기 위한 쿼리가 총 두 번 나가게 된다. 그 문제를 해결하는 것이 batchsize 설정이다.

배치 사이즈 설정

//필드로 따로 설정 하거나
@BatchSize(size = 1000)
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
//글로벌하게 할 수 있다.
spring:
    properties:
      hibernate:
        default_batch_fetch_size: 100

배치 사이즈 설정을 하면

하나의 쿼리에 팀 A와 팀B 연관된 멤버를 한번에 다 가져온다.

레이지 로딩을 할 때 한번에 넘길 수를 batchsize로 정할 수 있다.