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"
},
...
]
}
}
}
그리고 기존의 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 |
---|