트러블 슈팅_성능 개선(쿼리 수정)

star_pooh·2025년 1월 14일
0

에러 대응기

목록 보기
9/9

미니 프로젝트를 진행하면서 조회 API의 쿼리를 수정하여 성능 개선을 이룬 부분에 대해서 적어보려고 한다.

전제 조건

우리가 진행한 미니 프로젝트는 배달앱을 모티브로 하였으며, 1건의 주문에 1건의 리뷰(일반 사용자)를 달 수 있고,
작성된 리뷰 1건에 1건의 코멘트(사장님)를 달 수 있는 전제 조건을 가지고 있다. 즉, 리뷰와 코멘트는 일대일 연관관계를 가지고 있다.

성능 개선의 필요성 인식

DB에 너무 많은 요청을 보내는데..?

// Dto
@Getter
public class ReviewListDto {
  // 리뷰 정보
  private Long reviewId;
  private Long purchaseId;
  private Long userId;
  private Long shopId;
  private Long starPoint;
  private String reviewContent;
  private LocalDateTime createdAt;
  private LocalDateTime updatedAt;
  // 코멘트 정보
  private CommentReviewDto comment;
 
  // ... 생략
}

@Getter
@AllArgsConstructor
public class CommentReviewDto {

  private Long reviewId;
  private Long commentId;
  private Long userId;
  private String commentContent;
  private LocalDateTime createdAt;
  private LocalDateTime updatedAt;

  // ... 생략
  }
}
// Controller
@GetMapping("/shops/{shopId}")
public ResponseEntity<ApiResponse<List<ReviewListDto>>> getShopReview(
	@PathVariable Long shopId,
	@RequestParam(defaultValue = "1") Long minStarPoint,
	@RequestParam(defaultValue = "5") Long maxStarPoint) {

	List<ReviewListDto> reviewDtos = reviewService.getShopReview(shopId, minStarPoint, maxStarPoint);
    ApiResponse<List<ReviewListDto>> apiResponse = ApiResponse.success(HttpStatus.OK, "가게 리뷰 조회 성공", reviewDtos);

    return new ResponseEntity<>(apiResponse, HttpStatus.OK);
}
// Service
public List<ReviewListDto> getShopReview(Long shopId, Long minStarPoint, Long maxStarPoint) {
	// ... 생략

    List<Review> reviewList = reviewRepository.findAllByShopShopIdAndStarPointBetweenOrderByCreatedAtDesc(
        shopId, minStarPoint, maxStarPoint);
  
    return reviewList.stream().map(
        review -> ReviewListDto.builder()
        	.userId(review.getUser().getUserId())
            .createdAt(review.getCreatedAt()).updatedAt(review.getLastModifiedAt())
            .starPoint(review.getStarPoint()).reviewContent(review.getReviewContent())
            .shopId(review.getShop().getShopId()).reviewId(review.getReviewId())
            .purchaseId(review.getPurchase().getPurchaseId()).comment(
                Optional.ofNullable(
                	// 리뷰 1건의 데이터를 조회할 때마다 작성된 코멘트의 데이터도 조회
                	commentRepository.findByReviewReviewId(review.getReviewId()))
                    .map(CommentReviewDto::convertDto).orElse(null))
            .build()).toList();
  }

리뷰 조회시 코멘트에 대한 정보도 함께 출력해줘야 했으며, 위의 코드는 리뷰 조회 API의 일부 코드이다.
리뷰와 코멘트를 함께 출력해줘야 한다는 요구사항에 맞게 Dto 내부에 코멘트 Dto도 포함되어 있으며, 서비스에서는 조회한 리뷰 데이터마다 코멘트 데이터를 조회하고 있다. 프로그램 실행에 문제는 없었지만 이렇게 작성된 코드를 보니 DB에 보내는 요청을 너무나도 줄이고 싶었다.

지금 상태라면 n건의 리뷰 데이터가 있다고 가정할 때 2n번의 DB 접근이 필요하다.
그리고 commentContent라는 데이터 이외에는 필요 없는 데이터이기 때문에 힘들게 가져온 데이터 중에서도 상당 부분이 필요없는 데이터이다.

필요한 데이터만 최소한의 DB 접근으로 가져오자

그래서 우리는 DB 접근 횟수를 최소한으로 줄이면서 commentContent 만 가져올 수 있도록 수정을 시작했다.

// Dto
@Data
public class ReviewWithCommentDto {

  private Long reviewId;
  private Long userId;
  private LocalDateTime createdAt;
  private LocalDateTime lastModifiedAt;
  private Long starPoint;
  private String reviewContent;
  private Long shopId;
  private Long purchaseId;
  private String commentContent;
 
