안녕하세요. 낮에 포스팅을 하나 했는데 프로젝트 둘러보다가 운이 좋게도(?) 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까지 도달하지 않고 정보를 처리하여 쿼리를 최소한으로 보내게 개선해 보겠습니다.
읽어주셔서 감사합니다!!
'Project' 카테고리의 다른 글
[트러블 슈팅]백엔드개발자가 프론트엔드와 협업하기 (0) | 2025.05.11 |
---|---|
[트러블 슈팅] 인증/인가 캐싱처리로 DB접근 줄이기 (4) | 2025.05.05 |
현재 프로젝트에 CI 파이프라인 적용하기! (1) | 2025.05.03 |
[코틀린 프로젝트] - 새로운 프로젝트 구상(4/13~4/26) (2) | 2025.04.27 |
[프로젝트] 식당 예약 및 웨이팅 플랫폼 개발 - 주제선정 (2) | 2024.12.11 |