Home JPQL 문법 (*페치 조인)
Post
Cancel

JPQL 문법 (*페치 조인)

경로 표현식

점(.)을 찍어 객체 그래프를 탐색하는 것

  • 상태 필드 : 단순히 값을 저장하기 위한 필드
    • ex) m.username
  • 연관 필드 : 연관관계를 위한 필드
    • 단일 값 연관 필드
      • @ManyToOne, @OneToOne
      • 대상이 엔티티이다.
      • m.team
    • 컬렉션 값 연관 필드
      • @OneToMany, @ManyToMany
      • 대상이 컬렉션이다.
      • ex) m.orders (보통 orders가 양방향 매핑으로 컬렉션인 경우가 많다.)

상태 필드

경로 탐색의 끝이다. 더 이상 탐색할 수 없다.

1
select m.username from Member m

(m.username 이후 m.username.xx 와 같이 타고 들어가는게 불가능하다.)

단일 값 연관 필드

묵시적 내부 조인이 발생한다. 탐색이 더 가능하다.

1
select m.team from Member m

(m.team.name 과 같이 더 깊게 탐색이 가능하다. 물론 m.team.name은 상태필드이기 때문에 더 이상의 탐색이 불가능하다.)

묵시적 내부 조인

객체 입장에서는 단순하게 .을 찍어서 탐색하지만, DB에서는 탐색을 하기 위해 조인이 필요하다.

Member와 연관된 Team에 대한 테이블 정보를 가져와야 하기 때문에 내부 조인이 발생한다.

이러한 부분이 편해보이지만 묵시적 내부 조인이 일어나는 상황은 주의해서 사용해야 한다. 웬만하면 피해야 한다.

성능 튜닝과 관련해 큰 영향을 미칠 수 있다.

컬렉션 값 연관 경로

묵시적 내부 조인이 발생한다. 탐색이 더 불가능하다.

1
select t.memebers from Team t

t.members.name 이런식으로 추가적인 탐색이 가능할 것 같지만, 컬렉션 자체를 가르키는 것이기 때문에 추가적인 탐색이 불가능하다.

따라서 추가적인 탐색이 하고 싶은 경우에는 명시적인 조인을 사용해야 한다.

1
select m from Team t join t.members m

from절에서 명시적 조인을 통해 별칭을 얻어 별칭을 통한 탐색을 해야 한다.

실무 팁

실무에서는 묵시적 조인이 발생하는 경우를 아예 사용하지 않는 것이 권장된다. 실무에서 에러 상황을 만났을 때 대처하기 어렵다.

(조인이 SQL 튜닝에 중요한 포인트인데 묵시적 조인은 조인이 일어나는 상황을 한 눈에 파악하기 힘들다.)

페치 조인(fetch join)

실무에서 매우매우 중요한 부분이다.

  • SQL의 조인 종류가 아니다.
  • JPQL에서 성능 최적화를 위해 제공하는 기능이다.
  • 연관된 엔티티나 컬렉션을 SQL 한 번으로 모두 함께 조회하는 기능이다.
  • join fetch 명령어를 사용한다.
    • [LEFT [OUTER][INNER] ] JOIN FETCH + 조인 경로

예시를 보자.

1
select m from Member m join fetch m.team

회원을 조회하면서 연관된 팀도 함께 조회하고 싶은 경우.

위 JPQL이 SQL로 변환된 것을 보자.

1
2
select m.*, t.* from Member m
	inner join Team t on m.team_id = t.id

SQL을 보면 Member뿐 아니라 Team(t.*)도 함께 select 된다는 것을 볼 수 있다.

  • 회원 1과 2는 팀A에 속해있고 회원 3은 팀B에 속해있다.
    • 팀C는 현재 회원 테이블과 연관이 없다.
  • 의도는 현재 소속팀이 있는 회원 1,2,3을 뽑으면서 팀에 대한 정보도 같이 가져오고 싶다.
  • 페치조인을 해 가져오면 내부 조인이기 때문에 팀이 없는 회원 4의 정보는 누락된다.
  • 결과적으로는 JPA가 회원 1,2,3 그리고 팀A, B 이렇게 다섯 개의 엔티티를 만들어 영속성 컨텍스트(1차 캐시)에 보관한다.
    • 쿼리 한번으로.

