BackEnd/JPA

[JPA] 객체지향 쿼리 언어 3 (중급 문법)

샤아이인 2022. 4. 10.

내가 공부한것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼겸 상세히 기록하고 얕은부분들은 가겹게 포스팅 하겠습니다.

 

1. 경로 표현식

경로 표현식 이란? => .(점) 을 찍어 객체 그래프를 탐색하는 방법을 말한다.

select m.username -> 상태 필드
	from Member m
	join m.team t -> 단일 값 연관 필드
	join m.orders o -> 컬렉션 값 연관 필드
where t.name ='팀A'
 

● 경로 표현식 용어 정리

1. 상태 필드 : 단순히 값을 저장하기 위한 필드 (ex. m.username)

2. 연관 필드 : 연관관계를 위한 필드

- 단일 값 연관 필드 : @ManyToOne, @OneToOne, 대상이 엔티티 (m.team)

- 컬렉션 값 연관 필드 : @OneToMany, @ManyToMany, 대상이 컬렉션 (m.orders)

 

● 경로 표현식 특징

1. 상태 필드: 경로 탐색의 끝이다. 더이상 탐색이 불가능하다.

 

2. 단일 값 연관 경로묵시적 내부 조인(inner join)발생, 탐색을 더 진행할수가 있다.

예를 들어 Member에는 Team 이라는 필드가 있다.

Member : Team = N : 1 의 관계이기 때문에 단일값 연관 경로를 따라 Team에 접근할수가 있다.

다음과 같이 Team의 name에 접근하고 있다.

String query = "select m.team.name from Member m";
 

name은 상태필드 이다. 더이상 탐색이 불가능 하다.

 

이번에는 생성되는 쿼리를 확인해 보자.

내부적으로 join이 진행되었다. 우리야 사용하는 입장에서 m.team 과 같이 사용했지만. member에서 team 필드에 접근하여 사용하려면 DB에서는 JOIN으로 TEAM 테이블도 가져와야 사용이 가능하다. 따라서 묵시적으로 JOIN이 진행된다.

 

3. 컬렉션 값 연관 경로묵시적 내부 조인이 발생, 탐색 진행 불가능

다음과 같이 Team을 선행 테이블로 하고 JOIN 대상으로 members 를 지정하게 된다.

String query = "select t.members from Team t";
 

문제는 members와 JOIN해도 더이상 탐색을 진행할수가 없다.

이는 생각해보면 당연한데, members는 collection이다.

collection 내부에 어떠한 한 원소를 지정하지 않았기 때문에 t.members.name 과 같은 호출은 불가능하게 된다.

 

그나마 members.size 정도는 collection의 size를 구하는데 사용이 가능하다.

 

이는 From 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 해결이 가능하다.

select m.username from Team t join t.members m

 

위 쿼리는 팀과 팀이 보유한 회원목록(members)을 컬렉션 값 연관 필드로 Join한 것 이다.

따라서 명시적으로 join을 진행하고 있다. 이를 통해 얻은 별칭 m을 통해서 username에 접근이 가능하다.

 

결론 : 실무에서는 가급적 묵시적 조인 대신에 명시적 조인을 사용하자.

JOIN은 SQL 튜닝에 중요 포인트 이다.

묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵다.

 

2. 페치 조인 (Fetch join) - 기본

SQL 조인 종류는 아니고 JPA에서 제공하는 기능이다. JPQL에서 성능 최적화를 위해 제공하는 기능이다.

연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능

 

● join fetch 명령어 사용

페치 조인 :: = [LEFT [OUTER] | INNER ] JOIN FETCH 조인 경로
 

어떤 객체 그레프를 한번에 조회해올지 명시적으로 동적인 타이밍에 정할수가 있다.

 

● Entity 페치 조인

회원을 조회하면서 연관된 팀도 함께 조회(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을 보면 fetch join을 하고 있다. 또한 Select으로 m 만 지정하고 있다.

하지만 생성된 SQL을 보면 m, t 모두에 대한 정보를 찾아오게 된다.

 

명시적으로 동적인 타이밍에 찾아올 TABLE을 한번에 정할수가 있다.

 

● 예제로 확인해보기

사용되는 Member Entity는 다음과 같다.

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;
    private String username;
    private int age;

    @Enumerated(EnumType.STRING)
    private MemberType type;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}
 

우선 fetch 조인을 사용하지 않는 방식으로 조회해 보자!

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("회원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> resultList = em.createQuery(query, Member.class).getResultList();
for (Member member : resultList) {
    System.out.println("member = " + member.getUsername() + ", " + member.getTeam().getName());
}

transaction.commit();
 

실행 결과를 보면 다음과 같다.

우선 다음 코드가 실행되는 시점에서 Member Table에 대한 select 쿼리가 나가게 된다.

List<Member> resultList = em.createQuery(query, Member.class).getResultList();
 

하지만 Team은 FetchType.LAZY 라서 아직 select 쿼리가 나가지 않았다.

 

이후 for문을 순환할때의 코드를 보면 다음과 같다.

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

1) 회원1을 찾아야 한다.

