본 글은 인프런의 김영한님 강의 자바 ORM 표준 JPA 프로그래밍 - 기본편
을 수강하며 기록한 필기 내용을 정리한 글입니다.
-> 인프런
-> 자바 ORM 표준 JPA 프로그래밍 - 기본편 강의
SQL 조인의 종류는 아님
JPQL에서 성능 최적화를 위해 제공하는 기능이다.
연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능이다.
join fetch
명령어를 사용한다.
[LEFT [OUTER] | INNER] JOIN FETCH 조인경로
다음과 같이 동작한다.
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
fetch join을 써야하는 이유
예시 설정
N + 1 문제
만약 다음과 같이 Member 정보를 조회한 후 출력한다.
String query = "select m from Member m";
List<Member> result = em.createQuery(query, Member.class)
.getResultList();
for (Member m : result) {
System.out.println("memberinfo : " + m + ", teaminfo : " + m.getTeam());
}
그럼 처음 Member 엔티티 정보만 불러오는 쿼리가 전송된다. : 지연 로딩으로 설정되어 있으므로
이후 출력 과정에서 getTeam() 메서드로 프록시 객체로 설정되어 있던 Team 객체에 접근되고, 이에 따라 각 Member 데이터마다 각자의 Team 정보를 얻기 위한 쿼리가 전송된다.
여기서 회원1과 회원2는 같은 teamA 이기 때문에 회원1로 인해 조회된 teamA 데이터가 영속성 컨텍스트 1차캐시에 저장되어 있고, 회원2에서 teamA를 조회할 때는 해당 1차캐시에서 가져와 쿼리가 안나간다.
회원3에서는 다시 teamB를 조회하기 위한 쿼리가 나간다.
⇒ 이에 따라 최악의 경우, N + 1 개의 쿼리가 나가게 된다.
⇒ 처음 Member 테이블 조회 쿼리 1개, 조회 결과 N개에 대한 각각의 서로 다른 Team 정보를 얻기 위한 조회 쿼리 N개
fetch join을 쓸 경우
SELECT m FROM Member m join fetch m.team
join fetch
로 설정된 연관관계 엔티티의 정보까지 한 번의 쿼리로 다 가져오게 된다. (프록시 객체로 두지 않는다.)join fetch
키워드로 조회할 경우, 페치 조인이 이루어져 N + 1 문제를 막을 수 있다.String query = "select t from Team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class)
.getResultList();
for (Team t : result) {
System.out.println("memberinfo : " + t + ", teaminfo : " + t.getMembers());
}
⇒ 이 점을 유의해서 활용해야 한다.
DISTINCT
기능을 활용하는 것이다.DISTINCT
는 다음 2가지 기능을 제공한다.DISTINCT
를 추가한다.distinct
키워드를 추가한다.String query = "select distinct t from Team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class)
.getResultList();
for (Team t : result) {
System.out.println("memberinfo : " + t + ", teaminfo : " + t.getMembers());
}
이렇게 distinct
키워드를 추가해도 DB 단에서는 데이터가 줄어드는건 아님.
하지만 JPQL이 애플리케이션 단에서 중복 제거를 시도하게 된다.
같은 식별자를 가진 Team 엔티티를 제거하게 된다.
<distinct 안했을 때>
<distinct 했을 때>
일반 조인을 실행할 경우, SQL에서도 조인은 하지만, 해당 조인 쿼리의 select 절에 연관된 엔티티가 포함되지 않는다.
일반 조인
String query = "select t from Team t join t.members m";
List<Team> result = em.createQuery(query, Team.class)
.getResultList();
for (Team t : result) {
System.out.println("memberinfo : " + t + ", teaminfo : " + t.getMembers());
}
이렇게 MEMBER 테이블과 조인은 되지만, SELECT 절을 보면 TEAM 테이블의 컬럼만 조회하고 있는 것을 확인할 수 있다.
이에 따라 각 Team 엔티티와 연관된 Member 엔티티들은 프록시 객체로 설정되고, 후에 해당 Member 엔티티에 접근하면 해당 데이터를 조회하는 SQL이 또 보내지게 된다.
이렇게 teamA 에 해당하는 Member 데이터를 조회하는 쿼리를 보내고 난 후에 출력되고,
그 다음 teamB에 해당하는 Member 데이터를 조회하는 쿼리를 보내고 출력된다.
다음과 같이 일반 조인의 select 절에 명시적으로 Member 엔티티를 포함시켜도 안된다.
- 아예 쿼리 자체가 안나간다.
String query1 = "select t, m from Team t join t.members m";
String query2 = "select t.id, t.name, t.members from Team t join t.members m";
페치 조인
이에 반해 페치 조인은 다음과 같이 연관된 엔티티도 모두 SELECT 절에 포함되는 것을 확인할 수 있다.
String query = "select t from Team t join fetch t.members m";
List<Team> result = em.createQuery(query, Team.class)
.getResultList();
for (Team t : result) {
System.out.println("memberinfo : " + t + ", teaminfo : " + t.getMembers());
}
결국 페치 조인을 사용할 때만 연관된 엔티티도 함께 조회되며, 즉시 로딩이라고 보면 된다.
연관 관계로 이어진 객체 그래프를 SQL 한번에 조회하는 개념이다.
⭐ 대부분의 N + 1 문제는 페치 조인으로 해결될 수 있다. ⭐
페치 조인 대상에는 별칭을 줄 수 없다.
Hibernate는 가능하지만, 가급적 사용하면 안된다.
"select t from Team t join fetch t.members m"
위와 같이 t.members
에게 m
이라는 별칭을 주고, 다음과 같이 where절에서 조건을 부여하거나 그러면 안된다.
"select t from Team t join fetch t.members m where m.username like '%a%'"
JPA 객체 그래프는 데이터를 우선 다 갖고 있어야 맞는 개념인 것이다.
특정 팀과 연관된 모든 멤버 데이터들을 우선 다 갖고 있어야 하는게 맞는 것. 멤버 데이터에 조건을 부여해서 일부 데이터만 갖고 있는 것은 위험할 수 있다.
JPA의 객체 그래프 개념 상 팀과 연관된 멤버 데이터를 조회할 때 모든 데이터가 포함되어 있다는 것을 전제 하에 설계되어 있다.
만약 전체 멤버 데이터 중 일부 데이터를 조회하려면 팀 엔티티를 통해서 조회하는 것이 아닌, 멤버 엔티티를 대상으로 따로 조회하는게 맞는 것.
둘 이상의 컬렉션은 페치 조인 할 수 없다.
페치 조인은 하나의 컬렉션을 대상으로만 이루어져야 한다.
만약 Team 엔티티 내에 Member 엔티티 컬렉션, Order 엔티티 컬렉션이 있을 경우, 두 컬렉션을 하나의 페치 조인 쿼리에 다 넣으면 안된다.
"select t from Team t join fetch t.members join fetch t.orders"
둘 이상의 컬렉션에 대해 페치 조인 할 경우, 데이터가 엉켜서 엄청 뻥튀기 되어버린다.
DB에서 조인하는 경우도 생각해보면, 여러 테이블에 대해 조인을 수행하진 않음. 아마 결과가 조인 대상의 각 테이블마다 모두 추가되어 엄청 뻥튀기 돼서 나올 것이다.
컬렉션을 페치 조인하게 되면 페이징 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();
for (Team t : result) {
System.out.println("memberinfo : " + t + ", teaminfo : " + t.getMembers());
}
이렇게 돌려보면, 다음과 같은 경고문이 뜬다.
그리고 쿼리도 살펴보면, 페이징 처리하는 구문이 없다.
즉, 전체 데이터를 모두 메모리 상에 올려놓고, 메모리에서 페이징 처리를 하는 것이다.
만약 조회한 데이터가 수백만 건일 경우, 수백만 건의 데이터가 메모리에 올라가서 페이징 처리가 되어버린다.
⭐ 매우 위험하다!! ⭐
방법 1 : 페치 조인 방향을 뒤집으면 된다.
기존에 1 → N 방향이던 페치 조인 방향을 N → 1 로 뒤집으면 된다.
즉, Team → Member 이던 방향을 Member → Team 으로 뒤집으면 됨.
String query = "select m from Member m join fetch m.team";
List<Member> result = em.createQuery(query, Member.class)
.setFirstResult(0)
.setMaxResults(1)
.getResultList();
for (Member m : result) {
System.out.println("memberinfo : " + m + ", teaminfo : " + m.getTeam());
}
이렇게 하면 다음과 같이 쿼리도 페이징 처리 돼서 나가는 것을 확인할 수 있다.
방법 2 : 컬렉션 필드에 @BatchSize(size = ?)
어노테이션을 부여한다.
예를 들어 다음과 같이 Team 엔티티 내 Member 컬렉션 필드에 @BatchSize(size = 2)
어노테이션을 부여한다.
...
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
...
그리고 다음과 같이 Team 정보만 조회하도록 JPQL 을 구성하고, 페이징 처리를 한다.
- 페치 조인을 안쓰고 그냥 Team 엔티티만 조회하는 것
String query = "select t from Team t";
List<Team> result = em.createQuery(query, Team.class)
.setFirstResult(0)
.setMaxResults(2)
.getResultList();
for (Team t : result) {
System.out.println("teaminfo : " + t + ", memberinfo : " + t.getMembers());
}
이 때 처음 Team 데이터 조회하는 쿼리 1번, 그리고 기존의 경우, 각 Team 데이터의 Member 컬렉션에 접근할 때마다 각각 쿼리가 나갔을 것이다.
하지만 위 코드를 실행해보면 다음과 같이 Member 데이터가 한번에 조회되는 것을 확인할 수 있다.
즉, @BatchSize
어노테이션의 경우, 해당 어노테이션이 부여된 컬렉션 필드 데이터를 조회하는 쿼리를 보낼 때, 각각 따로 보내는게 아니라 설정된 size 크기만큼 묶어서 한번에 조회하도록 한다.
TEAM_ID in (?, ?)
을 통해 묶어서 한번에 조회하는 것을 알 수 있다.해당 방법을 통해서도 N + 1 문제를 해결할 수 있다.
이렇게 @BatchSize
어노테이션을 부여할 수도 있지만, Global Setting으로 설정할 수도 있다.
persistence.xml 파일에 다음 property를 설정한다.
<property name="hibernate.default_batch_fetch_size" value="100"/>
보통 실무에서는 이렇게 batch size를 글로벌로 세팅해놓고 개발한다.
OneToMany(fetch = FetchType.LAZY)