페치 조인의 장점

1
2
3
4
for (Member member : resultList) {
	System.out.println("member = " + member.getUsername() + ", "
		+ member.getTeam().getName());
}

위와 같이 결과를 가져와서 회원 이름과 팀 이름을 간단하게 출력한다고 가정해보자. (참고로 Member와 Team의 관계는 @ManyToOne이고 FetchType은 LAZY이다.)

만약 페치 조인을 하지 않는다면?

  • 멤버를 select 한다.
    • 회원 1,2,3의 3개의 Member가 select 될 것이다.
  • For 루프를 돌면서 우선 회원1의 팀인 팀A에 대한 정보를 이 때 가져온다.
    • member.getTeam()이 프록시이기 때문에 .getName()을 할 때 영속성 컨텍스트에 초기화를 요청하게 된다.
    • 즉 사용할 때 쿼리를 날려 팀A에 대한 정보를 가져오게 된다.
  • 그리고 루프롤 돌아 2번 회원에서는 팀A에 대한 정보가 이미 있기 때문에 그 부분을 재사용한다. 1차 캐시에 이미 존재하기 때문이다.
  • 그리고 3번 회원에서는 팀B에 대한 정보가 없기 때문에 다시 쿼리를 날려서 팀B에 대한 정보를 수집한다.
    • 팀B를 사용할 때 쿼리를 날린다.

만약 100명의 회원이 각각 다른 팀에 소속되어 있다면 100번의 추가 쿼리를 더 날리는 이른바 N+1 문제가 발생한다.

페치 조인을 사용하게 된다면?

  • 쿼리 한 번에 Member와 Team의 데이터를 조인해 전부 들고 온다.
  • 위의 member.getTeam() 이 부분에서 team은 프록시가 아닌 실제 엔티티이다.
    • 따라서 N+1 문제가 발생하지 않는다.
    • 페치 조인을 통해 한 번에 데이터를 가져왔기 때문에 지연 로딩이 발생하지 않는다.

컬렉션 페치 조인

1
2
select t from Team t join fetch t.members
	where t.name = '팀A'

반대로 member를 가지고 있는 Team을 조회하는 것인데, 그 때 team에 속한 member들의 정보까지 같이 가져오는 것이다.

이 때 team이 가지고 있는 member는 collection이다. (@OneToMany)

주의점

개발자가 의도한 바는 멤버가 소속된 팀들만 추출해 소속 팀원 수를 출력하는 것이라고 가정하자.

반복문을 통해 출력한다면 Team A에 Member는 2명, Team B에 Member는 1명 이러한 결과를 얻고 싶었을 것이다.

하지만 반복문을 돌면 Team A는 소속된 Member의 수 만큼 같은 내용을 반복해서 출력하게 된다.

1
2
3
4
5
6
7
8
//원하는 결과
Team A: 2명
Team B: 1명

//실제 결과
Team A: 2명
Team A: 2명
Team B: 1명

조인이 일어났기 때문에 하나의 팀에 두 로우가 되어 발생하는 현상이다.

페치 조인 & DISTINCT

위와 같이 의도치 않게 Team A가 중복되어 나타나는 부분을 제거할 수 있다.

  • SQL의 DISTINCT는 중복된 결과를 제거하는 명령어이다.
  • JPQL은 SQL의 DISTINCT와 조금 다르다. 2가지 기능을 제공한다.
    • SQL에 DISTINCT를 추가한다.
    • 애플리케이션에서 엔티티의 중복을 제거한다.
