Peony의 기록 창고 🌼
article thumbnail
반응형

경로 표현식

.을 찍어 객체 그래프를 탐색하는 것을 경로 표현식이라고 한다.

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

 

경로 표현식 용어 정리

  • 상태 필드 : 단순히 값을 저장하기 위한 필드 (ex : m.username)
  • 연관 필드 : 연관관계를 위한 필드 (단일 값, 컬렉션 값)
  • 단일 값 연관 필드 : @ManyToOne, @OneToOne, 대상이 엔티티 (ex: m.team)
  • 컬렉션 값 연관 필드 : @OneToMany, @ManyToMany, 대상이 컬렉션 (ex: m.orders)

 

경로 표현식 특징

  • 상태 필드
    • 경로 탐색의 끝으로 탐색을 더 이상 하지 않는다.
  • 단일 값 연관 경로
    • 묵시적 내부 조인(inner join)이 발생하며 탐색한다.
    • 즉, 쿼리 튜닝 시 복잡하다.
  • 컬렉션 값 연관 경로
    • 묵시적 내부 조인이 발생하며 탐색하지 않는다.
    • FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색이 가능하다.

 

String query = "select m.team From Member m";
Hibernate: 
    /* select
        m.team 
    From
        Member m */ select
            team1_.id as id1_3_,
            team1_.name as name2_3_ 
        from
            Member member0_ 
        inner join
            Team team1_ 
                on member0_.TEAM_ID=team1_.id

객체 지향 언어에선 바로 탐색이 가능하지만, 디비에서는 조인이 발생하는 것을 묵시적 내부 조인이라고 한다. 쿼리 튜닝 시에 단일 값 연관 경로를 사용하지 않도록 주의하자.

 

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

Collection result = em.createQuery(query, Collection.class).getResultList();

컬렉션 값 연관 경로에선 size만 조회가 가능하며 이후 탐색이 안된다. 다음과 같이 Collection 타입으로 결과를 받을 수는 있으나, 사용하지 않는다. 추가적으로 탐색을 원하는 경우 명시적 조인을 사용하자.

 

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

이렇게 from절에서 명시적 조인을 사용하면 컬렉션 값 연관 경로 이후 탐색이 가능해진다.

 

단일 값 연관 경로 탐색

JPQL : select O.member from Order o (묵시적 내부 조인 발생)

SQL : select * from Orders o inner join Member m on o.member_id = m.id

 

명시적 조인, 묵시적 조인

  • 명시적 조인 : join 키워드 직접 사용
    • select m from Member m join m.team t
  • 묵시적 조인 : 경로 표현식에 의해 묵시적으로 조인 발생 (내부 조인만 가능)
    • select m.team from Member m

 

예제

select o.member.team from Order o; // 성공 (조인 2번 발생)
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 튜닝에 중요 포인트기 때문에 쿼리를 파악하기 쉽게 만드는 것이 중요하다.

 

⭐️ 페치 조인(fetch join) ⭐️

  • JPQL에서 성능 최적화를 위해 제공하는 기능으로 연관된 엔티티나 컬렉션을 한 번에 조회 가능
  • SQL 조인의 종류 X
  • 페치 조인 :: = [ LEFT [OUTER] | INNER ] JOIN FETCH 조인경로

 

엔티티 패치 조인

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

회원을 조회하면서 연관된 팀도 함께 조회한다. (SQL 한번에)

 

예시

 

페치 조인을 사용하지 않았을 때

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.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> result = em.createQuery(query, Member.class).getResultList();
for (Member member : result) {
  System.out.println("member = " + member.getUserName() + " , " + member.getTeam().getName());
  // 회원1, 팀A(SQL 실행) - 영속성 컨텍스트에 없기 때문에 지연로딩으로 가져옴 (1차 캐시 등록)
  // 회원2, 팀A(1차 캐시) - 1차 캐시에서 바로 가져옴
  // 회원3, 팀B(SQL 실행) - 영속성 컨텍스트에 없기 때문에 지연로딩으로 가져옴 (1차 캐시 등록)
}
tx.commit();

현재 Member가 갖고있는 Team은 지연 로딩 방식이다. 로그를 보자

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 = 회원1 , 팀A
member = 회원2 , 팀A
Hibernate: 
    select
        team0_.id as id1_3_0_,
        team0_.name as name2_3_0_ 
    from
        Team team0_ 
    where
        team0_.id=?
member = 회원3 , 팀B

첫 번째 쿼리에서 Member에 대한 select문이 조회되면서 이 때 Team정보가 영속성 컨텍스트에 없다는 것을 알고 1차 캐시에 등록 요청을 한다.

두 번째 쿼리에서 팀이 같으므로 바로 1차 캐시에서 꺼내온다.

세 번째 쿼리는 팀이 다르므로 다시 SQL에서 가져온다.

 

