훈훈훈

Spring boot :: JPA에서 OneToOne 관계 N+1 문제 정리 본문

Spring Framework/개념

Spring boot :: JPA에서 OneToOne 관계 N+1 문제 정리

훈훈훈 2020. 12. 28. 03:41

이번에 간단하게 Entity를 조회하는 API인데 성능이 생각보다 안좋은 이슈를 발견하게 되었다.

 

그래서 Entity를 확인해보니 OneToOne 관계를 사용하고 있었고 로그를 확인 했을 때 쿼리가 한 번이 발생하는 것이 아닌 무수히 많은 쿼리가 발생하는 것을 보았다.

 

OneToOne 관계에서 지연로딩이 동작하지 않는다는 것은 인지하고 있지 못하였는데 이 기회에 한 번 정리해보려고 한다.

(예제 언어는 자바가 아닌 필자에게 익숙한 코틀린으로 작성하였다. 자바 예제는 추후 추가할 예정이다.)

 

 

 

먼저 결론을 말하자면 JPA 구현체인 Hibernate 에서는 양방향 OneToOne 관계에서는 지연로딩이 동작하지 않는다.

 

정확하게는 테이블을 조회할 때 외래 키를 갖고 있는 테이블(연관 관계의 주인)에서는 지연로딩이 동작하지만, mappedBy로 연결된 반대편 테이블은 지연로딩이 동작하지 않고 N + 1 쿼리가 발생한다.

 

왜 이런 현상이 발생하는지 아래 예제로 살펴보자.

 

아래는 예제를 위해 간단히 만든 Member 와 Locker Entity 이다.

두 관계는 양방향 OneToOne 관계로 설정하였으며, 외래 키는 Member가 관리한다.

 

 

ERD

 

Member Entity

@Entity
data class Member(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "Member_ID")
    var id: Long,

    @Column(name = "USERNAME")
    val userName: String,

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "LOCKER_ID")
    val locker: Locker
)

Locker Entity

@Entity
data class Locker(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "LOCKER_ID")
    var id: Long,

    val name: String,

    @OneToOne(fetch = FetchType.LAZY, mappedBy = "locker")
    val member: Member
)

 

먼저 아래 코드로 외래 키를 직접 관리하고 있는 Member Entity 클래스를 조회해보자.

@Component
@Transactional(readOnly = true)
class StartTest {

    @Autowired
    private lateinit var memberRepository: MemberRepository

    @EventListener
    fun onApplicationEvent(event: ApplicationStartedEvent) {
    
        println("======================")
        val member = memberRepository.findByIdOrNull(1) ?: throw Exception("fail")
        println("======================")
    }
}

 

아래와 같이 정상적으로 쿼리가 1개만 발생하는 것을 볼 수 있다.

 

 

이제 mappedBy로 매핑되어 있는 Locker Entity 클래스를 조회해보자.

@Component
@Transactional(readOnly = true)
class StartTest {

    @Autowired
    private lateinit var lockerRepository: LockerRepository

    @EventListener
    fun onApplicationEvent(event: ApplicationStartedEvent) {
    
        println("======================")
        val locker = lockerRepository.findByIdOrNull(1) ?: throw Exception("fail")
        println("======================")
    }
}

 

아래 로그를 보면 추가적으로 Member 를 조회하는 쿼리가 발생한 것을 볼 수 있다.

 

 

 

이제 왜 이러한 현상이 발생하였는지 살펴보자.

 

아래 Locker Entity 클래스를 보면 id, name, member 3개의 필드를 가지고 있는 것을 볼 수 있다.

그렇다면 DB에 존재하는 Locker 테이블은 Entity 클래스와 똑같은 필드를 가지고 있을까?

 

 

아래 그림과 같이 DB에 있는 Locker 테이블은 연관관계의 주인이 아니기 때문에 외래 키를 관리하지 않기 때문에 member 필드는 존재하지 않는다.

 

 

 

해당 이슈는 JPA의 구현체인 Hibernate 에서 프록시 기능의 한계로 지연 로딩을 지원하지 못하기 때문에 발생한다.

 

좀 더 자세하게는 프록시 객체를 만들기 위해서는 연관 객체에 값이 있는지 없는지 알아야 한다. 

그런데 Locker Entity 클래스에서 member 값을 알기 위해서는 무조건 Member를 조회해야지 알 수 있다.

 

따라서 어차피 Member에 대한 쿼리가 발생하기 때문에 hibernate는 프록시 객체를 만들 필요가 없어져 버린다.

