본문 바로가기
JPA/JPQL

[JPQL] 경로 표현식, 페치 조인(N+1문제 해결)

by yoon_seon 2023. 4. 26.

📌 경로 표현식

.(점)을 찍어 객체 그래프를 탐색하는 것

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

 

경로 표현식 용어 정리

  • 상태 필드(state field) : 단순히 값을 저장하기 위한 필드
    (ex: m.username)
  • 연관 필드(association field) : 연관관계를 위한 필드
    • 단일 값 연관 필드 : @ManyToOne, @OneToOne, 대상이 엔티티(ex: m.team)
    • 컬렉션 값 연관 필드 : @OneToMany, @ManyToMany, 대상이 컬렉션(ex: m.orders)

경로 표현식 특징

  • 상태 필드(state field): 경로 탐색의 끝, 탐색 X
  • 단일 값 연관 경로: 묵시적 내부 조인(inner join) 발생, 탐색 O
select m.team.name from Member m;

객체입장에서는 참조하면 되지만 테이블 입장에서는 Join을 해서 데이터를 찾아야 한다.

SQL에 Join절을 사용하지 않았지만 Member에서 Team을 찾아야 하기 때문에 Join이 사용된 것을 확인할 수 있다.

  • 컬렉션 값 연관 경로: 묵시적 내부 조인 발생, 탐색 X
    • FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색 가능
select m.username from Team t join t.members m;

 

실무 조언

조인은 SQL 튜닝에 중요 포인트다.

묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵다.

항상 명시적 조인을 쓰자!

 

 

📌 페치 조인(fetch join) ⭐⭐⭐

  • SQL 조인 종류는 아니다
  • JPQL에서 성능 최적화를 위해 제공하는 기능
  • 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능
  • join fetch 명령어 사용
  • 페치 조인 :: = [LEFT [OUTER] | INNER ] JOIN FETCH 조인 경로

 

엔티티 페치 조인

  • 회원을 조회하면서 연관된 팀도 함께 조회(SQL 한 번에)
  • SQL을 보면 회원뿐만 아니라 팀(T.*)도 함께 SELECT
  • [JPQL]
    select m from Member m join fetch m.team
    [SQL]
    select m.*, t.* from Member m inner join Team t on m.team_id = t.id;

간단한 예제로 이해해 보자.

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

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

Member member1 = new Member();
member1.setUsername("회원1");
member1.changeTeam(teamA);
em.persist(member1);

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

Member member3 = new Member();
member3.setUsername("회원3");
member3.changeTeam(teamB); // 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 member : result) {
	System.out.println("member : "+member.getUsername()+ ", team : "+member.getTeam().getName());
}

시나리오와 같이 회원 1, 회원 2는 팀 A에 회원 3은 팀 B로 설정

로그 분석

1. 처음에 member(회원 1, 회원 2, 회원 3)를 가져온다. team은 당장 사용하지 않으니 프록시로 가져온다.

2. 루프를 돌면서 team을 직접 사용함으로 DB에 요청하여 team을 영속성 컨텍스트에 가져온다.

2-1. 회원 1의 팀인 팀 A를 DB에서 가져온다.(영속성 컨텍스트에 없기 때문에)

2-2. 회원 2의 팀인 팀 A를 1차 캐시에서 가져온다.(회원 1을 가져올 때 팀 A를 영속성 컨텍스트에 가져왔기 때문)

2-3. 회원 3의 팀인 팀 B를 DB에서 가져온다.(영속성 컨텍스트에 없기 때문에)

 

⭐ 중요

최악의 경우, 만약 팀의 소속이 다른 회원 100명을 조회하면 쿼리가 100번 나갈 것이다.

첫 번째 쿼리를 날린 결과로 얻은 result 만큼 N번을 다시 날리는 것. 이것을 N+1 문제라고 한다.

N+1문제는 fatch join으로 해결해야 한다.

 

Jatch Join 적용