1차 캐시에 teamA의 저장되있는 데이터가 없다. 따라서 프록시에게 초기화 요청을 보내며, 이를 통해 teamA에 대한 데이터가 1차 캐시에 저장되어 영속성 컨텍스트의 관리를 받게 된다.

 

2) 회원 2를 찾아야 한다.

회원 2는 teamA에 저장되어 있다. 또한 1차 캐시에 teamA에 대한 정보가 이미 저장되어있다.

따라서 SELECT 쿼리가 따로 나가지 않고 바로 정보를 출력하게 된다.

 

3) 회원 3을 찾아야 한다.

회원3은 teamB에 저장되어 있다. 하지만 teamB에 대한 정보가 아직 1차 캐시에 없다.

따라서 SELECT 쿼리가 나가게 된다.

 

총 쿼리가 3번 나갔다. 즉, N+1 문제가 발생했다.

 

이번에는 쿼리를 fetch join을 사용하도록 변경해 보자.

String query = "select m From Member m join fetch m.team";
 

실행시 나오는 쿼리는 다음과 같다.

이번에는 애당초 INNER JOIN 하여 데이터를 한번에 가져오게 되었다.

위 예시는 Member 기준(선행테이블) 에서 Team 을 join 해오는 방식이다.

@ManyToOne 에서 Many 입장에서 조인을 한 것 이다.

 

● 컬렉션 페치 조인 (중요)

@OneToMany 에서 One 입장(Team)에서 조인을 한 것 이다.

//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';
 

이번 예제 코드는 다음과 같다.

String query = "select t From Team t join fetch t.members";
List<Team> resultList = em.createQuery(query, Team.class).getResultList();
for (Team t : resultList) {
    System.out.println("team = " + t.getName() + ", members SIZE :  " + t.getMembers().size());
}
 

join fetch를 하고 있다. 실행 결과는 다음과 같다.

결과가 2번이 아닌, 3번이나 출력되어 버렸다. teamA에 대한 정보가 중복되어 출력되었다.

One 입장에서 Many와 JOIN을 하면 데이터베이스가 뻥튀기 되어버릴수 있다.

 

이를 그림으로 확인하면 다음과 같다.

팀A는 1개이지만 그에 해당하는 멤버는 회원1과 회원2로 두개이기 때문에, 조회 결과는 위 표처럼 2개의 row가 된다.

또한 팀은 하나이기에 같은 주소값을 가진 결과가 두개가 나오고 팀A의 입장에서는 회원1, 회원2를 가진다.

 

이러한 중복을 제거해주기 위해 DISTINCT 라는 기능을 제공한다.

 

● DISTINCT

JPQL에서의 DISTINCT는 2가지 기능을 제공한다.

1. SQL에 DISTINCT 추가

2. 애플리케이션에서 엔티티 중복 제거

String query = "select distinct t From Team t join fetch t.members";
 

- SQL에 DISTINCT 추가하기

우선 이전에 본 JOIN된 결과 릴레이션을 확인해 보자.

DISTINCT는 모든 데이터가 싹다 동일해야 중복제거를 해준다.

따라서 SQL에 DISTINCT를 추가하지만 데이터가 일부 다르므로 SQL 결과 에서 중복제거 실패하게 된다.

 

- 애플리케이션에서 엔티티 중복 제거

DISTINCT가 애플리케이션에서 같은 식별자 값을 갖고있는 Entity를 중복 제거해 버린다.

따라서 실행해 보면 다음과 같이 중복이 제거된 결과를 받을수가 있다.

 

● 페치조인과 일반조인의 차이

일반조인의 경우 연관된 엔티티를 함께 조회하지 않는다.

예를 들어 다음과 같은 JPQL이 있다고 해보자.

String query = "select t From Team t join t.members m";
 

실행되는 쿼리를 보면 JOIN을 하기는 한다. 하지만 Team에 대한 정보만을 조회해 온다.

JPQL은 결과를 반환할 때 연관관계 고려하지 않는다. 단지 SELECT 절에 지정한 엔티티만 조회할 뿐이다.

여기서는 팀 엔티티만 조회하고, 회원 엔티티는 조회하지 않았다.

 

페치 조인을 사용할때만 연관된 엔티티도 함께 조회(즉시로딩) 된다!

 

3. 페치 조인 (Fetch Join) - 한계

● 페치 조인 대상에는 별칭을 줄 수 없다.

String query = "select t from Team t join fetch t.members as m"
// as m 이라는 별칭(alias)는 fetch join에서 사용할 수 없다.
 

하이버네이트에서 가능은 하지만, 사용하지 말기를 권장한다.

 

