일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 |
Tags
- jpa
- nosql
- 알고리즘
- DFS
- 운영체제
- 백준
- Docker
- BFS
- 트러블슈팅
- Data structure
- HTML
- 자료구조
- PYTHON
- It
- db
- Algorithm
- java
- 완전탐색
- redis
- CS
- 트랜잭션
- websocket
- 데이터베이스
- javascript
- 프로그래머스
- 영속성 컨텍스트
- CSS
- mysql
- OS
- spring
Archives
- Today
- Total
If at first you don't succeed, try again
[트러블슈팅] 커서 기반 무한 스크롤의 댓글 수, 좋아요 수를 조회하는 쿼리에서 발생했던 성능 문제(feat. N+1) 본문
* 문제 상황
public List<PostResponse> findPostsInfiniteScroll(final CustomUserDetails userDetails, boolean tagExist, Long lastPostId, final Integer pageSize) {
...
return posts.stream()
.map(post -> {
Long commentCount = commentRepository.findByPostId(post.getPostId());
Long likeCount = likeCountRepository.findByPostId(post.getPostId());
Long likeYn = likeRepository.findByPostIdAndUserId(post.getPostId(), post.getUser().getUserId());
return PostResponse.from(post, commentCount, likeCount, likeYn);
})
.toList();
}
게시글 피드 조회를 위해 무한 스크롤을 구현 중이었는데,
반복문을 통해 게시물, 댓글 수, 좋아요 수, 좋아요 여부를 가져오고 있었다.
구현한 후 서버 실행을 하고, show-sql을 통해 쿼리문을 보며 이 코드는 효율적이지 못하다는걸 알 수 있었다.
성능적으로 DB에 부하를 일으킨다. 왜일까?
* 원인
우선 해당 코드의 동작 흐름은 이렇다.
- List<Post> posts = postRepository.findAllByPostIds(postIds); 를 통해 쿼리가 1번 실행된다.
- Long commentCount = commentRepository.findByPostId(post.getPostId()); 를 통해 쿼리가 반복 횟수(n)만큼 실행된다.
- Long likeCount = likeCountRepository.findByPostId(post.getPostId()); 를 통해 쿼리가 반복 횟수(n)만큼 실행된다.
- Long likeYn = likeRepository.findByPostIdAndUserId(post.getPostId(), post.getUser().getUserId()); 를 통해 쿼리가 반복 횟수(n)만큼 실행된다.
즉 게시물이 N개 일 때, 3N + 1만큼 실행된다는 뜻이다.
게시물이 10000개 존재한다고 가정한다면, 30001번 실행된다.
조회 로직 API 1번 호출에 실행되는 쿼리가 30000개가 넘는다? 비효율적이다.
* 해결 방안
이를 해결하기 위해 반복문을 실행하기 전, IN 조건으로 일괄적으로 데이터를 조회했다(Bulk).
그리고 여기에 JPA에서 지원하는 Projection을 사용한다.
Projection은 클래스 타입을 지정해서 필요한 필드만 추출할 수 있도록 제공해준다.
또한 여러 Projection 객체를 만들어 select 시에 추출하는 필드를 변경할 수 있다.
public interface CommentCountProjection {
Long getPostId();
Long getCommentCount();
}
public interface LikeCountProjection {
Long getPostId();
Long getLikeCount();
}
public interface LikeProjection {
Long getPostId();
Boolean getLikeYn();
}
이렇게 원하는 쿼리 결과를 주입한다. 그리고 Map 형식으로 매핑해준다.
public List<PostResponse> findPostsInfiniteScroll(final CustomUserDetails userDetails, boolean tagExist, Long lastPostId, final Integer pageSize) {
...
Map<Long, Long> commentCountMap = commentRepository.countsByPostIds(postIds).stream()
.collect(Collectors.toMap(CommentCountProjection::getPostId, CommentCountProjection::getCommentCount));
Map<Long, Long> likeCountMap = likeCountRepository.findLikeCountByPostIds(postIds).stream()
.collect(Collectors.toMap(LikeCountProjection::getPostId, LikeCountProjection::getLikeCount));
Map<Long, Boolean> likeYnMap = likeRepository.findLikeYnByPostIdsAndUserId(postIds, userId).stream()
.collect(Collectors.toMap(LikeProjection::getPostId, LikeProjection::getLikeYn));
return posts.stream()
.map(post -> {
Long commentCount = commentCountMap.getOrDefault(post.getPostId(), 0L);
Long likeCount = likeCountMap.getOrDefault(post.getPostId(), 0L);
Boolean likeYn = likeYnMap.getOrDefault(post.getPostId(), false);
return PostResponse.from(post, commentCount, likeCount, likeYn);
})
.toList();
}
반복문 내의 Map에서 get하는 형식이므로 사실상 실행되는 쿼리는 4개인 셈이다.
따라서 Projection을 통해 3N + 1 → N + 1개로 쿼리 실행 개수를 줄여 성능을 개선할 수 있다.
'개발 > 트러블슈팅' 카테고리의 다른 글
[트러블슈팅] 엔티티에 @ToString 사용으로 인한 순환참조(feat. JPA) (0) | 2025.06.04 |
---|---|
[트러블슈팅] DTO 필드 변수의 prefix 이슈 (0) | 2025.06.04 |
[트러블슈팅] 게시물 작성에서 사진 업로드 시 발생했던 문제 (1) | 2025.06.04 |
[트러블슈팅] Gradle 버전 문제 (0) | 2023.10.05 |