티스토리 뷰

안녕하세요!

오늘은 JPA를 사용하다보면 한 번쯤 만나게 되는 에러인 N+1문제MultipleBagFetchException문제를 해결하는 방법에 대해 공유하고자 합니다. 

🥲 문제 상황

제 프로젝트에서 한 엔티티가 2개의 연관된 엔티티를 List 타입으로 가지고 있었습니다.

그리고 게시글 전체를 조회할 때 JobPost의 WorkDay(요일)와 Job(하는 일)은 모두 보여줘야 했습니다.

{
  "data": {
    "numOfPost": 0,
    "posts": [
            {
                "jobPostId": null,
                "title": "string",
                "storeName": "string",
                "latitude": 0.0,
                "longitude": 0.0,
                "salary": 0,
                "salaryType": "연봉",
                "currencyUnit": "만원",
                "jobTypes": [
                    "베이킹"
                ],
                "startTime": "09:00:00",
                "endTime": "23:00:00",
                "timeConsultable": true,
                "workDays": [
                    "금"
                ],
                "writeDateBefore": "8일 전",
                "employmentDuration": "1주일 이하",
                "durationConsultable": true
            }
    ]
  },
  "message": "string",
  "statusCode": 0
}

보면 알 수 있듯이 jobTypes와 workDays는 배열인 것을 알 수 있습니다. 

 

Java의 클래스에서는 하나의 객체에 List를 담을 수 있지만 데이터베이스에서는 그것이 불가능합니다.

즉, 다음과 같이 @OneToMany, @ManyToOne 관계를 맺어야 한다는 뜻입니다.

@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class JobPost extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "job_post_id")
    private Long jobPostId;

    // 게시글 내용
    private Long writerId;
    private String title;
    private String content;
    private String phoneNumber;
    private boolean isCompleted; //모집 종료 여부
    private String imageUrl;

    // 근무 시간
    private LocalTime startTime;
    private LocalTime endTime;
    private boolean timeConsultable; // 시간 협의 가능 여부

    // 근무 요일
    @OneToMany(mappedBy = "jobPost", cascade = CascadeType.ALL)
    private List<WorkDay> workDays = new ArrayList<>();

    // 근무 기간
    @Enumerated(EnumType.STRING)
    private EmploymentDuration employmentDuration;
    private boolean durationConsultable; // 근무 기간 협의 가능 여부

    // 가게 위치 정보
    private String storeName;
    private double latitude;
    private double longitude;

    // 급여 정보
    private Integer salary;
    @Enumerated(EnumType.STRING)
    private SalaryType salaryType;
    @Enumerated(EnumType.STRING)
    private CurrencyUnit currencyUnit;

    // 일종류
    @OneToMany(mappedBy = "jobPost", cascade = CascadeType.ALL)
    private List<Job> jobs = new ArrayList<>();
    
}
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class WorkDay {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "work_day_id")
    private Long workDayId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "job_post_id")
    private JobPost jobPost;

    @Enumerated(EnumType.STRING)
    private DOW dow;
    /**
     * 연관관계 매핑
     */
    public void setJobPost(JobPost jobPost){
        this.jobPost=jobPost;
        jobPost.getWorkDays().add(this);
    }

    public WorkDay(DOW dow){
        this.dow=dow;
    }
}
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class Job {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "job_id")
    private Long jobId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "job_post_id")
    private JobPost jobPost;

    @Enumerated(EnumType.STRING)
    private JobType jobType;

    /**
     * 연관관계 매핑
     */
    public void setJobPost(JobPost jobPost){
        this.jobPost=jobPost;
        jobPost.getJobs().add(this);
    }

    public Job(JobType jobType){
        this.jobType=jobType;
    }
}

 

😂 fetch join을 해서 N+1문제를 해결하려 했으나 . . .

    private List<JobPost> fetchJobPostsBasedOnLocation(GetAllPostRequest request) {
    ...
        return queryFactory
                .selectFrom(jobPost)
                .distinct()
                .leftJoin(jobPost.jobs, job).fetchJoin()
                .leftJoin(jobPost.workDays, workDay).fetchJoin()
                .where(jobPost.latitude.between(box.minLatitude, box.maxLatitude)
                        .and(jobPost.longitude.between(box.minLongitude, box.maxLongitude)))
                .fetch();
    ...
    }

