본문 바로가기

💻 개발/JPA

[JPA] @ManyToOne 연관 관계와 Select 쿼리

JPA의 @ManyToOne

JPA에서 엔티티 간의 다대일(N : 1) 연관 관계를 설정하기 위해서는 @ManyToOne 어노테이션을 사용하는데, 이 설정의 기본 페치 전략은 EAGER 즉, 즉시 로딩 전략으로 설정되어 있다.

    /** 
     * (Optional) Whether the association should be lazily 
     * loaded or must be eagerly fetched. The EAGER
     * strategy is a requirement on the persistence provider runtime that 
     * the associated entity must be eagerly fetched. The LAZY 
     * strategy is a hint to the persistence provider runtime.
     */
    FetchType fetch() default EAGER;

따라서 어떤 엔티티를 조회할 때 @ManyToOne에 대해 기본 페치 전략으로 설정된 연관 엔티티가 있으면 조회하는 시점에 연관된 엔티티도 함께 조회하게 된다.

하지만 엔티티를 조회하는 시점이나 방법에 따라서 실제로 실행되는 Select 쿼리가 달라질 수 있는데, 이번 포스팅에서는 @ManyToOne(fetcy = EAGER) 연관 엔티티가 존재할 때 엔티티 조회 시 각 상황별로 어떤 Select 쿼리가 실행되는지 한번 정리해보려고 한다.

 

테스트 환경

테스트를 진행한 환경의 버전 정보는 아래와 같다.

  • Java 11
  • JPA 2.2
  • Hibernate 5.5.0.Final

 

예제 엔티티

예제로 사용할 엔티티는 부서(Department)와 직원(Employee) 엔티티로, 하나의 부서는 여러 직원을 포함할 수 있으며 반대로 직원은 하나의 부서에만 들어갈 수 있다. 따라서 직원 : 부서의 관계는 1 : N 관계가 된다.

Employee Entity

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString
public class Employee {
    @Id
    private Long id;
    private String name;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "DEPARTMENT_ID")
    private Department department;

    public Employee(Long id, String name) {
        this.id = id;
        this.name = name;
    }
}

Department Entity

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString
public class Department {
    @Id
    private Long id;
    private String name;

    public Department(Long id, String name) {
        this.id = id;
        this.name = name;
    }
}

 

1) EntityManager의 find() 메서드로 조회

Java

Employee result = em.find(Employee.class, 1L);

SQL

select
    employee0_.id as id1_8_0_,
    employee0_.DEPARTMENT_ID as departme3_8_0_,
    employee0_.name as name2_8_0_,
    department1_.id as id1_7_1_,
    department1_.name as name2_7_1_ 
from
    Employee employee0_ 
left outer join
    Department department1_ 
        on employee0_.DEPARTMENT_ID=department1_.id 
where
    employee0_.id=?

EntityManagerfind() 메서드를 통해 Employee 엔티티를 조회하면 JOIN 쿼리가 실행되면서 Employee 엔티티와 Department 엔티티가 함께 조회된다.

FetchType이 EAGER로 설정된 경우 대부분의 JPA 구현체가 JOIN 쿼리를 사용하여 연관 엔티티를 한번에 조회하는데 하이버네이트 역시 그런것을 알 수 있다.

 

2) JPQL을 사용한 조회

반면 JPQL을 사용하여 조회를 하는 경우, 어쩌면 당연한 말이겠지만 사용하는 JPQL에 따라 실제로 실행되는 SQL이 조금 달라진다.

 

2-1) JPQL을 사용한 조회 - Join을 사용하지 않는 경우

Java

String jpql = "select e from Employee e";
List<Employee> results = em.createQuery(jpql, Employee.class).getResultList();

SQL

select
    employee0_.id as id1_8_,
    employee0_.DEPARTMENT_ID as departme3_8_,
    employee0_.name as name2_8_ 
from
    Employee employee0_ 
where
    employee0_.id=1
-----------------------------------------------
select
    department0_.id as id1_7_0_,
    department0_.name as name2_7_0_ 
from
    Department department0_ 
where
    department0_.id=?

먼저 "select e from Employee e where e.id=1L" 처럼 따로 JOIN을 사용하지 않고 조회하면 위와 같이 SELECT 쿼리가 2번 실행되는 것을 볼 수 있다.

