일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- 13975
- 백준
- 이진 탐색
- 14921
- 3187
- Lower bound
- 19598
- join제거
- greedy
- binary search
- DP
- 1495
- Upper bound
- 로그
- Promtail
- 11501
- 2512
- dto projection
- slice개념
- Blue/Green
- 12738
- 20115
- 무한페이징
- 그리디
- EntityGraph
- NCP
- Java
- 모니터링
- 이분 탐색
- no offset
- Today
- Total
멘지의 기록장
N+1 문제 해결 방법 본문
프로젝트를 하며 1번의 쿼리가 나갔을 때 각각의 row에 대해서 추가로 조회하기 위해서 N 번의 쿼리가 나가는 N+1 문제가 종종 발생하였습니다.
N+1 문제가 어떤 상황에서 발생하고, 어떻게 해결하는지에 대해 작성해보겠습니다.
Fetch Join
프로젝트에서 질문(Opinion)에 대한 모든 댓글(Comment)을 불러오는 부분에서 N+1 문제가 발생하였습니다.
처음 작성한 코드는 다음과 같습니다.
각 질문에 대한 모든 댓글의 Id 값을 return 할 때 질문과 함께 댓글 테이블의 값도 가져오기 때문에 추가적으로 쿼리가 나가는 문제가 생겼습니다.
public void showOpinionAndCommentList() {
List<Opinion> opinions = opinionRepository.findAll();
for (Opinion opinion : opinions) {
List<Long> opinionList = opinion.getComments().stream()
.map(Comment::getCommentId)
.toList();
log.info("opinionId: {}, comments: {}", opinion.getOpinionId(), opinionList);
}
}
3개의 질문이 DB에 등록되어 있기에 각 질문에 대한 댓글을 가져오기 위해
댓글 쿼리가 추가적으로 나가게 되어 총 4개의 쿼리가 나가는 것을 확인할 수 있습니다.
당장은 질문의 값이 많이 없어서 큰 문제가 발생하지 않지만 질문의 갯수가 많아질수록
추가적으로 나가는 쿼리의 갯수가 너무 많아지는 문제가 발생할 것입니다.
Hibernate: // 1
select
o1_0.opinion_id,
o1_0.comment_count,
o1_0.created_date,
o1_0.modified_date,
o1_0.question,
o1_0.view_count
from
opinion o1_0
Hibernate: // 2
select
c1_0.opinion_id,
c1_0.comment_id,
c1_0.content,
c1_0.created_date,
c1_0.heart_count,
c1_0.modified_date,
c1_0.user_id
from
comment c1_0
where
c1_0.opinion_id=?
2024-07-31T15:15:27.798+09:00 INFO 12048 --- [nio-8080-exec-1] c.t.s.opinion.service.OpinionService : opinionId: 1, comments: [1, 5, 10]
Hibernate: // 3
select
c1_0.opinion_id,
c1_0.comment_id,
c1_0.content,
c1_0.created_date,
c1_0.heart_count,
c1_0.modified_date,
c1_0.user_id
from
comment c1_0
where
c1_0.opinion_id=?
2024-07-31T15:15:27.802+09:00 INFO 12048 --- [nio-8080-exec-1] c.t.s.opinion.service.OpinionService : opinionId: 2, comments: [2, 4, 8, 9]
Hibernate: // 4
select
c1_0.opinion_id,
c1_0.comment_id,
c1_0.content,
c1_0.created_date,
c1_0.heart_count,
c1_0.modified_date,
c1_0.user_id
from
comment c1_0
where
c1_0.opinion_id=?
2024-07-31T15:15:27.806+09:00 INFO 12048 --- [nio-8080-exec-1] c.t.s.opinion.service.OpinionService : opinionId: 3, comments: [3, 6, 7]
그렇다면 그때그때 Comment 값을 가져오게 하도록 즉시로딩을 진행한다면 문제를 해결할 수 있을까요?
@Table(name = "comment")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Comment extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "comment_id")
private Long commentId;
@Column(name = "content", length = 500)
private String content;
@JoinColumn(name = "user_id")
@ManyToOne(fetch = FetchType.EAGER)
private User user;
@JoinColumn(name = "opinion_id")
@ManyToOne(fetch = FetchType.LAZY)
private Opinion opinion;
@OneToMany(mappedBy = "comment", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<Heart> hearts = new ArrayList<>();
@Column(name = "heart_count")
private Long heartCount;
}
아래의 결과를 보면 값을 즉시 가져올 뿐 N+1 문제를 해결하지는 못했습니다.
Hibernate: // 1
select
o1_0.opinion_id,
o1_0.comment_count,
o1_0.created_date,
o1_0.modified_date,
o1_0.question,
o1_0.view_count
from
opinion o1_0
Hibernate: // 2
select
c1_0.opinion_id,
c1_0.comment_id,
c1_0.content,
c1_0.created_date,
c1_0.heart_count,
c1_0.modified_date,
c1_0.user_id
from
comment c1_0
where
c1_0.opinion_id=?
2024-07-31T16:31:33.231+09:00 INFO 20412 --- [nio-8080-exec-1] c.t.s.opinion.service.OpinionService : opinionId: 1, comments: [1, 5, 10]
Hibernate: // 3
select
c1_0.opinion_id,
c1_0.comment_id,
c1_0.content,
c1_0.created_date,
c1_0.heart_count,
c1_0.modified_date,
c1_0.user_id
from
comment c1_0
where
c1_0.opinion_id=?
2024-07-31T16:31:33.234+09:00 INFO 20412 --- [nio-8080-exec-1] c.t.s.opinion.service.OpinionService : opinionId: 2, comments: [2, 4, 8, 9]
Hibernate: // 4
select
c1_0.opinion_id,
c1_0.comment_id,
c1_0.content,
c1_0.created_date,
c1_0.heart_count,
c1_0.modified_date,
c1_0.user_id
from
comment c1_0
where
c1_0.opinion_id=?
2024-07-31T16:31:33.237+09:00 INFO 20412 --- [nio-8080-exec-1] c.t.s.opinion.service.OpinionService : opinionId: 3, comments: [3, 6, 7]
그렇다면 N+1 문제를 어떻게 해결해야할까요?
이때 사용할 수 있는 첫번째 방법이 Fetch Join 입니다.
Fetch Join이란 JPQL에서 성능 최적화를 위해 제공하는 기능으로, 연관된 엔티티나 컬렉션을 한 번에 같이 조회할 수 있게 해줍니다.
Fetch Join을 사용하는 코드로 수정한 후 결과를 보도록 하겠습니다.
@Query("SELECT o FROM Opinion o JOIN FETCH o.comments ")
List<Opinion> findAllFetchJoin();
public void showOpinionAndCommentListFetchJoin() {
List<Opinion> opinions = opinionRepository.findAllFetchJoin();
for (Opinion opinion : opinions) {
List<Long> opinionList = opinion.getComments().stream()
.map(Comment::getCommentId)
.toList();
log.info("opinionId: {}, comments: {}", opinion.getOpinionId(), opinionList);
}
}
Fetch join을 사용했을 때 질문과 댓글 Table을 on 조건에 맞게 Join하고서 값을 가져오기 때문에
N+1 문제가 발생하지 않고 한번에 결과값이 나오는 것을 확인할 수 있었습니다.
Hibernate: // 1
select
o1_0.opinion_id,
o1_0.comment_count,
c1_0.opinion_id,
c1_0.comment_id,
c1_0.content,
c1_0.created_date,
c1_0.heart_count,
c1_0.modified_date,
c1_0.user_id,
o1_0.created_date,
o1_0.modified_date,
o1_0.question,
o1_0.view_count
from
opinion o1_0
join
comment c1_0
on o1_0.opinion_id=c1_0.opinion_id
2024-07-31T16:38:57.959+09:00 INFO 16540 --- [nio-8080-exec-1] c.t.s.opinion.service.OpinionService : opinionId: 1, comments: [1, 5, 10]
2024-07-31T16:38:57.959+09:00 INFO 16540 --- [nio-8080-exec-1] c.t.s.opinion.service.OpinionService : opinionId: 2, comments: [2, 4, 8, 9]
2024-07-31T16:38:57.959+09:00 INFO 16540 --- [nio-8080-exec-1] c.t.s.opinion.service.OpinionService : opinionId: 3, comments: [3, 6, 7]
@EntityGraph
N+1 문제를 해결하는 또 다른 방법으로 @EntityGraph가 있습니다.
[유저가 추가한 문제 or 관리자가 추가한 문제] 를 프론트에서 요청한 갯수만큼 랜덤하게 제공해야하는 부분에서 N+1 문제가 발생하였습니다.
WordQuiz를 return할 때 무조건 Word 테이블의 값을 가져오기 때문에 추가적으로 쿼리가 나가는 문제가 생겼습니다.
@Query("SELECT wq FROM WordQuiz wq WHERE wq.member = :member OR wq.member.role = 'ADMIN'")
List<WordQuiz> findSingleResultByMember(Member member, Pageable pageable);
public WordQuizSolveListResponse selectRandomWordQuiz(Long memberId, Integer quizNum) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND));
int totalQuizNum = getWordQuizCount(member);
List<WordQuizSolveResponse> wordQuizSolveResponseList =
RandNumUtil.generateRandomNumbers(0, totalQuizNum - 1, quizNum).stream()
.map(quizIdx -> generateRandomWordQuizPage(member, quizIdx))
.map(WordQuizSolveResponse::from)
.toList();
return new WordQuizSolveListResponse(wordQuizSolveResponseList);
}
private int getWordQuizCount(Member member) {
return wordQuizRepository.countByMemberOrAdmin(member);
}
private WordQuiz generateRandomWordQuizPage(Member member, int quizIdx) {
return wordQuizRepository.findSingleResultByMember(member, PageRequest.of(quizIdx, 1)).get(0);
}
3개의 WordQuiz를 요청했을 때 각 WordQuiz를 return할 때마다 Word 테이블의 값을 가져오기에
총 3개의 WordQuiz, 3개의 Word ⏩ 총 6개의 쿼리가 나가는 것을 확인할 수 있습니다.
Hibernate: // 1
select
wq1_0.quiz_id,
wq1_0.answer,
wq1_0.level,
wq1_0.member_id,
wq1_0.category,
wq1_0.title
from
word_quiz wq1_0
join
member m1_0
on m1_0.member_id=wq1_0.member_id
where
wq1_0.member_id=?
or m1_0.role='ADMIN'
limit
?, ?
Hibernate: // 2
select
w1_0.quiz_id,
w1_0.word_id,
w1_0.content
from
word w1_0
where
w1_0.quiz_id=?
Hibernate: // 3
select
wq1_0.quiz_id,
wq1_0.answer,
wq1_0.level,
wq1_0.member_id,
wq1_0.category,
wq1_0.title
from
word_quiz wq1_0
join
member m1_0
on m1_0.member_id=wq1_0.member_id
where
wq1_0.member_id=?
or m1_0.role='ADMIN'
limit
?, ?
Hibernate: // 4
select
w1_0.quiz_id,
w1_0.word_id,
w1_0.content
from
word w1_0
where
w1_0.quiz_id=?
Hibernate: // 5
select
wq1_0.quiz_id,
wq1_0.answer,
wq1_0.level,
wq1_0.member_id,
wq1_0.category,
wq1_0.title
from
word_quiz wq1_0
join
member m1_0
on m1_0.member_id=wq1_0.member_id
where
wq1_0.member_id=?
or m1_0.role='ADMIN'
limit
?, ?
Hibernate: // 6
select
w1_0.quiz_id,
w1_0.word_id,
w1_0.content
from
word w1_0
where
w1_0.quiz_id=?
위의 코드를 @EntityGraph를 사용하여 N+1 문제가 발생하지 않도록 수정해보겠습니다.
@EntityGraph(attributePaths = {"words"})
@Query("SELECT wq FROM WordQuiz wq WHERE wq.member = :member OR wq.member.role = 'ADMIN'")
Page<WordQuiz> findSingleResultByMember(Member member, Pageable pageable);
WordQuiz의 값을 가져올 때 Word 테이블을 left join하여 값을 가져오기 때문에
3번의 WordQuiz 요청에 3개의 쿼리만 나가 N+1 문제가 해결되는 것을 확인할 수 있습니다.
2024-07-30T15:02:12.363+09:00 WARN 2640 --- [nio-8080-exec-4] org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
Hibernate: // 1
select
wq1_0.quiz_id,
wq1_0.answer,
wq1_0.level,
wq1_0.member_id,
wq1_0.category,
wq1_0.title,
w1_0.quiz_id,
w1_0.word_id,
w1_0.content
from
word_quiz wq1_0
join
member m1_0
on m1_0.member_id=wq1_0.member_id
left join
word w1_0
on wq1_0.quiz_id=w1_0.quiz_id
where
wq1_0.member_id=?
or m1_0.role='ADMIN'
2024-07-30T15:02:12.390+09:00 WARN 2640 --- [nio-8080-exec-4] org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
Hibernate: // 2
select
wq1_0.quiz_id,
wq1_0.answer,
wq1_0.level,
wq1_0.member_id,
wq1_0.category,
wq1_0.title,
w1_0.quiz_id,
w1_0.word_id,
w1_0.content
from
word_quiz wq1_0
join
member m1_0
on m1_0.member_id=wq1_0.member_id
left join
word w1_0
on wq1_0.quiz_id=w1_0.quiz_id
where
wq1_0.member_id=?
or m1_0.role='ADMIN'
2024-07-30T15:02:12.400+09:00 WARN 2640 --- [nio-8080-exec-4] org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
Hibernate: // 3
select
wq1_0.quiz_id,
wq1_0.answer,
wq1_0.level,
wq1_0.member_id,
wq1_0.category,
wq1_0.title,
w1_0.quiz_id,
w1_0.word_id,
w1_0.content
from
word_quiz wq1_0
join
member m1_0
on m1_0.member_id=wq1_0.member_id
left join
word w1_0
on wq1_0.quiz_id=w1_0.quiz_id
where
wq1_0.member_id=?
or m1_0.role='ADMIN'
다만 이전에는 발생하지 않던 OOM(Out Of Memory)이 발생할 위험이 존재합니다.
@EntityGraph를 사용할 경우 N+1 문제를 해결하기 위해 조건절에 부합하는 WordQuiz와 Word의 모든 데이터를 메모리에 가져와서 Paging을 진행하기 때문입니다.
( ⚡ @EntityGraph와 Paging을 같이 쓰는 것은 위험하기 때문에 사용하지 않는게 좋습니다!)
이를 해결하기 위해선 Paging이 아닌, List를 사용하면 됩니다.
@EntityGraph(attributePaths = {"words"})
@Query("SELECT wq FROM WordQuiz wq WHERE wq.quizId = :quizId AND wq.member = :member OR wq.member.role = 'ADMIN'" )
List<WordQuiz> findSingleResultByMember(Member member, Long quizId);
List를 사용하면 OOM이 발생하지 않는 것을 확인할 수 있습니다.
Hibernate: // 1
select
wq1_0.quiz_id,
wq1_0.answer,
wq1_0.level,
wq1_0.member_id,
wq1_0.category,
wq1_0.title,
w1_0.quiz_id,
w1_0.word_id,
w1_0.content
from
word_quiz wq1_0
join
member m1_0
on m1_0.member_id=wq1_0.member_id
left join
word w1_0
on wq1_0.quiz_id=w1_0.quiz_id
where
wq1_0.quiz_id=?
and wq1_0.member_id=?
or m1_0.role='ADMIN'
Hibernate: // 2
select
wq1_0.quiz_id,
wq1_0.answer,
wq1_0.level,
wq1_0.member_id,
wq1_0.category,
wq1_0.title,
w1_0.quiz_id,
w1_0.word_id,
w1_0.content
from
word_quiz wq1_0
join
member m1_0
on m1_0.member_id=wq1_0.member_id
left join
word w1_0
on wq1_0.quiz_id=w1_0.quiz_id
where
wq1_0.quiz_id=?
and wq1_0.member_id=?
or m1_0.role='ADMIN'
Hibernate: // 3
select
wq1_0.quiz_id,
wq1_0.answer,
wq1_0.level,
wq1_0.member_id,
wq1_0.category,
wq1_0.title,
w1_0.quiz_id,
w1_0.word_id,
w1_0.content
from
word_quiz wq1_0
join
member m1_0
on m1_0.member_id=wq1_0.member_id
left join
word w1_0
on wq1_0.quiz_id=w1_0.quiz_id
where
wq1_0.quiz_id=?
and wq1_0.member_id=?
or m1_0.role='ADMIN'
해당 부분에서 Fetch Join이 아닌 @EntityGraph를 사용한 이유는 @Query를 통해 추가 조건을 주어야 하지만 Fetch Join에서는 WHERE절을 사용할 수 없기 때문입니다.
이처럼 조건을 넣어야 하는 경우에는 @EntityGraph를 사용함으로써 해결할 수 있습니다.
DTO
N+1 문제를 해결하는 방법 중 DTO를 사용하여 필요한 값만 가져오도록 하는 방법이 있습니다.
홈 화면에 큐레이션을 전달해야 하는데 매번 직접 만들기에는 효율적이지 않았기에 Gemini API에 3개의 아티클 제목을 주고서 공통 제목을 추출하기로 하였습니다.
이후 해당 값들은 자정마다 바뀌는 값들이기에 테이블에 저장하기보다 캐시에 저장하기로 하였습니다.
3개의 아티클(Post)을 저장할 때 Scrapped 테이블의 쿼리가 추가적으로 나가는 N+1 문제가 발생하였습니다.
private void saveCommonTitleAndPostsToCache(int count) {
Set<Long> selectedPostIds = getRandomIndices(count);
// 선택된 post_id에 해당하는 Post 객체 가져오기
List<PostSetsResponse> selectedPosts = postRepository.findSelectedPostInfoByIds(selectedPostIds);
// AI에게 3개의 Post 제목을 보내서 공통된 제목 추천받기
String commonTitle = generateCommonTitle(selectedPosts);
// commonTitle과 selectedPosts를 PostTopicCache에 저장
postTopicCache.saveToCache(commonTitle, selectedPosts);
log.info("post: {}", postTopicCache.getPostsFromCache(commonTitle));
}
Hibernate: // 1
select
p1_0.post_id,
p1_0.author,
p1_0.category,
p1_0.content,
p1_0.created_date,
p1_0.image_file_name,
p1_0.image_folder_name,
p1_0.image_url,
p1_0.scrap_count,
p1_0.title
from
post p1_0
where
p1_0.post_id in (?, ?, ?)
Hibernate: // 2
select
s1_0.post_id,
s1_0.id,
s1_0.created_date,
s1_0.modified_date,
s1_0.status,
s1_0.user_id
from
scrapped s1_0
where
s1_0.post_id=?
Hibernate: // 3
select
s1_0.post_id,
s1_0.id,
s1_0.created_date,
s1_0.modified_date,
s1_0.status,
s1_0.user_id
from
scrapped s1_0
where
s1_0.post_id=?
Hibernate: // 4
select
s1_0.post_id,
s1_0.id,
s1_0.created_date,
s1_0.modified_date,
s1_0.status,
s1_0.user_id
from
scrapped s1_0
where
s1_0.post_id=?
불필요한 Scrapped 쿼리를 제거하기 위해 DTO를 사용하여 필요한 Post 값들만 저장하도록 수정해보겠습니다.
@Query("SELECT new com.ticle.server.home.dto.response.PostSetsResponse(p.title, p.image.imageUrl, p.category, p.author, p.createdDate) "
+ "FROM Post p "
+ "WHERE p.postId IN (:postIds)")
List<PostSetsResponse> findSelectedPostInfoByIds(Set<Long> postIds);
Hibernate: // 1
select
p1_0.title,
p1_0.image_url,
p1_0.category,
p1_0.author,
p1_0.created_date
from
post p1_0
where
p1_0.post_id in (?, ?, ?)
이전과는 달리 Select 부분에서도 Post의 모든 값이 아닌 필요한 값만 들어가고,
Scrapped와 관련된 추가 쿼리는 나가지 않는 것을 확인할 수 있습니다.
이처럼 Table을 Join 하지 않고서 N+1 문제를 해결하고 싶을 때 DTO를 사용할 수 있습니다.
이외에도 @BatchSize, default_batch_fetch_size를 사용하여 N+1 문제를 해결할 수 있지만, 지정해 둔 size를 벗어나면 추가로 쿼리가 나간다는 문제가 존재하기 때문에 프로젝트에서 사용하지는 않았습니다.
N+1을 해결하는 다양한 방법에 대해 소개해보았습니다.
각 방법의 장단점을 고려하여 적절한 해결방안을 적용하시면 좋을거 같습니다 😄
'SpringBoot' 카테고리의 다른 글
Slice 개념을 활용하여 무한 페이징 리팩토링하기 (0) | 2025.01.01 |
---|---|
No Offset을 적용한 무한 페이징 구현하기 (성능 비교) (1) | 2024.12.20 |
[SpringBoot] Docker + Nginx + Github Action을 사용하여 무중단배포 CI/CD 구축하기 (0) | 2024.07.30 |
스프링 오답노트 (2) | 2024.04.05 |
@Valid를 이용한 유효성 검증 (0) | 2024.03.23 |