결과적으론 지연로딩으로 설정하여도 동작하지 않게 된다.

 

반대로 Member Entity 클래스를 조회할 때는 Locker에 대한 정보를 가지고 있기 때문에 프록시 객체를 생성 후 지연로딩으로 쿼리를 하게 된다.

 

 

 

이 문제에 대한 대표적인 해결 방법으로는 Fetch Join과 Entity Graph 두 가지가 있다.

사실 두 가지는 명칭만 다를 뿐이지 기능은 똑같다.

 

Fetch Join과 Entity Graph 를 사용하게 되면 연관된 엔티티도 함께 조회(즉시 로딩)을 할 수 있다.

즉 , 객체 그래프를 쿼리 한 번에 조회하는 개념으로 생각 할 수 있다. 

 

Fetch Join과 Entity Graph 는 아래 코드처럼 사용할 수 있다.

interface LockerRepository: CrudRepository<Locker, Long> {
    /* fetch join example  */
    @Query("select l from Locker l left join fetch l.member where l.id = :lockerId")
    fun findByIdWithFetchJoin(lockerId: Long): Locker

    /* entity graph example */
    @EntityGraph(attributePaths = ["member"])
    fun findTopById(lockerId: Long): Locker
}

 

Entity Graph 예제를 보면 쿼리 작성 없이 간단하게 어노테이션을 붙여서 쉽게 사용할 수 있다.

따라서 CrudRepository 인터페이스에서 기본적으로 제공해주는 간단한 쿼리는 @EntityGraph 를 사용할 수 있다.

 

하지만 쿼리가 복잡하다면 직접 작성을 할 수 밖에 없기 때문에 QueryDSL이나 JPQL을 사용하여 fetch join을 사용할 수 밖에 없다.

 

마지막으로 Fetch Join과 Entity Graph을 사용하여 Locker Entity 클래스를 조회하면 아래 처럼 쿼리 1개가 발생하는 것을 볼 수 있다.

 

 

여담으로 해당 이슈를 해결하기 위한 방법으로 hibernate에서 지원하는 Bytecode instrument 라는 것이 있다.

취지는 Bytecode를 수정해서 사용자가 원하는 기능으로 수행하도록 하는 것 같다.

 

실제로 사용은 Entity 클래스를 간단히 수정해서 적용 해보았을 때 N+1 문제 이슈는 해결되었지만, 사이드 이펙트로 해당 클래스에 있는 모든 fetchType으로 설정되어 있던 것들이 동작하지 않았다.

 

예를 들어 OneToOne 문제는 클래스 내부를 수정해서 Byte code instrument로 해결되었지만 클래스 내부에 있는 @ManyToOne(fetch = FetchType.LAZY) 과 같은 전략들이 동작하지 않았다. 따라서 클래스 내부에 있는 연관 객체 모두를 수정해줘야하는 번거로움이 있다.

 

또한 Bytecode 를 수정했을 때 인지하고 있지 않은 또 다른 사이드 이펙트가 발생할 수 있다고 생각이 들어서 해당 방법은 사용하지 않기로 했다. 

 

해당 내용은 아래 글들을 참고하였고 기회가 된 다면 블로그에 정리할 예정이다.

docs.jboss.org/hibernate/orm/5.0/topical/html/bytecode/BytecodeEnhancement.html

 

Bytecode Enhancement

Ultimately all enhancement is handled by the org.hibernate.bytecode.enhance.spi.Enhancer class. Custom means to enhancement can certainly be crafted on top of Enhancer, but that is beyond the scope of this guide. Here we will focus on the means Hibernate a

docs.jboss.org

devdoc.net/javaweb/hibernate/Hibernate-5.1.0/userGuide/en-US/html/ch03.html

 

Chapter 3. Bytecode Enhancement

Bytecode enhancement is the process of manipulating the bytecode (.class) representation of a class for some purpose. This chapter explores Hibernate's ability to perform bytecode enhancement.

devdoc.net

costajlmpp.wordpress.com/2019/09/14/how-to-fix-onetoone-n1-with-manual-enhance/

 

How to Fix OneToOne N+1 with manual enhance

Problem We are using Hibernate higher than version 5.1.xWe have a @OneToOne bidirectional relationship with the N+1 problemWe can’t use the documented plugin hibernate enhance because for exa…

costajlmpp.wordpress.com

kwonnam.pe.kr/wiki/java/hibernate/lazy_to_one

 

java:hibernate:lazy_to_one [권남]

 

kwonnam.pe.kr

 

Comments