최악의 경우 쿼리의 개수는 팀이 모두 다른 경우다. 이때는 쿼리가 4번이 나가게 된다.

즉, 지연 로딩을 사용해도 N + 1 문제가 발생한다. 이 문제를 페치 조인으로 해결해보자.

 

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

다음과 같이 페치 조인을 적용해서 결과를 보자.

/* 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 = 회원1 , 팀A
member = 회원2 , 팀A
member = 회원3 , 팀B

조인으로 쿼리 한 번으로 끝난다. result에 값이 들어가는 순간 Team은 프록시 객체가 아닌 실제 객체가 들어가있는 것을 볼 수 있다. 즉, 데이터가 처음부터 다 채워져 있는 상태이다. (페치 조인으로 회원과 팀을 함께 조회해서 지연 로딩이 아니다.)

 

컬렉션 페치 조인

일대다 관계에서의 컬렉션을 조회할 때 컬렉션 페치 조인이라고 불린다.

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

List<Team> result = em.createQuery(query, Team.class).getResultList();
for (Team team : result) {
  System.out.println("team = " + team.getName() +" members = " + team.getMembers().size());
}
/* 결과 
team = 팀A members = 2
team = 팀A members = 2
team = 팀B members = 1 */

결과 값은 일치하나 중복된 값이 존재한다.

즉, 컬렉션 페치 조인에서는 데이터가 중복된 값이 발생한다. 다음 그림에서 원인을 살펴보자.

team = 팀A members = 2
-> member = Member{id=3, userName='회원1', age=0, team=jpql.Team@20a7953c}
-> member = Member{id=4, userName='회원2', age=0, team=jpql.Team@20a7953c}

team = 팀A members = 2
-> member = Member{id=3, userName='회원1', age=0, team=jpql.Team@20a7953c}
-> member = Member{id=4, userName='회원2', age=0, team=jpql.Team@20a7953c}

team = 팀B members = 1
-> member = Member{id=5, userName='회원3', age=0, team=jpql.Team@5d4e13e1}

로그를 찍어보면 다음과 같이 나오는 이유는 DB에서 TEAM JOIN MEMBER 의 결과 값이 중복 되어 출력되기 때문이다. (같은 팀이지만, 회원의 PK가 다른 경우가 존재)

이 경우, 같은 주소값을 가진 채로 가져오고 JPA는 이 결과를 개발자가 판단하여 처리하도록 맡긴다.

일대다 관계, 컬렉션 페치 조인 (TEAM JOIN MEMBER 부분은 DB에서 가져온 반환 값)

 

페치 조인과 DISTINCT

SQL의 DISTINCT는 중복된 결과를 제거하는 명령이지만 JPQL은 다르다.

SQL에 DISTINCT를 제공하고 애플리케이션에서 엔티티 중복을 제거한다.

 

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

  • JPQL은 결과를 반환할 때 연관관계를 고려하지 않고 SELECT 절에 지정한 엔티티만 조회
  • 일반 조인은 지정한 엔티티만 조회하고 페치 조인에서는 연관된 엔티티도 함께 조회(즉시 로딩)

 

페치 조인 장점

  • 연관된 엔티티들을 SQL 한 번으로 조회 - 성능 최적화 가능
  • 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선함. (글로벌 로딩 전략 : @OneToMany(fetch = FetchType.LAZY))

 

페치 조인 한계

String query = "select t From Team t join fetch t.members m"
  • 이런 경우 같이 페치 조인 대상에는 별칭을 줄 수 없다. 하이버네이트는 가능하지만 가급적이면 사용하지 않는 것을 권장한다.

 

페치 조인의 의미 자체가 연관된 모든 엔티티를 가져오는 것이다. 이 중 몇 개만 가져오기 위해 별칭을 주어 조건을 거는 것은 JPA 사상에 맞지 않고(객체 그래프 - 모두 탐색 가능, 정합성 이슈), 처음부터 특정 데이터만 가져오는 쿼리를 별도로 사용하는 것이 바람직하다.
  • 둘 이상의 컬렉션은 페치 조인할 수 없다.(컬랙션은 하나만 지정 가능)

 

String query = "select t From Team t join fetch t.members m"
List<Team> result = em.createQuery(query, Team.class)
  .setFirstResult(0)
  .setMaxResults(1)
  .getResultList();
/*
다음과 같이 경고 로그 출력 
6월 12, 2023 2:28:31 오후 org.hibernate.hql.internal.ast.QueryTranslatorImpl list
WARN: HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
*/

 

  • 컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.
    • 일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 페이징 가능
    • 하이버네이트는 경고 로그를 남기고 메모리에서 페이징 (매우 위험) - 데이터가 중복되어있는 상태에서 페이징하려는 크기가 중복된 크기보다 작은 경우 결과가 다르게 나올 수 있고, 모든 데이터를 메모리에 올려서 페이징하기 때문.
    • 이 경우 반대로 (일대다 쿼리를 다대일로) 변경해서 단일 값 연관 경로로 변경해서 사용하거나 @BatchSize를 엔티티 필드 단에서 설정하거나 글로벌로 배치 사이즈를 설정한다.

 

