Link Search Menu Expand Document

객체지향 쿼리 언어2 - 중급 문법

경로 표현식

객체지향 쿼리 언어 - 01 예제 모델

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

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

경로 표현식 용어 정리

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

경로 표현식 특징

  • 상태 필드(state field): 경로 탐색의 끝, 탐색 불가능
  • 단일 값 연관 경로: 묵시적 내부 조인(inner join) 발생, 탐색 가능
  • 컬렉션 값 연관 경로: 묵시적 내부 조인 발생, 탐색 불가능
    • FROM 절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색 가능

상태 필드 경로 탐색

  • JPQL: select m.username, m.age from Member m
  • SQL: select m.username, m.age from Member m

단일 값 연관 경로 탐색

  • JPQL: select o.member from Order o

  • SQL:

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

명시적 조인, 묵시적 조인

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

경로 표현식 - 예제

  • select o.member.team from Order o :arrow_right: 성공, 묵시적 조인 2회 발생
  • select t.members from Team :arrow_right: 성공, 컬렉션이 경로 탐색의 끝
  • select t.members.username from Team t :arrow_right: 실패, 컬렉션이 경로 탐색의 끝이기 때문
  • select m.username from Team t join t.members m :arrow_right: 성공, 명시적 조인으로 컬렉션을 가져옴

경로 탐색을 사용한 묵시적 조인 시 주의사항

  • 항상 내부 조인
  • 컬렉션은 경로 탐색의 끝, 명시적 조인을 통해 별칭을 얻어야함
  • 경로 탐색은 주로 SELECT, WHERE 절에서 사용하지만 묵시적 조인으로 인해 SQL의 FROM (JOIN) 절에 영향을 줌

실무 조언

:warning: 가급적 묵시적 조인 대신에 명시적 조인 사용

  • 조인은 SQL 튜닝에 중요 포인트
  • 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어려움

페치 조인(fetch join)

기본

  • SQL 조인 종류가 아니지만 실무에서 정말 많이 사용
  • JPQL에서 성능 최적화를 위해 제공하는 기능
  • 연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능
  • join fetch 명령어 사용
  • 페치 조인 ::= [ LEFT [OUTER] INNER ] JOIN FETCH 조인경로

엔티티 페치 조인

  • 회원을 조회하면서 연관된 팀도 함께 조회 (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

엔티티 조회 예시

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("member1");
member1.setTeam(teamA);
em.persist(member1);

Member member2 = new Member();
member2.setUsername("member2");
member2.setTeam(teamA);
em.persist(member2);

Member member3 = new Member();
member3.setUsername("member3");
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());
}

조회 결과

Hibernate: 
    /* select
        m 
    from
        Member m */ select
            member0_.id as id1_0_,
            member0_.age as age2_0_,
            member0_.TEAM_ID as team_id4_0_,
            member0_.username as username3_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 = member1, teamA // SQL 조회
member = member2, teamA // 1차 캐시
Hibernate: 
    select
        team0_.id as id1_3_0_,
        team0_.name as name2_3_0_ 
    from
        Team team0_ 
    where
        team0_.id=?
member = member3, teamB // SQL 조회

페치 조인을 사용하지 않을 경우 N+1 문제 발생 (엔티티 목록 조회 1번, 각각의 엔티티에 대하여 조회 N번)

엔티티 페치 조인 사용 코드

String jpql = "select m from Member m join fetch m.team";
List<Member> members = em.createQuery(jpql, Member.class)
    .getResultList();

for (Member member : members) {
    //페치 조인으로 회원과 팀을 함께 조회해서 지연 로딩X
    System.out.println("username = " + member.getUsername() + ", " +
                       "teamName = " + member.getTeam().name());
}

조회 결과

