시작하며
리뷰 목록에서는 리뷰 내용과 함께 좋아요 수와 현재 사용자의 좋아요 여부를 제공하고 있습니다. 처음에는 리뷰 목록을 조회한 뒤 각 리뷰에 필요한 좋아요 정보를 추가로 조회하는 방식으로 구현했습니다. 그러던 중 코드 리뷰를 진행하면서 리뷰마다 DB 조회가 반복되고 있다는 점을 발견했습니다. 데이터가 적을 때는 큰 문제가 드러나지 않았지만 리뷰 수가 늘어날수록 조회 쿼리도 함께 증가하는 구조였습니다. 그래서 리뷰 수가 늘어나더라도 반복적인 DB 조회가 증가하지 않도록 조회 구조를 개선하기로 했습니다.
코드 리뷰에서 포착한 2N+1 문제
리뷰 목록을 먼저 조회한 뒤 각 리뷰에 필요한 좋아요 수와 현재 사용자의 좋아요 여부를 추가하는 구조였습니다. 문제는 이 과정에서 Java Stream 내부에서 호출하는 메서드가 리뷰마다 DAO를 다시 호출하고 있다는 점이었습니다.
public ReviewResponse.SliceList getReviewList(...) {
List<ReviewVo> reviewVoList = reviewDao.selectReviewCursor(...);
List<ReviewResponse.Review> enrichedReviews = reviewVoList.stream()
.map(vo -> enrichReviewWithLikeData(vo, currentUserId)) // 이 지점에서 데이터 개수만큼 쿼리가 나간다
.toList();
// ...
}
private ReviewResponse.Review enrichReviewWithLikeData(ReviewVo vo, String userId) {
int count = reviewLikeDao.countReviewLikes(vo.getReviewId()); // 건별 조회 쿼리 1
int exists = reviewLikeDao.existsReviewLike(userId, vo.getReviewId()); // 건별 조회 쿼리 2
return new ReviewResponse.Review(vo, count, exists > 0);
}
리뷰 목록 조회 자체는 한 번만 수행하지만 이후 각 리뷰마다 좋아요 수와 사용자 좋아요 여부를 각각 한 번씩 추가 조회하고 있었습니다. 따라서 한 페이지에서 리뷰를 N건 조회한다면 전체 쿼리 수는 다음과 같습니다.
1 Query (리뷰 목록 조회) + 2 Query * N (좋아요 수 + 여부 조회) → 데이터에 비례하여 늘어나는 쿼리 부하
이 구조가 실제 데이터가 늘어났을 때 어느 정도의 차이를 만드는지 확인하기 위해 특정 작품에 리뷰 100건을 구성하고 최신순 정렬과 Cursor Pagination을 적용해 size=10 조건으로 목록 조회 API를 10회 호출했습니다. 리뷰 10건을 조회하는 한 번의 요청에서는 리뷰 목록 조회 1회, 리뷰별 좋아요 수 조회 10회, 사용자 좋아요 여부 조회 10회가 발생해 총 21개의 쿼리가 실행됐습니다. 10회 반복 측정 결과는 다음과 같았습니다.
| 지표 | 결과 |
| 요청당 쿼리 수 | 21개 |
| 10회 총 쿼리 수 | 210개 |
| 평균 응답 시간 | 164.0ms |
| 최대 응답 시간 | 446ms |
현재 페이지에 포함되는 리뷰 수가 늘어날수록 좋아요 정보를 조회하기 위한 쿼리도 함께 증가하는 구조였습니다. size=10에서는 21개의 쿼리가 발생하지만 같은 구조에서 size=20이라면 41개의 쿼리가 발생할 수 있습니다. 기능 자체는 정상적으로 동작하고 있었지만 리뷰 수에 따라 DB 조회 횟수가 계속 증가한다는 점에서 개선이 필요하다고 판단했습니다.
JOIN vs Batch Select
이 문제를 해결하기 위해 두 가지 방안을 검토했습니다.
방안 1. JOIN
가장 먼저 떠올린 방법은 쿼리 자체에서 JOIN을 활용해 필요한 데이터를 한꺼번에 가져오는 것이었습니다. 기존 selectReviewCursor XML 쿼리에 LEFT JOIN을 추가하면 됩니다.
<select id="selectReviewCursor" resultType="ReviewVo">
SELECT R.REVIEW_ID, R.USER_ID, ...,
COUNT(RL.REVIEW_ID) AS LIKE_COUNT,
MAX(CASE WHEN RL.USER_ID = #{currentUserId} THEN 1 ELSE 0 END) AS IS_LIKED
FROM REVIEW R
LEFT JOIN REVIEW_LIKE RL ON R.REVIEW_ID = RL.REVIEW_ID
WHERE R.CONTENT_ID = #{contentId} AND R.DEL_YN = 'N'
<if test="cursorCreatedAt != null">
AND (R.CREATED_AT < #{cursorCreatedAt} OR ...)
</if>
GROUP BY R.REVIEW_ID, R.USER_ID, ...
ORDER BY R.CREATED_AT DESC, R.REVIEW_ID DESC
FETCH NEXT #{limit} ROWS ONLY
</select>
이 방식의 장점은 분명했습니다. 리뷰와 좋아요 정보를 한 번의 쿼리로 가져올 수 있습니다. 다만 리뷰와 좋아요는 일대다 관계이기 때문에 단순 JOIN 시 하나의 리뷰가 좋아요 수만큼 여러 row로 늘어날 수 있습니다.
예를 들어 리뷰별 좋아요 수가 다음과 같다고 가정해보겠습니다.
| REVIEW_ID | 좋아요 수 |
| 10 | 5 |
| 9 | 2 |
| 8 | 1 |
JOIN 결과는 다음처럼 만들어질 수 있습니다.
| REVIEW_ID | USER_ID |
| 10 | userA |
| 10 | userB |
| 10 | userC |
| 10 | userD |
| 10 | userE |
| 9 | userF |
| 9 | userG |
| 8 | userH |
실제 리뷰는 3건이지만 JOIN 결과는 8개의 row가 됩니다. 문제는 리뷰 목록 API에서 Pagination의 기준이 JOIN 결과 row 수가 아니라 리뷰 수여야 한다는 점이었습니다.
예를 들어 페이지 크기가 5라고 해서 JOIN 결과에 단순히 다음 조건을 적용하면
FETCH NEXT 5 ROWS ONLY
서로 다른 리뷰 5건이 아니라 리뷰 10번의 좋아요 row 5건만 조회될 수도 있습니다. 따라서 일대다 관계에서 JOIN을 사용할 경우에는 리뷰 단위의 결과를 먼저 보장한 뒤 Pagination을 적용해야 합니다.
한 가지 방법은 GROUP BY를 사용해 JOIN 결과를 리뷰 단위로 다시 집계하는 것입니다.
GROUP BY
R.REVIEW_ID,
...
ORDER BY
R.CREATED_AT DESC,
R.REVIEW_ID DESC
FETCH NEXT #{limit} ROWS ONLY
또 다른 방법은 서브쿼리에서 먼저 이번 페이지에 포함될 리뷰를 조회한 뒤 그 결과에 좋아요 테이블을 JOIN하는 방식입니다.
SELECT ...
FROM (
SELECT ...
FROM REVIEW
WHERE ...
ORDER BY
CREATED_AT DESC,
REVIEW_ID DESC
FETCH NEXT #{limit} ROWS ONLY
) R
LEFT JOIN REVIEW_LIKE RL
ON R.REVIEW_ID = RL.REVIEW_ID
두 방식 모두 구현할 수 있습니다. 다만 리뷰 조회 SQL에 집계나 서브쿼리가 추가되면서 조회 구조도 함께 복잡해집니다.
기존 Cursor Pagination 구조도 고려했습니다
리뷰 목록은 이미 createdAt과 reviewId를 기준으로 Cursor Pagination을 사용하고 있었습니다.
ORDER BY
CREATED_AT DESC,
REVIEW_ID DESC
다음 페이지를 조회할 때는 이전 페이지의 마지막 리뷰가 가진 두 값을 Cursor로 사용합니다.
WHERE (
CREATED_AT < #{cursorCreatedAt}
OR (
CREATED_AT = #{cursorCreatedAt}
AND REVIEW_ID < #{cursorReviewId}
)
)
ORDER BY
CREATED_AT DESC,
REVIEW_ID DESC
기존 구조에서는 selectReviewCursor()를 통해 먼저 현재 페이지에 포함될 리뷰 목록을 확정한 뒤 각 리뷰의 좋아요 정보를 추가로 조회하고 있었습니다. Cursor Pagination 자체는 이미 리뷰 단위로 동작하고 있었고 문제는 그 이후 단계에서 좋아요 수와 사용자 좋아요 여부를 리뷰마다 개별 조회하면서 1 + 2N개의 쿼리가 발생한다는 점이었습니다.
JOIN 방식으로 변경하려면 기존 리뷰 조회 쿼리에 REVIEW_LIKE를 결합하면서도 리뷰 단위의 Pagination 결과가 유지되도록 집계나 서브쿼리 구조를 함께 구성해야 했습니다. 반면 Batch Select 방식은 기존 selectReviewCursor()의 조회 흐름은 그대로 두고, 이후 리뷰마다 반복하던 좋아요 조회만 현재 페이지의 reviewId 목록을 기준으로 일괄 조회하도록 변경할 수 있었습니다. 그래서 이번에는 기존 Cursor Pagination 흐름은 유지하면서 성능 문제의 원인이었던 반복 조회만 제거하는 방식으로 Batch Select를 선택했습니다.
방안 2. Batch Select를 통한 애플리케이션 조립
두 번째 방식은 리뷰를 먼저 조회한 뒤 좋아요 데이터를 Batch Query로 가져오는 방식입니다.
왜 Batch Select인가?
Batch Select를 선택한 이유는 단순히 쿼리 수 감소 때문만은 아니었습니다.
1. 안정적인 Pagination
리뷰 목록을 먼저 가져온 뒤 관련 데이터를 조회하기 때문에 JOIN 시 발생할 수 있는 데이터 중복이나 페이징 결과 왜곡이 없습니다. 언제나 동일한 리뷰 집합을 기준으로 안정적인 데이터 조회가 가능합니다.
2. Query 책임 분리
각 쿼리는 리뷰 조회, 좋아요 집계, 사용자 상태 확인처럼 하나의 역할만 수행합니다. 이렇게 쿼리의 책임을 분리하면 쿼리 복잡도가 낮아지고 유지보수도 쉬워집니다.
3. 확장성
현재는 동일한 RDB 내에 데이터가 있지만 서비스가 커지면 좋아요 데이터가 Redis 캐시나 별도의 Microservice로 분리될 가능성도 있습니다. JOIN 구조는 이러한 아키텍처 변경이 발생할 경우 관련 쿼리를 함께 수정해야 하지만 Batch Select 구조를 사용하면 좋아요 데이터의 저장 위치나 조회 방식이 변경되더라도 리뷰 조회 로직은 그대로 유지할 수 있습니다
리팩토링 구현
1. MyBatis XML의 Batch 쿼리 추가
먼저 여러 개의 리뷰 ID를 한 번에 전달할 수 있도록 MyBatis의 <foreach>를 활용한 쿼리를 추가했습니다. 좋아요 수는 현재 페이지에 포함된 리뷰 ID 목록을 IN 조건으로 전달해 리뷰별로 한 번에 집계합니다. 현재 사용자가 좋아요한 리뷰도 동일한 ID 목록을 기준으로 한 번에 조회합니다
<select id="countReviewLikesBatch" resultType="map">
SELECT REVIEW_ID, COUNT(*) AS LIKE_COUNT
FROM REVIEW_LIKE
WHERE REVIEW_ID IN
<foreach collection="reviewIds" item="reviewId" open="(" separator="," close=")">
#{reviewId}
</foreach>
GROUP BY REVIEW_ID
</select>
<select id="selectUserLikedReviewIds" resultType="long">
SELECT REVIEW_ID
FROM REVIEW_LIKE
WHERE USER_ID = #{userId}
AND REVIEW_ID IN
<foreach collection="reviewIds" item="reviewId" open="(" separator="," close=")">
#{reviewId}
</foreach>
</select>
기존에는 리뷰 ID 하나를 전달해 좋아요 수와 사용자 좋아요 여부를 각각 조회했다면, 개선 후에는 현재 페이지의 리뷰 ID 전체를 한 번에 전달합니다
2. 애플리케이션 계층에서의 데이터 조립
메인 로직
리뷰 목록 조회 방식은 기존과 동일하게 유지했습니다. 먼저 Cursor Pagination을 통해 현재 페이지에 포함될 리뷰를 조회한 뒤 조회된 리뷰 목록 전체를 Batch 조립 로직에 전달합니다.
public ReviewResponse.SliceList getReviewList(...) {
List<ReviewVo> reviewVoList = reviewDao.selectReviewCursor(...);
if (reviewVoList.isEmpty()) {
return new ReviewResponse.SliceList(Collections.emptyList(), null, false);
}
// 배치 조회를 통해 데이터 조립
List<ReviewResponse.Review> enrichedReviews = enrichReviewsWithLikeDataBatch(reviewVoList, currentUserId);
// ... 슬라이스 처리 로직 ...
}
기존에는 reviewVoList를 순회하면서 각 리뷰마다 좋아요 정보를 개별 조회했다면, 개선 후에는 조회된 리뷰 목록 전체를 enrichReviewsWithLikeDataBatch()에 전달하도록 변경했습니다. 리뷰 목록을 가져오는 Cursor Pagination 로직은 그대로 두고 이후 좋아요 정보를 채우는 방식만 변경했습니다.
배치 조립 로직
Batch 조립 로직에서는 먼저 현재 페이지에 포함된 리뷰의 reviewId를 추출합니다. 이후 해당 ID 목록을 기준으로 좋아요 수와 현재 사용자의 좋아요 여부를 각각 한 번씩 조회하고 조회한 데이터를 다시 각 리뷰 응답에 조립합니다.
private List<ReviewResponse.Review> enrichReviewsWithLikeDataBatch(List<ReviewVo> reviewVoList, String currentUserId) {
if (reviewVoList.isEmpty()) return Collections.emptyList();
List<Long> reviewIds = reviewVoList.stream().map(ReviewVo::getReviewId).toList();
// 1. 좋아요 수 일괄 조회 및 결과 매핑
Map<Long, Map<String, Object>> batchResult = reviewLikeDao.countReviewLikesBatch(reviewIds);
Map<Long, Integer> likeCountMap = batchResult.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> ((BigDecimal)entry.getValue().get("LIKE_COUNT")).intValue()
));
// 2. 로그인 사용자의 좋아요 여부 일괄 조회
Set<Long> likedReviewIds = Collections.emptySet();
if (currentUserId != null) {
likedReviewIds = reviewLikeDao.selectUserLikedReviewIds(currentUserId, reviewIds).stream()
.collect(Collectors.toSet());
}
// 3. 조립
Set<Long> finalLikedReviewIds = likedReviewIds;
return reviewVoList.stream()
.map(vo -> {
int likeCount = likeCountMap.getOrDefault(vo.getReviewId(), 0);
Boolean isLiked = currentUserId != null ? finalLikedReviewIds.contains(vo.getReviewId()) : null;
return new ReviewResponse.Review(vo.getReviewId(), vo.getContentId(), vo.getUserId(),
vo.getReviewBody(), vo.getStartTime(), vo.getEndTime(), vo.getYoutubeId(),
vo.getIsSpoiler(), vo.getTrackId(), likeCount, isLiked, vo.getCreatedAt());
}).toList();
}
성능 테스트
리팩토링 전후의 차이를 확인하기 위해 동일한 조건에서 테스트했습니다. 테스트 조건은 다음과 같습니다.
- 특정 작품에 리뷰 데이터 100건 구성
- 최신순 정렬
- Cursor Pagination 적용
- 페이지 크기 size=10
- 목록 조회 API 10회 호출
리팩토링 전
기존 구조에서는 리뷰 10건을 조회할 때 총 21개의 쿼리가 발생했습니다.
1개: 리뷰 목록 조회
10개: 각 리뷰의 좋아요 수 조회
10개: 각 리뷰에 대한 현재 사용자의 좋아요 여부 조회
총 21개 쿼리
10회 호출 결과는 다음과 같았습니다.
| 요청 차수 | 요청 시각 | 처리 스레드 | 발생 쿼리 수 | 응답 시간 |
| 1번째 | 18:02:22.309 | exec-1 | 21개 | 167 ms |
| 2번째 | 18:02:37.779 | exec-3 | 21개 | 133 ms |
| 3번째 | 18:02:38.555 | exec-4 | 21개 | 141 ms |
| 4번째 | 18:02:39.351 | exec-5 | 21개 | 173 ms |
| 5번째 | 18:02:39.911 | exec-6 | 21개 | 123 ms |
| 6번째 | 18:02:40.542 | exec-7 | 21개 | 123 ms |
| 7번째 | 18:02:41.301 | exec-8 | 21개 | 114 ms |
| 8번째 | 18:02:41.882 | exec-9 | 21개 | 119 ms |
| 9번째 | 18:02:42.834 | exec-10 | 21개 | 446 ms |
| 10번째 | 18:02:44.333 | exec-2 | 21개 | 101 ms |
| 평균 | - | - | 21개 | 164.0 ms |
요청당 21개의 쿼리가 발생했고 10번의 요청에서는 총 210개의 쿼리가 실행됐습니다. 평균 응답 시간은 164.0ms, 최대 응답 시간은 446ms였습니다.
리팩토링 후
리팩토링 후에는 리뷰 목록 조회, 리뷰별 좋아요 수 조회, 현재 사용자의 좋아요 여부 조회를 각각 한 번씩 수행하도록 변경했습니다. 기존에는 리뷰마다 개별적으로 좋아요 정보를 조회했지만 개선 후에는 현재 페이지에 포함된 리뷰 ID 목록을 기준으로 IN 쿼리를 사용해 한 번에 조회합니다.
1개: 리뷰 목록 조회
1개: 리뷰별 좋아요 수 일괄 조회
1개: 현재 사용자의 좋아요 여부 일괄 조회
총 3개 쿼리
10회 호출 결과는 다음과 같았습니다.
| 요청 차수 | 요청 시각 | 처리 스레드 | 발생 쿼리 수 | 응답 시간 |
| 1번째 | 17:52:43.627 | exec-3 | 3개 | 78 ms |
| 2번째 | 17:52:44.802 | exec-4 | 3개 | 57 ms |
| 3번째 | 17:52:45.482 | exec-5 | 3개 | 67 ms |
| 4번째 | 17:52:46.190 | exec-6 | 3개 | 66 ms |
| 5번째 | 17:52:46.923 | exec-7 | 3개 | 52 ms |
| 6번째 | 17:52:47.559 | exec-8 | 3개 | 68 ms |
| 7번째 | 17:52:48.292 | exec-9 | 3개 | 73 ms |
| 8번째 | 17:52:48.962 | exec-10 | 3개 | 64 ms |
| 9번째 | 17:52:49.706 | exec-2 | 3개 | 60 ms |
| 10번째 | 17:52:50.509 | exec-1 | 3개 | 52 ms |
| 평균 | - | - | 3개 | 63.7 ms |
실제 실행된 쿼리도 다음과 같이 세 번으로 줄었습니다.
INFO 45960 : [Query 1] | 12 ms | SELECT R.REVIEW_ID, R.USER_ID, R.CONTENT_ID, R.REVIEW_BODY, R.IS_SPOILER, R.TRACK_ID, R.YOUTUBE_ID, R.START_TIME, R.END_TIME, R.CREATED_AT, R.UPDATED_AT FROM REVIEW R WHERE R.CONTENT_ID = 21 AND R.DEL_YN = 'N' ORDER BY R.CREATED_AT DESC, R.REVIEW_ID DESC FETCH NEXT 11 ROWS ONLY
INFO 45960 : [Query 2] | 4 ms | SELECT REVIEW_ID, COUNT(*) AS LIKE_COUNT FROM REVIEW_LIKE WHERE REVIEW_ID IN (362, 361, 360, 359, 358, 357, 356, 355, 354, 353) GROUP BY REVIEW_ID
INFO 45960 : [Query 3] | 5 ms | SELECT REVIEW_ID FROM REVIEW_LIKE WHERE USER_ID = 'user4' AND REVIEW_ID IN (362, 361, 360, 359, 358, 357, 356, 355, 354, 353)
============================================================
[API Performance Summary]
- Request : GET /api/v1/contents/21/reviews?size=10&sortBy=LATEST
- Duration : 52 ms
- Queries : 3 total
============================================================
개선 후에는 요청당 쿼리 수가 3개로 줄었으며 10번의 요청에서 총 30개의 쿼리가 실행됐습니다. 평균 응답 시간은 63.7ms였고 모든 요청이 52ms에서 78ms 사이에서 처리됐습니다.
최종 결과 비교
| 지표 | Before | After | 개선 효과 |
| 요청당 발생 쿼리 수 | 21개 | 3개 | 85.7% 감소 |
| 10회 테스트 총 쿼리 수 | 210개 | 30개 | 85.7% 감소 |
| 평균 응답 시간 | 164.0 ms | 63.7 ms | 약 61.2% 감소 |
| 최대 응답 시간 | 446 ms | 78 ms | 약 82.5% 감소 |
| 최저 응답 시간 | 101 ms | 52 ms | 약 48.5% 감소 |
리팩토링 후 요청당 쿼리 수는 21개에서 3개로 줄었고 평균 응답 시간은 164.0ms에서 63.7ms로 감소했습니다. 특히 기존 구조에서는 현재 페이지에 포함되는 리뷰 수가 증가하면 좋아요 정보 조회 쿼리도 함께 증가했습니다. 반면 Batch Select를 적용한 뒤에는 현재 페이지의 리뷰 ID를 기준으로 좋아요 정보를 일괄 조회하면서 반복적인 DB 접근을 제거할 수 있었습니다. 그 결과 쿼리 수를 85.7% 줄였고 평균 응답 시간도 약 61.2% 단축할 수 있었습니다.
마치며
이번 개선에서는 N+1 문제를 발견한 뒤 단순히 쿼리 수를 줄이는 데 집중하기보다 현재 조회 구조에서 어떤 방식이 가장 자연스러운지 고민했습니다.
처음에는 JOIN을 통해 필요한 데이터를 한 번에 조회하는 방법을 생각했습니다. 하지만 리뷰와 좋아요가 일대다 관계이고 이미 Cursor Pagination으로 리뷰 목록을 먼저 조회하고 있었기 때문에 기존 조회 쿼리까지 함께 변경하는 것이 꼭 필요한지 다시 생각해보게 됐습니다. 결국 현재 페이지에 포함된 리뷰는 이미 정해져 있으므로 해당 reviewId만 모아 좋아요 정보를 IN 절로 일괄 조회하는 Batch Select 방식을 선택했습니다. 기존 Pagination 흐름을 유지하면서도 문제였던 반복 조회만 제거할 수 있었기 때문입니다.
이번 경험을 통해 성능 개선에는 정해진 하나의 답이 있기보다 현재 데이터 관계와 조회 구조에 맞는 방식을 선택하는 것이 중요하다고 느꼈습니다.
'Troubleshooting' 카테고리의 다른 글
| [Kubernetes] Pod 재스케줄링으로 발생한 504 Gateway Timeout (1) | 2026.06.17 |
|---|---|
| [MyBatis/Oracle] ORA-17004: 열 유형이 부적합합니다 (TypeException) 해결 방법 (0) | 2026.03.04 |
| [Elasticsearch] AWS t3.micro에서 Elasticsearch 검색 쿼리 최적화하기 (0) | 2026.01.21 |
| [React] 빌드 무한 대기에서 초 단위 배포까지 (0) | 2026.01.17 |