공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.
경로 표현식
.(점)을 찍어 객체 그래프를 탐색하는 것
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) : 경로 탐색의 끝, 탐색이 불가능하다.
String query = "select m.username From Member m";
- m.username에서 . 을 찍어서 추가 탐색이 불가능하다.
단일 값 연관 경로 : 묵시적 내부 조인(inner join)이 발생하며 탐색이 가능하다.
- 객체 입장에서는 m.team 처럼 작성해도 되지만 테이블 입장에서는 조인을 해야 값을 가져올 수 있다. (묵시적 조인)
컬렉션 값 연관 경로 : 묵시적 내부 조인이 발생하며 탐색이 불가능하다.
select t.members From Team t
- t. 으로 탐색하는 것과 다르게 t.members는 컬렉션이기 때문에 쓸 수 있는 게 size 뿐이다.
하지만 FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색이 가능하다.
경로 탐색을 사용한 묵시적 조인 시 주의사항
- 항상 내부 조인
- 컬렉션은 경로 탐색의 끝, 명시적 조인을 통해 별칭을 얻어야 한다.
- 경로 탐색은 주로 SELECT, WHERE 절에서 사용하지만 묵시적 조인으로 인해 SQL의 FROM (JOIN) 절에 영향을 준다.
실무 조언
- 가급적 묵시적 조인 대신에 명시적 조인 사용
- 조인은 SQL 튜닝에 중요 포인트
- 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어려움
결론 : 묵시적 조인 쓰지말고 명시적 조인을 쓰자.
페치 조인 - 1 기본 (실무에서 엄청 중요한 파트)
- 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
- JPQL에서 select에 m만 적었지만 실행되는 SQL은 M과 T의 모든 정보를 가져오고 있다.
- 마치 즉시로딩처럼 동작하지만 원하는 데이터를 명시적으로 동적인 타이밍에 가져올 수 있다.
페치 조인 예제
- MEMBER에서 TEAM 정보를 가져올 때 모든 정보를 다 가져온다고 가정한다.
Member
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String username;
private int age;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
@Enumerated(EnumType.STRING)
private MemberType type;
...
}
fetch 조인을 사용하지 않은 코드
Team teamA = new Team();
teamA.setName("팀A");
em.persist(teamA);
Team teamB = new Team();
teamB.setName("팀B");
em.persist(teamB);
Member member1 = new Member();
member1.setUsername("회원1");
member1.setTeam(teamA);
em.persist(member1);
Member member2 = new Member();
member2.setUsername("회원2");
member2.setTeam(teamA);
em.persist(member2);
Member member3 = new Member();
member3.setUsername("회원3");
member3.setTeam(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() + ", " + member.getTeam().getName());
//회원1, 팀A(SQL)
//회원2, 팀A(1차캐시)
//회원3, 팀B(SQL)
}
tx.commit();
- Member 엔티티를 보면 team에 대한 패치 타입이 LAZY이다. 그럼 Team은 프록시로 처리되기 때문에 지연로딩이 발생한다. 따라서 Team에 대한 정보는 member.getTeam().getName()을 호출할 때마다 DB에서 가져온다.
- select 쿼리가 총 3번 나갔다. 그 이유를 분석해 보자.
- "select m From Member m"를 실행하여 Member 엔티티의 모든 인스턴스를 조회한다.
- 첫 번째 SQL : em.createQuery(query, Member.class).getResultList()가 호출될 때 DB에서 Member 엔티티들을 가져오고 영속성 컨텍스트에 등록된다.
- 두 번째 SQL: member.getTeam().getName()를 호출할 때 여기서 연관된 Team 엔티티도 프록시 객체를 통해 지연 로딩되어 실제 DB에서 조회된다. 회원1과 회원2는 같은 팀A에 속해 있으므로 회원1의 팀이 조회된 이후에는 회원2의 팀은 1차 캐시에서 조회된다. 따라서 회원2의 팀 조회 시 SQL 쿼리가 발생하지 않는다.
- 세 번째 SQL : 회원3의 팀B는 새로운 팀이므로 DB에서 조회된다.
요약
- 첫 번째 루프에서 Member 엔티티의 모든 인스턴스(회원1, 회원2, 회원3)는 영속성 컨텍스트에 등록된다.
- 각 Member 엔티티에 접근할 때 연관된 Team 엔티티도 지연 로딩된다.
- 회원1의 팀은 데이터베이스에서 조회되지만, 이후 회원2의 팀은 1차 캐시에서 조회된다.
- 회원3의 팀은 다시 데이터베이스에서 조회된다.
만약 회원을 100명 조회한다고 하면 N + 1의 쿼리가 발생한다. (101번 조회)
회원이 1000명이면? 1000000명이면? 상상만 해도 비효율적인 것을 알 수 있다.
fetch 조인을 사용한 코드
String query = "select m From Member m join fetch m.team";
- select 쿼리 한 번에 모든 데이터를 가져온다.
- 따라서 fetch 조인으로 위의 문제를 해결할 수 있다. (실무에서 엄청 많이 쓰임)
- 지연로딩보다 fetch조인이 먼저 실행된다.
컬렉션 페치 조인
1:N 관계에서의 조인을 말한다.
//[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'
- 1:N관계일 때 1의 입장에서 JOIN을 하면 데이터가 중복으로 조회될 수 있다.
페치 조인과 DISTINCT
- SQL의 DISTINCT는 중복된 결과를 제거하는 명령이다.
- JPQL의 DISTINCT 2가지 기능을 제공한다.
- SQL에 DISTINCT를 추가한다.
- 애플리케이션에서 엔티티 중복 제거한다.
예시
select distinct t from Team t join fetch t.members where t.name = ‘팀A’
- SQL에 distinct를 추가했지만 데이터가 100% 일치하지 않으므로 SQL 결과에서 중복제거에 실패한다.
SQL만으로는 중복 제거가 되지 않기 때문에 JPA에서는 애플리케이션에서 같은 식별자를 가진 엔티티를 제거한다.
String query = "select distinct t from Team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class).getResultList();
System.out.println("result = " + result.size());
for (Team team : result) {
System.out.println("team = " + team.getName() + " | members =" + team.getMembers().size());
for (Member member : team.getMembers()) {
System.out.println("-> member = " + member);
}
}
- 중복이 제거되었다.
하이버네이트6 변경 사항
- 하이버네이트 6부터는 DISTINCT 명령어를 사용하지 않아도 애플리케이션에서 중복 제거가 자동으로 적용된다.
페치 조인과 일반 조인의 차이
일반 조인 실행 시 연관된 엔티티를 함께 조회하지 않는다.
String query = "select t from Team t join t.members m";
- JPQL은 결과 반환 시 연관관계를 고려하지 않는다.
- SELECT 절에 지정한 엔티티만 조회한다.
- 여기서는 팀 엔티티만 조회하고, 회원 엔티티는 조회하지 않는다.
- 페치 조인을 사용할 때만 연관된 엔티티도 함께 조회한다. (즉시 로딩)
- 페치 조인은 객체 그래프를 한 번의 SQL로 조회하는 개념이다.
페치 조인 2 - 한계
1. 페치 조인 대상에는 별칭을 줄 수 없다.
String query = "select t from Team t join fetch t.members as m";
- 하이버네이트는 가능하지만 가급적 사용하지 않는 것을 권장한다.
예를 들어 아래와 같은 예가 있을 때
String query = "select t from Team t join fetch t.members as m where m.age > 10";
- 페치 조인은 조회하는 대상과 연관된 모든 데이터를 함께 가져오는 것을 의미한다.
- 그러나 WHERE 절로 데이터를 필터링하면 필요한 데이터가 누락될 가능성이 있다.
- 예를 들어, 팀에 속한 멤버 5명 중, 10살 초과인 3명만 조회할 경우, JPA는 객체 그래프를 통해 모든 데이터를 일관되게 조회하는 것을 의도하기 때문에 `Team.members`를 호출했을 때 원래는 5명이 다 나와야 하는 것이 맞다.
- 또한, 동일한 `members` 조회에서 한 경우는 5명, 다른 경우는 3명만 가져오게 되면, 영속성 컨텍스트에서는 이러한 데이터 정합성을 보장하지 않는다.
- 따라서 페치 조인을 사용할 때는 AS를 사용하지 않는 것이 권장된다. 이는 객체 그래프의 일관성을 유지하고 데이터 누락을 방지하기 위함이다.
2. 둘 이상의 컬렉션은 페치 조인을 할 수 없다.
- 이전 시간에서 1:N인 경우에 데이터가 뻥튀기가 된다고 배웠다. 지금은 1:N:N인 경우이기 때문에 데이터가 예상하지 못하게 늘어날 수 있다.
3. 컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.
- 1:1, N:1 같은 단일 값 연관 필드들은 페치 조인을 해도 페이징이 가능하다. (데이터 뻥튀기가 안되기 때문)
- 하이버네이트는 경고 로그를 남기고 메모리에서 페이징 한다.(매우 위험)
예시)
- 팀A는 데이터를 2건 가지고 있다.
- 하지만 페이지 사이즈를 1로 설정하면 빨간 박스 안에 있는 데이터만 나온다.
- 그럼 JPA는 팀A는 하나이고 회원1만 가졌다고 인식한다.
- 이런 데이터 불일치가 문제가 된다.
코드 예시)
String query = "select t from Team t join fetch t.members as m";
List<Team> result = em.createQuery(query, Team.class)
.setFirstResult(0)
.setMaxResults(1)
.getResultList();
- 경고 메시지의 의미는 JPA가 페이징을 SQL쿼리 레벨에서 처리할 수 없기 때문에, 메모리 내에서 페이징을 적용한다는 뜻이다.
- 즉 DB의 모든 데이터를 메모리에서 처리한다는 뜻이다.
- 예를 들어 데이터가 100만 건 있다고 하면 100만 건을 전부 메모리에 올린 다음 페이징을 적용할 것이다. 장애 나기 딱 좋다.
해결방법
1) 1:N -> N:1 관계로 변경
String query = "select m from Member m join fetch m.team t";
1:N 관계를 N:1 관계로 변경한다. from절을 Member에서 시작하면 된다.
2) BatchSize() 사용
@Entity
public class Team {
...
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
- Team 엔티티의 각 멤버를 접근할 때 지연 로딩이 발생하며, @BatchSize 설정에 따라 여러 멤버들은 한 번에 가져오도록 최적화한다.
- BatchSize를 사용하면 쿼리를 여러 번 사용할 필요 없이 한 번에 데이터를 가져올 수 있다.
아래와 같이 글로벌 설정으로 BatchSize를 적용할 수도 있다.
persistence.xml
<property name="hibernate.default_batch_fetch_size" value="100"/>
정리
- 연관된 엔티티들을 SQL 한 번으로 조회하여 성능을 최적화할 수 있다.
- 페치 조인은 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선한다.
- 글로벌 로딩 전략은 주로 `@OneToMany(fetch = FetchType.LAZY)`와 같은 지연 로딩을 사용한다.
- 실무에서는 글로벌 로딩 전략을 모두 지연 로딩으로 설정한다.
- 최적화가 필요한 곳에 페치 조인을 적용한다.
- 모든 경우에 페치 조인을 사용할 수는 없다.
- 페치 조인은 객체 그래프를 유지할 때 효과적이다.
- 여러 테이블을 조인해서 엔티티 모양이 아닌 다른 결과를 만들어야 할 때는 페치 조인보다 일반 조인을 사용하고 필요한 데이터만 조회하여 DTO로 반환하는 것이 효과적이다.
'BackEnd > JPA' 카테고리의 다른 글
[JPA] 도메인 분석 설계(1) (2) | 2024.09.23 |
---|---|
[JPA] 객체지향 쿼리 언어2 - 중급 문법(2) (0) | 2024.07.21 |
[JPA] 객체지향 쿼리 언어1 - 기본 문법(2) (0) | 2024.07.20 |
[JPA] 객체지향 쿼리 언어1 - 기본 문법(1) (0) | 2024.07.19 |
[JPA] 값 타입(2) (0) | 2024.07.18 |