모든 것을 페치 조인으로 해결할 수는 없다. 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다.

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

 

다형성 쿼리

  • 다음과 같이 다형성을 지닌 경우 JPA에서 지원하는 기능이 있다.

 

특정 자식 조회

// JPQL
select i from Item i
    where type(i) IN (Book, Movie)

// 실행 SQL
select i from i
  where i.DTYPE in ('B', 'M')

Item 중에 Book, Movie를 조회하는 경우 다음과 같이 type(i) 를 사용하여 조회할 수 있다.

 

TREAT (JPA 2.1)

// JPQL
select i from Item i
    where treat(i as Book).auther = 'kim'

// 실행 SQL
select i.* from i
  where i.DTYPE = 'B' and i.auther = 'kim'

자바의 타입 캐스팅처럼 특정 자식 타입을 treat를 사용하여 다룰 수 있다.

 

엔티티 직접 사용

// JPQL
select count(m.id) from Member m // 엔티티의 아이디를 사용
select count(m) from Member m // 엔티티를 직접 사용

// 두 JPQL 모두 동일한 SQL 실행 
select count(m.id) as cnt from Member m

JPQL에서 엔티티를 직접 사용하면 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();

 

외래 키 값

엔티티를 파라미터로 전달하는 경우

Team team = em.find(Team.class, 1L);
String query = "select m from Member m where m.team = :team";
List<Member> result = em.createQuery(query, Member.class).setParameter("team", team).getResultList();

 

식별자를 직접 전달하는 경우

Team team = em.find(Team.class, 1L);
String query = "select m from Member m where m.team.id = :teamId";
List<Member> result = em.createQuery(query, Member.class).setParameter("teamId", teamId).getResultList();

기본키와 외래 키 두 경우 모두 엔티티와 식별자가 동일한 쿼리문으로 실행된다.

 

Named 쿼리

미리 정의해서 이름을 부여해서 사용하는 JPQL이며 동적은 안되고 정적 쿼리만 가능하다.

어노테이션 또는 XML에 정의하고, 애플리케이션 로딩 시점에 초기화 후 재사용 (로딩 시점에 검증 후 1차 캐시에 보관)

@Entity
@NamedQuery(
        name = "Member.findByUsername",
        query = "select m from Member m where m.userName = :username"
)
public class Member {
  // ...
}

 

실행 결과

List<Member> result = em.createNamedQuery("Member.findByUsername", Member.class).setParameter("username", "회원1").getResultList();
for (Member member : result) {
System.out.println("member = " + member);
}

Named 쿼리는 로딩 시점에서 검증하는 것이 정말 유용하다. 이후 Spring Data JPA는 메서드 위에 @Query로 사용할 수 있다. 이게 Named 쿼리이다.

 

벌크 연산

: 쿼리 한 번으로 여러 테이블 로우를 변경하는 것

  • ex) 재고가 10개 미만인 모든 상품의 가격을 10% 상승하려면?
    • JPA 변경 감지 기능 (더티 체킹)으로 실행하려면 너무 많은 SQL 실행
      1. 재고가 10개 미만인 상품을 리스트로 조회
      2. 상품 엔티티의 가격을 10% 증가
      3. 트랜잭션 커밋 시점에 변경감지가 동작
    • 변경된 데이터가 100건이라면 100번의 UPDATE SQL 실행
int resultCount = em.createQuery("update Member m set m.age = 20").executeUpdate();
System.out.println("resultCount = " + resultCount);
  • 다음과 같이 executeUpdate() 를 통해 영향 받은 엔티티 수를 반환 (update, delete 지원)
  • query가 나가면 persist된 내용들은 자동으로 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());
  • 현재 벌크 연산 수행 후 결과를 출력해보면 age가 변경되지 않았다.
    (DB와 애플리케이션의 데이터가 다른 상황)
  • 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리를 실행한다.
    • 방법 1 : 벌크 연산을 먼저 실행
    • 방법 2 : 벌크 연산을 실행 후 영속성 컨텍스트 초기화 (DB의 값과 영속성 컨텍스트의 값이 달라질 수 있으므로) 후 em.find로 가져오자.

Spring Data JPA 에서 @Modifying 기능을 통해 automatically clear를 설정하지 않는 기능도 있다.

 

이 글은 김영한 님의 "자바 ORM 표준 JPA 프로그래밍 - 기본 편" 강의를 듣고 정리한 내용입니다.

반응형
profile

Peony의 기록 창고 🌼

@myeongju