경로 표현식
.(점)을 찍어 객체 그래프를 탐색하는 것
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): 연관관계를 위한 필드
- 단일 값 연관 필드
@ManyToOne, @OneToOne: 대상이 엔티티(ex: m.team) - 컬렉션 값 연관 필드
@OneToMany, @ManyToMany: 대상이 컬렉션(ex m.orders)
- 단일 값 연관 필드
(중요) 경로 표현식 특징
상태 필드(state field): 경로 탐색의 끝, 탐색x
- m.username 에서 또 .(점) 을 찍어서 탐색할 대상이 없다.
- JPQL: select m.username, m.age from Member m
- SQL: select m.username, m.age from Member m
"select m.username from Member m";
단일 값 연관 경로: 묵시적 내부 조인(inner join)발생, 탐색O
- 묵시적인 내부 조인을 웬만하면 피하는게 좋다. 운영 환경에선 수 많은 쿼리가 있기 때문에 JOIN을 파악하기 힘들다.
- 만약 DBA가 저 JOIN 쿼리는 찾아달라고 했을 때 찾는데 얼마나 걸릴지...
- JPQL: select o.member from Order o
- SQL: select m.* from Orders o inner join Member m on o.member_id = m.id
select m.team from Member m; //team에서 경로탐색이 더 가능하다
select m.team.name from Member m; //team의 name으로 경로 탐색
컬렉션 값 연관 경로: 묵시적 내부 조인 발생, 탐색 X
- 컬렉션 자체를 가리키기 때문에 더 이상 탐색할 수 있는게 없다. 있어봤자 size()
Team team = new Team();
em.persist(team);
Member member1 = new Member();
member1.setUsername("관리자1");
member1.changeTeam(team);
em.persist(member1);
Member member2 = new Member();
member2.setUsername("관리자2");
member2.changeTeam(team);
em.persist(member2);
em.flush();
em.clear();
String query = "select t.members from Team t";
Collection result = em.createQuery(query, Collection.class)
.getResultList();
System.out.println("result = " + result);
- 만약 컬랙션 값 연관 경로에서 추가적인 탐색을 하고 싶다면 FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색 가능하다.
String query = "select m.username from Team t join t.members m";
명시직 조인, 묵시적 조인
- 명시적 조인: join 키워드 직접 사용
- select m from Member m join m.team t
- 묵시적 조인: 경로 표현식에 의해 묵시적으로 SQL 조인 발생 (내부 조인만 가능)
- select m.team from Member m
경로 표현식 - 예제
- select o.member.team from Order o -> 성공
- 그러나 join이 두번 일어난다. 그리고 어떤 SQL 쿼리가 발생할지 예상하기 힘들다.
- select t.members from Team -> 성공 (컬렉션 값 연관 경로)
- select t.members.username from Team t -> 실패 (컬렉션 값 연관 경로)
- select m.username from Team t join t.members m -> 성공
경로 탐색을 사용한 묵시적 조인 시 주의사항
- 항상 내부 조인
- 컬렉션은 경로 탐색의 끝, 추가 탐색이 필요하면 명시적 조인을 통해 별칭을 얻어야함
- 경로 탐색은 주로 SELECT, WHERE 절에서 사용하지만 묵시적 조인으로 인해 SQL의 FROM (JOIN) 절에 영향을 줌
실무 조언
- 가급적 묵시적 조인 대신에 명시적 조인 사용
- 조인은 SQL 튜닝에 중요 포인트
- 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어려움
JPQL - 페치 조인(fetch join) (실무에서 정말정말 중요함)
- SQL 조인 종류는 아니고 JPA에서 제공하는 기능이다.
- JPQL에서 성능 최적화를 위해 제공하는 기능.
- 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능
- join fetch 명령어 사용
- 페치 조인 :: = [LEFT [OUTER] | INNER ] JOIN FETCH 조인 경로
엔티티 페치 조인
- 회원을 조회하면서 연관된 팀도 함께 조회하고 싶다.(SQL 한 번에)
- SQL을 보면 select 프로젝션에 회원 뿐만 아니라 팀(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;
팀이 있는 회원을 조회하고 싶을 때 fetch join을 사용하면 내부적으로 inner join을 사용하고 팀이 없는 회원은 누락된다.
결과적으론 fetch join 도식화 부분처럼 총 5개의 엔티티가 영속성 컨텍스트의 1차 캐시에 보관되고 관리되며 위 그림처럼 만들어서 반환해준다. (쿼리는 한방 쿼리로!!)
N+1문제 해결을 위한 페치 조인
N + 1문제 발생
우선 페치 조인을 사용하지 않았을 때 쿼리를 살펴보자. 이때 Member와 Team의 패치 전략은 LAZY이다.
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.changeTeam(teamA);
em.persist(member1);
Member member2 = new Member();
member2.setUsername("회원2");
member2.changeTeam(teamA);
em.persist(member2);
Member member3 = new Member();
member3.setUsername("회원3");
member3.changeTeam(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());
}
최초 jpql을 통해 Member를 조회해 올때 Team의 정보는 Proxy객체로 가지고 있다. (실제론 없다는 의미)
그렇기에 실제로 getTeam().getName()을 통해 팀의 정보를 조회하려고 할 때 SQL을 수행한다.
주석 내용대로 한번 가져온 Team의 정보는 1차 캐시에 올라가 있기 때문에 더 조회할 필요는 없지만, 회원을 N명 조회하게 되었을때 최대 N + 1 번 Team 조회 쿼리가 수행 될 수 있다.
결과적으로 다음 과정에 의해 쿼리가 실행된다.
- 회원 목록 조회(SQL)
- 회원 1, 팀A (SQL)
- 회원 2, 팀A (1차 캐시)
- 회원 3, 팀 B (SQL)
만약 이런 상황이 회원 100명과 소속 팀이 100개면?? => N + 1 문제가 발생하는 것이다.
이런 상황은 즉시 로딩이든 지연 로딩이든 다 발생하는 상황이다.
그래서 이런 상황을 해결하기 위해 패치 조인을 사용하는 것이다.
N + 1문제 페치 조인 해결
String query = "select m from Member m join fetch m.team";
List<Member> result = em.createQuery(query, Member.class)
.getResultList();
for(Member member : result){
//페치 조인으로 회원과 팀을 함께 조회해서 지연 로딩X
System.out.println("member = " + member.getUsername() + ", " + member.getTeam().getName());
}
결과를 보면 쿼리가 하나 실행되고 Join을 통해 데이터를 가져오는 것을 확인할 수 있다.
좀더 상세하게 설명하자면 이미 조인 쿼리를 통해 회원과 팀 데이터를 프록시가 아닌 실제 엔티티를 가져와 영속성 컨텍스트 1차 캐시에 넣는 것이다.
즉, 페치조인은 조회 당시에 실제 엔티티가 담긴다. 그렇기 때문에 지연로딩 없이 바로 사용이 가능하다.
컬렉션 페치 조인
일대다 관계, 컬렉션 페치 조인
//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';
이를 수행하면 Team은 하나지만 Member가 1개 이상일 수 있다.
팀A는 1개이지만 그에 해당하는 멤버는 회원1과 회원2로 두개이기 때문에, 기본적으로 조회 결과는 위 표처럼 2개의 row가 된다.
팀은 하나이기 때문에 같은 주소값을 가진 결과가 두개가 나온다.
(영속성 컨덱스트 입장에서 같은 Id를 갖고 있기 때문에 이때는 영속성 컨텍스트에 있는 같은 공간을 공유한다.).
그리고 팀A의 입장에서는 각각의 회원1, 회원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.getMembers()) {
//페치 조인으로 팀과 회원을 함께 조회해서 지연 로딩 발생 안함
System.out.println("->username = " + member.getUsername()+ ", member = " + member);
}
}
※ 참고
DB에 쿼리를 날리면 위와 같이 같은 팀을 갖지만 결과는 1개 이상이 나올 수가 있다. 이에 따른 중복 제거는 사용자한테 결정을 위임하는 것이다. JPA는 DB에서 나온 결과 수만큼 컬렉션 개수를 돌려주도록 설계가 되어 있다.
즉, 인위적으로 JPA가 중복을 제거하지 않는 다는 것이다.
페치 조인과 DISTINCT
Team을 fetch join 해서 가져왔을 경우 사이즈는 어떻게 나올 것인가?
String query = "select t from Team t";
String query2 = "select t from Team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class).getResultList();
List<Team> result2 = em.createQuery(query2, Team .class).getResultList();
System.out.println("result size::"+ result.size()); //2
System.out.println("result2 size::"+ result2.size());//3
- 일대다(1:N) 관계에서는 join fetch 결과가 뻥튀기 될 수 있다.
- 팀A는 하나지만 ROW는 2개로 뻥튀기 되었다
- SQL의 DISTINCT는 중복된 결과를 제거하는 명령
- (중요!) JPQL의 DISTINCT 2가지 기능 제공
- SQL에 DISTINCT를 추가
- 애플리케이션 레벨에서 엔티티 중복 제거
select distinct t from Team t join fetch t.members where t.name = '팀A';
위 코드를 실행하면 SQL에 DISTINCT를 추가하지만 데이터가 다르므로 SQL결과에서 중복제거 실패
- 쿼리만으로는 중복제거가 안되기 때문에 JPA 추가적으로 애플리케이션에서 중복 제거를 시도한다.
- 같은 식별자를 가진 Team 엔티티 제거
[DISTINCT 추가시 결과]
※ 참고
반대로 다대일(N:1)은 데이터가 뻥튀기 되지 않는다.
※ 참고
하이버네이트6 변경 사항
- DISTINCT가 추가로 애플리케이션에서 중복 제거시도
- 하이버네이트6 부터는 DISTINCT 명령어를 사용하지 않아도 애플리케이션에서 중복 제거가 자동으로 적용된다.
참고 링크 : https://www.inflearn.com/questions/717679
페치 조인과 일반 조인의 차이
일반 조인 실행 시 연관된 엔티티를 함께 조회하지 않음.
//JPQL
select t from Team t join t.members m where t.name = '팀A';
//SQL
select t.* from Team t inner join member m on t.id = m.team_id where t.name = '팀A';
해당 쿼리 수행시 일반 조인은 연관된 엔티티를 조회하지 않기 때문에 프록시 객체를 반환한다.
그리고 실제로 해당 엔티티를 사용할 때 실제 값을 조회한다.
- JPQL은 결과를 반환할 때 연관관계 고려 X
- 단지 SELECT 절에 지정한 엔티티만 조회할 뿐
- 여기서는 팀 엔티티만 조회하고, 회원 엔티티는 조회 X
페치 조인은 연관된 엔티티를 함께 조회함
//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';
반면, 페치 조인은 조회 시 연관관계도 같이 조회(즉시 로딩)
페치 조인은 객체 그래프를 SQL 한 번에 조회하는 개념이다.
※ 참고
대부분의 N+1 문제는 페치 조인으로 해결이 가능하다.
물론 다른 방법들도 있지만 대부분 해결 가능.
페치 조인의 특징과 한계
1. 페치 조인 대상에는 별칭을 줄 수 없다
String query = "select t from Team t join fetch t.members as m"
//as m 이라는 별칭(alias)는 fetch join에서 사용할 수 없다.
하이버네이트는 가능하지만, 가급적 페치조인에 별칭을 사용을 하지 않는게 좋다
→ ex: 팀을 조회하는 상황에서 멤버가 5명인데 3명만 조회하는 경우 => 이때 3명만 따로 조작하는 것은 몹시 위험.
String query = "select t from Team t join fetch t.members as m where m.age > 10"
- 기본적으로 JPA 설계 사상인 객체 그래프를 탐색한다는 것은 연관된 엔티티를 모두 가져온다는 것을 가정하고 만들어졌다.
- fetch join에 별칭을 붙히고 where절을 더해 필터해서 결과를 가져오게 되면 모든걸 가져온 결과와 비교하여 다른 갯수에 대해서 정합성을 보장하지 않는다.
물론 데이터가 100건 있을 때 팀과 연관된 멤버 5개만 가져오고 싶을 때가 있을 것이다. 이때는 팀에서 멤버들을 가져오는 것이 아니라 멤버 5개를 따로 조회하는 것이 낫다.
※ 참고
페치 조인 별칭에 대한 추가적인 내용은 아래 글 확인
[fetch join 시 별칭관련 질문입니다]
2. 둘 이상의 컬렉션은 페치 조인 할 수 없다.
- 1 : N 도 데이터 뻥튀기가 되는데 둘 이상 컬렉션 조회는 1 : N : N 으로 데이터 정합성 깨질 수가 있다.
- 그러므로 fetch join 의 조인 대상이 컬렉션인 경우 1개만 사용하자.
String query = "select t from Team t join fetch t.members, t.orders"
//불가능 fetch join에서 컬렉션은 1개만 사용하자.
3. 컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.
- 일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 페이징 가능 (데이터 뻥튀기가 안되므로)
- 하이버네이트는 경고 로그를 남기고 메모리에서 페이징(매우 위험)
팀A한테는 회원 2건이 있다. 그런데 만약 페이지 size를 1이라고 해서 데이터를 가져오면 팀A는 회원이 1명인 것으로 결정되어 버린다. 이러한 이유 때문에 매우 위험한 것이다!!
String query = "select t from Team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class)
.setFirstResult(0)
.setMaxResults(1)
.getResultList();
경고 로그 출력 후 메모리에서 페이징 하는데 쿼리를 보면 limit / offset이 없다. 즉, DB에서 팀에 대한 데이터를 다 끌고 온 것이다.
만약 팀에 100만건 있다면 이 100만건을 다 애플리케이션에 올리고 페이징하는 것이다...장애 발생!!
해결 방안
1. 일대다를 다대일로 방향을 전환하여 해결한다
String query = "select m from Member m join fetch m.team t";
2. BatchSize()
조회할 때 members를 BatchSize의 size만큼 조회해 온다.
public class Team{
...
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members;
...
}
=====
List<Team> result = em.createQuery("select t from Team t", Team.class)
.setFirstResult(0)
.setMaxResults(2)
.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);
}
}
실행된 쿼리를 살펴보면 Team을 조회했는데 결과는 2개로 동일하다. 하지만 이때 Member 쿼리를 살펴보면 TEAM_ID 에 대해 in 쿼리가 추가된 것을 확인할 수 있다.
즉, Member 를 가져올 때 리스트로 조회된 Team의 Member 들을 다 가져오겠다는 뜻이다.
다시 정리하자면 Team 을 조회할 당시 Member 는 지연(Lazy)로딩 상태이다. 그런데 이 Lazy 로딩 상태인 Member들을 조회해 올 때 List<Team> 에 속해 있는 모든 Team에 대한 Member 들을 한번에 in 쿼리로 조회해온 다는 것이다.
예시를 들자면 List<Team> 에 150 팀이 있으면 처음에는 100개 팀에 대한 in 쿼리를 날리고 그 후에 나머지 50개에 대한 in 쿼리를 날린다.
이 Batchsize를 이용하면 N+1 문제 또한 해결이 가능하다.
예를 들어 페이징 쿼리를 하는데 10 개 Team 이 조회됐다고 가정해 보자.
이때 원래라면 Team 과 연관된 Member 를 찾는 쿼리가 10번 실행되면서 N + 1 문제가 발생할 것이다.
(팀을 조회하는 쿼 1번 + 조회된 10개 팀에 대한 각각의 Member들을 조회하는 쿼리 10번)
위에서 살펴봤다시피 N + 1 문제를 해결하는 방법으로는 fetch join을 사용할 수 있다.
하지만 조회 대상이 컬렉션일 때는 fetch join으로 해결이 안되므로 Batchsize를 통해 페이징 결과에 대한 연관된 컬렉션을 in 쿼리로 한번에 가져와서 해결하는 것이다.
즉, Batchsize를 사용하게 되면 쿼리를 N + 1이 아닌 테이블 수에 맞춰 실행 시킬 수 있는 것이다.
BatchSize()는 글로벌 설정으로 할 수도 있다.
//persistence.xml
<property name="hibernate.default_batch_fetch_size" value="100"/>
정리
- 연관된 엔티티들을 SQL한 번으로 조회 - 성능 최적화
- 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선함
- @OneToMany(fetch = FetchType.LAZY)//글로벌 로딩 전략
- 실무에서 글로벌 로딩 전략은 모두 지연 로딩
- 최적화가 필요한 곳(N+1문제가 발생하는 곳)은 페치 조인 적용
- JPA 의 성능 문제 중 70~80%는 N+1 이므로 웬만하면 페치 조인으로 해결이 가능하다.
- 모든 것을 페치 조인으로 해결할 수는 없다.
- 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다.
- Member.team 이렇게 찾아갈 때는 효과적.
- 여러 테이블을 조인해서 엔티티가 아닌 전혀 다른 결과를 내야 하면(통계적 결과), 페치 조인 보다는 일반 조인을 사용하고 필요한 데이터들만 조회해서 DTO로 반환하는 것이 효과적
- 페치 조인을 통해 엔티티로 조회해와서 그대로 사용한다.
- 페치 조인을 통해 조회 후 애플리케이션에서 DTO로 변환하여 사용한다.
- JPQL 작성 시 new 연산자를 이용하여 DTO로 변환해서 가져온다.
다형성 쿼리
TYPE
- 조회 대상을 특정 자식으로 한정
- ex: Item 중 Book, Movie를 조회해라
//JPQL
select i from Item i where type(i) IN(Book, Movie)
//SQL
select i from Item i where i.DTYPE in('B', 'M');
TREAT(JPA2.1)
- 자바의 타입 캐스팅과 유사(형변환)
- 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용
- FROM, WHERE, SELECT(하이버네이트 지원)사용
- ex: 부모님 Item과 자식 Book이 있다.
//JPQL
select i from Item i where treat(i as Book).author = 'kim';
//SQL
select i.* from Item i where i.DTYPE = 'B' and i.author = 'kim';
엔티티 직접 사용
기본 키 값
아래 예제를 보면 count(m) 처럼 엔티티를 직접 사용하는 것을 볼 수 있다. 일반적으로는 SQL에 익숙한 사용자한테는 함수 매개변수로 데이터나 식별자를 넘기는데 엔티티 자체를 넘기는 것은 어색할 수가 있다.
하지만 JPQL은 객체를 중심으로 쿼리를 작성하기 때문에 엔티티를 직접 넘겨줄 수가 있다.
이때 JPQL에서 엔티티를 직접 사용하면 SQL에서 해당 엔티티의 기본 키 값을 사용한다.
//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
파라미터를 엔티티를 넘겨주거나 식별자를 넘겨주더라도 실행된 SQL은 같다.
/*엔티티를 파라미터로 전달*/
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();
위 두 JPQL의 실행 SQL은 아래와 같이 동일하다
select m.* from Member m where m.id = ?
외래 키 값
기본키와 로직은 동일하다. 엔티티 or 외래 키를 쓰면 실행 SQL은 동일하다
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();
실행된 SQL
select m.* from Member m where m.team_id = ?
Named 쿼리
- 미리 정의해서 이름을 부여해두고 사용하는 JPQL
- (중요) 정적 쿼리만 가능하다.
- 어노테이션, XML에 정의
@Entity
@NamedQuery(
name="Member.findByUsername", // name은 자유롭게 지정 가능하나 관례상 엔티티 명을 앞에 붙인다
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나 Hibernate가 SQL로 단 한번 parsing하고 캐싱해서 재활용한다.
- 이처럼 로딩 시점에 초기화가 된다면 parsing cost를 절약 가능하다.
- 애플리케이션 로딩 시점에 쿼리를 검증
만약 아래처럼 쿼리를 잘못 작성했다면 로딩 시점에 에러를 알려준다!!
@NamedQuery(
name = "Member.findByUsername",
query = "select m from MemberQQQ m where m.username = :username"
)
XML에 정의
//[META_INF?persistence.xml]
<persistence-unit name="jpabook">
<mapping-file>META-INF/ormMember.xml</mapping-file>
//[META-INF/ormMember.xml]
<?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를 사용 방법과 같다.
- NamedQuery와 XML에 정의된 Query중 XML이 항상 우선권을 가진다.
- 애플리케이션 운영 환경에 따라 다른 XML를 배포할 수 있다.
SpringData JPA를 사용하는 사람은 NamedQuery를 이미 사용.
@Repository
public interface MemberRepository extends JpaRepository<Member, Long>{
@Query("select u from User u where u.username = ?1")
Member findByUsername(String username);
}
@Repository 이 등록된 인터페이스에서 사용되는 @Query에 있는 JPQL(or native)들이 NamedQuery로써 컴파일 시에 등록되는 것이다.
※ 참고
엔티티에 @NamedQuery를 넣는 것은 물론 성능상 이점은 있지만 엔티티 클래스가 지저분해지기 때문에 보통은 SpringData JPA 처럼 사용된다고 보면 된다.
벌크 연산
벌크 연산이란 우리가 일반적으로 알고 있는 SQL의 update or delete 문을 생각하면 된다. PK를 딱 찍어서 한 건을 update or delete하는 연산을 제외한 나머지 모든 update or delete 문이다.
ex: 재고가 10개 미만인 모든 상품의 가격을 10% 상승하려면?
- JPA의 dirty check로 실행하기 위해서는 너무 많은 SQL이 실행되어야 한다.
- 재고가 10개 미만인 모든 상품을 리스트 조회
- 상품 엔티티의 가격 10% 증가
- 트랜잭션 커밋 시점에 dirty checking.
- 변경된 데이터가 100건이면 100건의 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();
벌크 연산 주의
- 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리를 실행한다.
해결법
- 벌크 연산을 먼저 실행
- 영속성 컨텍스트에 값을 넣거나 하는 작업 없이 처음부터 벌크 연산을 실행하거나 벌크 연산만 실행할 때
- 벌크 연산 수행 후 영속성 컨텍스트 초기화
- 벌크 연산도 결국 JPQL이 실행되는 것이기 때문이 Flush는 된다.
- 하지만 엔티티 조회 후 벌크연산으로 엔티티 업데이트가 되버리면 DB의 엔티티와 영속성 컨텍스트의 엔티티가 서로 다른 값이 되게 된다.
- 예를 들어 회원의 연봉을 엔티티 조회 시 5000만원이였을 때 벌크 연산이 날라가면서 6000만원이 되어버렸다.
그렇게 되면 DB에는 6000만원이고 애플리케이션에는 5000만원으로 남아 있기 때문에 서로 다른 값을 갖고 있는 것이다.
※ 참고
Flush란 영속성 컨텍스트와 DB의 Sync를 맞추는 것이다.
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.changeTeam(teamA);
em.persist(member1);
Member member2 = new Member();
member2.setUsername("회원2");
member2.changeTeam(teamA);
em.persist(member2);
Member member3 = new Member();
member3.setUsername("회원3");
member3.changeTeam(teamB);
em.persist(member3);
// Query에 의해 자동 Flush
int resultCount = em.createQuery("update Member m set m.age = 20")
.executeUpdate();
System.out.println("resultCount = " + resultCount);
System.out.println("member1.getAge() = " + member1.getAge());
System.out.println("member2.getAge() = " + member2.getAge());
System.out.println("member3.getAge() = " + member3.getAge());
tx.commit();
위 코드를 실행해보면 update 쿼리는 날라가는데 영속성 컨텍스트에 있는 엔티티들의 나이는 초기 값 0 인 것을 확인할 수 있다.
또한 DB에는 정상적으로 나이 20으로 update가 되었다.
그렇다고 아래처럼 find로 엔티티 매니저에서 다시 값을 찾는다고 해도 그대로 회원 나이는 0을 반환할 것이다.
// Query에 의해 자동 Flush
int resultCount = em.createQuery("update Member m set m.age = 20")
.executeUpdate();
Member findMember = em.find(Member.class, member1.getId());
System.out.println("findMember = " + findMember);
그러므로 벌크 연산을 진행한 후에는 반드시 영속성 컨텍스트를 초기화 해주자!!!
// Query에 의해 자동 Flush
int resultCount = em.createQuery("update Member m set m.age = 20")
.executeUpdate();
em.clear(); // 영속성 컨텍스트 초기화
Member findMember = em.find(Member.class, member1.getId());
System.out.println("findMember = " + findMember);
참고