[Spring boot] JPA N+1 문제 발견과 돌파

Byuk_mm·2022년 10월 16일
1

Spring Boot Development

목록 보기
11/13
post-thumbnail
post-custom-banner

Spring Boot 개발 중 학습이 필요한 내용을 정리하고,
트러블 슈팅 과정을 기록하는 포스팅입니다.




✅ Background

JPASpring boot와 함께 활용될 수 있는 매우 강력한 ORM입니다. 개발자에게 비즈니스 로직 개발에 더 집중할 수 있는 환경을 제공하지만, 의도치 않은 쿼리를 발생시켜 어플리케이션의 성능 저하를 일으키기도 합니다. 그 중 대표적인 문제가 N+1 문제입니다
이번 포스팅에서는 제가 프로젝트 개발 중 JPAN+1 문제를 발견하고 분석하여, 이를 해결한 과정에 대해서 적어보겠습니다.




✅ N+1 문제란??

N+1 문제JPA의 연관 관계가 있는 Entity를 조회(SELECT)할 경우에, 결과로 조회된 데이터 개수(N)만큼 연관 관계가 있는 데이터를 추가로 조회하는 쿼리를 발생시키는 문제입니다.

즉, 개발자는 한번의 쿼리Entity를 조회하는 것을 원했으나, 결과로 나온 N개의 Entity만큼 연관관계 매핑된 Entity를 조회하는 N개의 쿼리가 추가로 발생되는 현상입니다. (때문에, 1+N 맞지 않냐는 의견도 있습니다.)




✅ N+1 문제 발견


📌 활용 테이블 구조

실제로 제가 사용하는 ERD 구조에서 N+1 문제를 재현해보겠습니다.

user 테이블은 video_space_participant 테이블과 1:N의 관계입니다.
또한 video_space_participant 테이블은 individual_video 테이블과 1:N 관계입니다.


📌 즉시 로딩(EAGER)

JPA에서 연관관계가 있는 Entity를 조회할 때, 두가지 방식이 있습니다.
첫 번째는 즉시 로딩(FetchType.EAGER)이며, 두 번째는 지연 로딩(FetchType.LAZY) 입니다. 즉시 로딩 방식은 다음과 같이 Entitiy와 예시 구현 됩니다.


// User.java

@OneToMany(mappedBy = "user",fetch = FetchType.EAGER)
private List<VideoSpaceParticipant> videoSpaceParticipants = new ArrayList<>();


// VideoSpaceParticipant.java

@OneToMany(mappedBy = "videoSpaceParticipant",fetch = FetchType.EAGER)
private List<IndividualVideo> individualVideos = new ArrayList<>();

@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id")
private User user;


// IndividualVideo.java

@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "video_space_participant_id")
private VideoSpaceParticipant videoSpaceParticipant;

// userServiceTest.java

List<User> all = userRepository.findAll();

