일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- 1차원 DP
- 2차원 dp
- 99클럽
- @BeforeAll
- @BeforeEach
- @Builder
- @Entity
- @GeneratedValue
- @GenericGenerator
- @NoargsConstructor
- @Query
- @Table
- @Transactional
- Actions
- Amazon EFS
- amazon fsx
- Android Studio
- ANSI SQL
- api gateway 설계
- api gateway 필터
- ApplicationEvent
- assertThat
- async/await
- AVG
- AWS
- aws eks
- AWS 프리티어
- Azure
- bind
- bitnami kafka
- Today
- Total
기록
QueryDSL로 중첩 DTO를 조회하는 방법 본문
시작점
최근에 영화관 서비스의 관리자 화면 API를 설계하면서 이런 요구사항이 생겼다.
- 영화 상세 정보를 조회할 때, 해당 영화의 상영 일정 리스트를 함께 내려주자.
예를 들어, MovieInfoDetailDto 라는 DTO 안에 List<MovieScheduleDto>가 포함된 구조다.
public class MovieInfoDetailDto {
private Long movie_id;
private String title;
private String description;
private Long movie_info_id;
private LocalDate releaseDate;
private String director;
private List<MovieScheduleDto> schedules;
}
내가 처음 든 생각은 "QueryDSL로 한방에 가져올 수 있지 않을까?" 였다.
시도 1 - 한방 쿼리로 중첩 조회?
QueryDSL은 Projections.constructor()나 fields(), bean()을 제공한다. 하지만 이건 결국 납작(flat)한 구조의 DTO만 가능하다. List<MovieScheduleDto> 같은 컬렉션 필드까지 한 번에 채워주는 건 불가능하다.
혹시 groupBy 와 transform()을 쓰면 가능할까? 찾아봤다. 가능은 하지만, 아래와 같은 문제가 있었다.
- 쿼리 자체가 너무 복잡해진다.
- 페이징 처리 시 절대 불가능하다.
- 쿼리 결과를 해석하고 post-processing 하는 코드가 비직관적이다.
결론: 안 하는 게 맞다.
시도 2 - fetchJoin으로 해결?
페이징이 필요한 상황이었기 때문에 fetchJoin은 아예 쓸 수도 없었다. 이걸 강제로 쓰면 결과셋이 잘리거나, Hibernate에서 예외가 발생한다.
다시 말해, 중첩 컬렉션 + 페이징 조합은 fetchJoin 자체가 금지다.
고민
결국 두 가지 고민을 했다.
- 두 번 쿼리 후 조립이 맞는가?
- 퍼포먼스 문제가 생기지 않을까?
- 두 번 쿼리하면 코드가 지저분해지지 않을까?
- 조립 로직의 위치는 어디가 맞을까?
- Repository 에서 끝낼까?
- Service 레이어에서 조립하는 게 나을까?
정리한 결론
결국 답은 "두 번 쿼리 + 조립" 방식이다.
- 먼저 상위 객체 (MovieInfoDetailDto)를 가져온다.
- movie_info_id 리스트를 추출한다.
- IN 조건으로 MovieScheduleDto 리스트를 가져온다.
- Map<Long, List<MovieScheduleDto>> 형태로 그룹핑한다.
- DTO에 주입한다.
페이징 상황에서는 이 방식이 가장 깔끔하고 성능 문제도 없다. 한방 쿼리로 시도하면 조인 결과의 카르테시안 곱, distinct 처리, post-grouping까지 감당해야 하고 오히려 느려진다.
단건 상세 조회 예시
@Override
public Optional<MovieInfoDetailDto> findMovieInfoWithSchedulesById(Long movieInfoId) {
MovieInfoDetailDto movieInfo = queryFactory
.select(Projections.constructor(MovieInfoDetailDto.class,
movie.id.as("movie_id"),
movie.title,
movie.description,
movieInfoEntity.id.as("movie_info_id"),
movieInfoEntity.releaseDate,
movieInfoEntity.director
))
.from(movieInfoEntity)
.join(movieInfoEntity.movie, movie)
.where(movieInfoEntity.id.eq(movieInfoId))
.fetchOne();
if (movieInfo == null) return Optional.empty();
List<MovieScheduleDto> schedules = queryFactory
.select(Projections.constructor(MovieScheduleDto.class,
schedule.id,
schedule.startTime,
schedule.endTime,
schedule.screenNumber
))
.from(schedule)
.where(schedule.movieInfo.id.eq(movieInfoId))
.fetch();
movieInfo.setSchedules(schedules);
return Optional.of(movieInfo);
}
- 단건 상세 조회는 Repository 내부에서 조립하는 것이 가장 자연스럽다.
다건 + 페이징 예시
페이징 결과에 대해서도 마찬가지다.
List<Long> movieInfoIds = movieInfos.stream()
.map(MovieInfoDto::getMovie_info_id)
.toList();
List<MovieScheduleDto> schedules = queryFactory
.select(Projections.constructor(MovieScheduleDto.class,
schedule.id,
schedule.movieInfo.id,
schedule.startTime,
schedule.endTime,
schedule.screenNumber
))
.from(schedule)
.where(schedule.movieInfo.id.in(movieInfoIds))
.fetch();
Map<Long, List<MovieScheduleDto>> groupedSchedules = schedules.stream()
.collect(Collectors.groupingBy(MovieScheduleDto::getMovie_info_id));
movieInfos.forEach(info ->
info.setSchedules(groupedSchedules.getOrDefault(info.getMovie_info_id(), List.of()))
);
- 다건 + 페이징은 Service 레이어에서 조립하는 게 자연스럽다.
결론과 교훈
- QueryDSL 한방 쿼리는 매력적으로 보이지만, 한계가 분명하다.
- 중첩 컬렉션이 필요한 경우는 두 번 쿼리 후 조립이 가장 현실적이고 유지보수성이 높다.
- 페이징이 걸리는 상황에서는 더더욱 이 방법 외에는 답이 없다.
- 복잡하게 만들려고 하면 코드 수명은 짧아지고, 디버깅은 지옥이 된다.
내가 스스로 내린 결론:
조인을 욕심내지 말고, 필요한 만큼 가져와서 조립하자. 그게 결국 가장 성능 좋고, 가장 깨끗한 코드다.
'Web > Spring' 카테고리의 다른 글
멀티 모듈 기반 영화 예매 서비스 설계기 : 멀티 모듈 프로젝트 설계(2) - 헥사고날 아키텍처 적용 (0) | 2025.03.31 |
---|---|
멀티 모듈 기반 영화 예매 서비스 설계기 : 멀티 모듈 프로젝트 설계(1) - Domain 기반의 모듈 분리 (0) | 2025.03.31 |
Spring에서 multipart/form-data로 리스트(List<T>) 데이터를 받는 방법 (0) | 2025.03.23 |
Hibernate "unsaved transient instance" 오류 해결 방법 : CascadeType.ALL (0) | 2025.03.16 |
assertThat의 주요 메서드와 기본 사용법 (0) | 2025.02.21 |