[Spring Boot]1:N 관계에서의 N+1 문제 해결

한상욱·2024년 5월 28일
0

Spring Boot

목록 보기
9/19
post-thumbnail

들어가며

이 글은 Spring Boot를 공부하며 정리한 글입니다.

N+1 문제

entity가 1:N 관계가 설정되었을 때, 해당 데이터를 조회하게 되는 경우 하위 데이터까지 모두 조회할 수 있습니다. 어떻게 모든 하위 데이터를 조회하게 될까요?

여기, 이전에 구현한 게시판 서비스 예제를 통해서 알아보도록 하겠습니다. 게시글과 댓글은 서로 1:N 관계를 형성하고 있습니다.

package com.example.msyql_example.post.entity

import com.example.msyql_example.post.dto.PostResponseDto
import jakarta.persistence.*

@Entity
class Post (
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    val id : Long?,

    @Column(nullable = false, length = 100)
    var title : String,

    @Column(nullable = false, length = 2000)
    var post : String,

    @Column(nullable = false, length = 50)
    var userId : Long,

    @Column(nullable = false, length = 10)
    var isPublic : Boolean = true
) {
	
    // 1:N 관계 지정
    @OneToMany(fetch = FetchType.LAZY, mappedBy = "post", cascade = [CascadeType.ALL])
    var comments : List<Comment>? = null

    fun toResponse() : PostResponseDto = PostResponseDto(
        id = id,
        title = title,
        post = post,
        userId = userId,
        isPublic = isPublic,
        comments = comments?.map { it.toResponse() }
    )
}

@Entity
class Comment(

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    val id : Long?,

    @Column(nullable = false, length = 1000)
    var content : String,
	
    //N:1 관계 지정
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(foreignKey = ForeignKey(name = "fk_comment_post_id"))
    val post : Post,
) {
    fun toResponse() : CommentResponseDto = CommentResponseDto(
        id = id,
        content = content
    )
}

이제, 해당 게시글을 조회해보도록 하겠습니다. 그리고
스프링부트 프로젝트의 터미널에는 해당 메소드에 대한 SQL로그를 확인할 수 있습니다. 이를 통해 조회쿼리를 파악하겠습니다.

기존에 데이터에서 하위 데이터 조회를 위해 추가적인 쿼리가 발생하고 있습니다. 만약, 여기서 게시글 데이터가 추가되면 해당하는 댓글 데이터를 조회하기 위한 추가적인 쿼리가 발생하게 됩니다. 이는 곧 한번의 조회에 데이터의 갯수 N에 따라서 추가적인 쿼리가 발생함을 의미합니다. 이것을 N+1 문제라고 합니다.

쿼리가 많이 발생하면 뭐가 문제일까요? 바로 성능 문제가 발생하게 됩니다. 쿼리의 양이 많을 수록 그만큼 응답속도에 영향을 주게 됩니다. N의 크기가 커지면서 점점 응답속도가 느려지게 되겠죠.

N+1문제 해결방법

  1. Batch Size
@Entity
class Post (
	...
	
    // 배치 사이즈 지정
    @BatchSize(size = 100)
    @OneToMany(fetch = FetchType.LAZY, mappedBy = "post", cascade = [CascadeType.ALL])
    var comments : List<Comment>? = null

    fun toResponse() : PostResponseDto = PostResponseDto(
        id = id,
        title = title,
        post = post,
        userId = userId,
        isPublic = isPublic,
        comments = comments?.map { it.toResponse() }
    )
}

N+1 문제를 해결하기 위한 방법은 Batch Size 지정이 있습니다. Batch Size를 지정하게 되면 WHERE 절안에 IN을 통해서 해당 데이터를 조회하게 됩니다. IN은 PK를 통해 데이터를 조회하기 때문에 성능적으로 뛰어납니다. 다만, 사이즈를 넘게 되면 그 이후에 데이터는 조회하지 못합니다.

이제 사이즈 만큼 IN을 통해 해당 댓글을 조회하게 었고, 쿼리 역시도 줄어들은 것을 확인할 수 있습니다.

  1. JOIN FETCH 이용
interface PostRepository : JpaRepository<Post, Long> {
    fun findByUserId(id : Long) : Post

    @Query(value = "SELECT p FROM Post p LEFT JOIN FETCH p.comments")
    fun findAllByFetchJoin() : List<Post>
}

데이터 조회를 위해서는 findAll() 메소드를 이용하고 있었습니다. 이를, 쿼리를 통해서 새로운 메소드를 만들어 낼 수 있는데, 여기서 JOIN FETCH를 사용하는 것입니다. JOIN 쿼리의 경우 default값이 INNER JOIN을 실행하기 때문에 LEFT JOIN을 사용하여 댓글이 없어도 모두 JOIN하도록 지정하였습니다.

이를 통하면 SELECT 쿼리 한번으로 모든 데이터를 조회할 수 있습니다. 다만, @Query를 통해 SQL을 작성할 경우 오타로 인한 에러가 발생할 수 있고, 쿼리를 잘 알지 못한다면 작성하기 어렵다는 단점이 있습니다.

profile
자기주도적, 지속 성장하는 모바일앱 개발자가 되기 위해

0개의 댓글