/* 결과

Hibernate: select user0_.user_id as user_id1_7_, user0_.created_at as created_2_7_, user0_.update_at as update_a3_7_, user0_.email as email4_7_, user0_.webex_access_token as webex_ac5_7_, user0_.zoom_access_token as zoom_acc6_7_, user0_.last_access_individual_video_id as last_acc7_7_, user0_.name as name8_7_, user0_.picture as picture9_7_, user0_.role as role10_7_ from user user0_
Hibernate: select videospace0_.user_id as user_id4_10_0_, videospace0_.video_space_participant_id as video_sp1_10_0_, videospace0_.video_space_participant_id as video_sp1_10_1_, videospace0_.created_at as created_2_10_1_, videospace0_.update_at as update_a3_10_1_, videospace0_.user_id as user_id4_10_1_, videospace0_.video_space_id as video_sp5_10_1_ from video_space_participant videospace0_ where videospace0_.user_id=?
Hibernate: select individual0_.video_space_participant_id as video_sp7_2_0_, individual0_.individual_video_id as individu1_2_0_, individual0_.individual_video_id as individu1_2_1_, individual0_.created_at as created_2_2_1_, individual0_.update_at as update_a3_2_1_, individual0_.last_access_time as last_acc4_2_1_, individual0_.progress_rate as progress5_2_1_, individual0_.video_id as video_id6_2_1_, individual0_.video_space_participant_id as video_sp7_2_1_ from individual_video individual0_ where individual0_.video_space_participant_id=?
Hibernate: select videospace0_.user_id as user_id4_10_0_, videospace0_.video_space_participant_id as video_sp1_10_0_, videospace0_.video_space_participant_id as video_sp1_10_1_, videospace0_.created_at as created_2_10_1_, videospace0_.update_at as update_a3_10_1_, videospace0_.user_id as user_id4_10_1_, videospace0_.video_space_id as video_sp5_10_1_ from video_space_participant videospace0_ where videospace0_.user_id=?
Hibernate: select individual0_.video_space_participant_id as video_sp7_2_0_, individual0_.individual_video_id as individu1_2_0_, individual0_.individual_video_id as individu1_2_1_, individual0_.created_at as created_2_2_1_, individual0_.update_at as update_a3_2_1_, individual0_.last_access_time as last_acc4_2_1_, individual0_.progress_rate as progress5_2_1_, individual0_.video_id as video_id6_2_1_, individual0_.video_space_participant_id as video_sp7_2_1_ from individual_video individual0_ where individual0_.video_space_participant_id=?
Hibernate: select individual0_.video_space_participant_id as video_sp7_2_0_, individual0_.individual_video_id as individu1_2_0_, individual0_.individual_video_id as individu1_2_1_, individual0_.created_at as created_2_2_1_, individual0_.update_at as update_a3_2_1_, individual0_.last_access_time as last_acc4_2_1_, individual0_.progress_rate as progress5_2_1_, individual0_.video_id as video_id6_2_1_, individual0_.video_space_participant_id as video_sp7_2_1_ from individual_video individual0_ where individual0_.video_space_participant_id=?
Hibernate: select videospace0_.user_id as user_id4_10_0_, videospace0_.video_space_participant_id as video_sp1_10_0_, videospace0_.video_space_participant_id as video_sp1_10_1_, videospace0_.created_at as created_2_10_1_, videospace0_.update_at as update_a3_10_1_, videospace0_.user_id as user_id4_10_1_, videospace0_.video_space_id as video_sp5_10_1_ from video_space_participant videospace0_ where videospace0_.user_id=?
Hibernate: select individual0_.video_space_participant_id as video_sp7_2_0_, individual0_.individual_video_id as individu1_2_0_, individual0_.individual_video_id as individu1_2_1_, individual0_.created_at as created_2_2_1_, individual0_.update_at as update_a3_2_1_, individual0_.last_access_time as last_acc4_2_1_, individual0_.progress_rate as progress5_2_1_, individual0_.video_id as video_id6_2_1_, individual0_.video_space_participant_id as video_sp7_2_1_ from individual_video individual0_ where individual0_.video_space_participant_id=?
Hibernate: select individual0_.video_space_participant_id as video_sp7_2_0_, individual0_.individual_video_id as individu1_2_0_, individual0_.individual_video_id as individu1_2_1_, individual0_.created_at as created_2_2_1_, individual0_.update_at as update_a3_2_1_, individual0_.last_access_time as last_acc4_2_1_, individual0_.progress_rate as progress5_2_1_, individual0_.video_id as video_id6_2_1_, individual0_.video_space_participant_id as video_sp7_2_1_ from individual_video individual0_ where individual0_.video_space_participant_id=?
Hibernate: select videospace0_.user_id as user_id4_10_0_, videospace0_.video_space_participant_id as video_sp1_10_0_, videospace0_.video_space_participant_id as video_sp1_10_1_, videospace0_.created_at as created_2_10_1_, videospace0_.update_at as update_a3_10_1_, videospace0_.user_id as user_id4_10_1_, videospace0_.video_space_id as video_sp5_10_1_ from video_space_participant videospace0_ where videospace0_.user_id=?
Hibernate: select individual0_.video_space_participant_id as video_sp7_2_0_, individual0_.individual_video_id as individu1_2_0_, individual0_.individual_video_id as individu1_2_1_, individual0_.created_at as created_2_2_1_, individual0_.update_at as update_a3_2_1_, individual0_.last_access_time as last_acc4_2_1_, individual0_.progress_rate as progress5_2_1_, individual0_.video_id as video_id6_2_1_, individual0_.video_space_participant_id as video_sp7_2_1_ from individual_video individual0_ where individual0_.video_space_participant_id=?

*/

