배경
예전에 했었던 팀 프로젝트 코드를 복기하기 위해 로컬에서 프로젝트를 돌려봤습니다.
먼저 서버를 구동시키고 포스트맨으로 회원가입, 로그인부터 시작해서 일정 생성, 조회 등등을 해봤습니다.
그런데 일정 한 건을 조회하는 api를 포스트맨으로 돌려보고 서버 로그를 보니 무려 5번의 쿼리가 작동 했습니다.
해당 api는 팀 프로젝트 당시 제가 맡은 구현파트가 아니라 다른 팀원분께서 구현을 하셨었습니다.
일정 한 건을 조회하는데 5개의 쿼리가 작동하는 것을 개선할 수 있지 않을까 생각하여
팀원분이 작성하신 코드를 보며 리팩토링을 해보기로 결심했습니다.
본론
일정 테이블과 이에 연관된 테이블들은 아래와 같이 설계되어있습니다.

계획 - 세부일정 1:N
세부일정 - 장소 1:N
장소 - 장소 세부정보 1:1
각 테이블들을 설명해보자면,
여행일정을 생성할 때 계획 안에 여러 세부일정들이 있습니다.
세부일정은 day 1, day2, day3… 이런 식입니다. (2박 3일 여행을 계획한다고 가정했을 때)
그리고 그 세부일정안에서 장소를 여러개 선택합니다. -> 여행 가서 하루에 한 장소만 가진 않습니다.
마지막으로 해당 여행 장소에 대한 세부정보를 또 1:1 관계로 나타냈습니다.
만약 경복궁을 들릴 예정이라면 이에 대한 여행 경비, 방문 시각, 여행 메모 등이 포함됩니다.
얽히고 설켜있습니다..
제가 설계한 테이블이라지만 당시 제 선에서는 저게 최선이었던 것 같습니다.
이제 바로 5개의 쿼리가 작동하는 것을 살펴보겠습니다.
{
"title": "가을 여행 계획",
"startDate": "2024-10-01",
"endDate": "2024-10-05",
"region": "제주도",
"visible": true,
"copyAllowed": true,
"schedules": [
{
"day": 1,
"places": [
{
"kakaoPlaceId": "123",
"placeName": "제주 해변",
"latitude": 33.456,
"longitude": 126.567,
"address": "제주특별자치도 제주시 해변로 123",
"placeDetails": {
"memo": "일출 보기 좋은 곳",
"cost": 30000,
"visitTime": "08:00"
}
},
{
"kakaoPlaceId": "456",
"placeName": "한라산",
"latitude": 33.362,
"longitude": 126.533,
"address": "제주특별자치도 제주시 한라산로 123",
"placeDetails": {
"memo": "등산 준비물 체크 필요",
"cost": 30000,
"visitTime": "10:00"
}
}
]
},
{
"day": 2,
"places": [
{
"kakaoPlaceId": "789",
"placeName": "우도",
"latitude": 33.506,
"longitude": 126.953,
"address": "제주특별자치도 제주시 우도면",
"placeDetails": {
"memo": "전기 자전거 대여하기",
"cost": 20000,
"visitTime": "09:30"
}
}
]
}
]
}
위와 같은 요청 데이터로 게시물을 하나 생성하고 해당 게시물을 조회합니다.
Hibernate:
select // plan 조회
p1_0.plan_id,
p1_0.bookmark_count,
p1_0.copy_allowed,
p1_0.created_at,
p1_0.end_date,
p1_0.like_count,
p1_0.member_id,
p1_0.region,
p1_0.start_date,
p1_0.title,
p1_0.visible
from
plan p1_0
where
p1_0.plan_id=?
Hibernate:
select // plan의 member가 누군지 조회 (얘는 문제 X)
m1_0.member_id,
m1_0.email,
m1_0.kakao_id,
m1_0.nickname,
m1_0.password
from
member m1_0
where
m1_0.member_id=?
Hibernate:
select // 해당 여행일정의 schedule들을 조회
s1_0.plan_id,
s1_0.schedule_id,
s1_0.`day`
from
schedule s1_0
where
s1_0.plan_id=?
Hibernate:
select // 스케줄1의 Place와 placeDetails 조회
p1_0.schedule_id,
p1_0.place_id,
p1_0.address,
p1_0.kakao_place_id,
p1_0.latitude,
p1_0.longitude,
pd1_0.place_detail_id,
pd1_0.cost,
pd1_0.memo,
pd1_0.place_id,
pd1_0.visit_time,
p1_0.place_name
from
place p1_0
left join
place_details pd1_0
on p1_0.place_id=pd1_0.place_id
where
p1_0.schedule_id=?
Hibernate:
select // 스케줄2의 Place와 placeDetails 조회
p1_0.schedule_id,
p1_0.place_id,
p1_0.address,
p1_0.kakao_place_id,
p1_0.latitude,
p1_0.longitude,
pd1_0.place_detail_id,
pd1_0.cost,
pd1_0.memo,
pd1_0.place_id,
pd1_0.visit_time,
p1_0.place_name
from
place p1_0
left join
place_details pd1_0
on p1_0.place_id=pd1_0.place_id
where
p1_0.schedule_id=?
위와 같이 총 5개의 쿼리가 발생합니다. (member를 조회하는 것은 plan의 회원을 조회하는 것이기에 실질적으로는 4개의 쿼리가 작동한다고 볼 수도 있겠습니다.)
저 위 응답데이터에서 일정을 조회하고 응답을 받았을때, day1, day2 총 스케줄이 2개가 포함되어 있는 것을 볼 수 있었습니다.
각 스케줄마다 해당하는 장소(Place)와 장소 세부정보(placeDetails)를 조회하는 쿼리가 한번씩 작동하고 있습니다.
이 부분은 사용자가 작성한 여행 일정의 계획날짜가 길면 길수록 쿼리가 더 많이 발생할 것입니다.
가령 7박8일의 여행계획을 조회할 때, 8건의 스케줄이 포함되어 있을테니 각 스케줄마다 장소와 장소 세부정보를 조회하는 쿼리가 N+1번 발생하여 총 9번 발생할 것입니다. 거기에 n+1 문제에 포함되지 않는 여행일정(최상위 부모) 조회와 회원 조회까지 합치면 총 11번을 조회하게 되겠네요.
(실제로 7박 8일의 여행일정을 작성하고 조회하는 테스트를 해본 결과 11번의 쿼리가 작동되는 것을 확인 했습니다.)
그럼 이제 해당 api의 로직을 보겠습니다.
Controller
@GetMapping("/{planId}")
public ResponseEntity<ApiResult<PlanResponse>> getPlanDetails(@PathVariable Long planId, @AuthenticationPrincipal CustomUserDetails userDetails) {
Long memberId = userDetails.getId();
PlanResponse planDto = planService.getPlanDetails(planId, memberId);
return ResponseEntity.ok(new ApiResult<>(planDto));
}
- 시큐리티 컨텍스트에서 회원 정보 가져옴, 특정 여행 일정의 고유번호를 파라미터로 받아옴
- 받아온 정보들을 getPlanDetails 메서드에 전달하며 조회 로직 시작
Service
@Transactional(readOnly = true)
public PlanResponse getPlanDetails(Long planId, Long memberId) {
//여행 계획 존재하는지 확인
Plan plan = getPlanIfExists(planId);
//소유자 확인
//공개되지 않았고 소유자가 아닌 경우 접근 거부
checkPlanVisibility(plan, memberId);
return PlanResponse.from(plan);
}
private Plan getPlanIfExists(Long planId) {
return planRepository.findById(planId)
.orElseThrow(() -> new PlanNotFoundException("존재하지 않는 여행 계획입니다"));
}
private void checkPlanVisibility(Plan plan, Long memberId) {
boolean isOwner = plan.getMember().getId().equals(memberId);
if (!plan.isVisible() && !isOwner) {
throw new PlanNotVisibleException("접근이 거부되었습니다.");
}
}
- getPlanDetails 에서 해당 여행일정을 찾고 여행일정의 공개, 비공개 유무 판단
Fetch Join 사용
먼저 n + 1문제의 강력한 해결방법인 Fetch Join을 사용해봅니다.
@Query("SELECT p FROM Plan p " +
"JOIN FETCH p.schedules s " +
"JOIN FETCH s.place pl " +
"JOIN FETCH pl.placeDetails " +
"WHERE p.id = :planId")
Optional<Plan> findById(Long planId);
위와 같이 수정하고 다시 조회테스트를 해 본 결과,
조회 요청에 실패하고 서버로그에서MultipleBagFetchException: cannot simultaneously fetch multiple bags 라는 오류를 볼 수 있습니다.
.m.m.a.ExceptionHandlerExceptionResolver : Resolved [org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [com.dbfp.footprint.domain.plan.Schedule.place, com.dbfp.footprint.domain.plan.Plan.schedules]]
MultipleBagFetchException
해당 오류는 한 번의 쿼리로 여러 개의 컬렉션을 페치할 수 없기 때문에 발생하는 오류입니다.
이 말은 즉 1:N의 관계를 가지는 엔티티 2개 이상을 fetch join을 사용하여 조회할 수 없습니다.
fetch join 사용 시 ToOne 은 조건 없이 사용 가능.
ToMany는 1개만 가능합니다.
그래서 해당 오류를 어떻게 해결해야 하느냐?
서칭해본 결과 다양한 해결 방법이 있었지만 그 중에서도,
간단하게 Set을 사용하는 것만으로 해결할수 있는 방법을 채택했습니다.
List를 Set으로 변경하기
Schedule.java
@OneToMany(mappedBy = "schedule", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Place> place = new HashSet<>();
Plan.java
@OneToMany(mappedBy = "plan", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Schedule> schedules = new HashSet<>();
위와 같이 List를 Set으로 변경하고 조회를 해봤습니다.
Hibernate:
select
p1_0.plan_id,
p1_0.bookmark_count,
p1_0.copy_allowed,
p1_0.created_at,
p1_0.end_date,
p1_0.like_count,
p1_0.member_id,
p1_0.region,
s1_0.plan_id,
s1_0.schedule_id,
s1_0.`day`,
p2_0.schedule_id,
p2_0.place_id,
p2_0.address,
p2_0.kakao_place_id,
p2_0.latitude,
p2_0.longitude,
pd1_0.place_detail_id,
pd1_0.cost,
pd1_0.memo,
pd1_0.place_id,
pd1_0.visit_time,
p2_0.place_name,
p1_0.start_date,
p1_0.title,
p1_0.visible
from
plan p1_0
join
schedule s1_0
on p1_0.plan_id=s1_0.plan_id
join
place p2_0
on s1_0.schedule_id=p2_0.schedule_id
join
place_details pd1_0
on p2_0.place_id=pd1_0.place_id
where
p1_0.plan_id=?
MultipleBagFetchException 오류가 해결이 되었고 동시에 3번의 쿼리가 join을 통한 한방쿼리로 수행되며 N+1이 해결되었습니다.
Set은 중복을 허용하지 않으므로 조인 결과에서 중복된 데이터를 제거하면서 MultipleBagFetchException이 해결됩니다.
하지만 아래와 같은 단점이 따릅니다.
- Set 특성 상 정렬을 보장하지 않습니다.
- 조인 할 테이블이 많을수록 성능 저하 가능성이 높아집니다.
Set을 사용하게 되면서 일정생성과 조회 응답 데이터가 정렬이 되지않는 문제는 애플리케이션 레이어에서 수동으로 정렬해주었습니다.
1:N 연관관계가 2개 이상이면서 N+1 문제를 해결하고 MultipleBagFetchException을 해결하는 방법 중 Set를 사용하는 방법 외에도 JOOQ Batch Fetching, @BatchSize 등이 있었습니다.
하지만 Set 사용을 채택한 이유는 JOOQ를 사용하여 Batch Fetching 기법으로 쿼리를 최적화하는 것은 본 애플리케이션에서 오버엔지니어링이 아닐까 하는 생각이 들어 Set을 사용하게 되었습니다.
참고