숑숑이의 개발일기
article thumbnail
Published 2024. 1. 3. 23:18
대시보드 API 성능 개선기 Etc/Project

spring boot + react를 사용하는 프로젝트가 막을 내렸다. 그동안 시간이 부족해서 뒤로 미뤄뒀던 대시보드 API 구조개선, 속도개선의 과정을 작성해보도록 한다.

 

대시보드에 필요한 데이터로는 명언, 사용자의 신체정보 및 감정정보, 최근 7일의 분류별 섭취칼로리, 최근 7/30일, 12개월간 운동시간, 운동 소모칼로리, 수면시간이 있었다.

 

첫번째 시도 : 일단 무식하게라도

우선 차트에 그릴 걸 생각해서 가장 날짜 테이블을 사용했다. 그렇게해서 만들어진 중구난방 response. 소요되는 시간은 2.8s로 느린것과 별개로 메인페이지이므로 속도개선이 필연적이라고 느꼈다.

{
    "code": 200,
    "httpStatus": "OK",
    "message": "조회 성공",
    "data": {
        "sleep": {
            "monthly": [
                {
                    "totalTime": 0,
                    "date": "2023-12-04"
                },
                {
                    "totalTime": 0,
                    "date": "2023-12-05"
                },
                {
                    "totalTime": 0,
                    "date": "2023-12-06"
                },
                {
                    "totalTime": -19980,
                    "date": "2023-12-07"
                }
                ...
            ],
            "yearly": [
                {
                    "totalTime": 0,
                    "date": "2023-02"
                },
                {
                    "totalTime": 0,
                    "date": "2023-03"
                },
                ...
            ],
            "weekly": [
                {
                    "totalTime": 0,
                    "date": "2023-12-27"
                },
                {
                    "totalTime": 0,
                    "date": "2023-12-28"
                },
                {
                    "totalTime": 0,
                    "date": "2023-12-29"
                },
                ..
            ]
        },
        "default": {
            "pvTalker": "리첼 E. 구드리치",
            "pvContents": "자. 오늘이라는 완전이 새로운 날이 찾아왔다. 일어나 다시 시작해야 할 완벽한 이유다.",
            "score": 0,
            "burnKcal": 0.0,
            "wishEatKcal": 1600,
            "wishBurnKcal": 2000,
            "eatKcal": 0.0
        },
        "menuList": [
            {
                "kcalBreakfast": 0,
                "kcalLunch": 0,
                "kcalDinner": 0,
                "kcalSnack": 0,
                "date": "2023-12-27"
            },
            {
                "kcalBreakfast": 0,
                "kcalLunch": 0,
                "kcalDinner": 0,
                "kcalSnack": 0,
                "date": "2023-12-28"
            },
            ...
        ],
        "exercise": {
            "monthly": [
                {
                    "totalKcal": 0.0,
                    "totalTime": 0,
                    "date": "2023-12-04"
                },
                {
                    "totalKcal": 0.0,
                    "totalTime": 0,
                    "date": "2023-12-05"
                },
                {
                    "totalKcal": 0.0,
                    "totalTime": 0,
                    "date": "2023-12-06"
                },
                ...
            ],
            "yearly": [
                {
                    "totalKcal": 0.0,
                    "totalTime": 0,
                    "date": "2023-02"
                },
                {
                    "totalKcal": 0.0,
                    "totalTime": 0,
                    "date": "2023-03"
                },
                {
                    "totalKcal": 0.0,
                    "totalTime": 0,
                    "date": "2023-04"
                },
                {
                    "totalKcal": 0.0,
                    "totalTime": 0,
                    "date": "2023-05"
                },
                {
                    "totalKcal": 0.0,
                    "totalTime": 0,
                    "date": "2023-06"
                },
                {
                    "totalKcal": 0.0,
                    "totalTime": 0,
                    "date": "2023-07"
                },
                {
                    "totalKcal": 0.0,
                    "totalTime": 0,
                    "date": "2023-08"
                },
                {
                    "totalKcal": 0.0,
                    "totalTime": 0,
                    "date": "2023-09"
                },
                ...
            ],
            "weekly": [
                {
                    "totalKcal": 0.0,
                    "totalTime": 0,
                    "date": "2023-12-27"
                },
                {
                    "totalKcal": 0.0,
                    "totalTime": 0,
                    "date": "2023-12-28"
                },
                {
                    "totalKcal": 0.0,
                    "totalTime": 0,
                    "date": "2023-12-29"
                },
                ...
            ]
        }
    }
}

 

