본문 바로가기
Dev/JPA

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

by dev_jsk 2021. 9. 10.
728x90
반응형

경로 표현식

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

용어

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

특징

  • 상태 필드(state field) : 경로 탐색의 끝으로 더 이상 탐색 불가, JPQL과 SQL이 동일하다.
  • 단일값 연관 경로 : 묵시적 내부 조인(INNER JOIN)이 발생하며 추가적으로 탐색이 가능, 묵시적 조인이 발생하기 때문에 조심해서 사용해야 한다.
  • 컬렉션 값 연관 경로 : 묵시적 내부 조인이 발생하며 더 이상 탐색 불가, FROM절에서 명시적 조인을 통해 별칭을 얻으면 별칭을 통해 탐색 가능하다.

예제

단일값 연관 경로

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

Member member = new Member();
member.setUsername("member1");
member.setAge(10);
member.setTeam(team);
em.persist(member);

em.flush();
em.clear();

// 단일값 연관 경로
// m.team 내에 name까지 탐색
String query = "select m.team.name from Member m";
List<String> result = em.createQuery(query, String.class).getResultList();

for(String t : result) {
    System.out.println("t : " + t);
}

단일값 연관 경로 SQL 및 결과

컬렉션 값 연관 경로 (묵시적 조인)

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

Member member = new Member();
member.setUsername("member1");
member.setAge(10);
member.setTeam(team);
em.persist(member);

Member member2 = new Member();
member2.setUsername("member2");
member2.setAge(10);
member2.setTeam(team);
em.persist(member2);

em.flush();
em.clear();

// 컬렉션 값 연관 경로
// t.members에서 추가적인 탐색 불가
String query = "select t.members from Team t";
List<Collection> result = em.createQuery(query, Collection.class).getResultList();

for(Object o : result) {
    System.out.println("o : " + o);
}

컬렉션 값 연관경로 SQL 및 결과

컬렉션 값 연관 경로 (명시적 조인)

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

Member member = new Member();
member.setUsername("member1");
member.setAge(10);
member.setTeam(team);
em.persist(member);

Member member2 = new Member();
member2.setUsername("member2");
member2.setAge(10);
member2.setTeam(team);
em.persist(member2);

em.flush();
em.clear();

// 컬렉션 값 연관 경로
// 명시적 조인을 사용하여 추가 탐색
String query = "select m.username from Team t join t.members m";
List<String> result = em.createQuery(query, String.class).getResultList();

for(String s : result) {
    System.out.println("username : " + s);
}

컬렉션 값 연관경로 추가 탐색 SQL 및 결과

명시적 조인과 묵시적 조인

  • 명시적 조인 : JOIN 키워드를 직접 사용하여 하는 조인
  • 묵시적 조인 : 경로 표현식에 의해 묵시적으로 SQL조인이 발생 (내부 조인만 가능, 외부 조인은 명시적 조인 사용)

묵시적 조인 주의사항

  • 항상 내부 조인으로 동작한다.
  • 컬렉션은 경로 탐색의 끝이기 때문에 명시적 조인을 통해 별칭을 얻어야 추가 탐색이 가능하다.
  • 경로 탐색은 주로 SELECT, WHERE절에서 사용하지만 묵시적 조인으로 인해 SQL의 FROM (JOIN)절에 영향을 준다.
  • 조인은 SQL 튜닝에 중요 포인트이기 때문에 실무에서는 조인이 일어나는 상황을 파악하기 어려운 묵시적 조인 대신에 명시적 조인을 사용한다.

페치 조인 - 기본

  • 실무에서 매우매우 중요한 기능
  • SQL 조인의 종류가 아니고 JPQL에서 성능 최적화를 위해 제공하는 기능이다.
  • 연관된 엔티티나 컬렉션을 SQL 한번에 함께 조회하는 기능
  • [LEFT [OUTER] | INNER] JOIN FETCH 조인경로방식으로 사용한다.

엔티티 페치 조인

  • @ManyToOne관계나 엔티티일 때 사용한다.
  • 즉시로딩 방식과 유사하며 지연로딩 설정을 해도 페치 조인이 우선으로 동작한다.

구조

Member와 Team 구조 (좌측) / Member와 Team 조인 결과 (중앙) / Fetch Join (우측)

예제

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 join fetch m.team";
List<Member> result = em.createQuery(query, Member.class).getResultList();

for(Member m : result) {
    System.out.println("member.username : " + m.getUsername() + ", team.name : " + m.getTeam().getName());
}

Member와 Team은 다대일 관계로 엔티티 페치조인 사용

컬렉션 페치 조인

  • @OneToMany관계나 컬렉션일 때 사용한다.
  • 데이터베이스 입장에서 일대다 관계 조회 시 데이터가 뻥튀기 된다.

구조

Team과 Member 구조 (좌측) / Team과 Member 조인 결과 (중앙) / Fetch Join 동작 (우측)

예제

// Team, Member 생성 및 저장 (이전 예제 구문 참고)

// 컬렉션 페치 조인
String query = "select t from Team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class).getResultList();

for(Team t : result) {
    System.out.println("team.name : " + t.getName() + ", members : " + t.getMembers().size());
    for(Member m : t.getMembers()) {
        System.out.println("--> member : " + m);
    }
}

Team과 Member는 일대다 관계로 컬렉션 페치 조인 사용