1
2
select distinct t from Team t join fetch t.members
	where t.name = '팀A'

JPQL의 DISTINCT가 SQL의 DISTINCT와 다르게 추가 기능을 제공하는 이유

위의 예시로 보면 로우가 2개가 되면서 같은 팀이어도 데이터가 서로 다른 로우이기 때문에 중복으로 판단되지 않아 제거가 되지 않기 때문이다.

따라서 같은 식별자를 가진 Team 엔티티를 제거해주는 것이다.

하이버네이트6 부터 DISTINCT 명령어를 사용하지 않아도 애플리케이션에서 중복 제거가 자동으로 적용된다.

일반 조인과의 차이점

  • 일반 조인 실행 시 연관된 엔티티를 함께 조회하지 않는다.
  • JPQL은 결과를 반환 시 연관관계를 고려하지 않고 단지 select 절에 지정한 엔티티만 조회한다.
    • 페치 조인을 사용할 때에만 연관된 엔티티를 함께 조회한다.(즉시 로딩)
    • 페치 조인은 객체 그래프를 SQL 한 번에 모두 조회하는 개념인 것이다.
    • 대부분의 N+1 문제를 해결해준다.

페치 조인 한계점

  • 페치 조인 대상에게 별칭을 줄 수 없다.
    • 하이버네이트는 가능하지만 사용하지 않는 것을 권장한다.
    • join fetch t.members as m
      • 별칭을 주면 m.username 이런식으로 접근하겠다는 것.
      • 하지만 이렇게 하지 않고 따로 조회해주어야 한다.
      • 조인 과정에서 누락되는 데이터들을 확실하게 파악하지 않고 위와 같이 사용했을 경우 의도와 다르게 동작할 위험이 커진다.
  • 둘 이상의 컬렉션은 페치 조인을 할 수 없다.
    • 예를 들면 Team에 Members도 있고, Orders도 있다고 가정하면 둘 다 지정할 수 없다.
    • 데이터가 불어나면서 의도치 않은 에러가 발생할 수 있다.
  • 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다.
    • 일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 페이징이 가능하다.
    • 하이버네이트는 경고 로그를 남기고 메모리에서 페이징해준다.
      • 단 매우 위험하다.

(컬렉션에는 페치 조인에 대한 적용에 한계가 있기 때문에 지연 로딩을 사용하고 @BatchSize나 batch_size 글로벌 적용으로 대처한다.)

정리

  • 연관된 엔티티들을 SQL 한 번으로 조회하여 성능 최적화에 이점이 있다.
  • 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선한다.
    • @OneToMany(fetch = FetchType.LAZY) -> 글로벌 로딩 전략
  • 실무에서 글로벌 로딩 전략은 모두 지연 로딩이다.
  • 최적화가 필요한 곳에 페치 조인을 적용하면 된다.
  • 단, 모든 부분을 페치 조인으로 해결할 수는 없다.
    • 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다.
      • member -> team -> …
    • 여러 테이블을 조인해 엔티티가 가진 모양이 전혀 다른 결과를 내야 한다면, 페치 조인보다는 일반 조인을 사용하고 필요한 데이터들만 조회해 DTO로 반환하는 것이 효과적이다.

다형성 쿼리

위와 같이 다형적으로 설계가 된 상황에 사용한다.

  • 조회 대상을 특정 자식으로 한정할 수 있다.
  • ex) Item 중 Book, Movie를 조회
1
2
select i from Item i
	where type(i) in (Book, Movie)

TREAT (JPA2.1)

  • 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용한다.
  • 자바의 타입 캐스팅과 유사하다.
  • FROM, WHERE, SELECT절에서 사용한다.
  • ex) 부모인 Item과 자식 Book이 있을 때
1
2
select i from Item i
	where treat(i as Book).author = 'kim'

위 JPQL을 SQL로 변환하면?

