경로 표현식이란, 점을 찍어 객체 그래프를 탐색하는 것이다.
select m.username -> 상태 필드
from Member m
join m.team t -> 단일 값 연관 필드
join m.orders o -> 컬렉션 값 연관 필드
where t.name = '팀A'
묵시적 내부 조인은 운영시에 너무 힘들다. 그래서 JPQL을 작성할 때 SQL과 최대한 비슷하게 맞춰서 작성하는 것이 좋다.
실무에서 권장하는 방법은 묵시적 조인을 사용하지 않고 명시적 조인을 사용하는 것이다.
JPQL: select m.username, m.age from Member m
SQL: select m.username, m.age from Member m
JPQL과 SQL이 똑같다.
JPQL: select o.member from Order o
SQL:
select m.*
from Orders o
inner join Member m on o.member_id = m.id
묵시적 조인이 발생한다.
명시적 조인은 말 그대로 join 키워드를 직접 사용하는 것이다.
ex) select m from Member m join m.team t
묵시적 조인은 경로 표현식에 의해 묵시적으로 SQL 조인이 발생한다. (내부 조인만 가능하다. )
ex) select m.team from Member m
경로 탐색을 사용한 묵시적 조인시 주의사항
실무에선 묵시적 조인을 그냥 사용하지 않는 것이 좋다. 조인이 SQL 튜닝의 중요 포인트인데 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵다.
실무에서 굉장히 중요하다 !
SQL 조인 종류가 아니다. JPQL에서 성능 최적화를 위해 제공하는 JPQL 전용 기능이다.
연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능이다.
[ LEFT [OUTER] | INNER ] JOIN FETCH
조인경로회원을 조회하면서 연관된 팀도 함께 조회해보자. 즉 SQL 한번으로 회원과 팀을 같이 조회해보자.
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에선 분명이 m만 조회했는데, SQL을 보면 t와 m 모두 조회했다.
String query = "select m from Member m";
List<Member> resultList = em.createQuery(query, Member.class)
.getResultList();
for (Member member : resultList) {
System.out.println("member = " + member.getUsername() + ", " + member.getTeam().getName());
}
Hibernate:
/* select
m
from
Member m */ select
member0_.id as id1_0_,
member0_.age as age2_0_,
member0_.TEAM_ID as TEAM_ID5_0_,
member0_.type as type3_0_,
member0_.username as username4_0_
from
Member member0_
Hibernate:
select
team0_.id as id1_3_0_,
team0_.name as name2_3_0_
from
Team team0_
where
team0_.id=?
member = member1, teamA
member = member2, teamA
Hibernate:
select
team0_.id as id1_3_0_,
team0_.name as name2_3_0_
from
Team team0_
where
team0_.id=?
member = member3, teamB
먼저 member를 가져오고, for문을 돌면서 team 이름을 가져와야 하니, team에 대한 쿼리가 나간다.
member1 의 teamA를 조회할 땐 DB에서 가져오므로 쿼리가 나간다. 하지만 member2의 teamA를 조회할 땐 1차 캐시에 teamA가 존재하므로 쿼리가 나가지 않고, 1차 캐시에서 가져온다. 그리고 member의 teamB는 1차 캐시에 없으니 조회하기 위해 쿼리가 나간다.
위의 예시에선 예시가 얼마 되지않아 쿼리를 3번 날렸지만, 데이터가 많을 수록 쿼리가 너무 많이 나간다.
그렇다면 fetch join을 사용해보자.
String query = "select m from Member m join fetch m.team";
List<Member> resultList = em.createQuery(query, Member.class)
.getResultList();
for (Member member : resultList) {
System.out.println("member = " + member.getUsername() + ", " + member.getTeam().getName());
}
Hibernate:
/* select
m
from
Member m
join
fetch m.team */ select
member0_.id as id1_0_0_,
team1_.id as id1_3_1_,
member0_.age as age2_0_0_,
member0_.TEAM_ID as TEAM_ID5_0_0_,
member0_.type as type3_0_0_,
member0_.username as username4_0_0_,
team1_.name as name2_3_1_
from
Member member0_
inner join
Team team1_
on member0_.TEAM_ID=team1_.id
member = member1, teamA
member = member2, teamA
member = member3, teamB
member를 조회할 때 team의 프록시를 가져오는게 아니라 진짜 team을 가져온다. 그래서 위의 쿼리의 결과로 team이 모두 영속성 컨텍스트에 올라가있다.
따라서 데이터가 이미 다 채워져 있으므로 1차 캐시에서 조회한다. 그래서 추가적인 쿼리가 더 나가지 않는다. 지연 로딩을 설정해도 fetch join이 우선순위를 갖는다.
일대다 관계, 컬렉션 페치 조인
String query = "select t from Team t join fetch t.members";
List<Team> resultList = em.createQuery(query, Team.class)
.getResultList();
for (Team team : resultList) {
System.out.println("team.getName() = " + team.getName());
System.out.println("team.getMembers().size() = " + team.getMembers().size());
}
Hibernate:
/* select
t
from
Team t
join
fetch t.members */ select
team0_.id as id1_3_0_,
members1_.id as id1_0_1_,
team0_.name as name2_3_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_,
members1_.TEAM_ID as TEAM_ID5_0_0__,
members1_.id as id1_0_0__
from
Team team0_
inner join
Member members1_
on team0_.id=members1_.TEAM_ID
team.getName() = teamA
team.getMembers().size() = 2
team.getName() = teamA
team.getMembers().size() = 2
team.getName() = teamB
team.getMembers().size() = 1
result.size() = 3
데이터는 맞는데 teamA가 두번 출력됐다.
team에서 member를 join해서 가져오면 member 수 만큼 team을 가져온다.
그래서 teamA를 두번 출력하는 것이다.
member가 얼마나 있는지 미리 알 수 없으니, db가 주는대로 받는 수 밖에 없다.
String query = "select t from Team t join fetch t.members";
List<Team> resultList = em.createQuery(query, Team.class)
.getResultList();
for (Team team : resultList) {
System.out.println("team.getName() = " + team.getName());
System.out.println("team.getMembers().size() = " + team.getMembers().size());
for (Member member : team.getMembers()) {
System.out.println("member = " + member);
}
}
team.getName() = teamA
team.getMembers().size() = 2
member = Member{id=3, username='member1', age=0}
member = Member{id=4, username='member2', age=0}
team.getName() = teamA
team.getMembers().size() = 2
member = Member{id=3, username='member1', age=0}
member = Member{id=4, username='member2', age=0}
team.getName() = teamB
team.getMembers().size() = 1
member = Member{id=5, username='member3', age=0}
select t from Team t
의 사이즈를 출력하면 2가 나온다.
하지만 select t from join fetch t.members의 사이즈를 출력하면 3이 나온다.
이를 주의하자.
중복이 싫을 땐 어떻게 해야할까
select distinct t
from Team t join fetch t.members
where t.name = ‘팀A’
위와 같이 distinct 를 추가했다고 해보자. 그럼 중복을 제거할 수 있을까?
SQL의 distinct는 위의 그림에 있는 두개의 데이터가 완전히 일치해야 중복을 제거한다. 하지만 위의 데이터는 team의 이름과 id만 같을 뿐 member의 id와 이름이 다르다. 따라서 쿼리만으론 중복을 제거하지 못한다.
그럼 어떻게 하느냐
JPA의 DISTINCT는 추가로 애플리케이션에서 중복 제거를 시도 한다. 따라서 같은 식별자를 가진 Team 엔티티를 제거한다. 아래의 그림과 같다.
String query = "select distinct t from Team t join fetch t.members";
List<Team> resultList = em.createQuery(query, Team.class)
.getResultList();
for (Team team : resultList) {
System.out.println("team.getName() = " + team.getName());
System.out.println("team.getMembers().size() = " + team.getMembers().size());
for (Member member : team.getMembers()) {
System.out.println("member = " + member);
}
}
실행해보자.
team.getName() = teamA
team.getMembers().size() = 2
member = Member{id=3, username='member1', age=0}
member = Member{id=4, username='member2', age=0}
team.getName() = teamB
team.getMembers().size() = 1
member = Member{id=5, username='member3', age=0}
teamA가 한번만 출력되는 것을 확인할 수 있다.
페치 조인과 일반 조인의 차이
일반 조인 실행시 연관된 엔티티를 함께 조회하지 않음
즉 t만 조회된다. 하지만 fetch join을 사용하면 t와 m이 모두 조회된다.
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.username = " " ";
와 같이 조건을 걸어서 조회할 땐 fetch join을 사용하면 안된다. 데이터의 정합성이나, 객체 그래프의 사상이 맞지 않기 때문이다. 이런 경우 따로 조회해야한다. String query = "select t from Team t join fetch t.members ";
List<Team> resultList = em.createQuery(query, Team.class)
.setFirstResult(0)
.setMaxResults(1)
.getResultList();
실행하고 로그를 확인해보자.
9월 19, 2022 4:03:36 오후 org.hibernate.hql.internal.ast.QueryTranslatorImpl list
WARN: HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
하이버네이트의 경고 로그가 떴다.
Hibernate:
/* select
t
from
Team t
join
fetch t.members */ select
team0_.id as id1_3_0_,
members1_.id as id1_0_1_,
team0_.name as name2_3_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_,
members1_.TEAM_ID as TEAM_ID5_0_0__,
members1_.id as id1_0_0__
from
Team team0_
inner join
Member members1_
on team0_.id=members1_.TEAM_ID
team.getName() = teamA
team.getMembers().size() = 2
member = Member{id=3, username='member1', age=0}
member = Member{id=4, username='member2', age=0}
result.size() = 1
또한 쿼리를 보면 페이징 관련 쿼리가 없다.
db에서 team에 대한 데이터를 다 끌고 온것이다. '
그럼 어떻게 페이징할 수 있을까 ?
쿼리를 뒤집으면 된다.
String query = "select m from Member m join fetch m.team t";
아니면 fetch join을 없애는 방법도 있다.
String query = "select t from Team t ";
List<Team> resultList = em.createQuery(query, Team.class)
.setFirstResult(0)
.setMaxResults(2)
.getResultList();
for (Team team : resultList) {
System.out.println("team.getName() = " + team.getName());
System.out.println("team.getMembers().size() = " + team.getMembers().size());
for (Member member : team.getMembers()) {
System.out.println("member = " + member);
}
}
하지만 위의 코드를 실행하면, 쿼리가 member 수만큼 더 나가게 된다. 성능면에서 굉장히 좋지 못하다.
그럼 어떻게 해야할까
@BatchSize라는 걸 사용할 수 있다.
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
Team 클래스의 members를 위와 같이 설정하고 실행하면 아래와 같다.
Hibernate:
/* select
t
from
Team t */ select
team0_.id as id1_3_,
team0_.name as name2_3_
from
Team team0_ limit ?
team.getName() = teamA
Hibernate:
/* load one-to-many jpql.Team.members */ select
members0_.TEAM_ID as TEAM_ID5_0_1_,
members0_.id as id1_0_1_,
members0_.id as id1_0_0_,
members0_.age as age2_0_0_,
members0_.TEAM_ID as TEAM_ID5_0_0_,
members0_.type as type3_0_0_,
members0_.username as username4_0_0_
from
Member members0_
where
members0_.TEAM_ID in (
?, ?
)
Team을 조회 후 Member를 모두 한꺼번에 조회하는 것이다. Member는 LAZY 로딩 상태다. 그래서 member가 쓰일 때까지 기다렸다가 쓰일 때, Batchsize만큼 in 쿼리를 날린다.
<property name="hibernate.default_batch_fetch_size" value="100"/>
persistence에 설정할 수 있다.
연관된 엔티티들을 SQL 한 번으로 조회 - 성능 최적화
엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선함
실무에서 글로벌 로딩 전략은 모두 지연 로딩
최적화가 필요한 곳은 페치 조인 적용
모든 것을 페치 조인으로 해결할 수 는 없다.
페치 조인은 객체 그래프를 유지할 때 사용하면 효과적
여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하면, 페치 조인 보다는 일반 조인을 사용하고 필요한 데이터들만 조회해서 DTO로 반환하는 것이 효과적이다
보통 sql에선 식별자나, 컬럼을 count에 넣지 member 자체를 넣지 않기 때문에 조금 어색할 수 있다. 하지만 JPA에선 객체를 사용하기 때문에 가능하다.
String query = "select m from Member m where m = :member ";
Member findMember = em.createQuery(query, Member.class)
.setParameter("member", member1)
.getSingleResult();
System.out.println("findMember = " + findMember);
실행해보면 아래와 같다.
Hibernate:
/* select
m
from
Member m
where
m = :member */ select
member0_.id as id1_0_,
member0_.age as age2_0_,
member0_.TEAM_ID as TEAM_ID5_0_,
member0_.type as type3_0_,
member0_.username as username4_0_
from
Member member0_
where
member0_.id=?
findMember = Member{id=3, username='member1', age=0}
where 절에 member대신 member_id가 들어간 것을 확인할 수 있다.
직접 식별자를 넣어도 같다.
String query = "select m from Member m where m.id = :memberId ";
Member findMember = em.createQuery(query, Member.class)
.setParameter("memberId", member1.getId())
.getSingleResult();
System.out.println("findMember = " + findMember);
Hibernate:
/* select
m
from
Member m
where
m.id = :memberId */ select
member0_.id as id1_0_,
member0_.age as age2_0_,
member0_.TEAM_ID as TEAM_ID5_0_,
member0_.type as type3_0_,
member0_.username as username4_0_
from
Member member0_
where
member0_.id=?
findMember = Member{id=3, username='member1', age=0}
String query = "select m from Member m where m.team = :team ";
List<Member> members = em.createQuery(query, Member.class)
.setParameter("team", teamA)
.getResultList();
for (Member member : members) {
System.out.println("member = " + member);
} String query = "select m from Member m where m.team = :team ";
List<Member> members = em.createQuery(query, Member.class)
.setParameter("team", teamA)
.getResultList();
for (Member member : members) {
System.out.println("member = " + member);
}
Hibernate:
/* select
m
from
Member m
where
m.team = :team */ select
member0_.id as id1_0_,
member0_.age as age2_0_,
member0_.TEAM_ID as TEAM_ID5_0_,
member0_.type as type3_0_,
member0_.username as username4_0_
from
Member member0_
where
member0_.TEAM_ID=?
member = Member{id=3, username='member1', age=0}
member = Member{id=4, username='member2', age=0}
외래키인 team 또한 team으로 해도 team_id로 sql문이 나가는 것을 볼 수 있다.
미리 정의해서 이름을 부여해두고 사용하는 JPQL
정적 쿼리만 가능하다. 따라서 문자열을 붙이는 등의 변경이 불가능하다.
어노테이션, XML에 정의할 수 있다.
애플리케이션 로딩 시점에 초기화 후 재사용
애플리케이션 로딩 시점에 JPA나 하이버네이트가 쿼리를 SQL로 파싱해 캐시하고 있는다. 결국 JPQL은 SQL로 파싱되어 사용되어야 하기 때문에 이 과정에서 발생하는 코스트가 있다. 하지만 Named 쿼리는 애플리케이션 로딩 시점에 캐시해놓기 때문에 이런 코스트가 없다.
애플리케이션 로딩 시점에 쿼리를 검증
@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", "member1")
.getResultList();
for (Member member : resultList) {
System.out.println("member = " + member);
}
위와 같이 사용할 수 있는데, 실행해보자.
Hibernate:
/* Member.findByUsername */ select
member0_.id as id1_0_,
member0_.age as age2_0_,
member0_.TEAM_ID as TEAM_ID5_0_,
member0_.type as type3_0_,
member0_.username as username4_0_
from
Member member0_
where
member0_.username=?
member = Member{id=3, username='member1', age=0}
의도 대로 잘 실행 된 것을 볼 수 있다.
여기서 우리가 쿼리를 작성할 때 오타를 내게 되면 애플리케이션 로딩 시점에 syntax 에러를 내는 등 쿼리를 검증해준다. 대부분의 에러는 다 잡아주기 때문에 굉장한 메리트이다.
Named 쿼리는 XML에 정의할 수도 있다.
XML이 항상 우선권을 가지기 때문에 애플리케이션 운영 환경에 따라 다른 XML을 배포할 수 있다.
나중에 Spring Data JPA를 사용하게 되면 인터페이스 메서드에 바로 선언할 수 있다. 이름 없는 NamedQuery라고 하는데, 정말 유용하다.
이런 NamedQuery는 엔티티를 지저분하게 만들 수 있다. 그래서 실무에선 Data JPA를 섞어 사용하게 되는데 이때 이름 없는 NamedQuery를 사용하자
벌크연산이란, 일반적으로 아는 update, delete문을 말한다. pk 하나를 찍어 update, delete하는 걸 제외한 나머지 update, delete문이라고 생각하면된다.
예를 들어 재고가 10개 미만인 모든 상품의 가격을 10% 상승하려면 어떻게 해야될까
JPA 변경 감지 기능으로 실행하려면 너무 많은 SQL 실행해야한다.
1. 재고가 10개 미만인 상품을 리스트로 조회한다.
2. 상품 엔티티의 가격을 10% 증가한다.
3. 트랜잭션 커밋 시점에 변경감지가 동작한다.
변경된 데이터가 100건이라면 100번의 UPDATE SQL 실행
JPA는 단건에 더 최적화되어있다. 하지만 이런 벌크연산을 쓰지 않을 수 없으므로 벌크 연산을 지원한다.
쿼리 한 번으로 여러 테이블 로우 변경(엔티티) 한다.
int resultCount = em.createQuery("update Member m set m.age = 20")
.executeUpdate();
System.out.println("resultCount = " + resultCount);
Hibernate:
/* update
Member m
set
m.age = 20 */ update
Member
set
age=20
resultCount = 3
벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리를 날린다. 따라서 잘못하면 꼬일 수가 있는데, 이를 해결하기 위해선 벌크 연산을 먼저 실행하거나, 벌크 연산 수행 후 영속성 컨텍스트를 초기화 하면 된다. 벌크 연산 하면 어차피 쿼리가 나가는 것이기 때문에 flush는 된다. 따라서 이 부분은 고민할 필요가 없고, 영속성 컨텍스트만 초기화하면 된다.
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("member1");
member1.setTeam(teamA);
em.persist(member1);
Member member2 = new Member();
member2.setUsername("member2");
member2.setTeam(teamA);
em.persist(member2);
Member member3 = new Member();
member3.setUsername("member3");
member3.setTeam(teamB);
em.persist(member3);
em.flush();
em.clear();
int resultCount = em.createQuery("update Member m set m.age = 20")
.executeUpdate();
System.out.println("resultCount = " + resultCount);
위 예제에선 벌크 연산 전 em.flush를 해줬다. 하지만 이를 지워도 flush는 자동 호출된다.
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("member1");
member1.setTeam(teamA);
em.persist(member1);
Member member2 = new Member();
member2.setUsername("member2");
member2.setTeam(teamA);
em.persist(member2);
Member member3 = new Member();
member3.setUsername("member3");
member3.setTeam(teamB);
em.persist(member3);
int resultCount = em.createQuery("update Member m set m.age = 20")
.executeUpdate();
System.out.println("resultCount = " + resultCount);
em.createQuery에서 쿼리를 날리기 때문에 JPA는 쿼리를 날리면 기본으로 flush한다.
flush는 commit 시점이나, query가 나갈 때, 강제로 flush를 호출할 때 실행되기 때문이다.
따라서 벌크 연산시에 flush에 대한 처리는 고민하지 않아도 된다. 자동으로 flush되기 때문 !
int resultCount = em.createQuery("update Member m set m.age = 20")
.executeUpdate();
System.out.println("resultCount = " + resultCount);
Member findMember = em.find(Member.class, member1.getId()); System.out.println("member1.getAge() = " + findMember.getAge());
위와 같은 상황일 때 member1의 age는 20살로 변경되어 출력될까 ? 아니다. 위의 persist로 인해 영속성 컨텍스트, 즉 1차 캐시에 저장된 값은 10이다.
벌크 연산으로 모든 member의 age를 20으로 변경하였으나, 이는 DB로 쿼리가 바로 날아가 영속성 컨텍스트에 반영되지 않고 DB에만 반영된다. 따라서 영속성 컨텍스트를 초기화하지 않았기 때문에 현재 member1의 age를 조회하면 1차 캐시에 있는 10을 반환한다.
따라서 이러한 문제를 해결하기 위해 영속성 컨텍스트를 초기화해야한다.
int resultCount = em.createQuery("update Member m set m.age = 20")
.executeUpdate();
System.out.println("resultCount = " + resultCount);
em.clear();
Member findMember = em.find(Member.class, member1.getId()); System.out.println("member1.getAge() = " + findMember.getAge());
이렇게 em.clear()를 통해 영속성 컨텍스트를 초기화하면 find를 통해 member를 조회하면 1차 캐시가 비어있기 때문에 DB에서 값을 조회한다. 따라서 벌크 연산이 반영된 값을 가져올 수 있다.
참고로 Spring Data JPA에선 Modifying Query로 벌크 연산을 할 수 있다.