페치 조인과 DISTINCT

  • SQL의 DISTINCT는 중복된 결과를 제거하는 기능을 제공한다.
  • JPQL의 DISTINCT는 SQL에 DISTINCT를 추가하는 기능과 애플리케이션에서 같은 식별자를 가진 중복 엔티티를 제거하는 기능을 제공한다.

예제

컬렉션 페치 조인 예제 결과를 보면 TeamA에 관한 정보가 2번 출력이 된다. 이때의 중복을 제거해보자.

// Team, Member 생성 및 저장 (이전 예제 구문 참고)

// DISTINCT
String query = "select distinct t from Team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class).getResultList();

for(Team t : result) {
    System.out.println("team.name : " + t.getName() + ", members : " + t.getMembers().size());
    for(Member m : t.getMembers()) {
        System.out.println("--> member : " + m);
    }
}

DISTINCT 구문을 추가하여 중복인 TeamA정보를 한번만 출력한다.

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

  • JPQL은 결과를 반환할 때 연관 관계를 고려하지 않고 SELECT절에 지정한 엔티티만 조회하여 반환한다.
  • 일반 조인은 실행 시 연관된 엔티티를 함께 조회하지 않는다.
  • 페치 조인은 실행 시 연관된 엔티티도 함께 조회한다. (즉시 로딩)
  • 페치 조인은 객체 그래프를 SQL 한번에 조회하는 개념이라 볼 수 있다.

N+1 문제

첫번째 쿼리로 얻은 결과만큼 N번의 추가 쿼리를 날리는 것으로 즉시로딩, 지연로딩 상관없이 발생하는 문제로 페치 조인을 이용하여 해결 가능하다.

페치 조인 - 한계

특징과 한계

  • 페치 조인 대상에는 별칭을 줄 수 없다. (하이버네이트는 가능하지만 가급적 사용하지 않는 것을 권장한다.)
  • 여러 단계의 복잡한 페치 조인시에만 별칭을 사용하며 그 외에는 데이터 정합성이나 객체 그래프의 사상에 맞지 않기 때문에 별칭을 사용하지 않는다.
  • 둘 이상의 컬렉션은 페치 조인 할 수 없다.
  • 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다.
    일대일, 다대일 같은 단일값 연관 필드들은 페치 조인해도 페이징 API 사용 가능
    하이버네이트는 경고 로그를 남기고 메모리에서 페이징 (모든 데이터를 다 조회하기 때문에 매우 위험)
  • 연관된 엔티티들을 SQL 한번으로 조회하기 때문에 성능 최적화가 된다.
  • 엔티티에 직접 적용하는 글로벌 로딩 전략보다 우선적용 된다. (실무에서 글로벌 로딩 전략은 모두 지연 로딩)

페이징 예제

하이버네이트 페이징

// Team, Member 생성 및 저장 (이전 예제 구문 참고)

// 하이버네이트 페치 조인 페이징
String query = "select t from Team t join fetch t.members";
List<Team> result = em.createQuery(query, Team.class)
                       .setFirstResult(0)
                       .setMaxResults(1)
                       .getResultList();

System.out.println("result.size : " + result.size());

메모리 관련 경고 문구를 출력한다.

컬렉션 페이징

// Team, Member 생성 및 저장 (이전 예제 구문 참고)

// 컬렉션 페이징
String query = "select t from Team t";
List<Team> result = em.createQuery(query, Team.class).setFirstResult(0).setMaxResults(2).getResultList();

System.out.println("result.size : " + result.size());

for(Team t : result) {
    System.out.println("team.name : " + t.getName() + ", members : " + t.getMembers().size());
    for(Member m : t.getMembers()) {
        System.out.println("--> member : " + m);
    }
}

컬렉션 페이징 SQL 및 결과

위 컬렉션 페이징 결과를 보면 Member를 지연 로딩으로 조회하기 때문에 N+1 문제가 발생한다.

 

해당 문제에 대한 2가지 해결 방법이 있다.

  • @BatchSize 어노테이션 사용
  • persistence.xmlhibernate.default_batch_fetch_size 지정 (1000 이하로)

@BatchSize 사용

@Entity
public class Team {
    
    @Id @GeneratedValue
    private Long id;
    private String name;

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    // getter, setter
}

hibernate.default_batch_fetch_size 지정

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.2" xmlns="http://xmlns.jcp.org/xml/ns/persistence" 
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
    xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd">
    <persistence-unit name="hello">
        <properties>
            <!-- 필수 속성 -->
            <property name="javax.persistence.jdbc.driver" value="org.h2.Driver" />
            <property name="javax.persistence.jdbc.user" value="sa" />
            <property name="javax.persistence.jdbc.password" value="" />
            <property name="javax.persistence.jdbc.url" value="jdbc:h2:tcp://localhost/~/test" />
            <property name="hibernate.dialect" value="dialect.MyH2Dialect" />

            <!-- 옵션 -->
            <property name="hibernate.show_sql" value="true" />
            <property name="hibernate.format_sql" value="true" />
            <property name="hibernate.use_sql_comments" value="true" />
            <property name="hibernate.jdbc.batch_size" value="10" />
            <property name="hibernate.hbm2ddl.auto" value="create" />
            <property name="hibernate.default_batch_fetch_size" value="100" />  <!-- BatchSize 지정 (컬렉션 페이징) -->
        </properties>
    </persistence-unit>
</persistence>

위 2가지 방법을 사용하게 되면 다음과 같은 결과가 나온다.

조회한 Team 정보를 IN절을 사용하여 비교, 조회한다.

정리

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

댓글