문제 상황: 3N+1 문제와 성능 저하
개발 중에 다음과 같은 문제가 발생했습니다. 하나의 게시물 목록을 조회하는 코드에서, 3N+1 문제가 발생하면서 다수의 추가적인 쿼리가 나가고 있었습니다.
@Transactional(readOnly = true)
public PostsReadPageRespDto readPosts(final int page, final int limit) {
final Page<Post> posts = postRepository.findAll(
PageRequest.of(page, limit, Sort.by(Sort.Direction.DESC, "lastModifiedAt")));
final Page<PostsReadRespDto> postsResponse = posts.map(
post -> {
final int likeCount = likeRepository.countByPost(post);
final int commentCount = commentRepository.countByPost(post);
final int retweetCount = retweetRepository.countByPost(post);
return PostsReadRespDto.from(post.getUser(), post, likeCount, commentCount, retweetCount);
});
return PostsReadPageRespDto.of(postsResponse);
}
위 코드에서는 각 게시물에 대한 좋아요, 댓글, 리트윗 개수를 가져오기 위해 매번 반복적으로 쿼리를 실행하게 됩니다. 예를 들어, 10개의 게시물을 조회할 때, 각 게시물마다 3개의 추가 쿼리(좋아요, 댓글, 리트윗)가 발생하여 총 31개의 쿼리가 실행됩니다. 3N+1 문제가 발생하였습니다.
또한, post.getUser()는 Post 엔티티와 연관된 User 엔티티를 가져오기 위한 메서드로, User가 지연 로딩(Lazy Loading) 설정된 경우 처음에는 프록시 객체로 로드되다가, 데이터 접근 시점에 추가 쿼리가 발생해 실제 데이터를 가져옵니다. 이 과정에서 불필요한 추가 쿼리가 발생하여 성능이 저하될 수 있습니다.
원인 분석: 3N+1 문제와 프록시 강제 초기화
- 3N+1 문제: 각 게시물에 대해 좋아요 수, 댓글 수, 리트윗 수를 조회하기 위해 매번 반복적으로 추가적인 쿼리가 실행되며, 게시물 수가 늘어날수록 쿼리 수가 기하급수적으로 증가해 성능이 크게 저하됩니다.
- 프록시 강제 초기화: Hibernate의 Lazy Loading 설정으로 인해 User와 같은 연관 엔티티는 처음에는 프록시 객체로 로드됩니다. 그러나 post.getUser()와 같은 메서드 호출로 연관된 데이터를 조회할 때, Hibernate가 추가 쿼리를 실행해 프록시 객체를 실제 데이터로 초기화하면서 성능 저하가 발생합니다.
해결 방법: 프로젝션을 사용한 쿼리 최적화
이 문제를 해결하기 위해 프로젝션을 사용하여 필요한 데이터만 한 번에 조회하는 쿼리로 변경하였습니다. 이를 통해 추가적인 쿼리 발생을 방지하고 성능을 크게 향상시킬 수 있었습니다.
@Query("select u.nickname as userNickname, u.profileImg as userProfileImg, " +
"p.content as content, p.lastModifiedAt as modifiedAt, " +
"count(l.id) as likeCount, count(c.id) as commentCount, count(r.id) as retweetCount " +
"from Post p " +
"left join p.user u " +
"left join Like l on l.post.id = p.id " +
"left join Comment c on c.post.id = p.id " +
"left join Retweet r on r.post.id = p.id " +
"group by p.id, u.nickname, u.profileImg, p.content, p.lastModifiedAt " +
"order by p.lastModifiedAt desc")
Page<PostWithDetails> findAllWithDetails(Pageable pageable);
위 쿼리는 각 게시물에 대한 사용자 정보, 좋아요 수, 댓글 수, 리트윗 수를 한 번의 쿼리로 모두 가져올 수 있도록 최적화했습니다. 또한, 이 과정에서 필요한 필드만 선택적으로 조회하도록 설정하여 불필요한 프록시 초기화를 방지했습니다.
프로젝션을 통한 성능 최적화
- 프로젝션(Projection)은 JPA에서 엔티티 전체를 로드하지 않고 필요한 필드만 선택적으로 조회하는 방법입니다. 이를 통해 성능을 크게 향상시킬 수 있으며, 프록시 초기화를 방지하여 추가적인 쿼리 실행도 줄일 수 있습니다.
아래는 프로젝션을 위해 정의한 인터페이스입니다.
public interface PostWithDetails {
String getUserNickname();
String getUserProfileImg();
String getContent();
LocalDateTime getModifiedAt();
int getLikeCount();
int getCommentCount();
int getRetweetCount();
}
이러한 방식을 통해 Hibernate가 불필요하게 프록시를 초기화하지 않고도 필요한 데이터를 효율적으로 가져올 수 있었습니다.
최적화된 쿼리 실행 결과
최적화된 쿼리는 다음과 같이 한 번의 쿼리로 모든 데이터를 가져옵니다.
Hibernate:
select
count(p1_0.id)
from
posts p1_0
left join
users u1_0
on u1_0.id=p1_0.user_id
left join
likes l1_0
on l1_0.post_id=p1_0.id
left join
comments c1_0
on c1_0.post_id=p1_0.id
left join
retweets r1_0
on r1_0.post_id=p1_0.id
group by
p1_0.id,
u1_0.nickname,
u1_0.profile_img,
p1_0.content,
p1_0.last_modified_at
'WEB' 카테고리의 다른 글
회원가입 후 JWT 응답 제거 (1) | 2024.10.30 |
---|---|
날씨 API 사용과 리팩토링 (0) | 2024.10.15 |
JPA Update 실패 해결기 (0) | 2024.10.10 |
소프트 딜리트란? (1) | 2024.09.27 |
Spring Data JPA로 된 코드를 JDBC로 다시 짜보기 (1) | 2024.09.25 |
MDC를 이용한 로깅 도입기 (0) | 2024.09.23 |
@SpringBootTest vs @Mock (0) | 2024.09.12 |
스프링부트의 Tomcat과 Thread Pool (0) | 2024.09.10 |