현재 진행하고 있는 프로젝트 기능 중, 게시글을 최신 순으로 보여주는 기능이 있습니다. 컬렉션을 Join해야하는 상황에서 N + 1
문제가 발생합니다.
해당 문제를 해결하기 위해 3가지 방식을 사용했습니다.
Fetch Join
Batch Size 설정
IN절을 활용해 DTO로 조회하는 방식
현재 프로젝트에는 게시글 100만건, 회원 3만건, 댓글 3만건이 DB에 저장되어 있는 상태입니다.
// Service
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository;
//...
public List<BoardTotalInfoResponse> findLimitedBoardList(Pageable pageable) {
return boardRepository.findBoardsJoinCommentsAndMembers(pageable)
.stream()
.map(board -> BoardTotalInfoResponse.of(board, board.getCommentList()))
.collect(Collectors.toList());
}
}
// Repository
public interface BoardRepository extends JpaRepository<Board, Long> {
@Query("select b " +
"from Board b " +
"join b.member " +
"left join b.commentList c " +
"left join c.member m " +
"order by b.createdAt desc")
List<Board> findBoardsJoinCommentsAndMembers(Pageable pageable);
}
@Getter
@Setter
@EqualsAndHashCode(of = "boardId")
@NoArgsConstructor
public class BoardTotalInfoResponse {
private Long boardId;
private String title;
private String content;
private String memberName;
private LocalDateTime createdAt;
private List<CommentResponse> comments;
private int totalLikeCount = 0;
...
}
@Getter
@NoArgsConstructor
public class CommentResponse {
private Long boardId;
private String content;
private String commenter;
private LocalDateTime createdAt;
...
}
[
{
"boardId": 1000002,
"title": "안녕하세요",
"content": "ㅇㅇㅇㅇㅇㅇ",
"memberName": "User",
"createdAt": "2024-02-06 18:05:29",
"comments": [
{
"boardId": 1000002,
"content": "This is a sample comment",
"commenter": "User155_674513dd",
"createdAt": "2023-01-01 12:00:00"
},
{
"boardId": 1000002,
"content": "댓글입니다1.",
"commenter": "User1357_03d3c76d",
"createdAt": "2023-05-03 12:00:00"
}
],
"totalLikeCount": 0
},
{
"boardId": 553421,
"title": "Title_a9389c67f31cd82",
"content": "Content_40e9974a73895e6fbb8bd365531a1b4a",
"memberName": "User2919_f06cb712",
"createdAt": "2023-12-31 23:59:37",
"comments": [
{
"boardId": 553421,
"content": "댓글입니다.",
"commenter": "User2_7e5c4a9a",
"createdAt": "2023-12-03 12:00:00"
}
],
"totalLikeCount": 5
},
...
]
결과는 정상적으로 반환되는 것을 볼 수 있습니다. 하지만 Hibernate
에서 생성한 쿼리를 보면 N + 1
현상이 발생한 것을 알 수 있습니다.
Hibernate:
select
b1_0.board_id,
b1_0.content,
b1_0.created_at,
b1_0.member_id,
b1_0.modified_at,
b1_0.title,
b1_0.total_like_count,
b1_0.version
from
board b1_0
join
member m1_0
on m1_0.member_id=b1_0.member_id
left join
comments c1_0
on b1_0.board_id=c1_0.board_id
order by
b1_0.created_at desc limit ?,
?
Hibernate:
select
m1_0.member_id,
m1_0.created_at,
m1_0.login_account_id,
m1_0.modified_at,
m1_0.name,
m1_0.profile_image_url,
m1_0.role_type
from
member m1_0
where
m1_0.member_id=?
Hibernate:
select
c1_0.board_id,
c1_0.comments_id,
c1_0.comment_content,
c1_0.created_at,
c1_0.member_id
from
comments c1_0
where
c1_0.board_id=?
Hibernate:
select
m1_0.member_id,
m1_0.created_at,
m1_0.login_account_id,
m1_0.modified_at,
m1_0.name,
m1_0.profile_image_url,
m1_0.role_type
from
member m1_0
where
m1_0.member_id=?
Hibernate:
select
m1_0.member_id,
m1_0.created_at,
m1_0.login_account_id,
m1_0.modified_at,
m1_0.name,
m1_0.profile_image_url,
m1_0.role_type
from
member m1_0
where
m1_0.member_id=?
...
Board 엔티티
조회 -> Join
된 Member 엔티티
조회Comments 엔티티
조회 -> Join
된 Member 엔티티
조회Board 엔티티
에 Join
된 Member 엔티티
조회 -> 2번 과정 반복이처럼 최신 게시글 목록 10개를 조회하는 쿼리를 실행했는데, 수 많은 쿼리가 발생한 것을 확인할 수 있었습니다.
데이터가 많이 쌓인 실무 환경이라면 성능 저하를 일으킬 수 있는 상황이기 때문에 이에 대한 해결이 필요합니다.
N + 1
문제를 해결하기 위해서 Fetch Join
을 사용하거나 Batch Size
를 설정하는 방법이 있습니다.
Fetch Join
은 해당 기능에서 페이징을 사용하기 때문에 적절하지 않습니다.Fetch Join
을 사용하면, 데이터베이스가 조인된 모든 데이터를 메모리에 올려놓고 페이징 처리를 하기 때문에 성능 문제가 발생할 수 있습니다.Fetch Join
으로 조회할 경우, 50초가 넘는 조회 시간이 걸렸습니다.Batch Size
를 설정하여 N + 1
현상을 해결하겠습니다.## yml
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000
적절한
Batch Size
를 설정하는 것이 최적화에 도움이 됩니다.
크기가 너무 작을 경우 많은 쿼리를 발생시켜 오버헤드를 증가합니다. 또한, 크기가 너무 클 경우 많은 데이터를 로딩하여 쿼리 성능이 저하됩니다.
적절한 크기를 찾기 위해서는 프로파일링이나 모니터링, 테스트 등이 필요합니다.
여기서는 권장되는Batch Size
인 100~1000 사이로 설정했습니다.
결과는 위와 동일하게 나오는 것을 확인할 수 있었습니다.
Hibernate
에서 생성한 쿼리를 확인했을 때, N + 1
현상을 해결한 것을 확인할 수 있었습니다.
Hibernate:
select
b1_0.board_id,
b1_0.content,
b1_0.created_at,
b1_0.member_id,
b1_0.modified_at,
b1_0.title,
b1_0.total_like_count,
b1_0.version
from
board b1_0
join
member m1_0
on m1_0.member_id=b1_0.member_id
left join
comments c1_0
on b1_0.board_id=c1_0.board_id
order by
b1_0.created_at desc limit ?,
?
Hibernate:
select
m1_0.member_id,
m1_0.created_at,
m1_0.login_account_id,
m1_0.modified_at,
m1_0.name,
m1_0.profile_image_url,
m1_0.role_type
from
member m1_0
where
m1_0.member_id in(?,?,?,?,?,?,?,?,?)
Hibernate:
select
c1_0.board_id,
c1_0.comments_id,
c1_0.comment_content,
c1_0.created_at,
c1_0.member_id
from
comments c1_0
where
c1_0.board_id in(?,?,?,?,?,?,?,?,?)
Hibernate:
select
m1_0.member_id,
m1_0.created_at,
m1_0.login_account_id,
m1_0.modified_at,
m1_0.name,
m1_0.profile_image_url,
m1_0.role_type
from
member m1_0
where
m1_0.member_id in(?,?,?,?)
default_batch_fetch_size
설정에 따라 Hibernate
에서 효율적으로 데이터를 페치하고 성능을 최적화합니다. IN
절을 사용하여 한 번에 여러 엔티티를 로딩하고 N + 1
현상을 해결합니다.
현재 사용한 최적화 방식은 default_batch_fetch_size
설정을 통해 Hibernate
에서 자동으로 성능을 최적화한 것입니다. 조회된 엔티티를 DTO로 변환하는 과정에서 추가적인 쿼리가 발생하여 총 4번의 쿼리가 발생한 것을 볼 수 있습니다.
이것을 직접 IN절을 활용하여(DTO로 조회) 메모리에서 미리 조회해서 최적화하는 방식으로 변경해 조회하는 쿼리 수를 줄여보겠습니다.
// Service
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class BoardService {
...
private final BoardQueryRepository boardQueryRepository;
...
public List<BoardTotalInfoResponse> findLimitedBoardList_v1(Pageable pageable) {
return boardQueryRepository.findBoardTotalInfo(pageable);
}
}
// Repository
@Repository
@RequiredArgsConstructor
public class BoardQueryRepository {
private final JPAQueryFactory jpaQueryFactory;
public List<BoardTotalInfoResponse> findBoardTotalInfo(Pageable pageable) {
List<BoardTotalInfoResponse> boards = findBoards(pageable);
List<Long> boardIds = boards.stream()
.map(BoardTotalInfoResponse::getBoardId)
.toList();
Map<Long, List<CommentResponse>> commentsMap = findComments(boardIds).stream()
.collect(Collectors.groupingBy(CommentResponse::getBoardId));
boards.forEach(b -> b.setComments(commentsMap.getOrDefault(b.getBoardId(), new ArrayList<>())));
return boards;
}
public List<BoardTotalInfoResponse> findBoards(Pageable pageable) {
return jpaQueryFactory.select(
Projections.constructor(
BoardTotalInfoResponse.class,
board.id,
board.title.value,
board.content.value,
board.member.name,
board.createdAt,
board.totalLikeCount
)
)
.from(board)
.join(board.member, member)
.orderBy(board.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
public List<CommentResponse> findComments(List<Long> boardIds) {
return jpaQueryFactory.select(
Projections.constructor(CommentResponse.class,
comment.board.id,
comment.commentContent.value,
comment.member.name,
comment.createdAt
)
)
.from(comment)
.join(comment.board, board)
.join(comment.member, member)
.where(comment.board.id.in(boardIds))
.fetch();
}
}
결과는 앞서 작성했던 코드와 동일한 결과가 나왔습니다.
하지만 루트에서 1번, 컬렉션에서 1번을 조회하여 총 2번의 쿼리가 발생했습니다. Batch Size
설정을 통해 조회하는 방법에 비해 쿼리가 2번 줄어든 것을 확인할 수 있습니다.
Hibernate:
select
b1_0.board_id,
b1_0.title,
b1_0.content,
m1_0.name,
b1_0.created_at,
b1_0.total_like_count
from
board b1_0
join
member m1_0
on m1_0.member_id=b1_0.member_id
order by
b1_0.created_at desc limit ?,
?
Hibernate:
select
c1_0.board_id,
c1_0.comment_content,
m1_0.name,
c1_0.created_at
from
comments c1_0
join
board b1_0
on b1_0.board_id=c1_0.board_id
join
member m1_0
on m1_0.member_id=c1_0.member_id
where
c1_0.board_id in(?,?,?,?,?,?,?,?,?,?)
Batch Size
를 설정했을 때 보다 코드는 복잡해졌지만 쿼리가 간결해진 것을 확인할 수 있습니다.
해당 방식은 XToOne
관계에서 Join
할 수 있는 경우는 미리 Join
합니다. 그리고 식별자인 boardId
를 통해 XToMany
관계인 Comments 테이블
을 한 번에 조회합니다.
이렇게 최적화 하는 방식이 여러 방법이 있습니다.
Batch Size
를 설정하여 최적화하는 방식은 코드가 간단한 대신 추가적인 쿼리가 발생하고, 직접 IN절을 사용해서 DTO로 조회하는 방식
은 코드가 복잡하지만 쿼리 수를 줄일 수 있습니다.
구현을 우선적으로 해야 하는 상황이라면 Batch Size
를 사용하여 간결한 코드를 작성해야 합니다.
사용자의 요청이 많아 성능을 개선해야 하는 상황이라면 직접 IN절을 사용해 DTO로 조회하는 방식
을 선택하여 성능 개선하는 방식을 선택해야 합니다.