예를 들어 Team을 조회하는 상황에서 member를 10살 이상만 조회하고 싶다고 해보자.

String query = "select t from Team t join fetch t.members as m where m.age >= 10"
 

기본적으로 JPA에서 설계사상은 연관된 엔티티를 모두 가져온다는 것을 가정하고 만들어졌다.

객체그래프를 탐색한다는것은 team -> members 로 갈때 members가 전부 다 나온다는것을 가정하고 설계가 되어있다.

 

예를 들어 Team 과 연관된 Member가 5명이 있다고 해보자.

여기서 페치 조인 대상에 m이라는 별칭을 주고, m.age >= 10 과 같이 조건을 걸어서 해당되는 Member 1명만 가져오게 되면,

이후 코드를 잘못 조작하면 나머지 4명은 누락되어있기 때문에 문제가 될수있다.

 

fetch join에 별칭을 붙히고 where절을 더해 필터링 해서 결과를 가져오게 되면, 모든걸 가져온 결과와 비교하여 다른 갯수에 대해서 정합성을 보장하지 않는다.

 

● 둘 이상의 컬렉션은 페치 조인 할 수 없다.

예를 들어 Team에 members 라는 컬렉션 필드와, orders라는 컬렉션 필드가 있다고 해보자.

String query = "select t from Team t join fetch t.members, t.orders"
// 불가능 fetch join에서 컬렉션은 1개만 사용하자.
 

● 컬렉션을 페치조인 하면 페이징 API를 사용할수 없다. (어려운 내용)

- 일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 페이징 가능하다. (데이터가 뻥튀기 될일이 없으니까!)

- @OneToMany와 같이 one의 입장에서 Many를 조인한후 페이징을 하면, 하이버네이트는 경고 로그를 남기고 메모리에서 페이징(매우 위험)하게 된다.

 

예를 들어 페치조인 하면 데이터가 2건 나왔던 위에서 본 테이블을 생각해 보자.

여기서 만약 페이지 사이즈를 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();
 

결과는 다음과 같다.

WARN: ~~ applying in memory! 라는 경고가 발생하게 된다.

또한 쿼리를 보면 offset 과 같은 페이징 쿼리가 없다. => DB 에서 전부 full scan 해서 메모리까지 가져온것 이다.

메모리에 다 불러와서 메모리에서 페이징 처리를 하고있는것 이다!

 

안쓰는걸 추천!

 

● 해결 방안

1. 일대다 를 다대일 로 방향을 전환하여 해결한다.

String query = "select m from Member m join fetch m.team t";
 

2. BatchSize() 사용하기

Team에서 컬렉션에 @BatchSize()라는 에노테이션을 추가해 준다.

public class Team{
    ...
    @BatchSize(size = 100) // 배치 사이즈 추가!!!
    @OneToMany(mappedBy = "team")
    private List<Member> members;
    ...
}
 

이후 실행한 코드는 다음과 같다.

String query = "select t From Team t";
List<Team> resultList = em.createQuery(query, Team.class)
        .setFirstResult(0)
        .setMaxResults(2)
        .getResultList();
        
System.out.println("result SIZE = " + resultList.size());

for (Team team : resultList) {
    System.out.println("team = " + team.getName() + ", members = " + team.getMembers().size());
}

 

BatchSize가 없는 상황이였다면, select t 이기 때문에 Team에 대한 정보만 조회 한 후, 이후 member에 접근할때 LazyLoading 하면서 초기화 하게 된다.
 
하지만 우리는 BatchSize를 사용했다. 따라서 결과 쿼리는 다음과 같다.

빨간 박스 안에 ? 2개는 Team 을 2개 찾아왔다는 의미이다.

 

members는 원래 지연로딩 상태이지만, 조회할 때 members를 BatchSize의 size만큼 한번에 조회해 온다.

지금은 2개뿐이니 2개만 넘기는것 이다.

 

만약 150개의 member가 있다면 처음 쿼리 날릴때 100개 를 조회해 오고, 두번째로 남은 50개를 조회해 온다.

 

배치 사이즈는 글로벌 설정을 하고싶다면 persistenc.xml에 추가해주면 된다.

<property name="hibernate.default_batch_fetch_size" value="100"/>
 
 
▶ FetchJoin의 특징과 한계
- 연관된 엔티티들을 SQL한 번으로 조회 - 성능 최적화

- 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선함 => @OneToMany(fetch = FetchType.LAZY)//글로벌 로딩 전략

- 실무에서 글로벌 로딩 전략은 모두 지연 로딩을 사용

- 최적화가 필요한 곳은 페치 조인 적용하기

- 여러 테이블을 조인해서 엔티티가 아닌 전혀 다른 결과를 내야 하면, 페치 조인 보다는 일반 조인을 사용하고 필요한 데이터들만 조회해서 DTO로 반환하는 것이 효과적

댓글