상황
저번에 작성한 Routine Medicine 테이블의 insert문 배치 쿼리 적용에 이어서 연관관계 매핑을 위해서
Routine 엔티티를 조회해야 한다. 따라서 Routine을 조회하여, 존재한다면 매핑, 존재하지 않다면 새로운 Routine을 생성하고 Routine Medicine에 매핑하는 로직을 작성하였다.
아래 함수를 사용자 스케줄 엔티티, 날짜 리스트에 대한 중첩 for문을 통해서 모든 경우에 대해서 요청을 하고 있는 상태이다.
public RoutineEntity getRoutineByUserScheduleAndTakeDate(UserEntity userEntity, UserScheduleEntity userScheduleEntity, LocalDate takeDate) {
return routineRepository.findByUserScheduleIdAndTakeDate(userScheduleEntity.getId(), takeDate)
.orElseGet(() -> {
RoutineEntity newRoutineEntity = RoutineEntity.builder()
.user(userEntity)
.userSchedule(userScheduleEntity)
.takeDate(takeDate)
.build();
return routineRepository.save(newRoutineEntity);
});
}
이 경우 하나의 요청에 대해서 조회 쿼리가 많이 발생하기 때문에, 많은 네트워크 비용, DB 부하가 예상되었다.
따라서 조회 코드도 배치 처리를 할 수 있도록 수정하였다.
분석
루틴 조회 쿼리 구현 방법을 크게 4가지로 나누었다.
- UserSchedule * Dates의 수만큼 단일 쿼리 요청 → 기존방법 -> 30번의 네트워크 요청, 30번의 쿼리, 쿼리당 낮은 비용
- UserSchedule * Dates에 대해서 IN절을 활용하여 데이터를 한번에 요청 -> 1번의 네트워크 요청, 1번의 쿼리, 쿼리당 매우 높은 비용
- UserSchedule 리스트를 루프로 돌면서 Dates에 대해서 데이터를 한번에 요청
- → UserSchedule 리스트 size 만큼의 네트워크 요청, 쿼리당 비교적 높은 비용
- Dates 리스트를 루프를 돌면서 UserSchedule Ids에 대해서 데이터를 한번에 요청
- → Dates 리스트 size 만큼의 네트워크 요청, 쿼리당 비교적 높은 비용
어떤 방법이 더 효율적인지 비용을 계산해 보기로 하였다.
3번과 4번은 유사한 방법이나 Dates 기준으로 루틴을 정렬하기 때문에, 4번 방법과 2번 방법을 비교해보겠다.
기존방법 - 30번의 조회 쿼리
SQL 문을 작성하였으며, explain을 통해 비용 범위를 파악하여 비교하기로 하였다.
기존에 사용하던 단일 쿼리를 반복문의 순회수만큼 요청하는 방법이다.
explain select *
from routine
join user_schedule us on routine.user_id = us.user_id
where routine.user_id=7
and routine.user_schedule_id =37;
쿼리 비용은 아래와 같다.
총 30번의 요청을 보내기 때문에, 최대 3.47*30 + 30번의 네트워크 비용이 발생한다.
2번방법 - 조회 쿼리를 하나의 배치 쿼리로 요청
이어서 필요한 조건을 모두 추가한 2번 방법이다.
explain select *
from routine
join user_schedule us on routine.user_id = us.user_id
where routine.user_id=7
and routine.user_schedule_id in (37, 38, 39)
and routine.take_date in (
'2025-03-18', '2025-03-19', '2025-03-20','2025-03-21','2025-03-22','2025-03-23','2025-03-24','2025-03-25',
'2025-03-26','2025-03-27','2025-03-28'
);
결과는 아래와 같다. 최대 4.17+1번의 네트워크 비용이 발생한다.
4번방법 - 여러 개의 배치 쿼리 요청
explain select *
from routine
join user_schedule us on routine.user_id = us.user_id
where routine.user_id=7
and routine.user_schedule_id in (37, 38, 39);
최대 3.47의 비용이 발생한다.
2번, 4번 방법 비교
4번 방법의 경우 외부에서 Dates 수만큼 요청하게 된다.
따라서 2번 방법과 대략적으로 비교한다면, 아래와 같다.
4.17+네트워크 비용 < 3.47*dates의 수 + dates의 수만큼의 네트워크 비용.
압도적으로 2번 방법이 효율이 좋다고 판단하였다.
기존 방법에 비해서 3.47*30 / 4.17 = 24.96, 즉 약 25배 이상의 성능 향상이 있다.
추가 비교
그렇다면 배치 쿼리를 한번에 요청하는 것이 여러번 요청보다 항상 성능이 좋을까?
그것은 아니다.
현재는 간단한 조건이지만, 쿼리에 사용된 IN절의 경우, 조건이 100개, 1000개 늘어날 수록 효율이 현저히 떨어지게된다.
인덱스를 통해 어느정도 커버가 가능하지만, 입력 조건이 많아지면 DB연산이 늘어나게되고, 인덱스의 의미가 사라지게 된다.
따라서 현재 시스템 규모에 따라 더 효율적인 방법을 찾아서 사용해야한다.
구현
데이터베이스 쿼리 구현
배치 조회를 이용한 쿼리 방식을 JPQL를 통하여 구현하였다.
RoutineRepository.java
@Query(value = "SELECT r from RoutineEntity r " +
"where r.user.id=:userId " +
"and r.userSchedule.id in :userScheduleIds " +
"and r.takeDate in :takeDates")
List<RoutineEntity> findAllByByUserIdUserScheduleIdsAndTakeDates(
@Param("userId") Long userId,
@Param("userScheduleIds") List<Long> userScheduleIds,
@Param("takeDates") List<LocalDate> takeDates
);
사용자의 스케줄, 날짜에 포함되는 모든 루틴 리스트를 가져오는 쿼리를 작성하였다.
최종 목표는 사용자 루틴을 조회하고, 루틴이 존재한다면 Routine Medicine과 연관관계,
존재하지 않다면 Routine을 새로 생성 → 연관관계 매핑을 구현하는 것이다.
Service 로직 구현
RoutineRepository를 통해 얻어온 RoutineEntity 리스트를 통해 위에 설명한 목표를 구현하였다.
/**
* 루틴 조회 배치 처리
* 필요한 루틴들을 가져오고, 루틴이 존재하지 않다면 배치로 생성
* */
public Map<String ,RoutineEntity> getRoutinesWithUserSchedulesAndTakeDates(Long userId, List<UserScheduleEntity> userScheduleEntities, List<LocalDate> takeDates) {
UserEntity userEntity = userService.getUserById(userId);
List<Long> userScheduleIds = userScheduleEntities.stream().map(UserScheduleEntity::getId).toList();
List<RoutineEntity> existingRoutines=routineRepository.findAllByByUserIdUserScheduleIdsAndTakeDates(userId, userScheduleIds, takeDates);
Map<String, RoutineEntity> routineMap = existingRoutines.stream()
.collect(Collectors.toMap(
r -> r.getUserSchedule().getId() + "_" + r.getTakeDate(),
r -> r
));
List<RoutineEntity> newRoutines = new ArrayList<>();
for (UserScheduleEntity schedule : userScheduleEntities) {
for (LocalDate takeDate : takeDates) {
String key = schedule.getId() + "_" + takeDate;
// 루틴이 없으면 생성하여 리스트에 추가
routineMap.computeIfAbsent(key, k -> {
RoutineEntity newRoutine = RoutineEntity.builder()
.user(userEntity)
.userSchedule(schedule)
.takeDate(takeDate)
.build();
newRoutines.add(newRoutine);
return newRoutine;
});
}
}
// 새로 생성된 루틴 저장 (배치 처리)
if (!newRoutines.isEmpty()) {
routineRepository.saveAll(newRoutines);
}
return routineMap;
}
상세 설명
- 필요한 사용자의 루틴 리스트를 조회한다.
- 이 리스트에서 찾으려는 루틴을 빠르게 찾기 위해서 Map 형태로 변환시킨다.
- Schedule과 TakeDate 리스트를 순회하면서 Routine 리스트에 필요한 Routine이 존재하지 않는 경우 저장 전용 리스트와 Map에 추가한다.
- 저장 전용 루틴에 대해서 saveAll을 통해 영속성 컨텍스트에 저장 요청한다.
- 필요한 루틴의 정보가 담긴 Map을 반환한다.
결과
이 과정을 통해 30개의 약 루틴을 저장하는 상황에서 30개의 조회 쿼리 → 1개의 조회 쿼리로 대폭 감소하게 되었다.
성능 향상 파악
Insert, Select 쿼리에 배치 적용
30개의 요청 0.181초
2025-03-18 14:53:37.719 [http-nio-8080-exec-1] INFO c.m.d.r.controller.RoutineController - 루틴 저장 시작
2025-03-18 14:53:37.890 [http-nio-8080-exec-1] INFO c.m.d.r.controller.RoutineController - 루틴 저장 끝
2025-03-18 16:50:38.707 [http-nio-8080-exec-5] INFO c.m.d.r.controller.RoutineController - 루틴 저장 시작
2025-03-18 16:50:38.798 [http-nio-8080-exec-5] INFO c.m.d.r.controller.RoutineController - 루틴 저장 끝
300개의 요청 0.465
2025-03-18 14:54:24.765 [http-nio-8080-exec-3] INFO c.m.d.r.controller.RoutineController - 루틴 저장 시작
2025-03-18 14:54:25.230 [http-nio-8080-exec-3] INFO c.m.d.r.controller.RoutineController - 루틴 저장 끝
2025-03-18 16:49:58.214 [http-nio-8080-exec-3] INFO c.m.d.r.controller.RoutineController - 루틴 저장 시작
2025-03-18 16:49:58.456 [http-nio-8080-exec-3] INFO c.m.d.r.controller.RoutineController - 루틴 저장 끝
3000개의 요청 3.878
2025-03-18 14:55:02.299 [http-nio-8080-exec-5] INFO c.m.d.r.controller.RoutineController - 루틴 저장 시작
2025-03-18 14:55:06.277 [http-nio-8080-exec-5] INFO c.m.d.r.controller.RoutineController - 루틴 저장 끝
2025-03-18 16:48:45.419 [http-nio-8080-exec-1] INFO c.m.d.r.controller.RoutineController - 루틴 저장 시작
2025-03-18 16:48:47.177 [http-nio-8080-exec-1] INFO c.m.d.r.controller.RoutineController - 루틴 저장 끝
Insert 배치만 적용하였을 때
30개 요청
2025-03-18 14:58:07.802 [http-nio-8080-exec-1] INFO c.m.d.r.controller.RoutineController - 루틴 저장 시작
2025-03-18 14:58:08.198 [http-nio-8080-exec-1] INFO c.m.d.r.controller.RoutineController - 루틴 저장 끝
300개 요청
2025-03-18 14:58:53.446 [http-nio-8080-exec-3] INFO c.m.d.r.controller.RoutineController - 루틴 저장 시작
2025-03-18 14:58:56.213 [http-nio-8080-exec-3] INFO c.m.d.r.controller.RoutineController - 루틴 저장 끝
3000개 요청
2025-03-18 14:59:32.127 [http-nio-8080-exec-5] INFO c.m.d.r.controller.RoutineController - 루틴 저장 시작
2025-03-18 15:00:20.018 [http-nio-8080-exec-5] INFO c.m.d.r.controller.RoutineController - 루틴 저장 끝
루틴 Select 배치만 적용하였을 때
30개
2025-03-18 15:05:12.211 [http-nio-8080-exec-1] INFO c.m.d.r.controller.RoutineController - 루틴 저장 시작
2025-03-18 15:05:12.571 [http-nio-8080-exec-1] INFO c.m.d.r.controller.RoutineController - 루틴 저장 끝
300개
2025-03-18 15:05:48.458 [http-nio-8080-exec-3] INFO c.m.d.r.controller.RoutineController - 루틴 저장 시작
2025-03-18 15:05:51.402 [http-nio-8080-exec-3] INFO c.m.d.r.controller.RoutineController - 루틴 저장 끝
3000개
2025-03-18 15:06:51.956 [http-nio-8080-exec-8] INFO c.m.d.r.controller.RoutineController - 루틴 저장 시작
2025-03-18 15:07:17.729 [http-nio-8080-exec-8] INFO c.m.d.r.controller.RoutineController - 루틴 저장 끝
기존
30개 요청
2025-03-18 15:08:23.543 [http-nio-8080-exec-1] INFO c.m.d.r.controller.RoutineController - 루틴 저장 시작
2025-03-18 15:08:24.154 [http-nio-8080-exec-1] INFO c.m.d.r.controller.RoutineController - 루틴 저장 끝
300개 요청
2025-03-18 15:08:55.423 [http-nio-8080-exec-3] INFO c.m.d.r.controller.RoutineController - 루틴 저장 시작
2025-03-18 15:09:00.655 [http-nio-8080-exec-3] INFO c.m.d.r.controller.RoutineController - 루틴 저장 끝
3000개 요청
2025-03-18 15:09:46.652 [http-nio-8080-exec-5] INFO c.m.d.r.controller.RoutineController - 루틴 저장 시작
2025-03-18 15:10:39.320 [http-nio-8080-exec-5] INFO c.m.d.r.controller.RoutineController - 루틴 저장 끝
비교
30개의 루틴 저장 300개 3000개
기존 방식 | 0.611 | 5.232 | 52.668 |
Insert 배치만 적용 | 0.396 | 2.767 | 47.891 |
Select 배치만 적용 | 0.360 | 2.944 | 25.773 |
전부 적용 | 0.181 | 0.242 | 1.758 |
3000개 기준으로 약 30배의 성능 향상이 있었다.
데이터베이스 요청 횟수 변경점
데이터베이스 요청 횟수에도 많은 변화가 있었다.
사용자 복용 루틴 n개를 등록한다고 했을 때
기존 코드의 경우
사용자 조회 1번
사용자 스케줄 조회 1번
Routine 조회 n번
Routine 생성 n-루틴 존재 개수 번
Routine Medicine n번 삽입
총 3n+2-{Routine 존재 개수) 만큼의 데이터베이스 요청이 발생하였다.
개선된 코드의 경우
사용자 조회 쿼리 1번
루틴 조회 1번
루틴 저장 1번
Routine Medicine 1번 삽입
총 4번의 데이터베이스 요청이 발생한다.
감기약을 처방받았을 때를 예시로 들면, 사용자가 아침, 점심, 저녁에 약 3개를 일주일 동안 복용한다고 했을 때
대략 30배에서 40배 정도의 성능 향상이 있다.
'JPA' 카테고리의 다른 글
[JPA] Fetch Join과 Pageable을 함께 사용할 수 없는 이유와 해결 방법 정리 (0) | 2025.04.04 |
---|---|
[JPA] 배치를 이용한 쿼리 최적화 (1) (0) | 2025.03.18 |
[JPA] Batch Size 적용 (0) | 2025.03.18 |
[JPA] FETCH JOIN ORDER문 사용 (0) | 2025.03.17 |