💻기존 코드
주간 섭취 칼로리 조회 API는 요청한 주의 각 요일에 대한 MealType별 칼로리 값과, 전체 합산 칼로리 값을 반환한다.
월간/연관 섭취 칼로리 조회 API 역시 다음 메서드를 활용한다.
IntakeNutrientsService
public List<CaloriesOfMealType> searchWeeklyIntakeCalories(
Long memberId, LocalDate startDate, LocalDate endDate) {
memberQueryService.search(memberId);
return startDate
.datesUntil(endDate.plusDays(1))
.map(
date -> {
List<MealLog> mealLogs = findMealLog(memberId, date, date);
return sumCalorieByMealType(mealLogs);
})
.toList();
}
private List<MealLog> findMealLog(Long memberId, LocalDate startDate, LocalDate endDate) {
LocalDateTime startDateTime = startDate.atStartOfDay();
LocalDateTime endDateTime = endDate.atTime(23,59,59);
return mealLogRepository.findAllByMemberIdAndCreatedAtBetween(
memberId, startDateTime, endDateTime);
}
private CaloriesOfMealType sumCalorieByMealType(List<MealLog> mealLogs) {
Map<MealType, Integer> caloriesByMealTypeGroup = calcCaloriesByMealTypeGroup(mealLogs);
int snackTotalCalorie = sumSnackTotalCalorie(caloriesByMealTypeGroup);
return new CaloriesOfMealType(
caloriesByMealTypeGroup.getOrDefault(MealType.BREAKFAST, 0),
caloriesByMealTypeGroup.getOrDefault(MealType.LUNCH, 0),
caloriesByMealTypeGroup.getOrDefault(MealType.DINNER, 0),
snackTotalCalorie);
}
private Map<MealType, Integer> calcCaloriesByMealTypeGroup(List<MealLog> mealLogs) {
return mealLogs.stream()
.flatMap(
mealLog ->
mealLog.getMeals().stream()
.map(
meal ->
new AbstractMap.SimpleEntry<>(
mealLog.getMealType(),
meal.getCalorie())))
.collect(
Collectors.groupingBy(
Map.Entry::getKey, Collectors.summingInt(Map.Entry::getValue)));
}
MealLogRepository
List<MealLog> findAllByMemberIdAndCreatedAtBetween(
Long memberId, LocalDateTime startDate, LocalDateTime endDate);
기존 주간 섭취량 조회 API 로직은 위와 같다.
1. 기간 설정
입력 받은 startDate 부터 endDate까지 모든 날짜에 대해 작업을 수행한다.
2. 일별 조회
각 날짜에 해당하는 MealLog를 데이터베이스에서 조회한다.
이때, 하루에 해당하는 모든 MealLog를 가져오기 위해 날짜의 시작 시간 (`atStartDay`)와 종료 시간(`atTime( LocalTime.MAX))`를 사용해서 범위 검색을 수행한다.
3. 식사 유형(MealType)별 칼로리 계산
MealType은 아침,점심,저녁,아침간식,점심간식,저녁간식이 있다.
가져온 식사 기록(MealLog)로 부터 등록된 음식(Meal)들을 가져와 (MealType-calorie) 키와 쌍으로 Entry를 생성한다.
이후 식사 유형별로 calorie 값을 합산한다. 이를 통해 각 식사 유형별로 총 칼로리를 계산한다.
💻문제점
성능 테스트의 목표 지표는 다음과 같다.
- 요청에 대한 에러는 1% 미만
- 99% 요청을 1000ms 이내에 처리
Thread Properties
Number of Threads (users) | 100 |
Ramp-up period (seconds) | 60 |
Loop Count | infinite |
Duration(seconds) | 240 |
Constant Timer
설정: 1000ms
Constant Timer는 설정된 만큼 일정 시간을 지연해주는 기능을 한다.
실제 사용자들은 계속해서 요청을 보내지 않고, 대기하는 시간이 있는데 이를 Thinking Time이라고 한다.
정교한 Test Plan을 위해 추가했다.
Duration Assertion
설정: 1000ms
각 요청에 대한 응답 시간이 1000ms 이내인지 검증한다.
지정된 시간을 초과하여 응답이 온다면 해당 요청은 실패한 응답으로 처리 된다 (에러율 반영)
위와 같은 테스트를 수행할 때 MealLog는 약 100만건, Meal은 약 210만건 이다.
주간 섭취 칼로리 조회 API 성능
→ TPS 20.2/s
→ 평균 응답 시간 3304ms
→ 에러율 91.64%
월간 섭취 칼로리 조회 API 성능
→ TPS 27.5/s
→ 평균 응답 시간 2161ms
→ 에러율 88.34%
연간 섭취 칼로리 조회 API 성능
→ TPS 11.8/s
→ 평균 응답 시간 6329ms
→ 에러율 97.29%
성능 측정 결과, 주간/월간/연간 섭취 칼로리 조회 API의 평균 92%가 응답 시간 1000ms을 초과한다.
📃시도
cast 함수 (채택⭕→❌)
cast 함수를 사용하여 LocalDateTime 타입인 createdAt을 LocalDate 타입으로 변환했다.
이를 통해 서비스 코드에서 LocalDate → LocalDateTime의 형 변환을 수행하지 않도록 했다.
(이후 문제 발생으로 인해 변경된다)
@Query(" select m from MealLog m join fetch m.meals " +
"where m.member.id = :memberId " +
"and cast(m.createdAt as localdate) between :startDate and :endDate")
List<MealLog> findAllByMemberIdAndCreatedAtBetweenCastLocalTime(
Long memberId, LocalDate startDate, LocalDate endDate);
between 제거
cast를 통해 날짜로만 조회가 가능해졌으므로, between을 삭제했다.
@Query(" select m from MealLog m join fetch m.meals " +
"where m.member.id = :memberId " +
"and cast(m.createdAt as localdate) = :startDate")
List<MealLog> findAllByMemberIdAndCreatedAtCastLocalTime(
Long memberId, LocalDate startDate);
JPQL + DTO Projection (채택❌)
기존의 코드는 데이터베이스에서 가져온 MealLog를 통해 MealType별로 칼로리를 합산하는 복잡한 로직을 수행했다.
이는 낮은 응답 속도와 성능을 보였다.
나는 JPQL 사용해 데이터베이스 쿼리 단계에서 MealType별로 그룹핑해 calorie 값을 합산하도록 변경했고,
JPQL 쿼리 결과를 TotalCalorieOfMealType(임시이름)라는 Record 타입으로 매핑했다.
Projection; 테이블에서 원하는 컬럼만 뽑아서 조회
CaloriesOfMeal
public record TotalCalorieOfMealType(MealType mealType, Long totalCalories) {}
MealLogRepository
@Query("select new com.konggogi.veganlife.member.service.dto.TotalCalorieOfMealType(ml.mealType, SUM(m.calorie)) " +
"from MealLog ml join ml.meals m " +
"where ml.member.id = :memberId " +
"and cast(ml.createdAt as localdate) = :date " +
"group by ml.mealType")
List<TotalCalorieOfMealType> sumCaloriesOfMealTypeByMemberAndCreatedAt(
Long memberId, LocalDate date);
IntakeNutrientsService
private IntakeNutrients sumCalorieByMealTypeProjection(Long memberId, LocalDate date) {
List<TotalCalorieOfMealType> totalCalorieOfMealTypes = mealLogQueryService.searchByMemberAndDateProjection(memberId, date);
Map<MealType, Integer> caloriesMap = totalCalorieOfMealTypes.stream()
.collect(Collectors.toMap(CaloriesOfMeal::mealType, c -> c.totalCalories().intValue()));
return new IntakeNutrients(
caloriesMap.getOrDefault(MealType.BREAKFAST, 0),
caloriesMap.getOrDefault(MealType.LUNCH, 0),
caloriesMap.getOrDefault(MealType.DINNER, 0),
sumSnackTotalCalorie(caloriesMap));
}
하지만 이 방식은 package 경로를 적어줘야 했는데, 쿼리문이 너무 길어지고 가독성이 떨어졌다.
JPQL + Projection + Tuple (채택⭕→❌)
이 방법도 JPQL을 사용해 데이터베이스에서 MealType별로 그룹핑해 calories를 합산한다.
다른 점은 Tuple을 사용한 것이다.
MealLogRepository
@Query("select ml.mealType as mealType, SUM(m.calorie) as totalCalories " +
"from MealLog ml join ml.meals m " +
"where ml.member.id = :memberId " +
"and cast(ml.createdAt as localdate) = :date " +
"group by ml.mealType")
List<Tuple> sumCaloriesOfMealTypeByMemberAndCreatedAt(
Long memberId, LocalDate date);
IntakeNutrientsService
private IntakeNutrients sumCalorieByMealTypeTuple(Long memberId, LocalDate date) {
List<Tuple> caloriesOfMealTypes = mealLogQueryService.searchByMemberAndDate(memberId, date);
Map<MealType, Integer> caloriesMap = new EnumMap<>(MealType.class);
for (Tuple tuple : caloriesOfMealTypes) {
MealType mealType = tuple.get("mealType", MealType.class);
Integer totalCalories = tuple.get("totalCalories", Long.class).intValue();
caloriesMap.put(mealType, totalCalories);
}
return new IntakeNutrients(
caloriesMap.getOrDefault(MealType.BREAKFAST, 0),
caloriesMap.getOrDefault(MealType.LUNCH, 0),
caloriesMap.getOrDefault(MealType.DINNER, 0),
sumSnackTotalCalorie(caloriesMap));
}
이 방법은 데이터에 접근할 때 키 값이나 인덱스로 접근해야 했지만, 쿼리문은 JPQL + DTO Projection보다 깔끔하게 작성할 수 있었다. (그리고 금방 QueryDsl과 Dto Projection을 활용하는 방식으로 변경했다...)
복합 인덱스 설정(채택⭕)
MealLog 테이블에 member_id와 created_at 컬럼으로 복합 인덱스를 설정했다.
@Entity
@Getter
@Table(name = "meal_log", indexes = {
@Index(name = "idx_meal_log_on_member_created_at", columnList = "member_id, createdAt")
})
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MealLog extends TimeStamped {...}
MealLog에 대해서 member_id와 created_at을 사용한 조회가 자주 발생한다.
조회 수행 시에 group by를 사용하는데, group by는 컬럼이 정렬되어 있으면 속도가 빨라지기 때문에 인덱스를 설정해 성능을 향상하고자 했다.
그러나, 성능 개선이 있을거라고 생각한 것과 다르게 성능 차이가 없었다.
그 이유는 cast 함수 사용 때문이었다.
cast 함수를 사용하면 해당 컬럼의 데이터 타입을 변환하게 되는데, 변환된 타입에 대한 인덱스를 찾을 수 없어 사용하지 못한다. 그래서 인덱스 적용 전과 큰 차이가 없었던 것...
QueryDSL + DTO Projection (채택⭕)
이후 Projection 방식에 대해 조사해보았는데, 현재 내가 개발한 코드처럼 JPQL + Tuple을 잘 사용하는 것 같진 않았다.
이 방법은 데이터에 접근할 때 키 값이나 인덱스로 접근해야해서 휴먼 에러를 발생시킬 위험이 있었다.
DTO Projection은 컴파일 시점에 체크하지만, Tuple은 런타임 시점에 오류가 발생할 수 있어 안정성이 떨어진다.
또한, Tuple 자체는 쿼리 결과를 담는 것으로 Repository가 아닌 서비스 계층으로의 전달은 적합하지 않다고 한다.
이런 이유로 DTO Projection을 사용하기로 결정했고, QueryDsl을 사용해 Dto Projection 하였다.
TotalCalorieOfMealType
@Getter
@NoArgsConstructor
public class TotalCalorieOfMealType {
private MealType mealType;
private Integer totalCalorie;
}
MealLogCustomRepository
public interface MealLogCustomRepository {
List<TotalCalorieOfMealType> sumCaloriesOfMealTypeByMemberIdAndCreatedAtBetween(
Long memberId, LocalDateTime startDateTime, LocalDateTime endDateTime);
}
MealLogCustomRepositoryImpl
@Repository
public class MealLogCustomRepositoryImpl extends QuerydslRepositorySupport
implements MealLogCustomRepository {
public MealLogCustomRepositoryImpl() {
super(MealLog.class);
}
@Override
public List<TotalCalorieOfMealType> sumCaloriesOfMealTypeByMemberIdAndCreatedAtBetween(
Long memberId, LocalDateTime startDateTime, LocalDateTime endDateTime) {
return from(mealLog)
.join(mealLog.meals, meal)
.where(
mealLog.member
.id
.eq(memberId)
.and(mealLog.createdAt.between(startDateTime, endDateTime)))
.groupBy(mealLog.mealType)
.select(
Projections.fields(
TotalCalorieOfMealType.class,
mealLog.mealType,
meal.calorie.sum().as("totalCalorie")))
.fetch();
}
}
MealLogRepository
@Repository
public interface MealLogRepository extends JpaRepository<MealLog, Long>, MealLogCustomRepository {
// 다른 메서드
}
Projections.fields를 사용하여 TotalCalorieOfMealType 클래스의 필드명을 사용해 데이터를 넣어주었다.
필드 이름이 다른 경우는 위와 같이 alias를 사용해 맞춰주어야 한다.
🔍해결
위에서 채택된 내용을 적용하고, 성능을 측정해보았다.
주간 섭취 칼로리 조회 API 성능
→ TPS 83.8/s
→ 평균 응답 시간 39ms
→ 에러율 0.0%
월간 섭취 칼로리 조회 API 성능
→ TPS 84.7/s
→ 평균 응답 시간 27ms
→ 에러율 0.0%
연간 섭취 칼로리 조회 API 성능
→ TPS 74.4/s
→ 평균 응답 시간 164ms
→ 에러율 0.20%
👊정리
주간 섭취 칼로리 조회 API
리팩토링 전 | 리팩토링 후 | |
TPS | 20.2/s | 83.8/s |
평균 응답 시간 | 3304ms | 39ms |
에러율 | 91.64% | 0.0% |
월간 섭취 칼로리 조회 API
리팩토링 전 | 리팩토링 후 | |
TPS | 27.5/s | 84.7/s |
평균 응답 시간 | 2161ms | 27ms |
에러율 | 88.34% | 0.0% |
연간 섭취 칼로리 조회 API
리팩토링 전 | 리팩토링 후 | |
TPS | 11.8/s | 74.4/s |
평균 응답 시간 | 6329ms | 164ms |
에러율 | 97.29% | 0.20% |
'TIL' 카테고리의 다른 글
[TIL - 20240612] Swagger Failed to load remote configuration 해결 (0) | 2024.06.12 |
---|---|
[TIL-231219] 사용자 정의 애노테이션을 사용한 List 요소 검증 (0) | 2024.03.20 |
[TIL - 20240110] 컨트롤러 나누기?! (0) | 2024.01.10 |
[TIL - 20240103] Spring Data JPA @Modifying 문제 (0) | 2024.01.03 |
[TIL - 20231228] Interface의 default 메서드를 활용한 Enum 확장 (0) | 2023.12.28 |