  // ... 생략
  }
}
// Controller
@GetMapping("/shops/{shopId}")
public ResponseEntity<ApiResponse<List<ReviewWithCommentDto>>> getShopReview(
    @PathVariable Long shopId,
    @RequestParam(defaultValue = "1") Long minStarPoint,
    @RequestParam(defaultValue = "5") Long maxStarPoint) {

	List<ReviewWithCommentDto> reviewDtos = reviewService.getShopReview(shopId, minStarPoint, maxStarPoint);
    ApiResponse<List<ReviewWithCommentDto>> apiResponse = ApiResponse.success(HttpStatus.OK, "가게 리뷰 조회 성공", reviewDtos);

    return new ResponseEntity<>(apiResponse, HttpStatus.OK);
  }
// Service
public List<ReviewWithCommentDto> getShopReview(Long shopId, Long minStarPoint,
      Long maxStarPoint) {
    // ... 생략

    List<ReviewWithCommentDto> results = reviewRepository.findReviewsWithComment(shopId, minStarPoint, maxStarPoint);

    return results.stream()
        .map(dto -> ReviewWithCommentDto.builder()
            .reviewId(dto.getReviewId())
            .userId(dto.getUserId())
            .createdAt(dto.getCreatedAt())
            .lastModifiedAt(dto.getLastModifiedAt())
            .starPoint(dto.getStarPoint())
            .reviewContent(dto.getReviewContent())
            .shopId(dto.getShopId())
            .purchaseId(dto.getPurchaseId())
            .commentContent(dto.getCommentContent())
            .build())
        .toList();
  }
// Repository
public interface ReviewRepository extends JpaRepository<Review, Long> {
  @Query("""
          SELECT
              new Not.Delivered.review.domain.Dto.ReviewWithCommentDto(
                r.reviewId,
                r.user.userId,
                r.createdAt,
                r.lastModifiedAt,
                r.starPoint,
                r.reviewContent,
                r.shop.shopId,
                r.purchase.purchaseId,
                (SELECT c.commentContent FROM Comment c WHERE c.review.reviewId = r.reviewId)
              )
          FROM Review r
          WHERE r.shop.shopId = :shopId
          AND r.starPoint BETWEEN :minStarPoint AND :maxStarPoint
          ORDER BY r.createdAt DESC
      """)
  List<ReviewWithCommentDto> findReviewsWithComment(Long shopId, Long minStarPoint,
      Long maxStarPoint);
}

가장 크게 바뀐 것은 DtoRepository이다.
Dto에서는 Comment의 모든 필드가 아닌 꼭 필요한 commentContent만 포함하도록 수정했다.

이에 따라 Repository에서도 바뀐 Dto인 ReviewWithCommentDto의 필드에 맞게 리뷰 테이블과 코멘트 테이블을 한 번에 조회하도록 수정했다.
이제 리뷰 테이블과 코멘트 테이블을 따로 조회하지 않아도 되는 것이다.

그래서 결과는..?

API를 반복적으로 호출하면 캐싱과 같은 부가적인 요소 때문에 시간이 다르게 측정될 수 있음을 고려하여, 각 API 호출 전에 애플리케이션을 재시작하였다. 데이터 수와 테스트 수 모두 많지 않지만 유의미한 결과를 볼 수 있었다.

1. 데이터 수 : 리뷰 3건, 코멘트 3건 →

성능 개선 이전

  • DB 접근 횟수(쿼리 발생 횟수) : 6회
  • 처리 시간 : 141 ms

성능 개선 이후

  • DB 접근 횟수(쿼리 발생 횟수) : 2회
  • 처리 시간 : 88 ms

2. 데이터 수 : 리뷰 20건, 코멘트 20건

성능 개선 이전

  • DB 접근 횟수(쿼리 발생 횟수) : 40회
  • 처리 시간 : 162 ms

성능 개선 이후

  • DB 접근 횟수(쿼리 발생 횟수) : 2회
  • 처리 시간 : 95 ms

3. 데이터 수 : 리뷰 50건, 코멘트 50건

성능 개선 이전

  • DB 접근 횟수(쿼리 발생 횟수) : 100회
  • 처리 시간 : 233 ms

성능 개선 이후

  • DB 접근 횟수(쿼리 발생 횟수) : 2회
  • 처리 시간 : 110 ms

정리

무분별한 쿼리메소드 사용은 불필요한 데이터까지 얻어오고, 잘못된 로직 구성은 꽤나 큰 리소스 낭비로 이어진다는 것을 알 수 있었다.
앞으로도 최적화, 개선에 초점을 맞춰서 개발할 수 있는 자세를 가져야겠다는 생각을 가지게 되었다.

✅ 테스트 내용 요약
#1
DB 접근 횟수(쿼리 발생 횟수) : 6회 → 2회
처리 시간 : 141ms → 88ms
(약 1.6배의 처리 속도 증가 == 약 37.59%의 효율 증가)

#2
DB 접근 횟수(쿼리 발생 횟수) : 40회 → 2회
처리 시간 : 162ms → 95ms
(약 1.71배의 처리 속도 증가 == 약 41.36%의 효율 증가)

#3
DB 접근 횟수(쿼리 발생 횟수) : 100회 → 2회
처리 시간 : 233ms → 110ms
(약 2.12배의 처리 속도 증가 == 약 52.79%의 효율 증가)

0개의 댓글

관련 채용 정보