다음과 같이 fetch Join을 이용해서 N+1문제를 해결하려고 했습니다.

그. 러. 나 이때 바로 MultipleBagFetchException이 발생하게 됩니다... 


이 문제는 2개 이상의 OneToMany 자식 테이블에 Fetch Join을 선언했을때 발생합니다.

 

OneToOne, ManyToOne과 같이 단일 관계의 자식 테이블에는 Fetch Join을 써도 됩니다

이 문제에 대한 해결책으로 보통 2가지를 언급하는데요.

  • 1. 자식 테이블 하나에만 Fetch Join을 걸고 나머진 Lazy Loading로
  • 2. 모든 자식 테이블을 Lazy Loading으로

🧐 만약 2번으로 하게 된다면 총 쿼리가 몇 번 돌아가게 되는 걸까요?

만약 N개의 데이터를 조회한다면 게시물을 가져오기 위해 쿼리가 실행이 되고 이후 각 게시물에 대해 연관된 Job과 WorkDay 엔티티를 가져오기 위한 쿼리가 각각 실행됩니다.

따라서 총 쿼리의 수는:

1 (게시물 조회) + N (Job 조회) + N (WorkDay 조회) = 2N+1 이 되게 됩니다. . . 🫢

 

✔️ 해결책 1 : Hibernate default_batch_fetch_size

해결책은 하이버네이트의 default_batch_fetch_size 옵션에 있습니다.

hibernate.default_batch_fetch_size 옵션은 지정된 수만큼 in절에 부모 Key를 사용하게 해줍니다.

, 1000개를 옵션값으로 지정하면 1000 단위로 in절에 부모 Key 넘어가서 자식 엔티티들이 조회되는 것이죠.
단순하게 생각해도 쿼리 수행수가 1/1000 되는거겠죠?

  • 옵션 미적용시
    • 총 20,001번의 쿼리가 수행됩니다.
    • JobPost 조회 쿼리 1번
    • 각 JobPost의 WorkDay 조회 쿼리가 10,000번
    • 각 JobPost의 Job 조회 쿼리가 10,000번
  • 옵션 적용시 (1000개)
    • 총 21번의 쿼리가 수행됩니다.
    • JobPost 조회 쿼리 1번
    • 각 JobPost의 WorkDay 조회 쿼리가 10번 (10,000 / 1,000)
    • 각 JobPost의 Job 조회 쿼리가 10번  (10,000 / 1,000)

그런데 저는 항상 3개의 쿼리가 실행되게 수정하고 싶었습니다.

✔️ 해결책 2 : List엔티티를 분리해서 조회하기

  1. 먼저 PostContent 리스트를 가져옵니다. (첫 번째 쿼리)
  2. 해당 PostContent 리스트의 ID를 stream을 이용해 만듭니다.
  3. Job과 WorkDay의 관련 정보를  in 절을 사용하여 여러 ID를 기반으로 Job과 WorkDay 항목을 한 번에 조회합니다.(2-3번째 쿼리)

