기록

QueryDSL로 중첩 DTO를 조회하는 방법 본문

Web/Spring

QueryDSL로 중첩 DTO를 조회하는 방법

zyin 2025. 3. 27. 23:00

시작점

최근에 영화관 서비스의 관리자 화면 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 자체가 금지다.

고민

결국 두 가지 고민을 했다.

  1. 두 번 쿼리 후 조립이 맞는가?
    • 퍼포먼스 문제가 생기지 않을까?
    • 두 번 쿼리하면 코드가 지저분해지지 않을까?
  2. 조립 로직의 위치는 어디가 맞을까?
    • 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 한방 쿼리는 매력적으로 보이지만, 한계가 분명하다.
  • 중첩 컬렉션이 필요한 경우는 두 번 쿼리 후 조립이 가장 현실적이고 유지보수성이 높다.
  • 페이징이 걸리는 상황에서는 더더욱 이 방법 외에는 답이 없다.
  • 복잡하게 만들려고 하면 코드 수명은 짧아지고, 디버깅은 지옥이 된다.

내가 스스로 내린 결론:

조인을 욕심내지 말고, 필요한 만큼 가져와서 조립하자. 그게 결국 가장 성능 좋고, 가장 깨끗한 코드다.

Comments