첫 번째 SELECT 쿼리는 Employee를 조회하는 쿼리이고 두 번째 SELECT 쿼리는 Employee에 연관된 Department를 조회하는 쿼리인데, 자세히 살펴보면 첫 번 째 쿼리문이 바로 조회를 위해 사용한 JPQL이 SQL로 변경된 것임을 알 수 있다.

이렇듯 JPQL을 사용해 조회를 하게되면 JPQL 조회 쿼리 + 연관 엔티티 조회 쿼리가 실행되며 만약 Employee를 여러건 조회하는 경우 조회된 Employee의 수 만큼 Department를 조회하는 쿼리가 실행되어 소위 말하는 N+1 문제가 발생하게 된다.

 

2-2) JPQL을 사용한 조회 - Join을 사용하는 경우

Java

String jpql = "select e from Employee e join e.department";
List<Employee> results = em.createQuery(jpql, Employee.class).getResultList();

SQL

select
    employee0_.id as id1_8_,
    employee0_.DEPARTMENT_ID as departme3_8_,
    employee0_.name as name2_8_ 
from
    Employee employee0_ 
inner join
    Department department1_ 
        on employee0_.DEPARTMENT_ID=department1_.id
-----------------------------------------------------------
select
    department0_.id as id1_7_0_,
    department0_.name as name2_7_0_ 
from
    Department department0_ 
where
    department0_.id=?

그렇다면 JOIN을 사용하면 연관 엔티티를 조인하여 한번에 조회하는 걸까? 아쉽지만 아니다. 위의 결과를 보면 알 수 있듯이 Employee 조회 쿼리와 Department 조회 쿼리가 각각 실행된다. 단지 달라지는 부분은 첫 번째 쿼리에 inner join 쿼리가 사용되면서 Department와 조인하는 쿼리가 실행된다는 것인데, SELECT 절을 보면 Employee 테이블의 컬럼만 조회할 뿐 Department 테이블은 조회하지 않는다.

단순히 JPQL에 작성한 JOIN 절이 SQL의 JOIN절로 변경 되었을 뿐 데이터를 조회하는 방법은 앞서서 JOIN을 사용하지 않은 방법과 다르지 않다. 오히려 불필요한 JOIN 쿼리가 실행되는 셈이다.

 

2-3) JPQL을 사용한 조회 - Fetch Join을 사용하는 경우

Java

String jpql = "select e from Employee e join fetch e.department";
List<Employee> results = em.createQuery(jpql, Employee.class).getResultList();

SQL

select
    employee0_.id as id1_8_0_,
    department1_.id as id1_7_1_,
    employee0_.DEPARTMENT_ID as departme3_8_0_,
    employee0_.name as name2_8_0_,
    department1_.name as name2_7_1_ 
from
    Employee employee0_ 
inner join
    Department department1_ 
        on employee0_.DEPARTMENT_ID=department1_.id

JPQL사용시 연관된 엔티티를 조인하여 한번에 조회하려면 Fetch Join을 사용해야 한다. "select e from Employee e join fetch e.department" 처럼 join fetch <테이블> 형식으로 사용할 수 있다.

실행되는 SQL을 보면 EntityManagerfind() 메서드를 사용할때와 비슷하게 JOIN 쿼리가 실행되면서 Employee와 Department를 한번에 조회하는 것을 알 수 있다.

 

정리

일대다 연관관계의 FetchType이 EAGER인 경우에 대해 지금까지 테스트 해본 내용을 간단히 정리 해보면,

  • EntityManager.find() 메서드를 이용해 조회하면 JOIN 쿼리가 실행되며 연관된 엔티티를 한번의 SQL로 조회한다.
  • JPQL을 사용하여 조회하면 엔티티와 연관 엔티티를 모두 조회하긴 하지만 SELECT 쿼리가 여러번 수행될 수 있다. (N+1 문제 발생 가능)
  • JPQL을 사용하여 연관된 엔티티를 한번의 JOIN으로 조회하려면 Fetch Join을 사용해야 한다.

JPA를 사용하면 SQL을 직접 작성하지 않아도 되는 편리함이 있지만, 어찌됐든 최종적으로는 SQL이 실행 되기 때문에 내가 작성한 자바 코드가 어떤 SQL로 변환되어 실행되는지를 정확히 알고 개발 하는것이 중요하다고 생각한다.