1
2
3
4
select i.* from Item i
	where i.DTYPE = 'B' and i.author = 'kim'

//참고: 쿼리는 테이블 전략마다 다르다.

엔티티 직접 사용

1
2
select count(m.id) from Member m -> 엔티티의 아이디를 사용
select count(m) from MEmber m -> 엔티티를 직접 사용
  • JPQL에서 엔티티를 직접 사용하면 SQL에서는 해당 엔티티의 기본 키 값을 사용한다.
    • 위의 두 JPQL 모두 아래와 같은 SQL이 실행된다.
1
select count(m.id) as cnt from Member m

엔티티를 파라미터로 전달 / 직접 전달

1
2
3
4
String jpql = "select m from Member m where m = :member";
List resultList = em.createQuery(jpql)
					.setParameter("member", member)
					.getResultList();
1
2
3
4
String jpql = "select m from Member m where m.id = :memberId";
List resultList = em.createQuery(jpql)
					.setParameter("member", member.getId())
					.getResultList();

두 코드 모두 실행되는 SQL 결과는 같다.

외래 키인 경우에도 마찬가지이다.

1
2
select m from Member m where m.team = :team
select m from Member m where m.team.id = :teamId

두 코드 모두 실행되는 SQL 결과가 같다. m.team_id가 사용 된다.

Named 쿼리

1
2
3
4
5
6
7
8
@Entity
@NamedQuery(
	name = "Member.findByUsername",
	query = "select m from Member m where m.username = :username")
)
public class Member {
	//...
}
1
2
3
4
List<Member> resultList =
	em.createNamedQuery("Member.findByUsername", Member.class)
		.setParameter("username", "회원1")
		.getResultList();
  • 미리 정의해서 이름을 부여해두고 사용하는 JPQL이다.
  • 정적 쿼리만 가능하다.
  • 애노테이션, XML에 정의 가능하다.
  • 애플리케이션 로딩 시점에 초기화 후 재사용한다.
    • SQL을 로딩 시점에 미리 검증해둔다.
      • 로딩 시점에 즉시 오류 발생.
    • 미리 해둠으로써 발생할 수 있는 코스트가 줄어든다.

스프링 데이터 JPA의 @Query와 같다.

벌크 연산

SQL의 update, delete와 같다.

  • ex) 재고가 10개 미만인 모든 상품의 가격을 10% 상승시키고 싶다.
  • JPA 변경 감지 기능으로 실행하려면 너무 많은 SQL이 실행되어야 한다.
    • 재고가 10개 미만인 상품을 리스트로 조회
    • 상품 엔티티의 가격을 10% 증가
    • 트랜잭션 커밋 시점에 변경 감지 동작.
  • 변경된 데이터가 100건이라면 100번의 update SQL이 실행된다.

벌크 연산은 이러한 부분을 쿼리 한 번으로 해결해준다. 여러 테이블 로우를 변경한다.

1
2
3
4
5
6
7
String query = "update Product p " +
			"set p.price = p.price * 1.1 " +
			"where p.stockAmount < :stockAmount";

int resultCount = em.createQuery(query)
					.setParameter("stockAmount", 10)
					.executeUpdate();
  • executeUpdate()의 반환 결과는 영향을 받은 엔티티의 수이다.
  • update, delete 지원
  • insert(insert into .. select, 하이버네이트 지원)

주의점

  • 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리한다.

예를 들어 이전에 조회해놓았던 회원이 있다. 그런데 연봉이 4,000만원이었다.

그런데 벌크 연산을 수행해 6,000만원이 되었는데 영속성 컨텍스트에는 여전히 4,000만원으로 유지되어 있는 것이다. 이 부분에 문제가 발생한다.

  • 해결책
    • 벌크 연산을 먼저 실행하거나
    • 벌크 연산 수행 후 영속성 컨텍스트 초기화
This post is licensed under CC BY 4.0 by the author.