BACK/JPA

[JPA] TIL 10일차 JPQL -중급 문법 (경로 표현식, 패치 조인, Named 쿼리, 벌크 연산)

연듀 2024. 4. 7. 21:47

경로 표현식

 

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

 



 

상태 필드: 경로 탐색의 끝, 탐색 X

 

String query = "select m.username from Member m"; // m.username까지가 탐색의 끝

 

단일 값 연관 경로: 묵시적 내부 조인(inner join) 발생, 탐색 O

 

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

 

m.team을 했을 때 join 쿼리가 나간다.

실제 실무에서는 묵시적 내부 조인을 쓰면 안된다.

 

 

컬렉션 값 연관 경로: 묵시적 내부 조인 발생, 탐색 X

 

String query = "select t.members from Team t"; // members 이후에 탐색 불가능하다.컬렉션 자체를 가리키기 때문이다.size정도만 호출할 수 있다. 

Collection result = em.createQuery(query, Collection.class).getResultList();
for (Object s : result) {
    System.out.println("s = " + s);
}

 

t.members 이후에 탐색이 안되므로 아래와 같이 명시적 조인을 해야 한다.

String query = "select m.username From Team t join t.members m"; // 별칭 m을 얻어 탐색 가능

List<Collection> result = em.createQuery(query, Collection.class).getResultList();
System.out.println("result = " + result);

 

 

  • 묵시적 조인: 경로 표현식 (ex. m.team)에 의해 묵시적으로 SQL 조인 발생
  • 명시적 조인: join 키워드 직접 사용

 

명시적 조인을 사용하자.

 

 


 

페치 조인 - 기본

 

JPQL에서 성능 최적화를 위해 제공하는 전용 기능으로, 

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

 

join fetch 명령어를 사용한다.

명시적으로 동적으로 언제 조인해 가져올지 정한다. 

 

Team teamA = new Team();
teamA.setName("팀A");
em.persist(teamA);

Team teamB = new Team();
teamA.setName("팀B");
em.persist(teamB);

// member1에 teamA 저장
Member member1 = new Member();
member1.setUsername("회원1");
member1.setTeam(teamA);
em.persist(member1);

// member2에 teamA 저장
Member member2 = new Member();
member2.setUsername("회원2");
member2.setTeam(teamA);
em.persist(member2);

/// member3에 teamB 저장
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> result = em.createQuery(query, Member.class).getResultList();

for (Member member : result) {
    System.out.println("member.getUsername() = " + member.getUsername() + "," + member.getTeam().getName());
    // 회원1, 팀A(SQL)
    // 회원2, 팀A(1차캐시)
    // 회원3, 팀B(SQL)
}

 

member.getTeam.getName(): 실제 팀에서 getName을 호출하는 순간마다 영속성 컨텍스트에 조회를 하고, DB에 쿼리를 날린다.

 

첫번째 루프를 돌며 회원1을 가져오면, 팀이 프록시 이므로 영속성 컨텍스트에서는 실제 team을 달라고 한다.

이 때 DB 쿼리가 실행 되고, 영속성 컨텍스트에 teamA가 들어간다. 

첫번째 회원1 조회시에는 영속성 컨텍스트에 값이 없기 때문에 팀A를 가져올때 SQL쿼리가 직접 나가는 것이다.

 

회원2의 팀A를 가져오려 할 때는, 영속성 컨텍스트에 팀A가 있기 때문에 1차 캐시에서 가져온다. 그래서 이때 실제 쿼리는 안나간다.

 

회원3의 경우 영속성 컨텍스트에 팀B가 없기 때문에 쿼리가 나가 영속성 컨텍스트에 팀B를 올려놓고 결과를 반환한다.

 

최악의 경우, 만약 세명의 팀의 소속이 다 다를 경우에는 처음 회원과 팀 쿼리 2번, 다른 팀 조회 쿼리 1번, 다른 팀 조회 쿼리 1번

이렇게 총 4번의 쿼리가 나갈 것이다.

만약 회원 100명을 조회하고 100명이 모두 다 다른 팀 소속이라면, 쿼리가 100번 나가게 되는 것이다.

이러한 문제를 N+1 문제라고 한다. 

 

(회원을 가져오기 위해 날린 첫번째 쿼리 1개) + (n번 조회할 때 나가는 쿼리 n개) 

 

이러한 문제는 페치 조인으로 해결해야 한다.

 

 

 

페치 조인 사용

String query = "select m from Member m join fetch m.team"; // join해 한번에 fetch로 가지고 와라.

List<Member> result = em.createQuery(query, Member.class).getResultList();
for (Member member : result) {
    System.out.println("member.getUsername() = " + member.getUsername() + "," + member.getTeam().getName()); // 이때의 team 은 프록시가 아니다.
    // sql 쿼리에 멤버랑 팀의 데이터를 조인해 다 들고 온다.
    // 쿼리의 결과(엔티티)는 모두 영속성 컨텍스에 담겨진다.
    // 지연로딩 X
}

 

페치 조인을 하면 SQL 쿼리로 회원과 팀의 데이터를 모두 조인해 한꺼번에 가져온다.

 

만약 지연로딩을 설정해놓았어도 패치조인이 우선권을 가지기 때문에 지연로딩은 발생하지 않는다. 

 

참고) 1:N에서 조인시 데이터 중복 문제가 발생하는데, 

→ 하이버네이트6 부터는 distinct 명령어를 사용하지 않아도 엔티티의 중복을 제거하도록 변경되었다.

 

 

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

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

List<Team> result = em.createQuery(query, Team.class).getResultList();
System.out.println("result = " + result.size());

 

 

 

fetch join 을 안하면 join만 하고 select절에는 team만 가져온다. 회원 엔티티는 조회하지 않는다.

