프로젝트 진행 중 게시물을 단순 조회를 테스트해보고 있었는데 쿼리가 하나가 아닌 우다닥 나오고 있었다. 사실 이게 나왔을때 좀 기뻣다. 배운 내용을 드디어 적용해볼 수 있겠구나! 유명한 JPA N+1 문제를 드디어 만난거다.
지금은 단순한 로직이라 빨리 찾을 수 있었다. 현재 board 하나를 단순히 조회하는데 위 사진처럼 긴 쿼리가 날아가는 것을 볼 수 있었다. 엄청
아주 긴 쿼리가 날아가는 것을 볼 수 있다.
40개의 쿼리가 단순 조회로 인해 발생하고 있었다. (2개는 처음 나오는 Hibernate)
이녀석이 왜이렇게 날아가나 찾아보니 아래와 같은 로직때문에 이런일이 발생하는 것을 볼 수 있었다.
public static BoardResponse of(Board board) {
return BoardResponse.builder()
.boardId(board.getId())
.userName(board.getUser().getUsername())
.restaurantName(board.getRestaurantName())
.contentUrl(board.getContentUrl())
.content(board.getContent())
.category(board.getCategory())
.comments(board.getComments().stream()
.map(CommentResponse::of)
.collect(Collectors.toList()))
.likeCount(board.getLikeCount())
.build();
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Board {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "board_id")
private Long id;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "user_id")
private User user;
private String contentUrl;
private String content;
@OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
}
여기서 OneToMany인 comments 리스트와 User에서 N+1 문제가 발생하고 있다는 것을 알았다. 여기서 우리가 알아야될 개념은 즉시로딩과 지연로딩이다.
다들 즉시로딩은 왠만하면 지양하고 있다. 왜 그러는걸까?
EAGER 로딩 VS LAZY 로딩
로딩 시점 | 엔티티가 조회될 때 즉시 로딩 | 실제로 사용될 때 로딩 |
성능 | 많은 데이터를 미리 로딩하므로 성능 저하 가능 | 필요한 데이터만 로딩하여 성능 최적화 |
N+1 문제 | 발생할 수 있음 | 추가 쿼리가 실행되지만 필요할 때만 발생 |
사용 시기 | 연관된 엔티티가 항상 필요할 때 사용 | 연관된 엔티티가 필요할 때만 사용 |
내 코드에서도 User을 Eager로 쓰고 있는데 단순히 Board를 하나 조회할때는 성능 차이가 별로 발생하지 않는다. 그러나 Board 10개를 조회할 때마다 각 Board에 연결된 User를 함께 로딩하려면, 각 Board마다 User를 조회하는 쿼리가 실행되어 문제가 발생하고 있었다.
또한 Comment 리스트에선 OneToMany로 되어있는데 기본값이 지연로딩이기에 프록시 객체로 영속성컨텍스트에 저장되어 있는데 조회시에 실제 값을 채워넣어야함으로 steam에서 db에 값을 쏘는 쿼리가 댓글의 개수만큼 나가고 있는 것이다.
여기서 해결방법은 그 유명한 Fetch Join을 하는 방법이다.
Query("SELECT b FROM Board b LEFT JOIN FETCH b.comments WHERE b.id = :boardId")
Board findBoardWithComments(@Param("boardId") Long boardId);
여기서 의문점은 그냥 Join을 하면 되지 않나?라고 생각하지만 JPQL에서는 Join을 해도 프록시 객체로 가져와 꼭 Fetch조인을 해야 연관된 엔티티 정보를 모두 가져올 수 있다.
위를 적용해보자
위를 적용했지만 아직도 11개의 쿼리가 나가고 있는걸 볼 수 있었다. 찾아보니 똑같은 문제였다.
public static CommentResponse of(Comment comment) {
return CommentResponse.builder()
.commentId(comment.getId())
.userName(comment.getUser().getUsername())
.content(comment.getContent())
.build();
}
댓글을 응답할때 사용자 이름을 response로 응답하기에 여기서도 N+1개의 문제가 발생하고 있는걸 볼 수 있었고
@Query("SELECT b FROM Board b " +
"LEFT JOIN FETCH b.comments c " +
"LEFT JOIN FETCH b.user u " +
"WHERE b.id = :boardId")
Board findBoardWithComments(@Param("boardId") Long boardId);
User까지 fetchJoin으로 매핑하여 실행해봤다.
Hibernate:
select
b1_0.board_id,
b1_0.city,
b1_0.country,
b1_0.street,
b1_0.category,
c1_0.board_id,
c1_0.id,
c1_0.content,
c1_0.user_id,
b1_0.content,
b1_0.content_url,
b1_0.like_count,
b1_0.latitude,
b1_0.longitude,
b1_0.boards,
b1_0.restaurant_name,
u1_0.id,
u1_0.email,
u1_0.name,
u1_0.role,
u1_0.username
from
board b1_0
left join
comment c1_0
on b1_0.board_id=c1_0.board_id
left join
users u1_0
on u1_0.id=b1_0.user_id
드디어 내가 원하는 단 하나의 쿼리만을 내보낼 수 있었다.
하지만 좀 많은 fetchJoin이 필요하니 JPQL이 길어지고 코드에서 명확하게 어떤 로직인지 한눈에 안들어오는 단점이 있다. 이럴 경우 사용하는 것이 EntityGragh이다. 하드코딩을 해야되는 단점을 해결해줄 수 있다.
@Query("SELECT b FROM Board b WHERE b.id = :boardId")
@EntityGraph(attributePaths = {"comments", "user"})
Board findBoardWithComments(@Param("boardId") Long boardId);
똑같이 하나의 쿼리만 나간다!
하지만 절대로 쿼리의 개수를 줄인다고 효율적인 것은 아니다. 연관된 엔티티가 많을 경우 카르테시안 곱 문제나 불필요한 데이터 로딩을 너무 많이 하여 서버에 부담을 줄 수 있다. 그래서 필요한 데이터 컬럼이 적은 경우는 역정규화, 반정규화를 사용하여 Join을 하지 않고 해결하는 것이 더 효율적일 것이라 생각하고 여기선 userName을 불러오는 곳에 역정규화, 반정규화를 이용하면 더욱 더 서버에 부담을 줄일 수 있을 것이라고 생각한다.
'Project' 카테고리의 다른 글
[Project] 좋아요 로직 반정규화 및 동시성 처리 (0) | 2025.01.31 |
---|---|
[Project] 세종대학교 auth & 예약 시스템 (2) | 2025.01.17 |
[Project] Multipart/form 파일, Dto 동시 요청 시 발생 에러 (2) | 2025.01.16 |
[Project] 온프레미스 서버 구축기 (0) | 2025.01.16 |
[Project] Docker 적용기 (8) | 2024.10.16 |