String query = "select m From Member m join fetch m.team"; // fetch join 적용
List<Member> result = em.createQuery(query, Member.class)
		.getResultList();

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

패치조인으로 회원과 팀을 함께 조회해서 지연 로딩 X (우선순위 : 패치조인 > 지연로딩)

처음 조회 시 join으로 member 및 team을 가져오기 때문에 team은 프록시가 아닌 실제 객체이다.

 

컬렉션 페치 조인

  • 일대다 관계, 컬렉션 페치 조인
  • [JPQL]
    select t from Team t join fetch t.members where t.name = '팀A'
    [SQL]
    select  t.*, m.* from Team t inner join Member m on  t.id = m.team_id where t.name = '팀A'

예제를 보자

// 엔티티 페치조인과 회원 및 팀 설정 동일

//...

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()+ ", members size : "+team.getMembers().size());
}

로그분석

1. 루프를 돌면서 회원 1의 팀 A, 회원 사이즈 2

2. 루프를 돌면서 회원 2의 팀 A, 회원 사이즈 2

3. 루프를 돌면서 회원 3의 팀 B, 회원 사이즈 3

 

회원 1, 회원 2의 팀 A가 동일하기 때문에 팀 A는 하나지만 결과가 뻥튀기된다.

 

페치조인과 DISTINCT

  • SQL의 DISTINCT는 중복된 결과를 제거하는 명령
  • JPQL의 DISTINCT는 2가지 기능을 제공
    • SQL에 DISTINCT를 추가
    • 애플리케이션에서 엔티티 중복 제거

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

SQL에 DISTINCT를 추가하지만 DB입장에서는 데이터가 다르므로 SQL결과에서 중복 제거 실패한다.

  • 쿼리만으로는 중복제거가 안되기 때문에 JPA 추가적으로 DISTINCT가 추가로 애플리케이션에서 중복제거를 시도한다.
  • 같은 식별자를 가진 Team 엔티티 제거

그렇기 때문에 엔티티 입장에서는 DISTINCT를 추가하여 2건이 조회된다.

 

일대다 관계에서는 데이터의 개수가 뻥튀기되지만

다대일 관계에서는 데이터의 개수가 뻥튀기되지 않는다는 것을 꼭 기억하자.

 

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

  • 일반 조인 실행 시 연관된 엔티티를 함께 조회하지 않음
  • [JPQL]
    select t from Team t join t.members m where t.name = ‘팀A'
    [SQL]
    select t.* from Team t inner join Member m on t.id=m.team_id where t.name = '팀A'

반 조인은 연관된 엔티티를 먼저 조회하지 않기 때문에 프록시 객체 반환 후

실제로 해당 엔티티를 사용할 때 값을 조회한다.(지연로딩)

 

페치 조인은 조회 시 연관관계도 같이 조회한다.(즉시 로딩)프록시 객체를 반환하지 않는다.

치 조인은 객체 그래프를 SQL 한 번에 조회하는 개념이다.

 

 

페치 조인의 특징과 한계

  • 페치 조인 대상에는 별칭을 줄 수 없다. (Hibernate는 가능하지만 가급적 사용하지 않는 것이 좋다.)
    String query = "select t from Team t join fetch t.members as m";
    • 페치 조인은 객체 그래프 SQL을 한 번에 조회하는 개념으로 연관된 엔티티를 모두 가져와야 한다.
    • 기본적으로 JPA에서 설계사상은 객체 그래프를 탐색하여 연관된 엔티티를 모두 가져온다는 것을 가정하고 만들어졌다.
    • fetch join에 별칭을 붙이고 where절을 더해 필터 해서 결과를 가져오게 되면 모든 걸 가져온 결과와 비교하여 다른 개수에 대해서 정합성을 보장하지 않는다.
      ex. 팀과 연관된 회원이 5명일 때 회원을 3명만 조회한 경우 3명만 따로 조작하는 것은 위험하다.
             → 데이터에 따라 정합성이 보장되지 않을 수 있다.
  • 둘 이상의 컬렉션은 페치 조인 할 수 없다.
    일대다도 데이터가 뻥튀기가 되는데 둘 이상의 컬렉션(1:N:N)은 잘못하면 데이터가 예상하지 못하게 늘어날 수 있다.

  • 컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.
    • 일대일, 다대일 같은 단일 값 연관 필드는 페치 조인해도 페이징이 가능
    • Hibernate는 경고 로그를 남기고 메모리에서 페이징(매우 위험)

