Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#14] 복잡한 쿼리 최적화 & 동적 스케줄링으로 변경 #17

Merged
merged 13 commits into from
Apr 28, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 != null) ? vo.yearMonth() : null;
this.totalOrderCnt = (vo != null) ? vo.totalOrderCnt() : 0;
this.totalPaymentPrice = (vo != null) ? vo.totalPaymentPrice() : BigDecimal.ZERO;
this.totalCouponUseCnt = (vo != null) ? vo.totalCouponUseCnt() : 0;
this.totalCouponPrice = (vo != null) ? vo.totalCouponPrice() : BigDecimal.ZERO;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

생성자 파라미터로 null이 넘어와야하는 경우가 있나요? 만약 null이 넘어온다면 오류인 상황일 것으로 보이는데, 이 경우에는 명시적으로 에러를 throw해주는게 좋습니다. 이런식으로 단언문을 사용하기도 하니 참고해주세요~

Suggested change
public MonthlyOrderStatistics(MonthlyOrderStatisticsVo vo) {
this.yearMonth = (vo != null) ? vo.yearMonth() : null;
this.totalOrderCnt = (vo != null) ? vo.totalOrderCnt() : 0;
this.totalPaymentPrice = (vo != null) ? vo.totalPaymentPrice() : BigDecimal.ZERO;
this.totalCouponUseCnt = (vo != null) ? vo.totalCouponUseCnt() : 0;
this.totalCouponPrice = (vo != null) ? vo.totalCouponPrice() : BigDecimal.ZERO;
}
public MonthlyOrderStatistics(MonthlyOrderStatisticsVo vo) {
Assert.notNull(vo, "생성자 파라미터는 null일 수 없습니다");
...
}

Copy link
Collaborator Author

@codesejin codesejin Apr 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 제가 조회에서 데이터 가져올때 어차피 1개월 기준으로 받아오니까 List가 아닌 객체로 받으면 되지 않을까 싶었는데요!
List가 아닌 객체로 받아오니까 해당하는 기간 범위에 데이터가 없을때에 객체가 Null이 되버리는 문제가 있어서 추가했었습니다!

그런데, List로 받아오면 애초에 null인 객체가 List에 추가되지 않아서 List로 반환하는걸로 확정 했습니다.
그래서 말씀해주신 Null 체크는 없애도록 하겠습니다! 더 좋은 코드 추천해주셔서 감사합니다~!

}
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
Loading