Spring Boot 개발 중 학습이 필요한 내용을 정리하고,
트러블 슈팅 과정을 기록하는 포스팅입니다.
JPA
는 Spring boot
와 함께 활용될 수 있는 매우 강력한 ORM
입니다. 개발자에게 비즈니스 로직 개발에 더 집중할 수 있는 환경을 제공하지만, 의도치 않은 쿼리를 발생시켜 어플리케이션의 성능 저하를 일으키기도 합니다. 그 중 대표적인 문제가 N+1 문제
입니다
이번 포스팅에서는 제가 프로젝트 개발 중 JPA
의 N+1 문제
를 발견하고 분석하여, 이를 해결한 과정에 대해서 적어보겠습니다.
N+1 문제
는 JPA
의 연관 관계가 있는 Entity
를 조회(SELECT
)할 경우에, 결과로 조회된 데이터 개수(N)만큼 연관 관계가 있는 데이터를 추가로 조회하는 쿼리를 발생시키는 문제입니다.
즉, 개발자는 한번의 쿼리로 Entity
를 조회하는 것을 원했으나, 결과로 나온 N개의 Entity
만큼 연관관계 매핑된 Entity
를 조회하는 N개의 쿼리가 추가로 발생되는 현상입니다. (때문에, 1+N 맞지 않냐는 의견도 있습니다.)
실제로 제가 사용하는 ERD
구조에서 N+1
문제를 재현해보겠습니다.
user
테이블은 video_space_participant
테이블과 1:N의 관계
입니다.
또한 video_space_participant
테이블은 individual_video
테이블과 1:N 관계
입니다.
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
문제로 문제가 진화(?)한 것을 볼 수 있습니다.
그렇다면, 지연 로딩 방식을 활용하면 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 Entity
를 get
합니다. 결과를 보면 또 다시 4번의 쿼리가 추가로 조회된 것을 알 수 있습니다. 이후에 IndividualVideo Entity
를 조회할 때 또 다시 추가 커리가 발생할 것입니다. 이렇듯 N+1 문제가 발생하는 것은 똑같다고 할 수 있습니다.
앞서서 즉시 로딩 방식과 지연 로딩 방식 둘다 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
을 통해 User
를 get
하는 findByEmail
메소드입니다. 다음과 같이 User Entity
와 연관 관계가 있는 VideoSpaceParticipant Entity
를 기준으로 Fetch Join
을 합니다.
그렇다면, 기존의 JpaRepository
의 findByEmail
메소드와 Fetch Join
를 활용한 findByEmail
메소드의 차이를 확인해 봅시다.
// 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=?
*/
JpaRepository
의 findByEmail
메소드를 활용한 User Entity Get 결과입니다.
N+1 문제가 발생한 것을 확인할 수 있습니다.
User Entity
의 Select
쿼리 결과수가 1이기 때문에 1 + 1번 쿼리가 발생한 이후,
VideoSpaceParticipant
의 Select
쿼리 결과수가 2이기 때문에 2번의 쿼리가 추가 발생했습니다. 즉 1 + 1 + 2번의 쿼리가 발생했습니다.
// 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 Entity
의 Select
쿼리가 작동할 때, 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
아예 @ManyToOne과 같은 연관관계를 맺지 않으면, 외래키를 바로 사용할 수 있는 장점도 있을 것 같은데요. 또, join을 사용하지 않고 그냥 서비스단에서 두개의 테이블을 2번 select하는게 빠를 수도 있겠더라구요.