경고 로그

String query = "select t from Team t join fetch t.members m";
List<Team> result = em.createQuery(query, Team.class)
      .setFirstResult(0)
      .setMaxResults(1)
      .getResultList();

쿼리를 보면 페이징 쿼리가 전혀 없다. DB에서 Team에 대한 데이터를 전부 가져온 것이다.

JPA 입장에서는 객체 그래프를 탐색하기 때문에 Team이 만약 100만 건이면 100만 건의 데이터를 메모리에 전부 올리고 페이징 해야 한다...

절대 쓰지 말자.

 

해결방안 

1. 일대다 관계를 다대일 관계로 방향을 뒤집어 해결한다.

String query = "select t from Member m join fetch m.team t"; // 뒤집어 다대일 관계로 설정한다.
List<Member> result = em.createQuery(query, Member.class)
      .setFirstResult(0)
      .setMaxResults(1)
      .getResultList();

 

2. @BatchSize() 어노테이션 적용

//...
public class Team {
    //...	

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

}

@OneToMany로 지연로딩 상태이지만, 조회할 때 members를 BatchSize의 size만큼 조회해온다.

 

BatchSize는 글로벌 설정으로 할 수도 있다.(실무에서는 글로벌세팅으로 하고 사용한다.)

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

BatchSize를 사용하면 쿼리가 N+1이 아니라 테이블 수 만큼 맞출 수 있다.

 

  • 연관된 엔티티들을 SQL 한 번으로 조회 - 성능 최적화
  • 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선함
    @OneToMany(fetch = FetchType.LAZY) // 글로벌 로딩 전략
  • 실무에서 글로벌 로딩 전략은 모두 지연 로딩
  • 최적화가 필요한 곳은 페치 조인 적용

 

페치 조인 정리

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

 


N+1 문제 해결 정리

N+1 문제는 데이터베이스에서 발생하는 성능 이슈 중 하나로, 쿼리를 실행할 때 관련된 다른 테이블의 데이터를 가져오지 않고, 반복문에서 필요할 때마다 추가 쿼리를 실행하여 데이터를 가져오는 것입니다. 이는 데이터베이스에 많은 부하를 유발하므로 성능 저하를 초래할 수 있습니다. 이 문제를 해결하기 위한 몇 가지 방법은 다음과 같습니다.

 

1. 조인 사용

N+1 문제를 해결하는 가장 일반적인 방법은 조인을 사용하는 것으로 한 번의 쿼리 실행으로 필요한 데이터를 가져올 수 있다.

 

2. 즉시 로딩

필요한 데이터를 미리 쿼리해 놓고, 필요할 때 바로 가져올 수 있도록 즉시로딩으로 설정한다. 이를 통해 반복문 안에서 추가 쿼리를 실행하는 것을 방지할 수 있다.

 

3. fatch join 사용

fetch join을 사용하여 객체 그래프를 탐색해 엔티티 전체를 한번에 조회한다.

지연로딩으로 글로벌 전략을 설정해놓았어도 우선 순위가 fatch join가 더 높기 때문에 지연로딩이 아닌 fatch join으로 동작한다.


 

 

 


 

해당 글은 인프런의 [자바 ORM 표준 JPA 프로그래밍 - 기본편] 강의를 정리한 내용입니다.

 

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

JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자신있게 JPA를 사용할 수 있습니다., - 강의 소개 | 인프런

www.inflearn.com

댓글