ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JPA N+1 문제 해결 일지 - 답은 JPQL에 있었다.
    📖 개발 공부 2024. 3. 3. 11:22

    프로젝트 개발하면서 JPA에서 N+1 문제를 발견하여 원인과 해결한 방법들을 공유하고자 한다!

     

    다음과 같은 Candidate 엔티티가 존재한다.

    @Entity
    @Table(name = "candidate")
    class CandidateEntity(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        var id: Long? = null,
        @Enumerated(EnumType.STRING)
        var sourceType: SourceType, // 출처 소스 타입
        var sourceId: String, // 출처 소스의 id
        var createdAt: LocalDateTime, // 생성일
        @OneToOne(cascade = [CascadeType.ALL])
        @JoinColumn(name = "place_id", referencedColumnName = "id", insertable = true, updatable = true)
        var place: PlaceEntity?,
        @OneToOne(cascade = [CascadeType.ALL])
        @JoinColumn(name = "meta_id", referencedColumnName = "id", insertable = true, updatable = true)
        var meta: MetaEntity?,
     )

     

     이 Candidate 엔티티는 PlaceEntity와 MetaEntity를 일대일 관계로 가지고 있다.

     

    나는 특정 (sourceType, sourceId) 쌍을 통해 해당 엔티티를 조회하려고 한다. (findBySourceTypeAndSourceId)

     

    이때 OneToOne 관계인 PlaceEntity, MetaEntity를 보자!

    OneToOne 관계이므로 나는 즉시 로딩(Eager)으로 함께 조인되어 가져올 줄 알았다.

     

    하지만,, 해당 쿼리로 엔티티를 조회할 때 N초 이상이 걸려서 의아했다. 그것도 조회하는 CandidateEntity 수가 많아질수록 느려졌다.

    N+1 조회 문제임을 직감했다.

     

    나는 요렇게 JPA 에서 제공하는 메서드로 조회했다.

    interface CandidateJpaRepository : JpaRepository<CandidateEntity, Long> {
        fun findAllBySourceTypeAndSourceIdOrderByIdDesc(source: SourceType, sourceId: String): List<CandidateEntity>
    }
    

     

    SQL 쿼리의 실행 계획을 확인해보니

    같은 sourceType, sourceId를 가진 Candidate이 5개 있는 경우, 11개 쿼리가 나갔다.

     

    5개의 candidate을 조회하는 쿼리 1개, 5개의 place, meta 엔티티 각각을 불러오는 쿼리 10개

    무려 2N + 1 문제였당ㅋㅋ..

    나는 당연히 즉시로딩이니 조인해서 한번에 조회하는 건줄 알았는데! 아니었다…

     

    왜 추가 쿼리가 나가는 것일까?

    답은 바로 JPQL에 있었다!!!

     

    findAllBySourceTypeAndSourceIdOrderByIdDesc 대신 테스트로 findById를 호출해보겠다.

    override fun findAllBySource(source: CandidateModel.Source): List<CandidateModel> {
    //        return candidateJpaRepository.findAllBySourceTypeAndSourceIdOrderByIdDesc(source.type, source.id).map { it.toModel() }
            return listOf(candidateJpaRepository.findById(339).get().toModel())

     

    findById - inner join 쿼리

    2024-03-03T01:11:13.643+09:00 DEBUG 90868 --- [  XNIO-1 task-2] org.hibernate.SQL                        : 
        select
            c1_0.id,
            ...
        from
            candidate c1_0 
        left join
            meta m1_0 
                on m1_0.id=c1_0.meta_id 
        left join
            place p2_0 
                on p2_0.id=c1_0.place_id 
        where
            c1_0.source_type=? 
            and c1_0.source_id=? 
        order by
            c1_0.id desc

     

     

    이렇게 findById를 호출하니 내부적으로 inner join문 하나가 날아가서 Place와 Meta 엔티티가 동시에 조회되는 것을 알 수 있다. entityManager.find(); 같은 경우 jpa가 내부적으로 join문에 대한 쿼리를 만들어서 반환을 하여 N+1 문제가 발생하지 않는다.

     

    더보기

    findById 내부 구현 메서드

     

        public Optional<T> findById(ID id) {
            Assert.notNull(id, "The given id must not be null");
            Class<T> domainType = this.getDomainClass();
            if (this.metadata == null) {
                return Optional.ofNullable(this.em.find(domainType, id));
            } else {
                LockModeType type = this.metadata.getLockModeType();
                Map<String, Object> hints = this.getHints();
                return Optional.ofNullable(type == null ? this.em.find(domainType, id, hints) : this.em.find(domainType, id, type, hints));
            }
        }

     

    여기서 findById → findAllById로만 바꿔서 나가는 쿼리문을 확인해보자.

    다음과 같이 N+1 문제가 발생하는 것을 알 수 있다.

     

    N+1 문제의 SQL 쿼리

    2024-03-03T01:15:24.642+09:00 DEBUG 92499 --- [  XNIO-1 task-2] org.hibernate.SQL                        : 
        select
            c1_0.id,
            ...
        from
            candidate c1_0 
        where
            c1_0.id in (?,?,?,?,?)
    2024-03-03T01:15:24.694+09:00 DEBUG 92499 --- [  XNIO-1 task-2] org.hibernate.SQL                        : 
        select
            m1_0.id,
            ...
        from
            meta m1_0 
        where
            m1_0.id=?
    2024-03-03T01:15:24.710+09:00 DEBUG 92499 --- [  XNIO-1 task-2] org.hibernate.SQL                        : 
        select
            p1_0.id,
            ...
        from
            place p1_0 
        where
            p1_0.id=?
    2024-03-03T01:15:25.292+09:00 DEBUG 92499 --- [  XNIO-1 task-2] org.hibernate.SQL                        : 
        select
            m1_0.id,
            ... 
        from
            meta m1_0 
        where
            m1_0.id=?
    2024-03-03T01:15:25.309+09:00 DEBUG 92499 --- [  XNIO-1 task-2] org.hibernate.SQL                        :
    ...

     

    우리는 findById 뿐만 아니라 직접 JPQL을 짜기도 하고, data jpa에서 findBy~(위의 예시로 findAllById)의 쿼리메소드 같은 경우를 사용하기도 한다. 이때 모두 data jpa 내부에서 JPQL이 만들어진다.

     

    JPQL에서 N+1 문제가 발생하는 과정을 보자.

     

    1. 조건에 만족하는 Candidate 리스트 N개를 조회 (쿼리 수: 1개)
    2. Candidate에 즉시 로딩이 걸려있음을 감지
    3. 각각의 Candidate이 가진 Place와 Meta 엔티티를 각각 조회한다. (쿼리 수: N * 2)

    이렇게 해서 결국 N+1 문제가 발생한다.

     


     

    이제 N+1 문제를 해결법을 살펴보장!

     

    1) Fetch Join

    @Query(
        """
        SELECT c 
        FROM CandidateEntity c 
        **LEFT JOIN FETCH c.place  
        LEFT JOIN FETCH c.meta** 
        WHERE c.source_type = :sourceType 
          AND c.sourceId = :sourceId 
        ORDER by p.id DESC 
    """)
    fun findBySourceOrderByIdDesc(sourceType: sourceType, sourceId: String): List<CandidateEntity>
    

     

    JPQL문을 작성할 때 Fetch Join을 넣는 방법이 있다. 이는 jpaRepository에서 제공해주는 것은 아니고 JPQL로 작성해야 한다.

    요렇게 하면 하나의 쿼리로 조회할 수 있다.

     

    N+1 문제가 해결된 SQL 쿼리

    2024-03-03T01:37:54.459+09:00 DEBUG 2865 --- [  XNIO-1 task-2] org.hibernate.SQL                        : 
        select
            c1_0.id,
            ...
        from
            candidate c1_0 
        left join
            meta m1_0 
                on m1_0.id=c1_0.meta_id 
        left join
            place p2_0 
                on p2_0.id=c1_0.place_id 
        where
            c1_0.source_type=? 
            and c1_0.source_id=? 
        order by
            c1_0.id desc

     

     

     

    2) Entity Graph

     

    JPQL에서 Fetch Join을 하면 하드코딩으로 SQL문을 작성하게 된다는 단점이 있다.

     

    이 단점을 해소하기 위해 @EntityGraph를 사용하면 된다.

    @EntityGraph(attributePaths = ["place", "meta"], type = EntityGraphType.FETCH) // default: EntityGraphType.FETCH
    fun findAllBySourceTypeAndSourceIdOrderByIdDesc(sourceType: sourceType, sourceId: String): List<CandidateEntity>

     

    N+1 문제가 해결된 SQL 쿼리

    2024-03-03T01:40:35.625+09:00 DEBUG 4161 --- [  XNIO-1 task-2] org.hibernate.SQL                        : 
        select
            c1_0.id,
            ...
        from
            candidate c1_0 
        left join
            meta m1_0 
                on m1_0.id=c1_0.meta_id 
        left join
            place p2_0 
                on p2_0.id=c1_0.place_id 
        where
            c1_0.source_type=? 
            and c1_0.source_id=? 
        order by
            c1_0.id desc

     

     


    이 문제를 해결하려고 많은 사이트를 방문했는데, EAGER Fetch 방식이 JPQL 때문에 N+1 문제를 일으킬 수 있다는 것을 알았다. 

    잘 알지 못한 채 JPA를 적용하면 많은 성능 문제를 야기할 수 있겠구나 라는 생각이 들었다!!

     

    코드를 바꿔가면서 나가는 SQL 쿼리들 직접 확인하면서 해결하는 과정이 재밌었당ㅎㅎ

     

    반응형

    댓글

Designed by Tistory.