포스트맨에서는 2.5s 소요

 

그리고 기존의 service 코드다.

 @Transactional
    public ResultDto getDashboardInfo(Long memSeq, String date) {
        Member member = memberRepository.findById(memSeq).orElseThrow(() -> new CustomException(CustomExceptionCode.NOT_FOUND_USER));

        HashMap<String, Object> data = new HashMap<>();

        List<ExerciseStatsNativeVo> exerciseStatsWeeklyList =  exerciseRepository.findLastDaysExerciseStats(member.getMemSeq(), date, 6);
        List<ExerciseStatsNativeVo> exerciseStatsMonthlyList =  exerciseRepository.findLastDaysExerciseStats(member.getMemSeq(), date, 29);
        List<ExerciseStatsNativeVo> exerciseStatsYearlyList =  exerciseRepository.findLastMonthsExerciseStats(member.getMemSeq(), date, 11);

        HashMap<String, Object> exerciseMap = new HashMap<>();
        exerciseMap.put("weekly", exerciseStatsWeeklyList);
        exerciseMap.put("monthly", exerciseStatsMonthlyList);
        exerciseMap.put("yearly", exerciseStatsYearlyList);

        List<SleepStatsNativeVo> sleepStatsWeeklyList =  sleepRepository.findLastDaysSleepStats(member.getMemSeq(), date, 6);
        List<SleepStatsNativeVo> sleepStatsMonthlyList =  sleepRepository.findLastDaysSleepStats(member.getMemSeq(), date, 29);
        List<SleepStatsNativeVo> sleepStatsYearlyList =  sleepRepository.findLastMonthsSleepStats(member.getMemSeq(), date, 11);

        HashMap<String, Object> sleepMap = new HashMap<>();
        sleepMap.put("weekly", sleepStatsWeeklyList);
        sleepMap.put("monthly", sleepStatsMonthlyList);
        sleepMap.put("yearly", sleepStatsYearlyList);

        Optional<DashboardDefaultNativeVo> defaultData = exerciseRepository.findDefaultData(member.getMemSeq(), date);
        List<MenuStatsNativeVo> menuStatsNativeVoList = menuRepository.find7DaysMenuStats(member.getMemSeq(), date);

        data.put("exercise", exerciseMap);
        data.put("sleep", sleepMap);    
        data.put("default", defaultData);
        data.put("menuList", menuStatsNativeVoList);

        ResultDto resultDto = buildResultDto(200, HttpStatus.OK, "조회 성공", data);

        return resultDto;
    }

 

각각 

 

두번째 시도 : API를 분리해볼까?

명언은 화면의 최상단에 위치해있어 조금이라도 빨리 화면을 노출시키기 위해서 명언 API를 따로 분리했다. 그러나... 이렇게 두개의 URL을 사용하다보니 시간이 3.5s로 늘어나는 사태가 일어났다.

그리고 명언 API만 따로 분리하는것이 무슨 의미가 있을까? 사용자에게는 차트 데이터가 더 중요할 것이다.

 

랜덤 명언 단건 조회 service 코드다.

명언은 30개이내로 존재하여 이렇게 구현해봤다. MYSQL의 RAND()를 사용하여도 비슷한 속도.