즉시 로딩 방식은 Entitiy 조회시 연관 관계에 있는 모든 Entity를 한번에 불러옵니다.

위의 findAll 메소드를 예시로 결과를 살펴봅시다.
findAll 메소드를 통해 전체 User를 조회합니다. 현재 테스트 데이터로 User가 4개가 들어가 있습니다.

즉 위의 findAll 메소드를 통해서,
맨 위의 User Select 쿼리한 번 호출되는 것을볼 수 있습니다. N+1 문제를 고려하지 않는다면, 해당 select 쿼리 단 한번만 발생하는 것을 기대했을 것 입니다. 하지만, 이 후에도 여러번의 쿼리가 발생하는 것을 알 수 있습니다.

이 후의 쿼리를 분석해보면, 테스트 데이터로 들어가 있는 user의 개수인 4개만큼 VideoSpaceParticipant Select 쿼리가 발생합니다.
즉, User Select 쿼리결과값의 개수인 4만큼 쿼리가 4번 더 발생한 것입니다. 이것이 바로 N+1 문제입니다.

여기서 그치지 않고 VideoSpaceParticipant Entity는 또 다시 IndividualVideo Entity와 연관 관계가 있기 때문에 또 다시 Select 쿼리가 발생한 것을 볼 습니다. 즉 N+N+1 문제로 문제가 진화(?)한 것을 볼 수 있습니다.


📌 지연 로딩(LAZY)

그렇다면, 지연 로딩 방식을 활용하면 N+1 문제를 해결할 수 있을까요? 결론부터 말하자면 그렇지 않습니다. 지연 로딩 방식의 Entity는 다음과 같습니다.


// User.java

@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<VideoSpaceParticipant> videoSpaceParticipants = new ArrayList<>();


// VideoSpaceParticipant.java

@OneToMany(mappedBy = "videoSpaceParticipant", cascade = CascadeType.ALL, orphanRemoval = true)
private List<IndividualVideo> individualVideos = new ArrayList<>();

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;


// IndividualVideo.java

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "video_space_participant_id")
private VideoSpaceParticipant videoSpaceParticipant;