-> 이로 인해 N+1 문제가 개별 항목을 조회하기 위한 추가적인 쿼리가 필요하지 않으므로 N+1 문제를 피할 수 있습니다!!

    private List<PostBlocks> getPostBlocks(GetAllPostRequest getAllPostRequest) {

        BoundingBox box = calculateBoundingBox(getAllPostRequest.getLatitude(), getAllPostRequest.getLongitude(), getDistanceKm(getAllPostRequest));

        //게시글 내용만 전체 조회 -> 1번째 쿼리
        List<PostContent> postContents = queryFactory.select(new QPostContent(
                        jobPost.jobPostId,
                        jobPost.title,
                        jobPost.storeName,
                        jobPost.latitude,
                        jobPost.longitude,
                        jobPost.salary,
                        jobPost.salaryType,
                        jobPost.currencyUnit,
                        jobPost.startTime,
                        jobPost.endTime,
                        jobPost.timeConsultable,
                        jobPost.employmentDuration,
                        jobPost.durationConsultable,
                        jobPost.createdDate))
                .from(jobPost)
                .where(jobPost.latitude.between(box.minLatitude, box.maxLatitude)
                        .and(jobPost.longitude.between(box.minLongitude, box.maxLongitude)))
                .fetch();
                
                
        // 게시글의 아이디 리스트
        List<Long> jobPostIds = postContents.stream().map(PostContent::getJobPostId).collect(Collectors.toList());


        // Job 리스트 조회 -> 2번째 쿼리
        List<Job> jobs = queryFactory.selectFrom(job)
                .where(job.jobPost.jobPostId.in(jobPostIds))
                .fetch();
                
                
        // WorkDay 리스트 조회 -> 3번째 쿼리
        List<WorkDay> workDays = queryFactory.selectFrom(workDay)
                .where(workDay.jobPost.jobPostId.in(jobPostIds))
                .fetch();
                

        Map<Long, List<JobType>> jobTypeMap = jobs.stream()
                .collect(Collectors.groupingBy(job -> job.getJobPost().getJobPostId(), // Job의 JobPost의 jobPostId를 사용하여 그룹화
                        Collectors.mapping(Job::getJobType, Collectors.toList())));


        Map<Long, List<DOW>> dowMap = workDays.stream()
                .collect(Collectors.groupingBy(workDay -> workDay.getJobPost().getJobPostId(), // WorkDay의 JobPost의 jobPostId를 사용하여 그룹화
                        Collectors.mapping(WorkDay::getDow, Collectors.toList())));


        List<PostBlocks> postBlocks = postContents.stream()
                .map(postContent -> {
                    List<JobType> jobTypesForCurrentPost = jobTypeMap.getOrDefault(postContent.getJobPostId(), Collections.emptyList());
                    List<DOW> dowsForCurrentPost = dowMap.getOrDefault(postContent.getJobPostId(), Collections.emptyList());
                    return new PostBlocks(postContent, jobTypesForCurrentPost, dowsForCurrentPost);
                })
                .collect(Collectors.toList());

        return postBlocks.stream()
                .filter(p -> isTimeMatching(p, getAllPostRequest))
                .filter(p -> areJobsAndWorkdaysMatching(p, getAllPostRequest))
                .filter(p -> isWithinDistance(p, getAllPostRequest))
                .filter(p -> areEmploymentDurationsMatching(p, getAllPostRequest))
                .collect(Collectors.toList());
    }

실제로 아래와 같이 총 3개의 쿼리만 실행됩니다

 

쿼리 개수가 줄어듦에 따라 API 호출 속도도 눈에 띄게 빨라 졌습니다. 100번 API를 호출한 결과 다음과 같이 속도가 빨라진 걸 경험할 수 있었습니다. 

1. 초기 상태

2. Hibernate default_batch_fetch_size 이용

3. 분리해서 조회


JPA를 사용하면서 효율적인 쿼리 작성과 최적화는 항상 중요한 주제입니다. 특히 복잡한 연관 관계가 있는 엔티티를 사용할 때는 N+1 문제나 다양한 예외 상황에 주의를 기울여야 합니다. 이번 공유에서는 이러한 문제를 해결하는 방법을 중점으로 다루었는데, 개발자로서 항상 데이터베이스에 어떤 쿼리가 실행되고 있는지 알고, 필요한 경우 적절한 최적화 전략을 선택하는 능력을 키워야 한다고 생각합니다.

프로젝트를 진행하면서 저도 여러 시행착오를 겪었고, 이를 통해 다양한 해결책을 배울 수 있었습니다. 그 중에서도 두 가지 핵심적인 해결 방법을 소개했습니다. 하나는 hibernate.default_batch_fetch_size 옵션을 사용한 것이고, 다른 하나는 엔티티를 분리해서 조회하는 방법이었습니다. 이러한 해결책은 저만의 방법이 아닌, JPA와 데이터베이스 성능 최적화에서 광범위하게 알려진 방법들이기에 여러분들의 프로젝트에도 도움이 될 것이라 생각합니다.

 

이번 공유가 여러분들에게 도움이 되길 바랍니다. 피드백이나 추가적인 의견 있으시면 언제든지 공유해주세요!