페치 조인
은 SQL에서 이야기하는 조인의 종류는 아니다.
페치 조인
은 JPQL의 성능 최적화를 위해 제공하는 기능이다.
페치 조인은, 연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능으로 join fetch
명령어로 사용할 수 있다.
select m
from Member m join fetch m.team
페치 조인을 사용해서 회원 엔티티를 조회하면서 연관된 엔티티도 함께 조회하는 JPQL
이다. 단순히 join
옆에 fetch
를 불였을 뿐이지만 회원 엔티티와 팀 엔티티를 함께 조회한다. 참고로, m.team
과 같은 코드는 alias
를 사용할 수 없다.
SELECT M.*, T.*
FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID=T.ID
엔티티 페치 조인
에서 JPQL
에서 select m
으로 회원 엔티티만 선택했는데 실행된 SQL
을 보면 SELECT M.*, T.*
로 회원과 연관된 팀도 함께 조회된 것을 확인할 수 있다. 즉, 일반 JOIN
은 SELECT M.*
만 했던 것과 달리 SELECT M.*, T.*
로 값을 가져온다. 그리고 회원
과 팀 객체
가 객체 그래프를 유지하면서 조회된 것을 확인할 수 있다.
String jpql = "select m from Member m join fetch m.team"
List<Member> members = em.createQuery(jpql, Member.class).getResultList();
for(Member member : members) {
// 페치 조인으로 회원과 팀을 함께 조회해서 지연 로딩이 발생하지 않는다.
System.out.println("username = " + member.getUsername() + ", " +
"teamname = " + member.getTeam().name());
}
username = 회원1, teamname = 팀A
username = 회원2, teamname = 팀A
username = 회원3, teamname = 팀B
회원
과 팀
을 지연 로딩으로 설정했다고 가정해 보자. fetch = FetchType.LAZY
회원을 조회할 때 페치 조인을 사용해서 팀도 함께 조회했으므로 연관된 팀 엔티티는 프록시가 아닌 실제 엔티티가 된다. 즉, 지연 로딩으로 설정했음에도 지연 로딩이 일어나지 않는다. (회원 엔티티가 준영속이 되어도 팀은 조회할 수 있다는 장점이 생긴다.)
1대 N 관계
인 컬렉션을 페치 조인해 보자.
select t
from Team t join t.members
where t.name = '팀A'
SELECT T.*, M.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID=M.TEAM_ID
WHERE T.NAME = '팀A'
컬렉션을 페치 조인한 JPQL
에서 select t
로 팀만 선택했는데 SQL
을 보면 T.*
, M.*
로 팀
과 연관된 회원
도 함께 조회하고 있다.
ID(PK) | NAME | ID(PK) | TEAM_ID(FK) | NAME |
---|---|---|---|---|
1 | 팀A | 1 | 1 | 회원1 |
1 | 팀A | 2 | 1 | 회원2 |
팀A
는 1개이지만, Team
과 Member
를 함께 조인하면서 회원1의 팀A
, 회원2의 팀A
이렇게 2개가 조회되었음을 알 수 있다.
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) {
System.out.println("teamname = " + team.getName() + ", team = " + team);
for(Member member : team.getMember()) {
// 페치 조인으로 팀과 회원을 함께 조회해서 지연 로딩 발생 안 함
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
teamname
출력 결과를 보면 같은 팀A
가 2건 조회된 것을 확인할 수 있다.
SQL
의 DISTINCT
는 중복된 결과를 제거하는 명령어다.
JPQL
의 DISTINCT
는 SQL
은 물론 애플리케이션 단에서도 DISTINCT
를 추가한다.
select distinct t
from Team t join fetch t.members
where t.name = '팀A'
컬렉션 페치 조인은 팀A
가 중복으로 조회된다. 이를 해결하기 위해서 위와 같이 DISTINCT
구문을 사용하면 된다.
select t
from Team t join t.members m
where t.name = '팀A'
SELECT T.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID=M.TEAM_ID
WHERE T.NAME = '팀A'
SELECT M.*
FROM MEMBER M
INNER JOIN TEAM T ON T.ID=M.TEAM_ID
WHERE TEAM.NAME = '팀A'
일반 조인의 JPQL
은 결과를 반환할 때 연관관계까지 고려하지 않는다. 단지, SELECT 절에 지정한 엔티티만 조회할 뿐이다. 따라서, 예제를 기준으로 말하면 팀 엔티티만 조회하고 연관된 회원 컬렉션은 조회하지 않는다.
LAZY
: 프록시로 가져오기에 연관 객체를 가져올 때 쿼리를 호출한다.
EAGER
: jpql은 단순히 SQL로 변환하여 실행할 뿐이다. 그렇기에, 우선은 단순히 한 개의 엔티티 값
만을 조인해서 가져오고 연관 관계가 비었기에 다시 select
를 통해 값을 가져온다.
select t
from Team t join t.members
where t.name = '팀A'
SELECT T.*, M.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID=M.TEAM_ID
WHERE T.NAME = '팀A'
이에 비해, 페치조인은 연관된 객체들을 한 번에 가져온다는 특징이 있다.
페치 조인을 사용하면 SQL
을 1번
으로 연관된 엔티티들을 함께 조회할 수 있어서 SQL
호출 횟수를 줄여 성능을 최적화할 수 있다.
@OneToMany(fetch = FetchType.LAZY) // 글로벌 로딩 전략
위와 같이, 엔티티에 직접 적용하는 로딩 전략은 글로벌 로딩 전략
이라고 부른다. (애플리케이션 전체에 영향을 미치므로)
페치 조인
> 글로벌 로딩 전략
페치 조인은 글로벌 로딩 전략보다 우선한다. 예를 들어, 글로벌 로딩 전략을 지연 로딩으로 설정해도 JPQL에서 페치 조인을 사용하면 페치 조인을 함께 적용해서 조회한다.
최적화를 위해 글로벌 로딩 전략을 즉시 로딩으로 설정하면 애플리케이션 전체에서 항상 즉시 로딩이 일어난다. 물론, 일부는 빠를 수는 있지만 전체로 보면 사용하지 않는 엔티티를 자주 로딩하므로 오히려 성능에 악영향을 미칠 수 있다.
따라서 글로벌 로딩 전략은 될 수 있으면 지연 로딩
을 사용하고 최적화가 필요하면 페치 조인
을 적용하는 것이 효율적이다. 또한, 페치 조인을 사용하면 연관된 엔티티를 쿼리 시점에 조회하므로 지연 로딩이 발생하지 않는다. 따라서, 준영속 상태에서도 객체 그래프를 탐색할 수 있다.
페치 조인 대상
에는 별칭을 줄 수 없다. (기준 엔티티는 가능)
문법상 별칭을 줄 수 없고 SELECT
, WHERE
, 서브 쿼리
에 페치 조인 대상을 사용할 수 없다.
Hibernate
와 같은 몇몇 JPA 구현체
들은 페치 조인에 별칭을 지원하기도 한다. 단, 구현체마다 다르고 별칭을 잘못 사용하면 무결성이 깨질 수 있고 특히 연관된 데이터 수가 달라진 상태에서 2차 캐시에 저장되면 다른 곳에서 조회할 때도 연관된 데이터 수가 달라지는 문제가 발생할 수 있다.
둘 이상의 컬렉션을 페치할 수 없다.
구현체에 따라 사용은 가능하나, 컬렉션 1의 수
* 컬렉션 2의 수
카테시안 곱이 만들어지므로 주의해야 한다.
Hibernate
에서는 에러가 발생한다.
컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다.
컬렉션(일대다)
이 아닌 단일 값 연관 필드(일대일, 다대일)
들은 페치 조인을 사용해도 페이징 API를 사용할 수 있다.
Hibernate
에서 컬렉션을 페치 조인하고 페이징 API
를 사용하면 경고 로그를 남기면서 메모리에서 페이징 처리를 한다. 데이터가 적으면 상관없겠지만 데이터가 많으면 성능 이슈와 메모리 초과 예외가 발생할 수 있어서 위험하다.
페치 조인은 SQL 한 번으로 연관된 여러 엔티티를 조회할 수 있어서 성능 최적화에 상당히 유용하다. 하지만, 몇몇 한계점들이 존재하기에 모든 것을 페치 조인으로 해결할 수는 없다.
페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다. 반면에 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 한다면 억지로 페치조인을 사용하기보다는 여러 테이블에서 필요한 필드들만 조회해서 DTO로 반환하는것이 더 효과적일 수 있다.
public class Team {
... // 생략
@OneToMany(mappedby="team")
private List<Member> members = new ArrayList();
... // 생략
}
String query = "select t From team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class)
.setFirstResult(0)
.setMaxResults(1)
.getResultList();
앞서 언급했듯이 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다.
이는, 객체 그래프가 추구하는 모든 데이터를 가져와서 탐색할 수 있어야 한다.
를 위반하기 때문이다. 하이버네이트에서는 사용은 가능하게 해주나, 경고 로그
를 띄우고 메모리
에서 작업을 하도록 한다.
String query = "select m From Member m join fetch m.Team";
List<Team> result = em.createQuery(query, Team.class)
.setFirstResult(0)
.setMaxResults(1)
.getResultList();
컬렉션을 사용한다는 것은 일대다 관계를 말한다. 그러므로 이런 방향을 뒤집으면 해결될 문제이다.
String query = "select t From team t"; // 조인이 없다.
List<Team> result = em.createQuery(query, Team.class)
.setFirstResult(0)
.setMaxResults(1)
.getResultList();
for(Team team : result) {
for(Member member : team.getMembers()) { <- 여기서 select 발생
.. //
}
}
이 상태로만 코드를 실행하면, fetch join
이 아니므로 limit 쿼리
가 나온다. 하지만, 우리가 원하는 건 호출했을 때, N+1
을 막고 연관된 객체들을 가져오는 것이다. 이 상태로 SQL 실행을 보면, Team 조회
-> member 조회
-> limit 만큼 반복
이렇게 동작한다. 또한, 이 같은 상태를 Lazy
에서의 N+1
문제라고 말한다. 이를 해결하기 위해서 @BatchSize
를 Team
클래스 List<Member> 연관 관계 매핑
에 붙여 넣을 것이다.
@BathSize(100) // 일반적으로 1000 이하로 준다.
@OneToMany(mappedby="team")
private List<Member> members = new ArrayList();
@BatchSize
는 Member의 건수만큼 추가 SQL을 날리지 않고, 조회한 Member의 id들을 모아서 SQL IN 절
을 날린다.
// 레이지 로딩 구문
select
members1_.TEAM_ID as TEAM_ID5_0_0__,
members1_.id as id1_0_1_,
members1_.id as id1_0_0_,
members1_.age as age2_0_1_,
members1_.TEAM_ID as TEAM_ID5_0_1,
members1_.type as type3_0_1,
members1_.username as username4_0_1_
from
Member members0_
where
members0_. TEAM_ID in (
?, ?
)
조인을 하지 않지만, IN 절
을 이용해서 한 번에 연관된 Member
들을 가져온 것이다. List<Member> members
는 Lazy loading
이다. 여기서 @BatchSize
를 사용하면 Lazy loading
시점(사용하는 시점)에 결과로 얻은 List<Team>
을 IN
쿼리로 넘기는 것이다.
즉, 레이지 로딩 시 List<Team>
에 담긴 모든 Team 을 사이즈만큼 IN 쿼리
로 넘겨서 레이지 로딩을 한번에 가져오는 것이다. 그래서 N+1이 아니라 테이블 수만큼만 가져오는 최적화를 이룰 수 있다.
@BatchSize
는 글로벌 세팅을 할 수 있다. <property name="hibernate.default_batch_fetch_size" value="5" />
select m.username -> 상태 필드
from Member m
join m.team t -> 단일 값 연관 필드
join m.orders o -> 컬렉션 값 연관 필드
where t.name ='팀A'
상태 필드(state field)
: 단순히 값을 저장하기 위한 필드 (ex: m.usernmae)연관 필드(association field)
: 연관관계를 위한 필드단일 값 연관 필드
: XToOne, 대상이 엔티티 (ex: m.team)컬렉션 값 연관 필드
: XToMany, 대상이 컬렉션 (ex m.orders)상태 필드(state field) : 경로 탐색의 끝, 탐색 x
단일 값 연관 경로 : 묵시적 내부 조인(inner join) 발생, 탐색 O
select m.team.name from Member m; //team에서 경로탐색이 더 가능하다. (name)
컬렉션 값 연관 경로 : 묵시적 내부 조인 발생, 탐색 X
select m.username from Team t join t.members m;
// JPQL
select i from Item i where type(i) IN(Book, Movie)
// SQL
select i from Item i where i.DTYPE in('B', 'M');
// JPQL
select from Item i where treat(i as Book).author = 'kim';
// SQL
select i.* from Item i where i.DTYPE = 'B' and i.author = 'kim';
// JPQL
select count(m.id) from Member m // 엔티티의 아이디를 사용
select count(m) from Member m // 엔티티를 직접 사용
// SQL(JPQL 둘 다 같은 다음 SQL 실행)
select count(m.id) as cnt from Member m
// 엔티티를 파라미터로 전달
String jpql = "select m from Member m where m = :member";
List resultList = em.createQuery(jpql)
.setParameter("member", member)
.getResultList();
// 식별자를 직접 전달
String jpql = "select m from Member m where m.id = :memberId";
List resultList = em.createQuery(jpql)
.setParameter("memberId", memberId)
.getResultList();
select m.* from Member m where m.id = ?
Team team = em.find(Team.class, 1L);
String query = "select m from Member m where m.team = :team";
List resultList = em.createQuery(query)
.setParameter("team", team)
.getResultList();
String query = "select m from Member m where m.team.id = :teamId";
List resultList = em.createQuery(query)
.setParameter("teamId", teamId)
.getResultList();
select m.* from Member m where m.team_id = ?
@Entity
@NamedQuery(
name="Member.findByUsername",
query="select m from Member m where m.username = :username")
public class Member { ... }
...
List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
.setParameter("username", "회원1")
.getResultList();
애플리케이션 로딩 시점에 초기화 후 재사용
→ JPA는 결국 SQL로 parsing 되어 사용되는데 로딩 시점에 초기화가 된다면 parsing cost를 절약 가능.
애플리케이션 로딩 시점에 쿼리를 검증
<persistence-unit name="jpabook">
<mapping-file>META-INF/ormMember.xml</mapping-file>
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="htt://xmlns.jcp.org/xml/ns/persistence/orm" version="2.1">
<named-query name="Member.findByUsername">
<query>
<![CDATA[ select m from Member m where m.username = :username]]
</query>
</named-query>
</entity-mappings>
사용법은 @NamedQuery
의 query를 사용방법과 같다.
@Repository
public interface MemberRepository extends JpaRepository<Member, Long>{
@Query("select u from User u where u.username = ?1")
Member findByUsername(String username);
}
@Repository annotation
이 등록된 인터페이스에서 사용되는 @Query annotation
에 있는 JPQL(or native)
들이 NamedQuery
로써 컴파일 시에 등록되는 것이다.
일반적으로 우리가 알고 있는 SQL의 update
or delete
문을 생각하면 된다.
ex) 재고가 10개 미만인 모든 상품의 가격을 10% 상승하려면?
JPA의 dirty check로 실행하기 위해서는 너무 많은 SQL이 실행돼야 한다.
1. 재고가 10개 미만인 상품을 리스트 조회
2. 상품 엔티티의 가격 10% 증가
3. 트랜잭션 커밋 시점에 dirty checking
UPDATE SQL
실행executeUpdate()
의 결과는 영향받은 엔티티 수 반환UPDATE
, DELETE
지원INSERT
(insert into ... select, 하이버네이트 지원)String query = "update Product p "+
"set p.price = p.price * 1.1 where p.stockAmount < :stockAmount";
int resultCount = em.createQuery(qlString)
.setParameter("stockAmount", 10)
.executeUpdate();
벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리
벌크 연산을 먼저 실행
벌크 연산 수행 후 영속성 컨텍스트 초기화
→ 엔티티 조회 후 벌크 연산으로 엔티티 업데이트가 돼버리면 DB의 엔티티와 영속성 컨텍스트의 엔티티가 서로 다른 값이 되게 된다.