2023-04-19 @이영훈
N+1 문제란
N+1 문제는 데이터베이스와 연관된 애플리케이션에서 발생하는 성능 문제 중 하나입니다.
이 문제는 한 개의 쿼리로 가져올 수 있는 데이터를 여러 번의 쿼리로 가져오게 되면서 발생합니다.
특히 객체 관계 매핑(Object-Relational Mapping, ORM) 기술을 사용하는 경우 더 자주 발생할 수 있습니다.
N+1 문제가 발생하는 상황
예를 들어, 게시글(Post)과 댓글(Comment)을 조회하는 웹 애플리케이션이 있다고 가정합니다.
Post와 Comment의 Entity입니다.
@Entity
class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
var title: String? = null
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
var comments: MutableList<Comment> = ArrayList()
}
@Entity
class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
var content: String? = null
@ManyToOne(fetch = FetchType.LAZY)
var post: Post? = null
}
Kotlin
복사
Reository와 Service 로직입니다.
@Repository
interface PostRepository : JpaRepository<Post, Long>
@Service
class PostService(private val postRepository: PostRepository) {
@Transactional(readOnly = true)
fun findAllPostsWithComments(): List<Post> {
val posts = postRepository.findAll()
// N+1 문제 발생
posts.forEach { post ->
val comments = post.comments
println("게시글: ${post.title}")
comments.forEach { comment ->
println("댓글: ${comment.content}")
}
}
return posts
}
}
Kotlin
복사
위 코드에서 N+1 문제가 발생할 수 있습니다. 예를 들어, 100개의 게시글이 있다고 가정하면, PostRepository의 findAll() 메서드가 호출될 때 모든 게시글을 조회합니다.
이때, 각 게시글의 댓글을 조회하기 위해 추가로 100번의 쿼리가 실행됩니다. 이는 총 101번의 쿼리 실행으로 이어지며, 매우 비효율적인 방법입니다.
즉, 다음과 같이 SQL이 실행되면서 N+1 문제가 발생한 것을 알 수 있습니다.
SELECT * FROM post;
SQL
복사
SELECT * FROM comment WHERE post_id = 1;
SELECT * FROM comment WHERE post_id = 2;
SELECT * FROM comment WHERE post_id = 3;
...
SELECT * FROM comment WHERE post_id = 99;
SELECT * FROM comment WHERE post_id = 100;
SQL
복사
N+1 문제를 해결하는 방법
1.
Fetch Join: 연관된 엔티티를 쿼리 시 함께 조회하는 방법입니다. JPQL(Java Persistence Query Language)이나 Criteria API를 사용하여 작성할 수 있습니다.
예를 들어, JPQL에서는 다음과 같이 작성할 수 있습니다.
val jpql = "SELECT p FROM Post p JOIN FETCH p.comments" // ✅ fetch join 사용
val postsWithComments = entityManager.createQuery(jpql, Post::class.java).resultList
Kotlin
복사
2.
Batch Size: @BatchSize 어노테이션을 사용하여 한 번의 쿼리로 여러 개의 연관된 엔티티를 조회하는 방법입니다. 이를 통해 쿼리 수를 줄일 수 있습니다.
예를 들어, Post 엔티티에서 comments에 @BatchSize를 설정하면 다음과 같습니다.
@Entity
class Post(
// ... 생략 ...
@BatchSize(size = 10)
@OneToMany(mappedBy = "post")
val comments: MutableList<Comment> = ArrayList()
)
Kotlin
복사
@BatchSize 는 지정된 사이즈만큼 데이터를 한번에 조회합니다. 예를 들어, @BatchSize(10)
이 지정된 경우, 10개의 데이터를 한번에 조회하여 성능 향상을 기대할 수 있습니다.
다음과 같이 IN 절로 한 번에 여러 게시글의 댓글들을 가져오는 것을 확인할 수 있습니다.
SELECT * FROM post;
SELECT * FROM comment WHERE post_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
SQL
복사
3.
Entity Graph: 그래프 기능을 사용하여 필요한 연관된 엔티티를 한 번의 쿼리로 조회하는 방법입니다.
예를 들어, @NamedEntityGraph를 사용하여 Post와 Comment를 함께 조회하는 방법은 다음과 같습니다.
@Entity
@NamedEntityGraph(
name = "post-with-comments",
attributeNodes = [NamedAttributeNode("comments")]
)
class Post(
// ... 생략 ...
@OneToMany(mappedBy = "post")
val comments: MutableList<Comment> = ArrayList()
)
Kotlin
복사
그런 다음, post-with-comments를 사용하여 게시글과 댓글을 함께 조회할 수 있습니다.
val entityGraph = entityManager.getEntityGraph("post-with-comments")
val postsWithComments = entityManager.createQuery("SELECT p FROM Post p", Post::class.java)
.setHint("javax.persistence.fetchgraph", entityGraph)
.resultList
Kotlin
복사
4.
Querydsl: Querydsl은 타입 세이프한 쿼리를 작성할 수 있는 프레임워크로, JPA와 함께 사용할 수 있습니다. 이를 이용해 조인을 사용하여 연관된 엔티티를 함께 조회하는 방법입니다.
예를 들어, 게시글과 댓글을 함께 조회하는 Querydsl 쿼리를 작성하면 다음과 같습니다.
import com.querydsl.jpa.impl.JPAQueryFactory
@Service
class PostService(
private val queryFactory: JPAQueryFactory
) {
@Transactional(readOnly = true)
fun findAllPostsWithComments(): List<Post> {
val QPost = QPost.post
val QComment = QComment.comment
val postsWithComments = queryFactory
.selectFrom(QPost)
.leftJoin(QPost.comments, QComment).fetchJoin() // ✅ fetch join 사용
.fetch()
// ... 생략 ...
return postsWithComments
}
}
Kotlin
복사