Hibernate: 
    /* 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_id4_0_0_,
            member0_.username as username3_0_0_,
            team1_.name as name2_3_1_ 
        from
            Member member0_ 
        inner join
            Team team1_ 
                on member0_.TEAM_ID=team1_.id
member = member1, teamA
member = member2, teamA
member = member3, teamB
객체지향 쿼리 언어 - 02 페치 조인 예시 1 객체지향 쿼리 언어 - 03 페치 조인 예시 2
객체지향 쿼리 언어 - 04 페치 조인 예시 3  

컬렉션 페치 조인

  • 일대다 관계, 컬렉션 페치 조인
  • [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 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);
	}
}

조회 결과

teamname = 팀A, team = Team@0x100
-> username = 회원1, member = Member@0x200
-> username = 회원2, member = Member@0x300
teamname = 팀A, team = Team@0x100
-> username = 회원1, member = Member@0x200
-> username = 회원2, member = Member@0x300

:arrow_forward: 중복 발생

객체지향 쿼리 언어 - 05 엔티티 페치 조인 1 객체지향 쿼리 언어 - 06 엔티티 페치 조인 2
객체지향 쿼리 언어 - 07 엔티티 페치 조인 3  

페치 조인과 DISTINCT

  • SQL의 DISTINCT는 중복된 결과를 제거하는 명령

  • JPQL의 DISTINCT 2가지 기능 제공

    1. SQL에 DISTINCT를 추가

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

  • select distinct t from Team t join fetch t.members where t.name = ‘팀A’
  • SQL에 DISTINCT를 추가하지만 데이터가 다르므로 SQL 결과에서 중복제거 실패

객체지향 쿼리 언어 - 06 엔티티 페치 조인 2

  • DISTINCT가 추가로 애플리케이션에서 중복 제거시도
  • 같은 식별자를 가진 Team 엔티티 제거

객체지향 쿼리 언어 - 08 페치 조인과 DISTINCT

[DISTINCT 추가시 결과]

teamname = 팀A, team = Team@0x100
-> username = 회원1, member = Member@0x200
-> username = 회원2, member = Member@0x300

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

  • 일반 조인 실행시 연관된 엔티티를 함께 조회하지 않음
  • [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은 결과를 반환할 때 연관관계 고려하지 않음
  • 단지 SELECT 절에 지정한 엔티티만 조회할 뿐
  • 여기서는 팀 엔티티만 조회하고, 회원 엔티티는 조회하지 않음
  • 페치 조인을 사용할 때만 연관된 엔티티도 함께 조회 (즉시 로딩)
  • 페치 조인은 객체 그래프를 SQL 한번에 조회하는 개념

페치 조인 실행 예시

  • 페치 조인은 연관된 엔티티를 함께 조회함
  • [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’

페치 조인의 특징과 한계

  • 페치 조인 대상에는 별칭을 줄 수 없음
    • 하이버네이트는 가능하지만 가급적 사용하지 않는 것을 권고
    • 객체의 상태와 DB의 상태 일관성 (데이터 정합성)이 깨질 가능성이 있음
  • 둘 이상의 컬렉션은 페치 조인 할 수 없음
    • 객체의 상태와 DB의 상태 일관성 (데이터 정합성)이 깨질 가능성이 있음
  • 컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없음
    • 일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 페이징 가능
      • 일대다를 페이징하면 데이터가 뻥튀기된다. 페이지 사이즈가 1이라고 가정하면 팀A에는 회원1만 들어있게 된다. 데이터 정합성이 맞지 않는 상황이다.
    • 하이버네이트는 경고 로그를 남기고 DB에서 페이징하는 것이 아니라 메모리에서 페이징 (매우 위험, 장애 원인)
  • 연관된 엔티티들을 SQL 한 번으로 조회 - 성능 최적화
  • 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선함
    • @OneToMany(fetch = FetchType.LAZY) //글로벌 로딩 전략
  • 실무에서 글로벌 로딩 전략은 모두 지연 로딩
  • 최적화가 필요한 곳은 페치 조인 적용

정리

  • 모든 것을 페치 조인으로 해결할 수는 없음
  • 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적
  • 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 하면, 페치 조인 보다는 일반 조인을 사용하고 필요한 데이터들만 조회해서 DTO로 반환하는 것이 효과적

참고

즉시 로딩과 페치 조인의 차이

https://www.inflearn.com/questions/30446

https://www.inflearn.com/questions/39516

페치 조인 대상에 별칭을 주지 않는 이유

https://www.inflearn.com/questions/15876

다형성 쿼리

객체지향 쿼리 언어 - 09 다형성 쿼리

TYPE

  • 조회 대상을 특정 자식으로 한정
  • 예) Item 중에 Book, Movie를 조회해라
  • [JPQL] select i from Item i where type(i) IN (Book, Movie)
  • [SQL] select i from i where i.DTYPE in (‘B’, ‘M’)

TREAT(JPA 2.1)

  • 자바의 타입 캐스팅과 유사
  • 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용
  • FROM, WHERE, SELECT(하이버네이트 지원) 사용
  • 예) 부모인 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’

엔티티 직접 사용

기본 키 값

  • 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

엔티티를 파라미터로 전달

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();

실행된 SQL

select m.* from Member m where m.id=?

외래 키 값

Team team = em.find(Team.class, 1L);

String qlString = select m from Member m where m.team = :team;
List resultList = em.createQuery(qlString)
    .setParameter("team", team)
    .getResultList();
String qlString = select m from Member m where m.team.id = :teamId;
List resultList = em.createQuery(qlString)
    .setParameter("teamId", teamId)
    .getResultList();

실행된 SQL

select m.* from Member m where m.team_id=?

Named 쿼리

  • 미리 정의해서 이름을 부여해두고 사용하는 JPQL
  • 정적 쿼리
  • 어노테이션, XML에 정의
  • 애플리케이션 로딩 시점에 초기화 후 재사용
  • 애플리케이션 로딩 시점에 쿼리를 검증
    • 가장 좋은 오류는 컴파일 시점에 나오는 오류, 그 다음은 런타임 시점에 나오는 오류, 가장 나쁜 오류는 사용 시점에 나오는 오류

어노테이션

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

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="http://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>  
    <named-query name="Member.count">
        <query>select count(m) from Member m</query>
    </named-query>
</entity-mappings>

Named 쿼리 환경에 따른 설정

  • XML이 항상 우선권을 가진다.
  • 애플리케이션 운영 환경에 따라 다른 XML을 배포할 수 있다.

벌크 연산

  • 재고가 10개 미만인 모든 상품의 가격을 10% 상승하려면?

  • JPA 변경 감지 기능으로 실행하려면 너무 많은 SQL 실행

    1. 재고가 10개 미만인 상품을 리스트로 조회한다.

    2. 상품 엔티티의 가격을 10% 증가한다.

    3. 트랜잭션 커밋 시점에 변경감지가 동작한다.

  • 변경된 데이터가 100건이라면 100번의 UPDATE SQL 실행

예제

  • 쿼리 한 번으로 여러 테이블 로우 변경(엔티티)
  • executeUpdate()의 결과는 영향받은 엔티티 수 반환
  • UPDATE, DELETE 지원
  • INSERT(insert into .. select, 하이버네이트 지원)
String qlString = "update Product p " +
    "set p.price = p.price * 1.1 " +
    "where p.stockAmount < :stockAmount";
int resultCount = em.createQuery(qlString)
    .setParameter("stockAmount", 10)
    .executeUpdate();

주의

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

    • 벌크 연산을 먼저 실행
    • 벌크 연산 수행 후 영속성 컨텍스트 초기화
  • 벌크 연산을 먼저 실행하지 않을 경우

    Member member1 = new Member();
    member1.setUsername("회원1");
    member1.setAge(0);
    member1.setTeam(teamA);
    em.persist(member1);
      
    Member member2 = new Member();
    member2.setUsername("회원2");
    member2.setAge(0);
    member2.setTeam(teamA);
    em.persist(member2);
      
    Member member3 = new Member();
    member3.setUsername("회원3");
    member3.setAge(0);
    member3.setTeam(teamB);
    em.persist(member3);
      
    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.getAge()); // findMember = 0
    

벌크 연산이 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리하기 때문에 findMember에 변경된 내용이 반영되지 않아 데이터 정합성이 깨졌다.