Java Persistence API를 활용하여 DB에 복잡한 질의를 생성하는 경우는 보통
여러 테이블을 조인하여 원하는 결과를 만들어야 할 때 입니다. 이때, 여러테이블을 조인하는 과정에서 N+1 문제를 해결하기 위해
Fetch Join을 많이 쓰지만, 좀 더 구체적인 상황에서 Fetch Join 사용을 구분해보려고 합니다
그래서 다음 2가지의 경우를 나누어 정리해 보고자 합니다.
- fetch join을 사용해야하는 적절한 경우
- fetch join을 사용할 수 없는 경우
쿼리를 생성할때는 QueryDSL을 사용하였습니다.
1. Fetch Join은 무엇인가?
JPA는 Entity를 조회할때 기본적으로 지연로딩(Lazy Loading)이 원칙입니다
public class Board extends BaseEntity {
@Id
@Column(name = "board_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 30)
private String title;
@Lob
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member writer; //Lazy Loading 원칙이므로 DB에서 조회되지 않음
@OneToMany(mappedBy = "board")
private List<Reply> replies = new ArrayList<>(); //Lazy Loading 원칙이므로 DB에서 조회되지 않음
}
엔티티 클래스에서 참조하는 다른 엔티티의 인스턴스는 접근 시점에 또다른 쿼리를 발생시킵니다.
예상치 못한 쿼리 발생은 성능저하를 일으키기 때문에
이러한 문제를 해결하고자 Fetch Join을 사용하게 됩니다.
2. Join이 아닌 굳이 Fetch Join을 사용해야하나?
일반적인 SQL에서 따져보아도 Join은 연관된 엔티티를 조회하는 것이 아니라,
해당 관계가 필요할 때 그 관계를 이용해서 조건을 걸거나 필터링하는 데 사용됩니다.
그후 조회하고자 하는 데이터는 select절에 추가합니다.
하지만 객체 세상에서는 상위 객체가 하위 객체를 포함하고 있기 때문에
하위 객체를 조회할 때도 상위 객체의 필드처럼 접근할 수 있습니다.
즉, SQL에서는 각 엔티티를 개별적으로 조회하는 반면,
JPA 에서는 연관된 엔티티들을 동시에 불러올 수 있습니다.
이러한 개념을 적용한 것이 Fetch Join 입니다.
@Test
public void 숙소_예약_조인_querydsl() {
RentalHome rh = queryFactory.selectFrom(rentalHome)
.join(rentalHome.reservations, reservation)
.where(rentalHome.id.eq(6486L))
.fetchOne();
boolean rentalHomeLoaded = emf.getPersistenceUnitUtil().isLoaded(rh);
assertThat(rentalHomeLoaded).isTrue();
boolean reservationsLoaded2 = emf.getPersistenceUnitUtil().isLoaded(rh.getReservations());
assertThat(reservationsLoaded2).isFalse();
}
따라서 fetch join을 사용하여 한번의 쿼리로 모두 가져 올 수 있습니다.
@Test
public void 숙소_예약_패치조인_querydsl() {
RentalHome rh = queryFactory.selectFrom(rentalHome)
.join(rentalHome.reservations).fetchJoin()
.where(rentalHome.id.eq(6486L))
.fetchOne();
boolean rentalHomeLoaded = emf.getPersistenceUnitUtil().isLoaded(rh);
assertThat(rentalHomeLoaded).isTrue();
boolean reservationsLoaded2 = emf.getPersistenceUnitUtil().isLoaded(rh.getReservations());
assertThat(reservationsLoaded2).isTrue();
}
즉 Fetch Join은 Join + 조회의 의미를 갖는다고 볼 수 있습니다.
3. 하나의 쿼리에서 Fetch Join을 사용해야하는 경우와 사용하지 않아야 하는 관계
복잡한 쿼리를 작성하다보면 테이블을 조인해야 하는 경우는 크게 2가지 경우인것 같습니다.
- 실제 select절에 포함되어 데이터로서 사용하기 위해 Join하는 경우 -> fetch join 사용
- select절에 포함되지 않지만 조건 또는 필터링을 위해 Join하는 경우 -> join 사용
여기서 본격적인 적용전 JPA 즉, ORM 기술의 의도를 한번더 되짚어보고 가야합니다.
객체와 관계형 데이터베이스의 데이터를 매핑하는 기술
-> 데이터베이스의 데이터를 마치 객체처럼 사용한다.
즉, 우리는 JPA를 사용할때 select 절에 우리가 원하는 모든 엔티티를 직접 작성하지 않고
상위 엔티티만을 작성후 join을 통해 자동으로 하위 엔티티들이 로딩될 것을 기대합니다.
제가 진행한 프로젝트에서 마주쳤던 문제를 예로하겠습니다.
ex) 리뷰순 숙소 검색
숙소(rental_home)은 테마와 지역을 가지고 있습니다.
또한 리뷰는 예약을 한 게스트만 남길수 있기에 예약과 1대1관계를 이루고 있습니다.
그리고 노출되어야 하는 데이터는 숙소의 이름, 지역, 주소, 수용인원, 가격, 청소비, 리뷰 개수, 리뷰 평균 입니다.
조인되는 엔티티의 사용 용도는 다음과 같습니다.
- 데이터 용도 - rental_home, review, region
- 조건 및 필터링 용도 - rental_home_theme, theme, reservation, region
review의 경우 집계 함수의 대상으로서 사용됨으로 group by를 사용하였고
중요한 것은 region과 나머지 조건및 필터링의 용도로 사용된 엔티티들의 차이입니다.
List<Tuple> result = queryFactory.select(rentalHome, review.count(), review.score.avg())
.from(rentalHome)
.join(rentalHome.region, region).fetchJoin() //select 해야할 데이터 -> fetchJoin
.leftJoin(rentalHome.reservations, reservation)
.leftJoin(review).on(review.reservation.id.eq(reservation.id))
.leftJoin(rentalHome.rentalHomeThemes, rentalHomeTheme)
.leftJoin(rentalHomeTheme.theme, theme)
.where(regionNameContains(regionName),
themeNameContains(themeName),
setMaxPrice(maxPrice),
setMinPrice(minPrice),
reservation.status.eq(ProcessStatus.COMPLETE))
.groupBy(rentalHome.id)
.orderBy(review.score.avg().desc(), review.count().desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> totalCount = queryFactory.select(rentalHome.id.countDistinct())
.from(rentalHome)
.join(rentalHome.region, region) // 조건으로 사용될 데이터 -> join
.leftJoin(rentalHome.reservations, reservation)
.leftJoin(rentalHome.rentalHomeThemes, rentalHomeTheme)
.leftJoin(rentalHomeTheme.theme, theme)
.where(regionNameContains(regionName),
themeNameContains(themeName),
setMaxPrice(maxPrice),
setMinPrice(minPrice),
reservation.status.eq(ProcessStatus.COMPLETE));
즉, 실제로 가져와야하는 데이터가 아닌 조건 및 필터링 용도의 entity는 join을 사용해야합니다.
4. Fetch Join을 사용하지 않아야 할 때 1 - Projections 또는 DTO로 조회
Projections나 DTO를 통해 결과를 가져올때는 우리가 필요한 데이터를 각각 지정해주게 됩니다.
@Override
public Page<BoardResDto> getList(Pageable pageable) {
List<BoardResDto> listResult = queryFactory.select(new QBoardResDto(
board.id,
board.title,
board.content,
member.nickname,
board.viewCount,
board.createdDate,
boardLike.member.id.countDistinct().as("likeCount"),
reply.id.countDistinct().as("replyCount"))
)
.from(board)
.join(board.writer, member)
.leftJoin(boardLike).on(boardLike.board.eq(board))
.leftJoin(reply).on(reply.board.eq(board))
.where(board.writingStatus.eq(WritingStatus.NORMAL))
.groupBy(board.id)
.orderBy(board.createdDate.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> totalCount = queryFactory.select(board.id.count())
.from(board)
.where(board.writingStatus.eq(WritingStatus.NORMAL));
return PageableExecutionUtils.getPage(listResult, pageable, totalCount::fetchOne);
}
이렇게 가져온 데이터는 영속성 컨텍스트에서 관리되지도 않을 뿐더러 SQL문으로 데이터를 직접 가져오는 행위와
크게 다르지 않습니다. 따라서 join을 사용하여 데이터를 가져오는 것이 적합하다고 판단됩니다.
5. Fetch Join을 사용하지 않아야 할 때 2 - 다중 컬렉션 구조
하나의 게시물을 가져올 때 해당 게시물의 댓글들, 해당 댓글들의 대댓글을 모두 가져와야 합니다.
@Test
public void 게시글가져오기() {
Board findBoard = queryFactory.selectFrom(board)
.leftJoin(board.replies, reply).fetchJoin()
.leftJoin(reply.repliesToReply, reply).fetchJoin()
.where(board.id.eq(61L))
.fetchOne();
assertThat(findBoard.getReplies().size()).isEqualTo(2);
assertThat(findBoard.getReplies().get(0).getRepliesToReply().size()).isEqualTo(3);
}
하지만 이 메서드는 다음과 같은 에러를 발생시킵니다.
즉, 일대다 다중 컬렉션의 형태이므로 Fetch Join을 사용할 수 없습니다.
이유는 다음과 같습니다.
위의 메서드를 실행시키면 DB에서는 6개의 데이터행을 반환하며 중복된데이터들이 존재합니다.
하지만 hibernate는 이러한 중복된 행들을 처리하는데 한계가 있기 때문에 다중 컬렉션의 처리를 허용하고 있지 않습니다.
따라서 게시글, 댓글, 대댓글로 쿼리를 3개로 분리하였고 Java의 stream api를 사용하여 다중 컬렉션 구조를 만들었습니다.
@Override
public Optional<BoardDetailResDto> get(Long boardId) {
// likeCount 서브쿼리
Expression<Long> likeCount = ExpressionUtils.as(JPAExpressions.select(boardLike.countDistinct())
.from(boardLike)
.where(boardLike.board.id.eq(boardId)),"likeCount");
//board
BoardDetailResDto boardResult = queryFactory.select(Projections.constructor(BoardDetailResDto.class,
board.id,
board.boardScope,
board.title,
board.content,
member.id,
member.nickname,
board.viewCount,
likeCount,
board.createdDate))
.from(board)
.join(board.writer, member)
.where(board.id.eq(boardId)
.and(board.writingStatus.eq(WritingStatus.NORMAL)))
.fetchOne();
if (boardResult == null) {
throw new NoSuchElementException();
}
//댓글 리스트
List<ReplyResDto> replyResDtoList = queryFactory.select(Projections.constructor(ReplyResDto.class,
reply.id,
reply.board.id,
reply.writer.id,
reply.writer.nickname,
Expressions.cases().when(reply.writingStatus.eq(WritingStatus.DELETED)).then("삭제된 댓글입니다.").otherwise(reply.content),
reply.createdDate))
.from(reply)
.where(reply.board.id.eq(boardId))
.orderBy(reply.createdDate.desc())
.fetch();
//모든 대댓글 리스트
QReply parentReply = new QReply("parent");
List<ReplyToReplyResDto> reReplyList = queryFactory.select(Projections.constructor(ReplyToReplyResDto.class,
reply.id,
reply.parent.id,
reply.writer.id,
reply.writer.nickname,
reply.content,
reply.createdDate))
.from(reply)
.where(reply.parent.id.in(
JPAExpressions
.select(parentReply.id)
.from(parentReply)
.where(parentReply.board.id.eq(boardId)))
.and(reply.writingStatus.eq(WritingStatus.NORMAL)))
.orderBy(reply.createdDate.asc())
.fetch();
//부모 댓글 번호별로 그룹화
Map<Long, List<ReplyToReplyResDto>> groupedReReplyMap = reReplyList.stream()
.collect(Collectors.groupingBy(ReplyToReplyResDto::getParentId));
replyResDtoList.forEach(replyResDto -> replyResDto.setReplyToReplyList(groupedReReplyMap.get(replyResDto.getId())));
boardResult.setReplyList(replyResDtoList);
return Optional.ofNullable(boardResult);
}
따라서 get() 메서드 실행시 고정적으로 3개의 쿼리만 생성됩니다.
'Java > JPA' 카테고리의 다른 글
@DataJpaTest를 활용한 Repository 테스트 오류 (0) | 2024.11.16 |
---|---|
JPA Hibernate의 프록시와 일대일 관계에서의 N+1 문제 (0) | 2024.10.06 |
AWS 프리티어 환경에서 데이터 20만건 조회하기 (0) | 2024.09.22 |