public ResultDto getRandomPositive() {
        List<Positive> allPositive = positiveRepository.findAll();
        Positive onePositive = null;
        if (!allPositive.isEmpty()) {
            Random random = new Random();
            onePositive = allPositive.get(random.nextInt(allPositive.size()));
        }

        HashMap<String, Object> data = new HashMap<>();
        data.put("Positive", onePositive);
        ResultDto resultDto = buildResultDto(200, HttpStatus.OK, "조회 성공", data);
        return resultDto;
    }

 

세번째 시도 : 모두 합쳐서 DB에 요청을 최소화하자

응답 본문에는 최근 7일간의 데이터가 많다. 현재 구조는 불필요하게 DB를 많이 다녀온다.

그림에서 위의 형태가 DB를 3번 다녀오는 개선전 형태이다. 아래의 형태로 변경해 한번에 가져오기로 했다.

 

기존 쿼리의 동일한 부분에서 select 문을 작성하니 연관관계가 없는 테이블이라 연산 오류가 발생했다.

WITH RECURSIVE DateRange AS (
  SELECT CAST('2023-12-24' AS DATE) AS date
  UNION ALL
  SELECT date - INTERVAL 1 DAY
  FROM DateRange
  WHERE date > CAST('2023-12-24' AS DATE) - INTERVAL 6 DAY
)
SELECT
  DateRange.date,
  COALESCE(SUM(TIMESTAMPDIFF(MINUTE, tb_sleep.sleep_godate, tb_sleep.sleep_wudate)), 0) AS totalTime,
  SUM(CASE WHEN ml.menu_when = 1 THEN ROUND(ml.menu_gram / 100 * food.food_kcal) ELSE 0 END) AS kcalBreakfast,
  SUM(CASE WHEN ml.menu_when = 2 THEN ROUND(ml.menu_gram / 100 * food.food_kcal) ELSE 0 END) AS kcalLunch,
  SUM(CASE WHEN ml.menu_when = 3 THEN ROUND(ml.menu_gram / 100 * food.food_kcal) ELSE 0 END) AS kcalDinner,
  SUM(CASE WHEN ml.menu_when = 4 THEN ROUND(ml.menu_gram / 100 * food.food_kcal) ELSE 0 END) AS kcalSnack
FROM
  DateRange
LEFT JOIN
  tb_sleep ON DateRange.date = DATE(tb_sleep.sleep_wudate) AND tb_sleep.mem_seq = 120
LEFT JOIN
  tb_menu_list AS ml ON DateRange.date = ml.menu_date AND ml.mem_seq = 120
LEFT JOIN
  tb_food AS food ON ml.food_seq = food.food_seq
GROUP BY
  DateRange.date
ORDER BY
  DateRange.date;

 

개선후 쿼리

WITH RECURSIVE daterange AS (
  SELECT CAST('2023-12-24' AS DATE) AS date
  UNION ALL
  SELECT date - INTERVAL 1 DAY
  FROM daterange
  WHERE date > CAST('2023-12-24' AS DATE) - INTERVAL 6 DAY
)
SELECT daterange.date,
       Coalesce(sleep.totaltime, 0)    AS sleepTime,
       Coalesce(exercise.totaltime, 0) AS exerciseTime,
       Coalesce(exercise.totalkcal, 0) AS exerciseKcal,
       Coalesce(ml.kcalbreakfast, 0)   AS breakfastKcal,
       Coalesce(ml.kcallunch, 0)       AS lunchKcal,
       Coalesce(ml.kcaldinner, 0)      AS dinnerKcal,
       Coalesce(ml.kcalsnack, 0)       AS snackKcal