단지 select 절에 지정한 엔티티만 조회할 뿐이다.

그리고 지연로딩이기 때문에 나중에 team을 실제로 사용할 때 select team 쿼리가 나간다.

→ 연관된 엔티티를 함께 조회하지 않음

 

 

 

fetch join 하면 select 절에 회원과 team 데이터가 다 포함이 되어있고 지연로딩은 하지 않는다.

→ 페치 조인을 사용하면 연관된 엔티티도 함께 조회(즉시 로딩)

객체 그래프를 SQL로 한번에 조회하는 개념

 

 

페치 조인 - 한계

 

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

 

ex) select t from Team t join fetch t.members as m (X)

 

왜냐하면 기본적으로 연관된 것들을 모두 가져오는 것이기 때문에 where 절을 쓸 수 없다.

만약 팀에서 멤버를 조회할 때 상위 5개만 가져오고 싶은 경우가 있을 수 있다.

이럴때 팀에서 members를 가져오면 안되고 그냥 select로 멤버를 5개 조회하는 쿼리를 따로 날려야 한다.

기본적으로 JPA의 객체 그래프 탐색은 모든 것을 다 가져온다는 것을 가정하고 설계가 되어있다.

 

 

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

 

데이터 정합성에 안맞고

데이터가 예상하지 못하게 곱해져 늘어날 수 있다.

 

 

3. 컬렉션을 페치 조인하면 페이징을 사용할 수 없다.

 

만약 팀A가 여러 회원을 가지고, 팀 1건만 조회하는 페이징을 한다고 했을 때 팀A는 한명의 회원만 가진다는 식으로 정리가 되어버린다.

이건 객체 그래프 사상에 안맞다.

이렇게 페이징 시도시 경고를 남기고, DB에서는 값을 다 가져온 다음에 메모리에서 페이징 하기 때문에 메모리상으로도 안좋아 쓰면 안된다.

 

이 문제는 회원을 select하고 fetch join으로 팀을 가져오는 식으로(다대일) 뒤집어 해결할 수 있다.

select m from Member m join fetch m.team t

 

아니면 @BatchSize(size=xxx)를 사용할 수 있다.

 

@BatchSize 가 적용된 엔티티는 N+1문제에서 N번 호출될 때 N번의 쿼리가 나가는 것이 아니라, in 조건절로 1번의 쿼리로 만드는 것이다.

size로 몇개의 데이터를 조회할 지 개수를 지정할 수 있다. 

 

 

ex) Post : Comment = 1 : N

comment에 @batchSize(size=5) 적용 

select * from post;

select * from comment where post_id in (1,2,3,4,5)

 

 

 

정리

-페치 조인은 연관된 엔티티를 SQL 한번으로 조회하므로 성능을 최적화 할 수 있다.

-엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선한다.

-실무에서는 글로벌 로딩 전약은 모두 지연로딩으로 하고 , N+1문제가 터지는(최적화가 필요한) 곳에만 페치 조인을 사용하도록 하면 된다.

-모든 것을 페치 조인으로 해결할 수는 없다. 

-페치 조인은 객체 그래프(탐색)를 유지할 때 효과적이다.

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

=> 방법 3가지

  1. 패치조인으로 엔티티를 조회해 그대로 쓴다.
  2. 패치조인 후 DTO에서 바꿔서 반환한다.
  3. jpql을 짤 때부터 new 연산으로 DTO로 변환해 가져온다.

 


Named 쿼리

미리 정의해서 이름을 부여해두고 사용하는 JPQL

애플리케이션 로딩 시점에 Hibernate가 SQL로 파싱해 캐시하고 있는다.

애플리케이션 로딩 시점에 쿼리를 검증할 수 있다.

@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")
                    .setParameter("username", "회원1")
                    .getResultList();

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

 

스프링 data JPA에서 인터페이스의 메서드 위에 선언한 @Query 가 바로 네임드 쿼리이다.

JPA가 자동으로 등록하고, 애플리케이션 로딩 시점에 다 파싱해서 문법 오류가 있으면 잡아준다.

 


벌크 연산

delete, update 지원

쿼리 한번으로 여러 테이블의 로우 변경

int resultCount = em.createQuery("update Member m set m.age = 20").executeUpdate(); // 이 때 자동으로 플러시 호출 
// 플러시는 커밋을 하거나 쿼리가 나갈 때 자동 호출 

System.out.println("resultCount = " + resultCount); // 영향받은 행의 수 

 

영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리가 들어간다.

잘못하면 꼬일 수 있기 때문에, (ex.영속성 컨텍스트에는 update가 안됐는데 DB는 업데이트 된 상태)

영속성 컨텍스트에 값을 넣지 않고 벌크 연산을 먼저 실행하거나

벌크 연산 수행 후 영속성 컨텍스트를 초기화해야 한다.

 

아래는 해당 내용에 관한 예시이다. 

 

int resultCount = em.createQuery("update Member m set m.age = 20").executeUpdate(); // 영향을 받은 카운트

System.out.println("resultCount = " + resultCount);
System.out.println("member1.getAge() = " + member1.getAge()); // 0
System.out.println("member2.getAge() = " + member2.getAge()); // 0
System.out.println("member3.getAge() = " + member3.getAge()); // 0

 

member1,2,3이 persist되는 코드는 생략을 했다.

member1,2,3이 persist되어 영속성 컨텍스트에서는 age가 0으로 초기화 되어 있는데,

DB에는 update문이 나가 20으로 바뀐다.

그런데 영속성 컨텍스트에 남아있는 값 때문에 출력해보면 age가 모두 0이다.

데이터 정합성에 맞지 않기 때문에 벌크 연산 후 영속성 컨텍스트를 clear해주는 것이 좋다.