기록
N + 1 문제
개요
팀프로젝트 진행 중 대시보드에 있는 인기 리뷰를 계산하는 과정에서 흔히 말하는 N + 1 문제인 .. 2N + 1문제가 발생하는 이슈가 있었다. 처음 팀프로젝트에서 기록을 하지 않아 아쉬웠던 점이 있어서 다음부터는 꼭 기록하자고 생각했었는데 처음 겪어보는 문제라 경험한 과정들을 기록하고 싶어서 올립니다.. ㅎ
문제
처음에 아무 생각 없이 그냥 다 갖고와서 계산 때리면 되는거 아닌가? 얼마나 차이가 나겠어~ 싶어서 만들었더니 엄청난 문제가 있었다..
기존에 인기 리뷰 구하는 로직에 모든 활성 리뷰(findAllByIsDeletedFalse())를 조회한 후, 각 리뷰에 대한 루프를 돌면서 해당 기간 내의 좋아요 수(likedUserIdRepository.countBy…)와 댓글 수(commentRepository.countBy…)를 별도의 쿼리로 조회를 하는데, 만약 활성 리뷰가 N개 있다면, 총 1(리뷰 조회) + N(좋아요 수 조회) + N(댓글 수 조회) = 1 + 2N 번의 쿼리가 실행되어 리뷰 수가 많아지면 쿼리 수가 폭발적으로 늘어나 성능에 엄청난 이슈가 생기게 될거 같다는 것을 알았습니다.
실제 발생했던 쿼리는 아니지만, 예를 들어 reviewId가 각각 a1, b2, c3라고 가정하면
Hibernate:
select
review0_.id as id1_0_,
review0_.title as title2_0_,
review0_.is_deleted as is_delete3_0_,
review0_.created_at as created_4_0_,
review0_.updated_at as updated_5_0_
from
review review0_
where
review0_.is_deleted=false;
Hibernate:
select
count(*) as col_0_0_
from
review_likes likeduser0_
where
likeduser0_.review_id='a1'
and likeduser0_.created_at between '2025-04-01' and '2025-04-25';
Hibernate:
select
count(*) as col_0_0_
from
review_likes likeduser0_
where
likeduser0_.review_id='b2'
and likeduser0_.created_at between '2025-04-01' and '2025-04-25';
Hibernate:
select
count(*) as col_0_0_
from
review_likes likeduser0_
where
likeduser0_.review_id='c3'
and likeduser0_.created_at between '2025-04-01' and '2025-04-25';
Hibernate:
select
count(*) as col_0_0_
from
comment comment0_
where
comment0_.review_id='a1'
and comment0_.created_at between '2025-04-01' and '2025-04-25';
Hibernate:
select
count(*) as col_0_0_
from
comment comment0_
where
comment0_.review_id='b2'
and comment0_.created_at between '2025-04-01' and '2025-04-25';
Hibernate:
select
count(*) as col_0_0_
from
comment comment0_
where
comment0_.review_id='c3'
and comment0_.created_at between '2025-04-01' and '2025-04-25';
이런식으로 1 + 2N개의 쿼리가 나가게 되는데, 지금은 3개로 예를 들어 7개만 나갔지만 데이터가 늘어나면.. 엄청난 성능 이슈가 될 것 같다는 생각을 하게 됐는데요
해결 방법
이를 해결하기 위해 Review를 기준으로 LikedUserId와 Comment를 LEFT JOIN하고, 기간 및 삭제 여부 조건을 건 뒤, Review 별로 GROUP BY를 사용하여 필요한 정보를 한 번의 쿼리로 가져오는 방법으로 리팩토링을 할 생각이었습니다만 생각해보니 인기 리뷰는 댓글이랑 좋아요가 없으면 즉, 좋아요 0, 댓글 0 이면 인기 리뷰에 반영되지 않습니다. 그럼 조건을 계산할 기간 내의 좋아요나 댓글이 1개 이상이라도 있는 리뷰를 필터링해서 가져오는게 훨씬 효율적일 것 같다는 것을.. 알았습니다.
간략하게 흐름을 보자면
스케쥴 실행 : @Scheduled에 의해 calculatePopularReviews 메서드가 실행
기간별 계산 : 각 PeriodType에 대해
현재 시간(now)와 PeriodType을 기준으로 집계 시작 시간(startTime)을 계산(만약 All Time이면 null)
쿼리를 실행하여 조건에 만족하는 데이터를 DB에서 가져옴
조건 : 삭제되지 않은, LEFT JOIN을 통해 startTime ~ now 사이에 생성된 LikedUserId 정보와 삭제되지 않은 comment 정보
GROUP BY를 통해 리뷰별로 집계
HAVING 절을 사용하여 해당 기간 내 좋아요 > 0 또는 댓글 > 0 인 리뷰만 필터링
결과로 Review, 해당 기간 내 좋아요 수, 해당 기간 내 댓글 수를 받음
필터링해서 DB에서 가져온 리뷰들에 대해 점수를 계산
점수를 기준으로 내림차순 정렬
기존 데이터 삭제하고 새 데이터 저장
이런 흐름으로 로직을 만들면 될거 같아 시도를 해봤습니다.
해결 시도
처음에 짠 calculatePopularReviews() 메서드를 보면
List<Review> allActiveReviews = reviewRepository.findAllByIsDeletedFalse();
for (PeriodType period : PeriodType.values()) {
// ALL_TIME은 startTime을 null로 설정하여 전체 기간을 대상으로 합니다.
Instant startTime = period.toStartInstant(now);
log.info("{} 기간 인기 리뷰 계산 시작 (시작 시간: {})", period, startTime);
// 삭제되지 않은 모든 리뷰 조회
List<PopularReviewData> scoredReviews = new ArrayList<>();
for (Review review : allActiveReviews) {
int likesInPeriod;
int commentsInPeriod;
// 기간별 좋아요/댓글 수 계산
likesInPeriod = likedUserIdRepository.countByReviewAndCreatedAtBetween(review, startTime, now);
commentsInPeriod = commentRepository.countByReviewAndIsDeletedFalseAndCreatedAtBetween(review, startTime, now);
// 좋아요 또는 댓글이 있는 경우에만 점수 계산
if (likesInPeriod > 0 || commentsInPeriod > 0) {
double score = (likesInPeriod * 0.3) + (commentsInPeriod * 0.7);
scoredReviews.add(new PopularReviewData(review, score, likesInPeriod, commentsInPeriod));
}
}
// 점수 내림차순 정렬
scoredReviews.sort(Comparator.comparingDouble(PopularReviewData::getScore).reversed());
// 기존 데이터 삭제
popularReviewRepository.deleteByPeriod(period);
log.debug("{} 기간 기존 인기 리뷰 데이터 삭제 완료", period);
int rank = 1;
List<PopularReview> popularReviewsToSave = new ArrayList<>();
for (PopularReviewData data : scoredReviews) {
PopularReview popularReview = PopularReview.builder()
.review(data.getReview())
.period(period)
.score(data.getScore())
.rank(rank++)
.likeCount(data.getLikesInPeriod())
.commentCount(data.getCommentsInPeriod())
.reviewRating((double) data.getReview().getRating())
.build();
popularReviewsToSave.add(popularReview);
}
if (!popularReviewsToSave.isEmpty()) {
popularReviewRepository.saveAll(popularReviewsToSave);
} else {
log.info("{} 기간 인기 리뷰 없음", period);
}
}
log.info("인기 리뷰 점수 계산 및 랭킹 업데이트 완료");
}
위에서 언급한대로 findAllByIsDeletedFalse() 를 1회 호출 후, 각 리뷰마다 count를 해 발생하는 쿼리 문제가 보이고, 성능이 안좋아 보인다 .. 그래서 위에서 계획했던 쿼리를 작성하려 했는데 JPQL을 쓸지 QeuryDSL을 쓸지 고민했지만, QueryDSL이 팀원이 코드를 봤을 때, 가독성도 좋고 유지보수도 더 용이할 것 같아서(사실은 공부하면서 적용해 보고 싶었다 ㅎ) QueryDSL을 사용해 구현해 보았다.
private List<PopularReviewData> fetchAndScoreReviewsForPeriod(PeriodType period,
Instant startTime, Instant now) {
// JOIN 조건 생성
BooleanExpression likeJoinCondition = startTime !=
null ? likedUserId.createdAt.between(startTime, now) : null;
BooleanExpression commentJoinCondition = comment.isDeleted.eq(false);
if (startTime != null) {
commentJoinCondition = commentJoinCondition.and(comment.createdAt.between(startTime, now));
}
// 쿼리 실행
List<Tuple> results = queryFactory
.select(
review,
likedUserId.id.countDistinct(),
comment.id.countDistinct()
)
.from(review)
.leftJoin(review.likedUserIds, likedUserId)
.on(likeJoinCondition)
.on(likeJoinCondition != null
? likeJoinCondition
: likedUserId.isNotNull())
.leftJoin(review.comments, comment).on(commentJoinCondition)
.where(review.isDeleted.eq(false))
.groupBy(review)
.having(
likedUserId.id.countDistinct().gt(0L)
.or(comment.id.countDistinct().gt(0L))
)
.fetch();
일단 fetchAndScoreReviewsForPeriod 로 따로 빼서 Period동안 좋아요 또는 댓글이 1개 이상인 리뷰 정보를 DB에서 조회하는 메서드를 만들었다.
leftJoin 부분에서 on(null) 전달 시 NullPointerException이 발생할 수 있다는 피드백을 받고 on(likeJoinCondition != null ? likeJoinCondition : likedUserId.isNotNull())를 추가해 NPE를 막고자 했다.
코드에 대한 설명은 딱히 할말이 없는 것 같다.. 잘짜서가 아니라 문제가 있다면 여기서 어떤 부분이 문제인지를 모르겠다.. 일단 위에서 생각한대로 짜긴 했는데 얼추 생각대로 만든 것 같긴 하다.
프로젝트를 하거나 공부를 할 때 문제가,, 아는게 많이 없으니 어떤 부분이 문제인지, 어떻게 리팩토링하면 좋을지, 후에 어떤 이슈가 발생하게 될지.. 정말 모르겠다. 프로젝트 경험을 쌓고 많이 공부하는 수밖에 없겠죠 ㅎㅎ..
아무튼 완성하고 쿼리를 날렸더니 이것도 이렇게 나가진 않지만 대략적으로
SELECT
r.id AS r_id,
r.user_id AS r_user_id,
r.book_id AS r_book_id,
r.content AS r_content,
r.rating AS r_rating,
r.is_deleted AS r_is_deleted,
COUNT(DISTINCT lu.id) AS like_count,
COUNT(DISTINCT c.id) AS comment_count
FROM review r
LEFT JOIN review_likes lu
ON lu.review_id = r.id
AND lu.created_at BETWEEN ? /* startTime */ AND ? /* now */
LEFT JOIN comment c
ON c.review_id = r.id
AND c.created_at BETWEEN ? /* startTime */ AND ? /* now */
WHERE r.is_deleted = FALSE
GROUP BY
r.id,
r.user_id,
r.book_id,
r.content,
r.rating,
r.is_deleted
HAVING
COUNT(DISTINCT lu.id) > 0
OR COUNT(DISTINCT c.id) > 0
이런 식으로 쿼리가 나간다. 처음 로직에선 쿼리가 7방 나가는데, 위처럼 한방 쿼리 나가는게 성능적으로 훨씬 좋아보인다.
마무리
DB에서 삭제되지 않은 모든 리뷰를 조회한 후, 각 리뷰에 대해 for문을 돌면서 Period별로 좋아요, 댓글 수를 따로 계산하는 방법이랑 DB에서 좋아요, 댓글 수가 1 이상인 리뷰를 조건으로 걸고 갖고와서 계산하는 것은 직관적으로 보면 엄청난 성능 차이가 있는 것을 알지만, 얼마나 차이가 나는지는 따로 계산해보진 않았습니다.
프로그래밍을 배운지 얼마 안돼서 처음에 설계하는게 진짜 중요하다고 느꼈다.. 처음부터 설계를 잘했으면 고생을 덜 하지 않았을까? QeuryDSL이 아직 익숙하지 않기도 하고 어떻게 짜야할지 고민하느라 시간이 좀 오래 걸렸다 ㅠ 다음번에 이런 복잡한 로직이 있을 때는 좀 더 생각해보고 설계를 해야겠어요 😡
프로젝트를 하며 처음 겪어본 N + 1 문제였는데 생각보다 쉽지가 않다.. 구글 서치해보면 fetch join 딸깍하면 된다는데 와닿지가 않긴 하다.. 하하 이런식으로 부딛히면서 성장하는 거 아닐까요..
아 참고로 프로젝트 기간이 길지 않아서 우리팀은 깃에 coderabbitai를 도입했는데 정말 좋은 것 같다. 팀 규칙상 랜덤으로 팀원 2명이 코드 리뷰를 하고 승인 후 머지하는 방식이어서 커밋이 많다거나 볼륨이 좀 큰 작업을 했을 때 시간이 오래 걸리는 단점이 있었지만.. 이 coderabbitai가 문제점과 수정해야할 점, 후에 발생할 수 있는 문제들을 알려주었기에 코드 리뷰 하는 시간이 상당히 줄어들어서 좋았던 것 같다. 하지만 체험기간이 끝나고 유료 가격은.. 경험해본 것에 의미를 두겠습니다 ㅎㅎ..