Skip to content

Commit

Permalink
[#14] 복잡한 쿼리 최적화 & 동적 스케줄링으로 변경 (#17)
Browse files Browse the repository at this point in the history
* feat : 월별 통계 조회 기능

* refactor : 불필요한 내용 제외 및 주석 수정

* refactor : 동적 스케줄링으로 변경

* refactor : 순서 변경

* refactor : 주석정리

* style : 코드 스타일 정리

* refactor : 인덱스 설정한 내용 추가

* refactor : 집계함수 분리 시 사용했던 VO제거

* refactor : 요청 일자 검증 예외 추가

* refactor : 요청 일자 검증 Exception 추가

* refactor : 로직 변경 - 1달 기준으로 안해도 됨

* refactor : 병렬스트림 사용 시 Thread safe 한 자료구조 사용 및 파라미터로 Collection 넘기지 말고 반환타입으로 사용하기

* refactor : MySQL 문법 SNAKE_CASE로 변경 및 원본객체를 정렬할때는 동시성이슈를 피하기 위해 stream적용
  • Loading branch information
codesejin authored Apr 28, 2024
1 parent bc8e470 commit 00e6fca
Show file tree
Hide file tree
Showing 24 changed files with 512 additions and 27 deletions.
8 changes: 8 additions & 0 deletions src/main/java/com/flab/offcoupon/OffcouponApplication.java
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
package com.flab.offcoupon;

import com.flab.offcoupon.component.scheduler.ScheduleRunner;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

import java.util.TimeZone;

@RequiredArgsConstructor
@SpringBootApplication
public class OffcouponApplication {
private final ScheduleRunner scheduleRunner;
@PostConstruct
public void started() {
// timezone UTC 셋팅
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
}
@PostConstruct
public void scheduleRun() {
scheduleRunner.run();
}
public static void main(String[] args) {
SpringApplication.run(OffcouponApplication.class, args);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,14 @@ public class MessageQueueCountChecker {
* @See Client-Provided Connection Name, Passive Declaration
*/
public int getMessageCount(String queueName) {

// RabbitMQ 연결 설정
ConnectionFactory factory = new ConnectionFactory();
factory.setHost(rabbitmqHost);
factory.setUsername(rabbitmqUsername);
factory.setPassword(rabbitmqPassword);
try (Connection connection = factory.newConnection(); Channel channel = connection.createChannel()) {
log.info("RabbitMQ 연결에 성공했습니다");
// queueDeclarePassive : RabbitMQ에게 특정 큐가 존재하는지 확인 요청하는 메서드
DeclareOk queueDeclareOk = channel.queueDeclarePassive(queueName);
return queueDeclareOk.getMessageCount();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.flab.offcoupon.component.scheduler;

import lombok.RequiredArgsConstructor;
import org.springframework.scheduling.Trigger;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;

/**
* 동적 스케줄러(ThreadPoolTaskScheduler)를 생성하는 컴포넌트 클래스입니다.<br>
* 스케줄러를 시작하거나 종료할 수 있습니다.<br>
* <p>
* 동적 스케줄링을 구현한 이유는 @Scheduled 로 작성된 스케줄러의 경우 여러 디렉토리가 분산되어 있다면 동료 개발자에게 혼동을 줄 수 있기 때문입니다.
* </p>
*/
@RequiredArgsConstructor
public class DynamicScheduler {
private ThreadPoolTaskScheduler scheduler;
private final Runnable runnable;
private final Trigger trigger;

public void startScheduler() {
scheduler = new ThreadPoolTaskScheduler();
scheduler.initialize();
// 스케줄러가 시작되는 부분
scheduler.schedule(runnable, trigger);
}

public void stopScheduler() {
scheduler.shutdown();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.flab.offcoupon.component.scheduler;

import com.flab.offcoupon.component.scheduler.coupon_issue.ConsumeMqScheduler;
import com.flab.offcoupon.component.scheduler.coupon_issue.UpdateTotalCouponIssueCntScheduler;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@RequiredArgsConstructor
@Component
public class ScheduleRunner {

private final ConsumeMqScheduler consumeMqScheduler;
private final UpdateTotalCouponIssueCntScheduler updateTotalCouponIssueCntScheduler;
public void run() {
consumeMqScheduler.startScheduler();
updateTotalCouponIssueCntScheduler.startScheduler();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.flab.offcoupon.component.scheduler.coupon_issue;

import com.flab.offcoupon.component.scheduler.DynamicScheduler;
import com.flab.offcoupon.service.coupon_issue.async.consumer.CouponIssueConsumer;
import org.springframework.scheduling.Trigger;
import org.springframework.scheduling.support.PeriodicTrigger;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

/**
* RabbitMQ를 이용한 비동기 쿠폰 발행을 처리하는 컨슈머 클래스와 스케줄러를 연결하는 클래스입니다.
*/
@Component
public class ConsumeMqScheduler {
private CouponIssueConsumer couponIssueConsumer;

public ConsumeMqScheduler(CouponIssueConsumer couponIssueConsumer) {
this.couponIssueConsumer = couponIssueConsumer;
}

// 실행 로직
private final Runnable runnable = () -> couponIssueConsumer.consumeCouponIssueMessage();

// 실행 주기
private final Trigger trigger = new PeriodicTrigger(3, TimeUnit.SECONDS);

public void startScheduler() {
DynamicScheduler scheduler = new DynamicScheduler(runnable, trigger);
scheduler.startScheduler();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.flab.offcoupon.component.scheduler.coupon_issue;

import com.flab.offcoupon.component.scheduler.DynamicScheduler;
import com.flab.offcoupon.service.coupon_issue.async.consumer.CouponIssueConsumer;
import org.springframework.scheduling.Trigger;
import org.springframework.scheduling.support.PeriodicTrigger;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

/**
* 비동기 쿠폰 발행 쿠폰 발행 총 갯수를 업데이트하는 스케줄러를 연결하는 클래스입니다.
*/
@Component
public class UpdateTotalCouponIssueCntScheduler {
private CouponIssueConsumer couponIssueConsumer;

public UpdateTotalCouponIssueCntScheduler(CouponIssueConsumer couponIssueConsumer) {
this.couponIssueConsumer = couponIssueConsumer;
}

private final Runnable runnable = () -> couponIssueConsumer.updateTotalCouponIssueCount();

private final Trigger trigger = new PeriodicTrigger(10, TimeUnit.SECONDS);

public void startScheduler() {
DynamicScheduler scheduler = new DynamicScheduler(runnable, trigger);
scheduler.startScheduler();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
"/api/v1/sse/**",
"/api/v1/my-page/**",
"/api/v1/orders/**",
"/api/v1/event/**")
"/api/v1/event/**",
"/api/v1/statistics/**")
.permitAll()
.requestMatchers(
"/member").hasAnyRole("USER")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,16 @@ public class CouponIssueController {

@PostMapping("/{eventId}/issues-sync")
public ResponseEntity<ResponseDTO<String>> syncIssue(@PathVariable final long eventId,
@RequestParam final long couponId,
@RequestParam final long memberId) throws InterruptedException {
@RequestParam final long couponId,
@RequestParam final long memberId) throws InterruptedException {
LocalDateTime currentDateTime = LocalDateTime.now();
return ResponseEntity.status(HttpStatus.CREATED).body(couponIssueRequestService.syncIssueCoupon(currentDateTime, eventId, couponId, memberId));
}

@PostMapping("/{eventId}/issues-async")
public ResponseEntity<ResponseDTO<String>> asyncIssue(@PathVariable final long eventId,
@RequestParam final long couponId,
@RequestParam final long memberId) {
@RequestParam final long couponId,
@RequestParam final long memberId) {
LocalDateTime currentDateTime = LocalDateTime.now();
return ResponseEntity.status(HttpStatus.CREATED).body(couponIssueRequestService.asyncIssueCoupon(currentDateTime, eventId, couponId, memberId));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.flab.offcoupon.controller;

import com.flab.offcoupon.dto.request.StatisticsRequest;
import com.flab.offcoupon.dto.response.MonthlyOrderStatistics;
import com.flab.offcoupon.service.StatisticsService;
import com.flab.offcoupon.util.ResponseDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RequiredArgsConstructor
@RequestMapping("/api/v1/statistics")
@RestController
public class StatisticsController {

private final StatisticsService statisticsService;

/**
* 1000만의 데이터로 월별 주문 통계 조회에 대한 쿼리 최적화와 속도 개선을 목표로 했습니다.
* 관련된 내용을 포스팅하여 블로그에 작성했으며, 링크는 아래에 있는 @See에서 확인할 수 있습니다.
* <p>
* 1. DB 쿼리 속도 개선
* <ul>
* <li> 테이블 당 1000만건의 데이터가 있다고 가정하고, csv파일로 로컬 DB에 import했습니다.</li>
* <li> EXPLAIN명령어를 사용하여 실행계획을 분석하고, 복합인덱스, 커버링인덱스를 사용하여 약 2배의 속도를 개선했습니다.</li>
* </ul>
* </p>
* <p>
* 2. 애플리케이션 속도 개선
* <ul>
* <li> 쿼리를 최적화여 속도는 개선했지만, where절 기준으로 여전히 읽어야할 레코드의 수가 많아서 만족스러운 속도가 나오지 않았습니다.</li>
* <li> 따라서 요청의 시작일과 종료일을 1달 기준으로 분리하여 쿼리를 날렸지만, 결국에 월별로 실행된 쿼리도 1s씩 합쳐지게 되어 결국 속도가 다를바 없었습니다. </li>
* <li> 병렬 스트림을 사용하여 월별로 쿼리를 병렬적으로 수행하여 약 3배의 속도를 개선했습니다</li>
* </ul>
* </p>
* @param request 월별 주문 통계 조회 요청
* @return 월별 주문 통계 조회 결과
* @See <a href="https://strong-park.tistory.com/entry/1000%EB%A7%8C%EA%B1%B4%EC%9D%98-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%A5%BC-%EB%8C%80%EC%83%81%EC%9C%BC%EB%A1%9C-%EC%BF%BC%EB%A6%AC%EC%B5%9C%EC%A0%81%ED%99%94%EB%A5%BC-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B4%EC%9E%90">1000만건의 데이터를 대상으로 쿼리최적화 with. 복합인덱스, 커버링인덱스</a>
*/
@GetMapping("/monthly-order")
public ResponseEntity<ResponseDTO<List<MonthlyOrderStatistics>>> getMonthlyOrderStatistics(@RequestBody final StatisticsRequest request) {
return ResponseEntity.status(HttpStatus.OK).body(statisticsService.getMonthlyOrderStatistics(request));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.flab.offcoupon.domain.vo.persistence.statistics;

import java.math.BigDecimal;
import java.time.YearMonth;

/**
* MyBatis에서 여러개의 반환 값을 전달 받기 위한 VO
*
* 월별 주문 통계를 조회하기 위해 사용
*/
public record MonthlyOrderStatisticsVo(
YearMonth yearMonth,
long totalOrderCnt,
BigDecimal totalPaymentPrice,
long totalCouponUseCnt,
BigDecimal totalCouponPrice
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.flab.offcoupon.domain.vo.persistence.statistics;

import java.time.LocalDate;

public record MonthlyStatisticsParameterVo(
LocalDate startedAt,
LocalDate endedAt
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.flab.offcoupon.dto.request;

import lombok.*;

import java.time.LocalDate;

@Generated
@Getter
@EqualsAndHashCode
@RequiredArgsConstructor
public final class StatisticsRequest {
@NonNull
private final LocalDate startedAt;
@NonNull
private final LocalDate endedAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.flab.offcoupon.dto.response;

import com.flab.offcoupon.domain.vo.persistence.statistics.MonthlyOrderStatisticsVo;
import lombok.AllArgsConstructor;
import lombok.Getter;

import java.math.BigDecimal;
import java.time.YearMonth;


@Getter
@AllArgsConstructor
public final class MonthlyOrderStatistics {

private final YearMonth yearMonth;
private final long totalOrderCnt;
private final BigDecimal totalPaymentPrice;
private final long totalCouponUseCnt;
private final BigDecimal totalCouponPrice;

public MonthlyOrderStatistics(MonthlyOrderStatisticsVo vo) {
this.yearMonth = vo.yearMonth();
this.totalOrderCnt = vo.totalOrderCnt();
this.totalPaymentPrice = vo.totalPaymentPrice();
this.totalCouponUseCnt = vo.totalCouponUseCnt();
this.totalCouponPrice = vo.totalCouponPrice();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.flab.offcoupon.exception.statistics;

import com.flab.offcoupon.exception.CustomException;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;


@ResponseStatus(value = HttpStatus.BAD_REQUEST)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class LocalDateBadRequestException extends CustomException {

public LocalDateBadRequestException(String message) {
super(message);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.flab.offcoupon.exception.statistics;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class StatisticsErrorMessage {
public static final String START_MUST_BE_BEFORE_THANT_END = "시작일은 종료일보다 이전이어야 합니다. startedAt : %s, endedAt : %s";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.flab.offcoupon.exception.statistics;

import com.flab.offcoupon.util.ResponseDTO;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import static com.flab.offcoupon.exception.GlobalExceptionHandler.HTTP_REQUEST;

@Slf4j
@RestControllerAdvice
public class StatisticsExceptionHandler {
@ExceptionHandler(LocalDateBadRequestException.class)
public ResponseEntity<ResponseDTO<String>> couponNotFountException(LocalDateBadRequestException ex, HttpServletRequest request) {
log.info(HTTP_REQUEST, request.getMethod(), request.getRequestURI(),
ex.getMessage(), HttpStatus.BAD_REQUEST);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ResponseDTO.getFailResult(ex.getMessage()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.flab.offcoupon.repository.mysql;

import com.flab.offcoupon.domain.vo.persistence.statistics.MonthlyOrderStatisticsVo;
import com.flab.offcoupon.domain.vo.persistence.statistics.MonthlyStatisticsParameterVo;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface StatisticsRepository {

/**
* 월별 주문 통계를 조회합니다.
* <p>
* 다음과 같은 통계를 월별로 조회합니다:
* <ol>
* <li>조회하는 연도와 월</li>
* <li>주문 수량 총합</li>
* <li>주문 총 금액</li>
* <li>주문에 사용된 쿠폰 수량 총합</li>
* <li>주문에 사용된 쿠폰 총 금액</li>
* </ol>
*
* @param monthlyStatisticsParameterVo 월별 통계 조회 조건
* @return 월별 주문 통계
*/
List<MonthlyOrderStatisticsVo> getMonthlyOrderStatistics(final MonthlyStatisticsParameterVo monthlyStatisticsParameterVo);

}
Loading

0 comments on commit 00e6fca

Please sign in to comment.