본문 바로가기

Project

Join Fetch 를 사용하여 N+1 문제 해결하기

안녕하세요. 낮에 포스팅을 하나 했는데 프로젝트 둘러보다가 운이 좋게도(?) N+1 문제를 발견하여 해결하는 간단한 과정을 보여드리고자 작성을 시작합니다. N+1 문제에 대한 자세한 내용은 따로 포스팅으로 작성해놓았으니 읽어주시면 감사하겠습니다.

 

사실 제가 프로젝트 시작부터 코드를 제대로 작성하지 않아서 발생한 게 큰 부분이지만, "읽어주시는 분들에게 정보제공을 위해 일부러 이상하게 작성했다"라고 믿어주시고 봐주셨으면 좋겠습니다! 😅

 

 

현재 상황

필터를 통해 로그인 된 유저가 요청을 보내면 위 사진과 같이 게시글이 리스트로 반환되고 있습니다.

 

...그런데 서버 측에서 로그를 보니 다음 사진과 같이 쿼리가 나가고 있더라고요..😂

Hibernate가 세개...?

 

 

보여드리기 부끄럽긴 한데 그래도 어쩔 수 없습니다. 쿼리가 왜 저렇게 나오는걸까요..?

 

1. 첫 번째 쿼리

일단 첫번째 쿼리는 Spring Security를 사용하고 있어서 Filter 단에서 로그인된 유저의 정보를 검증하는 데에 쿼리가 한번 날아가게 됩니다.

 

구현해 놓은 이 부분에서 첫 번째로 요청을 보내면서 쿼리가 발생하게 되는것 입니다. 이 내용은 추후에 Redis 에 로그인된 유저 정보를 저장하는 방식으로 개선하거나 로컬 캐시 등을 활용해서 처음 이후 쿼리를 보내지 않게 개선해보도록 하겠습니다. 그럼 첫번째 쿼리는 넘어가겠습니다.

 

2. 두 번째 쿼리

    @Transactional
    fun getMyPosts(userDetails: UserDetailsImpl): List<PostResponse> {
        // PostRepository를 사용하여 userId로 게시물 조회
        val posts = postRepository.findByUserId(userDetails.getUser().id!!)

        return posts.map {
            PostResponse(
                id = it.id!!,
                title = it.title,
                content = it.content,
                createdAt = it.createdAt,
                modifiedAt = it.modifiedAt,
                nickname = it.user.nickname
            )
        }
    }

 

내가 쓴 전체 게시글을 불러오는 기능에서 postRepository에서 DB에 쿼리를 한번 보내게 됩니다. 이 행동이 목표니 두 번째로 발생했던 쿼리가 메인이었던 것입니다.

 

3. 세 번째 쿼리

@Entity
@Table(name = "posts")
class Post(
    @Column(nullable = false)
    var title: String,
    @Column(nullable = false)
    var content: String,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_Id")
    var user: User
) : Timestamped() {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null

    constructor() : this("", "", User())

    fun updateContent(newContent: String) {
        this.content = newContent
        this.modifiedAt = LocalDateTime.now()
    }
}

 

@Entity
@Table(name = "users")
class User(
    //기본  필드 생략
) : Timestamped() {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null

    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true)
    val posts: List<Post> = mutableListOf()

    // JPA 를 위한 public 기본 생성자
    constructor() : this("", "", "", UserRole.USER, "")

    // 메서드 생략
}

 

Post와 User Entity입니다.

 

현재 `User` 엔티티에서 posts 컬렉션에 접근할 때 세 번째 쿼리가 발생하게 됩니다. 

 

User 엔티티에서 posts를 Lazy 로딩으로 설정해 두었기 때문에, user.posts 에 접근할 때 실제  데이터베이스에서 User와 관련된 Post들을 로딩하려고 시도하는 시점에서 추가적인 쿼리가 발생하고 있습니다.

 

이 쿼리가 발생한 이유는 `Post` 엔티티에 user 정보가 또 Lazy 로딩으로 설정되어 있기 때문입니다. 

 

즉, Post를 조회하고 나서 User 와 관련된 데이터를 실제로 로딩할 때 쿼리가 발생합니다. 좀 복잡하군요.

 

해결방법은?!

위에 하이퍼링크로 달아놓은 글에서 다양한 방법이 작성되어 있었지만 이번 포스팅에서는 `Join Fetch` 방법으로 해결해보려고 합니다.

 

Lazy 로딩 문제를 해결하려면, User 와 Post 를 함께 조회할 수 있도록 쿼리를 설계하면 됩니다.

interface PostRepository : JpaRepository<Post, Long> {

    @Query("SELECT p FROM Post p JOIN FETCH p.user WHERE p.user.id = :userId")
    fun findByUserId(userId: Long): List<Post>
}

 

기존에 작성되어 있던 findByUserId 메서드 상단에 @Query 어노테이션을 추가하여 JOIN FETCH 부분으로 한 번에 두 개다 조회합니다.

 

이렇게 하면 3개로 날아가던 쿼리가 2개로 줄었습니다. postRepository에 조회 쿼리를 보낼 때, user 도 같이 가져오기 때문입니다.

 

쿼리에서 select 되는 부분이 길어져서 비효율적인 거 아닌가?라는 생각이 드실 수 있지만, 실제 DB에 접근하는 횟수를 줄여 DB문을 두드리는 것을 줄이는 게 훨씬 효율적이기 때문에 이 방식이 선호됩니다.

 

이렇게 JOIN FETCH 방식으로 N+1 문제를 해결하는 포스팅을 작성해 보았습니다. 백엔드 개발자분들이라면 틈틈이 꼭 어떤 쿼리가 발생하고 있는지 확인하시길 바랍니다!!

 

다음 포스팅은 캐시나 Redis 서버에 로그인된 정보를 저장해 두었다가 DB까지 도달하지 않고 정보를 처리하여 쿼리를 최소한으로 보내게 개선해 보겠습니다.

 

읽어주셔서 감사합니다!!

728x90
반응형