2022. 7. 29. 17:16ㆍLanguage`/JPA
※ Join Query를 위한 테스트 데이터
내부조인
JPQL에서 기본적으로 내부 조인은 "INNER JOIN"으로 사용하고 여기서 INNER은 생략할 수 있다
회원 조회 : 팀이 Team-A인 회원 (팀에 대한 내부 조인)
// INNER 명시
List<Member> result = em.createQuery(
"SELECT m FROM Member m INNER JOIN m.team WHERE m.team.name = :name",
Member.class
).setParameter("name", "Team-A")
.getResultList();
// INNER 명시 X
List<Member> result = em.createQuery(
"SELECT m FROM Member m JOIN m.team WHERE m.team.name = :name",
Member.class
).setParameter("name", "Team-A")
.getResultList();
INNER을 생략해도 동일한 inner join query가 나감을 알 수 있다
위의 JPQL 조인을 SQL 조인 형태로 바꿔도 과연 제대로 쿼리가 나가는지 확인해보자
List<Member> result = em.createQuery(
"SELECT m FROM Member m INNER JOIN Team t WHERE t.name = :name",
Member.class
).setParameter("name", "Team-A")
.getResultList();
이처럼 Team에 대한 엔티티를 직접 매핑하게 된다면 query의 조인 조건인 on절이 제대로 표시되지 않음을 확인할 수 있다
>> JPQL 조인의 가장 큰 특징은 연관 필드를 사용한다는 것이다
JPQL은 반드시 조회의 Root Entity는 하나만 존재해야 하고 만약 다른 엔티티와의 join을 표현하고 싶다면 해당 엔티티를 직접 명시하는게 아니라 Root Entity로부터의 연관 필드로써 참조해야 한다
지금까지는 join을 통해서 최종적으로 1개의 엔티티(Member)에 대한 프로젝션을 했는데 Member와 Team을 동시에 프로젝션해보자
List<Object[]> result = em.createQuery(
"SELECT m, t FROM Member m INNER JOIN m.team t WHERE m.team.name = :name"
)
.setParameter("name", "Team-A")
.getResultList();
for (Object[] row : result) {
Member member = (Member) row[0];
Team team = (Team) row[1];
System.out.println(member + " - " + team);
}
이처럼 두개의 엔티티를 동시에 조회하게 되면 특정 타입으로 지정할 수 없으므로 return은 Object로 return이 되고 Object로부터 값을 꺼내야 한다
그리고 JPQL에서의 내부 조인은 항상 "왼쪽의 엔티티"를 기준점으로 잡고 그 이외의 엔티티간의 inner join을 수행하기 때문에 Root Entity를 변경하고 싶다면 Root Entity를 join 기준 왼쪽에 작성해주면 된다
외부조인
JPQL에서 외부조인은 "LEFT OUTER JOIN"만 허용된다
따라서 OUTER JOIN 위치 기준 왼쪽의 엔티티를 기준으로 잡고 왼쪽 엔티티는 전부 가져온다고 보면 된다
- 외부 조인도 OUTER 키워드는 생략할 수 있고 LEFT JOIN으로만 명시해도 자동적으로 LEFT OUTER JOIN이 나가게 된다
List<Member> result = em.createQuery(
"SELECT m FROM Member m LEFT OUTER JOIN m.team t",
Member.class
)
.getResultList();
이 쿼리를 실제로 DB에 날리게 되면 DB는 어떠한 값을 OUTER JOIN해서 가져오는지 확인해보자
LEFT OUTER JOIN이므로 Member 데이터는 전부 당겨오고 Member에 대한 Team 조각들을 맞춰서 결과가 도출되었다
당연히 Member 데이터가 전부 당겨져 왔으니까 Team이 없는 Member는 Team이 null로 들어가게 된다
컬렉션 조인
컬렉션 조인은 말그대로 단일값 연관 필드에 대한 join이 아닌 "컬렉션 값 연관 필드"에 대해서 join을 거는 것이다
Member → Team = 단일값 연관 필드
Team → Member = 컬렉션 값 연관 필드
@Entity
@Table(name = "team")
public class Team {
...
...
@OneToMany(mappedBy = "team")
private List<Member> memberList = new ArrayList<>();
}
Team → Member로 관계를 매핑해줌으로써 Member, Team은 양방향 연관관계를 가지게 되었다
이제 Team → Member로 컬렉션 조인을 걸어서 Root Entity로써 Team을 설정해보자
List<Object[]> result = em.createQuery(
"SELECT t, m FROM Team t INNER JOIN t.memberList m"
).getResultList();
for (Object[] row : result) {
Team team = (Team) row[0];
System.out.println("Team = " + team + " - Member = " + row[1]);
}
Team과 해당 Team에 소속된 List<Member>간의 컬렉션 내부 조인이다
컬렉션 외부 조인도 그냥 동일하게 LEFT OUTER JOIN으로 외부 조인을 해주면 된다
List<Object[]> result = em.createQuery(
"SELECT t, m FROM Team t LEFT OUTER JOIN t.memberList m"
).getResultList();
for (Object[] row : result) {
Team team = (Team) row[0];
System.out.println("Team = " + team + " - Member = " + row[1]);
}
세타 조인
WHERE절에 조건을 추가해줌으로써 세타 조인을 활용할 수 있다. 그리고 세타 조인은 "내부 조인만 지원한다"
세타 조인을 활용함으로써 전혀 관계없는 엔티티도 "CROSS JOIN"으로 조인할 수 있다
세타 조인을 활용할 때 WHERE절에 별다른 조건이 없다면 세타 조인을 하는 두 엔티티의 모든 instance의 경우의 수가 결과로 도출된다
Member, Team간의 세타 조인
List<Object[]> result = em.createQuery(
"SELECT m, t FROM Member m, Team t"
).getResultList();
for (Object[] objects : result) {
System.out.println(objects[0] + " - " + objects[1]);
}
System.out.println("총 결과 수 = " + result.size());
Member와 Team간의 Cross Join의 결과 Instance 수는 10 X 3 = 30건이 도출될 것이다
여기서 당연히 WHERE절에 조건을 주게 된다면 해당 조건을 만족하는 "모든 경우의 수"가 도출될 것이다
List<Object[]> result = em.createQuery(
"SELECT m, t FROM Member m, Team t WHERE m.team.name = 'Team-A'"
).getResultList();
for (Object[] objects : result) {
System.out.println(objects[0] + " - " + objects[1]);
}
System.out.println("총 결과 수 = " + result.size());
Team-A인 Member는 5명이기 때문에 결과로 5 X 3 = 15건이 도출될 것이다
페치 조인
페치 조인은 SQL의 종류가 아니다
페치 조인이란 JPQL에서 성능 최적화를 위해서 제공해주는 기능이다
페치 조인을 활용하면 Root Entity로부터 "연관된 모든 엔티티를 쿼리 하나로 전부 끌어올 수 있다"
Member - Team간의 Join (Fetch Join)
1-1. 엔티티 조인
일단 Member를 조회할 때 Team을 fetch join하지말고 그냥 join을 써서 쿼리를 날려보자
List<Member> result = em.createQuery(
"SELECT m FROM Member m JOIN m.team WHERE m.team.name = :name",
Member.class
).setParameter("name", "Team-A")
.getResultList();
for (Member member : result) {
System.out.println("Member = " + member);
System.out.println("\tTeam = " + member.getTeam()); // LAZY 강제 초기화
}
조회한 첫번째 Member인 Avenus의 Team을 조회하려고 하니까 Team에 대한 select query가 한번 더 나가게 된 것을 확인할 수 있다
>> 이것이 지연로딩으로 인한 JPQL의 N + 1 문제이다
현재 Member Entity상에서의 연관 엔티티인 Team은 FetchType이 LAZY = 지연로딩으로 설정되어 있다
따라서 Member를 대상으로 조회를 하면 그와 연관된 엔티티인 Team은 "실제 Entity가 아니라 프록시 객체"로 Member상에 들어가게 된다
이 Team이라는 프록시 객체는 내부적으로 target이라는 "실제 Entity를 가리키는 참조변수"를 가지고 있다
조회한 Member에 대해서 "연관된 Team을 실제로 access하는 순간 LAZY 강제 초기화"가 발생하게 된다
1. LAZY 초기화가 발생하면 일단 영속성 컨텍스트는 DB로부터 해당 Team에 대한 정보를 조회해온다.
2. 그리고 나서 프록시 객체 내부의 target이 "조회한 실제 Entity를 가리키게 한다"
따라서 위에서 member.getTeam()은 사실 getTeam()을 한 순간 LAZY 초기화가 발생되고 그 후 target이 실제 Team Entity를 가리킴에 따라 member.getTeam()은 target에 의해서 값이 불러와지는 것이다
이렇게 지연로딩으로 인한 JPQL의 N + 1 문제를 해결해주는 것이 바로 Fetch Join이다
1-2. 엔티티 페치 조인
위에서 지연로딩으로 인해 발생한 N + 1 문제를 페치 조인으로 해결해보자
List<Member> result = em.createQuery(
"SELECT m FROM Member m JOIN m.team WHERE m.team.name = :name",
Member.class
).setParameter("name", "Team-A")
.getResultList();
for (Member member : result) {
System.out.println("Member = " + member);
System.out.println("\tTeam = " + member.getTeam());
}
fetch join으로 연관된 Team을 한번에 끌어오니까 지연로딩으로 인한 N + 1 문제도 해결되고 쿼리도 1건으로 모든 데이터를 조회할 수 있게 되었다
2-1. 컬렉션 조인
이번에도 먼저 fetch join을 사용하지 않고 Team → List<Member>에 대한 컬렉션 조인을 해보자
List<Member> result = em.createQuery(
"SELECT m FROM Member m JOIN m.team WHERE m.team.name = :name",
Member.class
).setParameter("name", "Team-A")
.getResultList();
for (Member member : result) {
System.out.println("Member = " + member);
System.out.println("\tTeam = " + member.getTeam()); // LAZY 강제 초기화
}
마찬가지로 Team에 대한 List<Member>를 조회할 때 추가적으로 select query가 1번 더 나가는 N + 1문제가 발생하게 되었다
그리고 또 다른 문제는 지금 결과로 도출된 것을 보게되면 3가지 모두 "중복되는 데이터"들이다
다대일 관계에서의 join은 결과적으로 도출되는 데이터가 뻥튀기가 될 일이 전혀 없지만, 일대다 관계에서의 컬렉션 조인은 "다"와의 join이므로 결과 데이터가 당연히 뻥튀기가 된다
따라서 컬렉션 조인을 통해서 데이터를 얻기 위해서는 JPQL에 "DISTINCT" 키워드를 붙여줘야 한다
"DISTINCT" 키워드는 SQL에 DISTINCT를 붙여주는 것 뿐만 아니라 조회한 데이터를 애플리케이션 레벨에서 1번 더 중복을 제거해준다
List<Member> result = em.createQuery(
"SELECT m FROM Member m JOIN m.team WHERE m.team.name = :name",
Member.class
).setParameter("name", "Team-A")
.getResultList();
for (Member member : result) {
System.out.println("Member = " + member);
System.out.println("\tTeam = " + member.getTeam()); // LAZY 강제 초기화
}
DISTINCT를 추가해줌에 따라 중복 데이터가 사라진 것을 확인할 수 있다
그러면 이제 페치 조인을 활용해서 컬렉션 조인 간 N + 1 문제도 해결해보자
2-2. 컬렉션 페치 조인
List<Member> result = em.createQuery(
"SELECT m FROM Member m JOIN m.team WHERE m.team.name = :name",
Member.class
).setParameter("name", "Team-A")
.getResultList();
for (Member member : result) {
System.out.println("Member = " + member);
System.out.println("\tTeam = " + member.getTeam());
}
이렇게 페치 조인을 활용함으로써 지연 로딩 간에 N + 1 문제를 해결할 수 있다
페치 조인의 특징과 한계
fetch join을 계속 사용하다 보면 "이렇게 LAZY에 대한 fetch join을 할 바에 그냥 EAGER로 바꿔서 항상 즉시 로딩이 일어나게 하면 되는거 아닌가?"라는 생각이 들 수도 있다
하지만 이러한 생각은 올바르지 않은 생각이다
왜냐하면 API 스펙에 따라서 언제나 엔티티에 대해서 필요한 필드는 달라진다
그런데 EAGER로 맨날 즉시 로딩으로 데이터를 당겨와버리면 연관 엔티티가 필요하지 않은 시점에서도 항상 당겨오기 때문에 사용하지 않는 엔티티를 자주 로딩함에 따라 오히려 성능에 악영향을 줄 수 있다
>> 따라서 무조건 LAZY로 전략을 잡고, 필요한 순간에 Fetch Join으로 한번에 당겨오는 방법이 가장 올바르고 좋은 방법이다
1. 페치 조인 대상에는 별칭을 줄 수 없다
이 특징은 JPA 표준에 대해서는 맞는 말이지만 일부 JPA의 구현체에 대해서는 틀린 말이다
왜냐하면 대표적인 JPA의 구현체인 Hibernate는 페치 조인 대상에 별칭을 줄 수 있기 때문이다
List<Member> result = em.createQuery(
"SELECT m FROM Member m JOIN m.team WHERE m.team.name = :name",
Member.class
).setParameter("name", "Team-A")
.getResultList();
for (Member member : result) {
System.out.println("Member = " + member);
System.out.println("\tTeam = " + member.getTeam());
}
현재 페치 조인 대상인 m.team에 대해서 별칭 t를 주고 where절에서 별칭을 사용해서 조건을 지정하고 있다.
이렇게 페치 조인 대상에 Alias를 부여해도 결과는 정상적으로 나온다
하지만 되도록이면 페치 조인 대상에 별칭을 사용하지 않는 것이 좋다
그 이유는 다음과 같다
페치 조인에 별칭을 지원하는 것은 JPA 표준이 아니라 JPA의 몇몇 구현체들이다
그리고 별칭을 잘못 사용하게 되면 연관된 데이터 수 자체가 달라져서 데이터 무결성이 깨질 수 있기 때문에 사용하더라도 굉장히 많은 고민을 하고 신중하게 사용해야 한다
특히 이 페치 조인 별칭과 관련된 문제는 영속성 컨텍스트의 2차 캐시와 함께 사용할 때 더욱더 조심해야 한다
왜냐하면 연관된 데이터 수가 달라진 상태에서 2차 캐시에 저장된다면 다른 곳에서 조회할 때도 연관된 데이터 수가 달라지는 문제가 발생할 수 있기 때문이다
2. 둘 이상의 컬렉션을 Fetch할 수 없다
물론 이 경우도 구현체에 따라 가능할 수 있는데 결과로 "컬렉션 × 컬렉션의 카티션 곱"이 발생하기 때문에 Hibernate에서는 "org.hibernate.loader.MultipleBagFetchException"이 발생하게 된다
3. 컬렉션 페치 조인을 사용하면 "페이징 처리가 불가능하다"
사실 완전 불가능하지는 않고 가능은 한데 심각한 오류가 발생할 수 있다
현재 TeamA에는 [1, 2, 6, 8]이 존재하고 TeamB에는 [4, 5, 7]이 존재하고 TeamC에는 [9]가 존재한다
여기서 Team 이름에 대한 내림차순 정렬 후 2건의 페이징 처리한 결과를 도출해보자
List<Team> result = em.createQuery(
"SELECT t FROM Team t JOIN FETCH t.memberList ORDER BY t.name DESC",
Team.class
).setFirstResult(0)
.setMaxResults(2)
.getResultList();
불가능이라고 했는데 페이징 처리 결과를 보면 정상적으로 페이징 처리가 된 것을 확인할 수 있다
하지만 이 결과 위의 로그를 보면 얘기가 달라진다
밑줄친 부분을 보게되면 "applying in memory!"라는 경고 로그가 출력됨을 확인할 수 있다
이 말의 뜻은 "모든 데이터를 DB에서 메모리로 읽어오고 메모리에서 직접 페이징처리"를 한다는 의미이다
물론 당겨오는 데이터건이 얼마 되지 않으면 상관이 없을 것이다
하지만 실시간 대용량 트래픽이 발생하는 서비스에서 이러한 경고 로그가 출력되면 그 서비스는 곧 마비가 됨을 예상할 수 있다
왜냐하면 DB로부터 읽어오는 데이터 건수가 몇만, 몇십만, 몇백만 건이라면 이 모든 데이터를 메모리에 전부 올리고 나서 메모리 레벨에서 직접 페이징을 한다는 것이기 때문이다
따라서 성능 이슈 + "Out of Memory Exception"이 터질 수 있기 때문에 컬렉션 페치 조인에 대해서는 페이징 처리를 할 수 없다
그러면 정말 컬렉션 페치 조인에 대해서는 페이징을 할 수 없을까??
※ @BatchSize / default_batch_fetch_size
이 두가지 옵션을 통해서 컬렉션에도 우회적으로 페이징 처리를 할 수 있다
일단 컬렉션을 포함한 조회의 경우 다음 순서를 따르는 것이 가장 좋은 방법이다
1. @xxxToOne은 전부 Fetch Join으로 당겨온다
2. @xxxToMany는 LAZY로 조회한다
3. @xxxToMany에 대한 지연로딩 최적화를 위해서 다음 속성들을 적용한다
- @BatchSize
- 지연 로딩시 한번에 당겨올 사이즈의 수 (Local 최적화)
- hibernate.default_batch_fetch_size
- 지연 로딩시 한번에 당겨올 사이즈의 수 (Global 최적화)
hibernate.default_batch_fetch_size는 Global하게 속성을 적용해주면 되고, @BatchSize는 Local하게 속성을 적용해주면 된다
- Global = application.properties / application.yml
- Local = 필드레벨 / 엔티티 레벨
Local하게 적용하는 @BatchSize를 붙이는 경우의 수는 다음 2가지이다
- 컬렉션 = 그냥 바로 컬렉션 위에다 붙이기
- "일(Entity)" = 엔티티 레벨에 붙이기
주로 Global하게 hibernate.default_batch_fetch_size를 활용하고 이 수치는 대부분 "1000"으로 적용해준다
사실 이 사이즈를 정하는 것은 순간 부하를 어디까지 견딜 수 있느냐에 달려있다
결국 batch_fetch_size란 batch적으로 한번에 데이터를 얼마나 당겨올지를 결정하는 값이므로 보통 100 ~ 1000 사이의 값으로 선택하는 것을 권장한다
만약 1000개의 데이터를 불러올 경우 size가 100이라면 총 10번의 select query가 나가게 되고, size가 1000이라면 1번의 select query가 나가게 된다
>> 이 사이즈를 결정할 때 가장 중요하게 인식해야 할 점은 "DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있느냐"이다
그리고 이 batch_fetch_size를 활용하면 테이블 단위로 정규화된 데이터를 IN쿼리로 가져오기 때문에 데이터간 중복이 발생할 염려가 없다
테스트를 위해서 Member Entity상에 "다대일 관계인 Team / 일대다 관계인 orderList"를 연관관계를 맺어두었다
List<Member> result = em.createQuery(
"SELECT m FROM Member m JOIN FETCH m.team ORDER BY m.username",
Member.class
).setFirstResult(0)
.setMaxResults(5)
.getResultList();
일단 첫번째로 Member와 다대일 관계인 Team은 "fetch join"으로 한번에 당겨오고 페이징 처리도 해주었다
그리고 Global 속성으로 default_batch_fetch_size는 1000으로 설정하였다
이제 위의 쿼리로 어떤 결과가 도출될지 한번 확인해보자
당겨온 데이터는 위와 같고 이제 날라간 쿼리들을 한번 살펴보자
- 중간에 Avenus3은 Team이 존재하지 않기 때문에 INNER JOIN의 대상에서 제거된 것이다
왼쪽 쿼리는 Member와 fetch join을 한 Team이 다 당겨와진것을 볼 수 있다
그리고 오른쪽 쿼리는 "지연로딩 최적화를 위해 설정한 batch_fetch_size"만큼의 OrderList가 IN쿼리로 당겨와진것을 확인할 수 있다
사실 batch_fetch_size를 1000으로 해서 사이즈에 따라 다른 쿼리를 인식하기 힘든데 여기서 batch_fetch_size를 단계적으로 나누어서 할당해보자
default_batch_fetch_size = 2
총 나가야 하는 5개의 IN쿼리를 batch_size에 의해서 2개씩 쪼개져서 날라간 것을 확인할 수 있다 (2, 2, 1)
default_batch_fetch_size = 3
default_batch_fetch_size = 5
>> 따라서 batch_fetch_size에 따라 발생하는 select IN query 개수가 다르고 결론적으로 지연 로딩 최적화를 위해서 batch_fetch_size는 반드시 사용하는 것이 성능상에서 큰 이점을 얻을 수 있다