N+1 Problem

N+1 Problem

N + 1 문제란 연관관계가 설정된 엔티티를 조회할 때 연관된 엔티티를 조회하는 쿼리가 N + 1번 발생하는 문제임.

왜 발생할까?

  • JPA와 같은 ORM에서는 연관된 엔티티를 Proxy 객체로 Lazy Loading을 함.

    • Proxy 객체 : 실제 객체를 대신하여 사용자의 요청이 있을 때 실제 객체를 생성하여 반환하는 객체.

    • Lazy Loading : 연관된 엔티티를 조회하는 시점에 쿼리를 실행하여 조회하는 방식.

  • 지연로딩을 사용하는 이유?

    • 불필요한 데이터를 조회하지 않기 위함.

    • 일대다의 연관관계의 경우 많은 데이터를 한 번에 조회하게 되면 DB 성능에 문제가 발생 할 수 있음.

N + 1 예시 코드

아래와 같은 Entity가 있다고 가정할 때,

@Entity(name = "post")
data class Post (
    @field:Id
    @field:GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    val title: String,
    val content: String,
    val author: String,
    @field:OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
    val comments: List<Comment> = listOf()
)

/api/posts/{id} API를 호출하면 PostResponse를 1개 반환하고 이 API를 사용할 때 발생한 쿼리는 아래와 같음.

위 쿼리를 보면 Post를 조회하기 위한 쿼리가 1번 Comment를 조회하기 위한 쿼리가 1번 발생할 수 있음. 이를 N + 1 문제라고 함.

왜 문제가 될까?

예를 들어서 Post가 20개 정도있고 Comment가 2000개 정도 있다고 가정할 때, Post를 조회하는 API를 호출하면 20개의 Post를 조회하는 쿼리가 발생하고, 각 Post마다 Comment를 조회하는 쿼리가 20번 발생하게 됨. 이렇게 되면 총 40번의 쿼리가 발생하게 되어 DB에 부하가 걸리게 됨. 이러한 경우, DB의 성능이 충분하다면 단 한 번의 조인으로 모든 데이터를 조회하는 것이 효율적임.

해결 방법

🚨 fetchType.EAGER을 사용하면 안됨!!

    fun findAll(): List<PostResponse> {
        return postRepository.findAll().map{
            logger.info("Comment 조회 전")
            val result = PostConverter.toResponseUseRepository(it)
            logger.info("Comment 조회 후")
            result
        }
    }

현재 Comment가 객체탐색되는 코드는 위 코드에서 toResponseUseRepository 부분임. 그래서 해당 라인 앞 뒤로 log를 찍어보면 Lazy Loading에서는 아래와 같은 결과가 나옴.

하지만 EAGER로 설정하면 아래와 같은 결과가 나옴.

위와 같은 결과가 나오는 이유는 Post N개를 조회하는 상황에서는 Lazy와 Eager의 차이는 Comment가 조회되는 시점에 Comment를 조회하는지(Lazy), Post를 조회하는 시점에 Comment를 조회하는지(Eager)의 차이이기 때문임.

Post 1개를 조회하는 상황에선 FetchType.EAGER을 사용하면 N + 1문제가 발생하지 않고 Left Join이 걸림.

참조 PR : Post.comments fetchType Eager Lazy 비교

FetchJoin 사용

@Query Annotation을 사용해서 FetchJoin을 사용하는 방법.

    @Query("SELECT p FROM post p JOIN FETCH p.comments")
    fun findAllPostFetchJoinComments(): List<Post>
  • 👇 실제 발생 쿼리

Hibernate: select p1_0.id,p1_0.author,c1_0.post_id,c1_0.id,c1_0.author,c1_0.content,p1_0.content,p1_0.title from example.post p1_0 join example.comment c1_0 on p1_0.id=c1_0.post_id

BatchSize

  • Lazy Loading시 프록시 객체를 조회할 때 where in절로 묶어서 한번에 조회 할 수 있게 해주는 옵션.

  • yml에 전역 옵션으로 적용할 수 있고 @BatchSize를 통해 연관관계 BatchSize를 다르게 적용가능.

  • Batch Size 100~1000정도로 적용하고 DBMS에 따라서 where in 절은 1000까지 제한하는 경우가 있으므로 1000이상은 설정을 잘 하지않음.

참조 PR

EntityGraph 사용

  • @EntityGraph Annotation을 사용하면 EntityGraph의 로딩을 기본 Lazy에서 Eager로 변경할 수 있음.

  • EntityGraph를 사용하는 경우 outer left join을 수행하여 데이터를 가져옴.

    @EntityGraph(attributePaths = ["comments"])
    override fun findAll(): List<Post>

참조 PR

그 외 방법들

MyBatis나 QueryDSL 같은 QueryBuilder를 사용해서도 N + 1 문제를 해결할 수 있음.

결론

  • DB 성능을 고려하고 쿼리를 잘게 쪼갤 수 있는 방식인 @BatchSize Annotation을 사용하는 방식이 적절하다고 느껴짐.

  • 1:1 관계의 경우에서는 FetchType.EAGER을 사용해도 큰 문제가 없음.

  • N + 1 문제가 과연 문제가 맞는지?도 고려해봐야 할 대상임.

Referenec

Last updated