배경

예전에 했었던 팀 프로젝트 코드를 복기하기 위해 로컬에서 프로젝트를 돌려봤습니다.
먼저 서버를 구동시키고 포스트맨으로 회원가입, 로그인부터 시작해서 일정 생성, 조회 등등을 해봤습니다.

그런데 일정 한 건을 조회하는 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을 사용하게 되었습니다.

참고