Hibernate에서 @ManyToOne의 FetchType을 LAZY로 설정해도 추가 쿼리가 실행되는 이유와 N+1 현상이 발생하는 과정을 확인해보겠습니다.

직원 Employee와 회사 Company가 있고 이 둘 사이는 N:1 관계이기 때문에 Employee에 @ManyToOne을 설정해 보겠습니다. 참고로 DB Table 기준으로 본다면 employee가 company_no 컬럼을 가지고 있기 때문에 이 관계의 주인이라고 할 수 있습니다.

@Entity
@Table(name = "company")
data class Company(
    @Id
    @Column(name = "company_no")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val companyNo: Long = 0
) {
    @Column(name = "name")
    var name: String = ""
}
@Entity
@Table(name = "employee")
data class Employee(
    @Id
    @Column(name = "employee_no")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val employeeNo: Long = 0,

    @ManyToOne
    @JoinColumn(name = "company_no")
    val company: Company
) {
    @Column(name = "name")
    var name: String = ""
}

그리고 테스트를 위해 필요한 기본 데이터를 insert 하겠습니다.

insert into company(name) values ('대박회사');  
insert into employee(name, company_no) VALUES ('김직원', 1);  
insert into employee(name, company_no) VALUES ('이직원', 1);  
insert into company(name) values ('우량회사');  
insert into employee(name, company_no) VALUES ('박직원', 2);  
insert into employee(name, company_no) VALUES ('전직원', 2);

FetchType.EAGER

@ManyToOne의 기본 FetchType은 EAGER이기 때문에 관계에 필요한 데이터를 바로 조회 합니다. Employee를 Direct fetching 조회하면 Hibernate는 left join으로 쿼리를 생성해서 조회합니다.

entityManager.find(Employee::class.java, 1L)
select
    ...
from
    employee employee0_ 
left outer join
    company company1_ 
        on employee0_.company_no=company1_.company_no 
where
    employee0_.employee_no=?

inner join으로 변경하고 싶다면 @ManyToOne의 optional을 false로 변경하면 됩니다. 기본값은 true 입니다.

@ManyToOne(optional = false)
@JoinColumn(name = "company_no")
val company: Company

다시 실행하면 inner join으로 변경된 것을 확인할 수 있습니다.

select
    ...
from
    employee employee0_ 
inner join
    company company1_ 
        on employee0_.company_no=company1_.company_no 
where
    employee0_.employee_no=?

N+1

앞서 하나의 Employee를 조회해봤고 이번에는 JQPL을 이용해서 전체 Employee를 조회해 보겠습니다.

entityManager.createQuery(
    """
    select e
    from Employee e
""".trimIndent(), Employee::class.java
).resultList

총 3개의 쿼리가 실행됩니다. Employee를 조회하기 위한 쿼리와 Employee와 관계 있는 Company를 조회하기 위한 쿼리입니다.

select
    ...
from
    employee employee0_

select
    ... 
from
    company company0_ 
where
    company0_.company_no=?

select
    ... 
from
    company company0_ 
where
    company0_.company_no=?

Employee가 속한 Company 만큼 추가 쿼리가 실행되기 때문에 이와 같은 현상을 N+1이라고 합니다.

이런 현상이 생기는 이유는 예제에서 JPQL로 Employee만 조회했고 조회한 이후에 @ManyToOne 관계를 확인하게 되는데 FetchType이 EAGER이기 때문에 뒤늦게 Company 를 가져오기 위해 추가 쿼리가 실행되는 것입니다.

FetchType.LAZY

만약 FetchType을 LAZY로 변경하게 되면 이런 현상은 발생하지 않습니다. Company 를 proxy 객체로 가지고 있고 필요한 시점에 쿼리를 호출하기 때문입니다.

참고로 JPA에서 기본이 EAGER로 되어 있기 때문에 Hibernate에서도 스펙에 따르고 있지만 기본적으로 LAZY로 변경해서 사용하길 권장하고 있습니다.

FetchType.LAZY로 변경했을 때 주의점은 트랜잭션에 내에서 toString()이나 property 복사 같은 작업을 하면서 EmployeeCompany에 접근하게 되면 추가 쿼리가 실행되면서 역시 N+1이 발생하게 됩니다.

트랜잭션 밖에서 EmployeeCompany에 접근하게 되면 LazyInitializationException 오류가 발생합니다.

Fetch Join

1개 이상의 Employee를 조회할 때는 N+1 발생 가능성에 대해서 항상 인지하고 있어야합니다. @ManyToOne의 FetchType을 LAZY로 설정했지만 Company까지 필요한 경우에는 Fetch Join을 이용합니다.

entityManager.createQuery(
    """
    select e
    from Employee e
    join fetch e.company
""".trimIndent(), Employee::class.java
).resultList

join fetch으로 join 쿼리가 실행되도록 할 수 있습니다. left outer join으로 처리하고 싶다면 join fetch 대신 left join fetch로 변경하면 됩니다.

Kotlin All-open plugin

kotlin은 기본이 final 클래스입니다. LAZY 동작이 정상적으로 되려면 Hibernate가 프록시 객체 생성할 수 있도록 Entity는 open 클래스 이어야 합니다. 직접 하나 하나 지정할 수도 있고 All-open 플러그인을 이용해서 한번에 처리할 수 있습니다. All-open에 대한 자세한 내용은 All-open compiler plugin를 참고하세요.

결론

@ManyToOne의 기본 FetchType은 EAGER이지만 특별한 경우가 아니라면 LAZY를 기본 세팅해서 사용하는게 좋습니다. LAZY로 사용하더라도 N+1이 발생할 수 있음을 인지하고 사용해야하며 필요한 경우에는 fetch join이나 entity graphs를 이용해서 한번에 데이터를 모두 조회하도록 합니다.

끝.

Reference

댓글남기기