FROM   daterange
       LEFT JOIN (SELECT Date(sleep_wudate)                                AS
                         sleep_date,
                         Timestampdiff(minute, sleep_godate, sleep_wudate) AS
                         totalTime
                  FROM   tb_sleep
                  WHERE  mem_seq = 62) AS sleep
              ON daterange.date = sleep.sleep_date
       LEFT JOIN (SELECT el.el_date,
                         Sum(el.el_time) AS totalTime,
                         ( Round(Sum(calc.ec_calc * ( 3.5 * bd.body_weight *
                                                    el.el_time ) / 1000
                                     * 5), 2)
                         )               AS totalKcal
                  FROM   tb_exercise_list AS el
                         JOIN tb_exercise_calc AS calc
                           ON el.ec_seq = calc.ec_seq
                         JOIN tb_body AS bd
                           ON el.mem_seq = bd.mem_seq
                  WHERE  el.mem_seq = 62
                  GROUP  BY el.el_date) AS exercise
              ON daterange.date = exercise.el_date
       LEFT JOIN (SELECT menu_date,
                         Sum(CASE
                               WHEN menu.menu_when = 1 THEN Round(
                               menu.menu_gram / 100 * food.food_kcal)
                               ELSE 0
                             end) AS kcalBreakfast,
                         Sum(CASE
                               WHEN menu.menu_when = 2 THEN Round(
                               menu.menu_gram / 100 * food.food_kcal)
                               ELSE 0
                             end) AS kcalLunch,
                         Sum(CASE
                               WHEN menu.menu_when = 3 THEN Round(
                               menu.menu_gram / 100 * food.food_kcal)
                               ELSE 0
                             end) AS kcalDinner,
                         Sum(CASE
                               WHEN menu.menu_when = 4 THEN Round(
                               menu.menu_gram / 100 * food.food_kcal)
                               ELSE 0
                             end) AS kcalSnack
                  FROM   tb_menu_list AS menu
                         JOIN tb_food AS food
                           ON menu.food_seq = food.food_seq
                  WHERE  menu.mem_seq = 62
                  GROUP  BY menu.menu_date) AS ml
              ON daterange.date = menu_date
GROUP  BY daterange.date
ORDER  BY daterange.date;

개선 후는 서브쿼리를 사용하여 group by절로 묶어 연산을 수행하고, 마지막에 일자 테이블과 LEFT JOIN을 수행했다. 값이 없는경우 0이 노출되도록 Colaesce 함수를 사용했다.

 

이렇게 가장 좌측은 가상 일자 테이블로 최근 7일의 일자를 고정하고, 연관관계 없는 테이블에서 결과값이 정상적으로 출력되는 것을 확인할 수 있었다.

 

 

그리고 최근 30일, 12개월 데이터는 api를 따로 정의하려고 한다.

이렇게해서 대시보드 API 구조 변경 및 중복 쿼리 최소화를 통해 2.8s -> 1.3s 115%의 성능개선을 이뤄냈다!

 

서비스 코드도 굉장히 깔끔해졌다! 

@Transactional
    public ResultDto getDashboardInfo(Long memSeq, String date) {
        HashMap<String, Object> data = new HashMap<>();

        List<DefaultChartVo> chartDataList = exerciseRepository.find7DaysChartData(memSeq, date);
        DashboardDefaultNativeVo defaultData = exerciseRepository.findDefaultData(memSeq, date);

        data.put("chart", chartDataList);
        data.put("default", defaultData);

        return ResultDto.of(HttpStatus.OK.value(), HttpStatus.OK, "성공", data);
    }

 

이전에 많이 미숙했어서 성능개선이라고 말할 수 있을까 부끄럽지만 그래도 이번 대시보드 API를 구현하며 WITH RECURSIVE를 활용한 임시 일자 테이블과의 데이터를 얻어낼 수 있는 방법을 알게됐다.

 

이후엔 서브쿼리가 아닌 일반 JOIN으로 해결할 수 있는지 확인해보고, 다시 한 번 개선해보려 한다!

'Etc > Project' 카테고리의 다른 글

[프로젝트] 국비지원 1차 프로젝트 소소한 회고  (0) 2023.06.27
profile

숑숑이의 개발일기

@숑숑-

풀스택 개발자 준비중입니다