``` java

// userServiceTest.java

List<User> all = userRepository.findAll();

/* 결과
Hibernate: select user0_.user_id as user_id1_7_, user0_.created_at as created_2_7_, user0_.update_at as update_a3_7_, user0_.email as email4_7_, user0_.webex_access_token as webex_ac5_7_, user0_.zoom_access_token as zoom_acc6_7_, user0_.last_access_individual_video_id as last_acc7_7_, user0_.name as name8_7_, user0_.picture as picture9_7_, user0_.role as role10_7_ from user user0_

지연 로딩 방식은 연관 관계에 있는 Entity들을 프록시 객체로 조회합니다. 이 후에, 조회된 프록시들을 실제로 사용할 때, DB에서 조회 쿼리를 날려서 초기화합니다. 즉, 실제로 필요한 시점에 쿼리를 발생시킵니다.

한번더 findAll 메소드의 결과를 보겠습니다. 지연 로딩 방식을 활용하면 결과와 같이 한번의 쿼리가 발생하는 것을 확인할 수 있습니다. 그렇다면 이것이 N+1 문제를 해결했다고 볼 수 있을까요? 앞서 말했듯 즉시 로딩 방식과 지연 로딩 방식의 차이는 추가 쿼리가 발생하는 시점입니다. 즉 추가 쿼리를 발생시킨다는 사실은 변하지 않습니다. 아래 예시를 봅시다.


List<User> all = userRepository.findAll();

for (User user : all) {
    log.info(String.valueOf(user.getVideoSpaceParticipants().size()));
}

/* 결과
Hibernate: select user0_.user_id as user_id1_7_, user0_.created_at as created_2_7_, user0_.update_at as update_a3_7_, user0_.email as email4_7_, user0_.webex_access_token as webex_ac5_7_, user0_.zoom_access_token as zoom_acc6_7_, user0_.last_access_individual_video_id as last_acc7_7_, user0_.name as name8_7_, user0_.picture as picture9_7_, user0_.role as role10_7_ from user user0_
Hibernate: select videospace0_.user_id as user_id4_10_0_, videospace0_.video_space_participant_id as video_sp1_10_0_, videospace0_.video_space_participant_id as video_sp1_10_1_, videospace0_.created_at as created_2_10_1_, videospace0_.update_at as update_a3_10_1_, videospace0_.user_id as user_id4_10_1_, videospace0_.video_space_id as video_sp5_10_1_ from video_space_participant videospace0_ where videospace0_.user_id=?
Hibernate: select videospace0_.user_id as user_id4_10_0_, videospace0_.video_space_participant_id as video_sp1_10_0_, videospace0_.video_space_participant_id as video_sp1_10_1_, videospace0_.created_at as created_2_10_1_, videospace0_.update_at as update_a3_10_1_, videospace0_.user_id as user_id4_10_1_, videospace0_.video_space_id as video_sp5_10_1_ from video_space_participant videospace0_ where videospace0_.user_id=?
Hibernate: select videospace0_.user_id as user_id4_10_0_, videospace0_.video_space_participant_id as video_sp1_10_0_, videospace0_.video_space_participant_id as video_sp1_10_1_, videospace0_.created_at as created_2_10_1_, videospace0_.update_at as update_a3_10_1_, videospace0_.user_id as user_id4_10_1_, videospace0_.video_space_id as video_sp5_10_1_ from video_space_participant videospace0_ where videospace0_.user_id=?
Hibernate: select videospace0_.user_id as user_id4_10_0_, videospace0_.video_space_participant_id as video_sp1_10_0_, videospace0_.video_space_participant_id as video_sp1_10_1_, videospace0_.created_at as created_2_10_1_, videospace0_.update_at as update_a3_10_1_, videospace0_.user_id as user_id4_10_1_, videospace0_.video_space_id as video_sp5_10_1_ from video_space_participant videospace0_ where videospace0_.user_id=?
*/

findAll 메소드를 이용하여 User Entitiy List를 조회한 후, 각각의 User에서 VideoSpaceParticipant Entityget합니다. 결과를 보면 또 다시 4번의 쿼리가 추가로 조회된 것을 알 수 있습니다. 이후에 IndividualVideo Entity를 조회할 때 또 다시 추가 커리가 발생할 것입니다. 이렇듯 N+1 문제가 발생하는 것은 똑같다고 할 수 있습니다.




✅ N+1 문제 돌파


📌 Fetch Join

앞서서 즉시 로딩 방식과 지연 로딩 방식 둘다 N+1 문제를 발생시킨다고 말했지만, 분명 즉시로딩 방식과 지연 로딩 방식에 있어서 차이는 존재합니다. 즉시로딩 방식은 연관 관계가 필요 없는 경우에도 항상 데이터를 한번에 다 조회하기 때문에 반드시 성능 문제가 발생할 수 밖에 없습니다. 때문에 지연 로딩을 기본으로 사용해야합니다.

이렇게 지연 로딩을 기본으로 사용하면서, N+1 문제 해결하여 성능 최적화가 필요한 경우에는 Fetch Join을 활용해야합니다.
Fetch Join은 지연 로딩 과정에서 한번에 연관 관계에 있는 Entity 데이터들을 join하여 한번에 불러올 수 있게끔 합니다.


// UserDao

public User findByEmail(final String email) {

    Optional<User> account = Optional.ofNullable(query.select(QUser.user)
            .from(QUser.user)
            .leftJoin(QUser.user.videoSpaceParticipants, QVideoSpaceParticipant.videoSpaceParticipant)
            .fetchJoin()
            .where(QUser.user.email.eq(email))
            .distinct().fetchOne());

    // not found exception
    account.orElseThrow(() -> new UserNotFoundException(email));

    return account.get();
}

위 코드는 Query DSL Fetch Join을 활용해서 Email을 통해 Userget하는 findByEmail 메소드입니다. 다음과 같이 User Entity와 연관 관계가 있는 VideoSpaceParticipant Entity를 기준으로 Fetch Join을 합니다.

그렇다면, 기존의 JpaRepositoryfindByEmail 메소드Fetch Join를 활용한 findByEmail 메소드의 차이를 확인해 봅시다.


📌 findByEmail, Fetch Join 활용 전


// JpaRepository의 findByEmail
User user = userRepository.findByEmail(email).orElseThrow(() -> {
    throw new UserNotFoundException(email);
});

/* 결과
Hibernate: select user0_.user_id as user_id1_7_, user0_.created_at as created_2_7_, user0_.update_at as update_a3_7_, user0_.email as email4_7_, user0_.webex_access_token as webex_ac5_7_, user0_.zoom_access_token as zoom_acc6_7_, user0_.last_access_individual_video_id as last_acc7_7_, user0_.name as name8_7_, user0_.picture as picture9_7_, user0_.role as role10_7_ from user user0_ where user0_.email=?
Hibernate: select videospace0_.user_id as user_id4_10_0_, videospace0_.video_space_participant_id as video_sp1_10_0_, videospace0_.video_space_participant_id as video_sp1_10_1_, videospace0_.created_at as created_2_10_1_, videospace0_.update_at as update_a3_10_1_, videospace0_.user_id as user_id4_10_1_, videospace0_.video_space_id as video_sp5_10_1_ from video_space_participant videospace0_ where videospace0_.user_id=?
Hibernate: select individual0_.video_space_participant_id as video_sp7_2_0_, individual0_.individual_video_id as individu1_2_0_, individual0_.individual_video_id as individu1_2_1_, individual0_.created_at as created_2_2_1_, individual0_.update_at as update_a3_2_1_, individual0_.last_access_time as last_acc4_2_1_, individual0_.progress_rate as progress5_2_1_, individual0_.video_id as video_id6_2_1_, individual0_.video_space_participant_id as video_sp7_2_1_ from individual_video individual0_ where individual0_.video_space_participant_id=?
Hibernate: select individual0_.video_space_participant_id as video_sp7_2_0_, individual0_.individual_video_id as individu1_2_0_, individual0_.individual_video_id as individu1_2_1_, individual0_.created_at as created_2_2_1_, individual0_.update_at as update_a3_2_1_, individual0_.last_access_time as last_acc4_2_1_, individual0_.progress_rate as progress5_2_1_, individual0_.video_id as video_id6_2_1_, individual0_.video_space_participant_id as video_sp7_2_1_ from individual_video individual0_ where individual0_.video_space_participant_id=?
*/

JpaRepositoryfindByEmail 메소드를 활용한 User Entity Get 결과입니다.
N+1 문제가 발생한 것을 확인할 수 있습니다.
User EntitySelect 쿼리 결과수가 1이기 때문에 1 + 1번 쿼리가 발생한 이후,
VideoSpaceParticipantSelect 쿼리 결과수가 2이기 때문에 2번의 쿼리가 추가 발생했습니다. 즉 1 + 1 + 2번의 쿼리가 발생했습니다.


📌 findByEmail, Fetch Join 활용


// QueryDSL, Fetch Join 활용
User user = userDao.findByEmail(email);

/* 결과
Hibernate: select distinct user0_.user_id as user_id1_7_0_, videospace1_.video_space_participant_id as video_sp1_10_1_, user0_.created_at as created_2_7_0_, user0_.update_at as update_a3_7_0_, user0_.email as email4_7_0_, user0_.webex_access_token as webex_ac5_7_0_, user0_.zoom_access_token as zoom_acc6_7_0_, user0_.last_access_individual_video_id as last_acc7_7_0_, user0_.name as name8_7_0_, user0_.picture as picture9_7_0_, user0_.role as role10_7_0_, videospace1_.created_at as created_2_10_1_, videospace1_.update_at as update_a3_10_1_, videospace1_.user_id as user_id4_10_1_, videospace1_.video_space_id as video_sp5_10_1_, videospace1_.user_id as user_id4_10_0__, videospace1_.video_space_participant_id as video_sp1_10_0__ from user user0_ left outer join video_space_participant videospace1_ on user0_.user_id=videospace1_.user_id where user0_.email=?
Hibernate: select individual0_.video_space_participant_id as video_sp7_2_1_, individual0_.individual_video_id as individu1_2_1_, individual0_.individual_video_id as individu1_2_0_, individual0_.created_at as created_2_2_0_, individual0_.update_at as update_a3_2_0_, individual0_.last_access_time as last_acc4_2_0_, individual0_.progress_rate as progress5_2_0_, individual0_.video_id as video_id6_2_0_, individual0_.video_space_participant_id as video_sp7_2_0_ from individual_video individual0_ where individual0_.video_space_participant_id in (?, ?)
*/

쿼리의 개수가 2번으로 줄어든 것을 확인할 수 있습니다.
User EntitySelect 쿼리가 작동할 때, Fetch Join을 통해 VideoSpaceParticipant까지 Join된 채로 한번에 불러옵니다. 이 부분이 Fetch Join을 활용해 N+1 문제가 개선된 첫 번째 쿼리입니다. 기존에는 1+1(발견된 유저의 수가 1)이어서 2번의 쿼리가 발생했지만, Fetch Join을 통해 1번의 쿼리로 줄어든 모습입니다.

이번 경우에는 겨우 한번의 쿼리가 덜 발생한 것 같지만, 검색된 유저의 수가 만약 10000개였다면 10001 vs 1로 비교가 됩니다. 상당한 성능 차이라고 볼 수 있습니다.

연관 관계가 하나인 경우에는 첫 번째 쿼리만 발생할 것입니다. 하지만 현재 Entity 구조가 VideoSpaceParticipant Entity가 또 다시 IndividualVideo Entity와 연관 관계가 있기 때문에 쿼리가 하나 더 발생했습니다.

사실 Fetch Join은 2개 이상의 OneToMany 자식 테이블에서 사용하지 못합니다. 그럴 경우 MultipleBagFetchException이 발생합니다.

때문에 자식 테이블 중 데이터가 많은 하나에만 Fetch Join을 걸고 나머지는 Lazy Loading으로 활용한 뒤, 하이버네이트의 default_batch_fetch_size 옵션을 활용해서 성능 개선을 할 수 있습니다. 해당 내용은 추후의 포스팅에서 기술할 예정입니다. 이번 포스팅에서의 주제는 첫번째 쿼리에서의 성능 개선입니다.


📌 느낀점,,

프로젝트를 진행하면서 N+1 문제를 직접 확인하고 해결하는 과정에 있어서 JPA의 동작 방식에 대해 조금 더 이해할 수 있었던 것 같습니다.
이번 포스팅에서의 주요 해결책이었던 Fetch Join은 당연히 만능이 아닙니다. 적절한 상황에서 사용하지 않는다면, 코드 작성량이 많아지고 JPA를 활용하는 가장 큰 이유인 능률 향상을 저하시킨다고 생각합니다.

여러가지 상황에서 성능 이슈가 발생하는지에 대해 계속해서 고민해보고 검증하며 문제를 파악할 수 있는 직관을 길러야겠다고 생각이 들었습니다.
또한 문제를 파악했을 때, 무조건 한가지 방법, 무조건 한가지 기술이 아닌 여러가지 대안과 방법을 검토해봐야겠다고 다짐했습니다.




✅ 참고


https://jojoldu.tistory.com/m/414

https://programmer93.tistory.com/83

https://www.inflearn.com/questions/39516

https://www.inflearn.com/questions/30446

https://jojoldu.tistory.com/m/457

https://jojoldu.tistory.com/m/457?category=637935

profile
어디야 벽벽 / 블로그 이전 -> byuk.dev
post-custom-banner

2개의 댓글

comment-user-thumbnail
2022년 12월 24일

아예 @ManyToOne과 같은 연관관계를 맺지 않으면, 외래키를 바로 사용할 수 있는 장점도 있을 것 같은데요. 또, join을 사용하지 않고 그냥 서비스단에서 두개의 테이블을 2번 select하는게 빠를 수도 있겠더라구요.

1개의 답글