개발기록

N+1 문제와 해결 방법: Kotlin과 Spring Data JPA 본문

Spring

N+1 문제와 해결 방법: Kotlin과 Spring Data JPA

Danuvibe 2024. 9. 13. 15:08

N+1 문제란?

N+1 문제는 ORM(Object-Relational Mapping)을 사용할 때 자주 발생하는 성능 이슈입니다. 이 문제는 데이터베이스에서 데이터를 조회할 때, 의도치 않게 많은 수의 추가 쿼리가 발생하는 현상을 말합니다.

  • N+1 이름의 유래:
    • 1: 부모 엔티티를 조회하는 첫 번째 쿼리
    • N: 각 부모 엔티티와 연관된 자식 엔티티를 조회하는 N번의 추가 쿼리

이 문제는 애플리케이션의 성능을 크게 저하시키고, 데이터베이스에 불필요한 부하를 줄 수 있습니다.

N+1 문제 예시

게시글(Post)과 댓글(Comment) 엔티티가 있는 블로그 시스템을 예로 들어보겠습니다.

@Entity
class Post(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    val title: String,
    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
    val comments: List<Comment> = emptyList()
)

@Entity
class Comment(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    val content: String,
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    val post: Post
)

interface PostRepository : JpaRepository<Post, Long>

@Service
class PostService(private val postRepository: PostRepository) {
    fun findAllPostsWithComments(): List<Post> {
        val posts = postRepository.findAll()
        posts.forEach { post ->
            post.comments.size // 이 부분에서 N+1 문제 발생
        }
        return posts
    }
}

이 코드에서 findAllPostsWithComments() 메소드를 호출하면 N+1 문제가 발생합니다:

  1. postRepository.findAll()로 모든 게시글을 가져옵니다. (1번의 쿼리)
  2. 각 게시글에 대해 post.comments.size를 호출할 때, 해당 게시글의 댓글을 로딩하기 위해 추가 쿼리가 발생합니다. (N번의 쿼리)

N+1 문제 해결 방법

Fetch Join 사용

Fetch Join을 사용하면 연관 엔티티를 함께 조회할 수 있습니다.

interface PostRepository : JpaRepository<Post, Long> {
    @Query("SELECT p FROM Post p LEFT JOIN FETCH p.comments")
    fun findAllWithComments(): List<Post>
}

@Service
class PostService(private val postRepository: PostRepository) {
    fun findAllPostsWithComments(): List<Post> {
        return postRepository.findAllWithComments()
    }
}

이 방법은 한 번의 쿼리로 모든 게시글과 그에 연관된 댓글을 함께 가져옵니다.

@EntityGraph 사용

@EntityGraph를 사용하여 연관 엔티티를 함께 로딩하도록 JPA에 지시할 수 있습니다.

interface PostRepository : JpaRepository<Post, Long> {
    @EntityGraph(attributePaths = ["comments"])
    override fun findAll(): List<Post>
}

@Service
class PostService(private val postRepository: PostRepository) {
    fun findAllPostsWithComments(): List<Post> {
        return postRepository.findAll()
    }
}

배치 사이즈 설정

@BatchSize를 사용하여 지정된 크기만큼의 연관 엔티티를 한 번에 로딩할 수 있습니다.

import org.hibernate.annotations.BatchSize

@Entity
class Post(
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long = 0,
    val title: String,
    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
    @BatchSize(size = 20)
    val comments: List<Comment> = emptyList()
)

@Service
class PostService(private val postRepository: PostRepository) {
    fun findAllPostsWithComments(): List<Post> {
        val posts = postRepository.findAll()
        posts.forEach { post ->
            post.comments.size // 이제 배치 크기만큼 한 번에 로딩됩니다.
        }
        return posts
    }
}

이 방법은 N+1 문제를 완전히 해결하지는 않지만, 쿼리 수를 크게 줄일 수 있습니다.

결론

N+1 문제는 ORM을 사용할 때 흔히 발생하는 성능 이슈입니다. Fetch Join, @EntityGraph, 배치 사이즈 설정 등의 방법으로 이 문제를 해결하거나 완화할 수 있습니다. 각 방법에는 장단점이 있으므로, 상황에 따라 적절한 방법을 선택해야 합니다.

성능 최적화 시 항상 실제 성능 향상을 측정하고, 애플리케이션의 요구사항에 맞는지 확인하는 것이 중요합니다. 또한, 데이터베이스 인덱싱, 쿼리 최적화 등 다른 성능 개선 기법들과 함께 사용하면 더 나은 결과를 얻을 수 있습니다.

Comments