본문 바로가기
Backend/JPA & QueryDSL

[QueryDSL] Projections(Dto 조회)와 @QueryProjection

by 제이동 개발자 2023. 7. 16.
728x90

Projections(Dto 조회)와 @QueryProjection

 특정 Entity를 조회할 때 Entity의 모든 값들이 필요하지 않은 경우가 있습니다. 예를 들면 게시판 목록을 보여줄 때 게시판의 내용(contents)이 필요하지 않고, 게시판의 제목, 작성자, 작성일과 같이 일부 데이터만 필요한 경우가 있습니다. 때문에 게시판 목록 조회 시 게시판 내용과 같은 데이터들을 DB에서 같이 조회해 오는 것은 불필요한 자원낭비가 되어 성능이 저하되는 원인이 됩니다. 이러한 문제를 해결하기 위해 QueryDSL에서는 불필요한 값들은 조회하지 않고, 필요한 값들만 조회할 수 있도록 성능을 최적화시켜 주는 Projections을 제공합니다.

 

 Projections은 쿼리 결과를 원하는 형식(Dto)으로 변환하기 위해 사용되는 기능으로 다음과 같은 네 가지 방법이 있습니다.

  • Projections.bean(..)
  • Projections.field(..)
  • Projections.construct(..)
  • @QueryProjection

 

 예제 코드는 Github에서 확인하실 수 있습니다.

 

 

1. Projections.bean(..)

 JavaBeans Property를 이용한 Projections.bean(..)Setter기본 생성자가 필요합니다. 하지만 Dto 객체는 불변 객체로 사용하는 것을 지향하기 때문에 Setter가 필요한 Projections.bean(..)은 좋은 방법이 아니라고 생각합니다.

  • Setter기본생성자 필요
@Getter
@Setter
@NoArgsConstructor
public class ArticleBeanDto {
    private Long id;
    private String title;
    private LocalDateTime lastModifiedDate;
    private Long lastModifiedBy;
}
@DisplayName("Projections.bean 사용")
@Test
public void projections_bean() {
    Article savedArticle = articleRepository.save(Article.builder()
            .title("타이틀 테스트")
            .contents("내용 테스트")
            .build());

    ArticleBeanDto articleBeanDto = queryFactory
            .select(Projections.bean(ArticleBeanDto.class,
                    article.id,
                    article.title,
                    article.lastModifiedBy,
                    article.lastModifiedDate
            ))
            .from(article)
            .where(article.id.eq(savedArticle.getId()))
            .fetchOne();
    Assertions.assertThat(articleBeanDto.getId()).isEqualTo(savedArticle.getId());
    Assertions.assertThat(articleBeanDto.getTitle()).isEqualTo(savedArticle.getTitle());
    Assertions.assertThat(articleBeanDto.getLastModifiedBy()).isEqualTo(savedArticle.getLastModifiedBy());
}

 

 

2. Projections.fields(..)

 Dto의 필드 명과 Projection의 컬럼 명으로 일치하는 경우 매핑하는 Projections.fields(..)Setter가 없어도 필드명으로 매핑되기 때문에 Dto 객체를 불변 객체로 사용할 수 있어 제가 추천하는 방법입니다. 만약 필드명과 컬럼 명이 다를 경우에는 별칭(alias)을 사용하여 매핑할 수 있습니다.

  • 필드명과 매핑, 기본 생성자 필요
@Getter
public class ArticleFieldsDto {
    private Long id;
    private String title;
    private LocalDateTime lastModifiedDate;
    private Long lastModifiedBy;
}
@DisplayName("Projections.fields 사용")
@Test
public void projections_field() {
    Article savedArticle = articleRepository.save(Article.builder()
            .title("타이틀 테스트")
            .contents("내용 테스트")
            .build());

    ArticleFieldsDto articleFieldsDto = queryFactory
            .select(Projections.fields(ArticleFieldsDto.class,
                    article.id.as("id"), // as("id")는 생략이 가능하다(필드명과 일치)
                    article.title.as("title"), // as("title")는 생략이 가능하다(필드명과 일치)
                    article.lastModifiedBy,
                    article.lastModifiedDate
            ))
            .from(article)
            .where(article.id.eq(savedArticle.getId()))
            .fetchOne();
    Assertions.assertThat(articleFieldsDto.getId()).isEqualTo(savedArticle.getId());
    Assertions.assertThat(articleFieldsDto.getTitle()).isEqualTo(savedArticle.getTitle());
    Assertions.assertThat(articleFieldsDto.getLastModifiedBy()).isEqualTo(savedArticle.getLastModifiedBy());
}

 

 주의할 점은 @AllArgsConstructor를 단독으로 사용할 경우 기본 생성자가 없기 때문에 Exception이 발생할 수 있습니다. 따라서 @AllArgsConstructor와 같이 매개변수가 있는 생성자를 만들었을 경우에는 기본 생성자를 추가해 주셔야 합니다.

Exception 예시 

@AllArgsConstructor // 기본 생성자가 없으므로 예외 발생
public class ArticleFieldsDto {

    private Long id;
    private String title;
    private LocalDateTime lastModifiedDate;
    private Long lastModifiedBy;
}

 

 

3. Projections.constructor(..)

 생성자를 사용하여 필드에 매핑시켜 주는 방법입니다. 즉, 매핑할 필드들을 생성자의 매개변수에 추가하여 매핑할 수 있습니다. 일반적으로 사용할 때 Dto에 모든 필드들을 매핑하므로 @AllArgsConstructor를 사용합니다. 주의할 점은 생성자의 파라미터 순서와 Projection의 순서가 일치하지 않는다면 Exception이 발생할 수 있습니다. 때문에 Projection의 순서와 Dto의 매핑되는 필드와 순서가 일치해야 하므로 조회하는 Projection이 많게 되면 실수가 발생할 수도 있고, 유지보수가 어려워져 권장하지 않는 방법입니다.

  • 매핑할 필드를 매개변수로 받는 생성자 필요
  • 생성자의 매개변수 순서와 Projection의 순서가 다를 경우 예외 발생
@Getter
@AllArgsConstructor // 필드의 순서대로 매개변수를 갖는 생성자를 만들어준다.
public class ArticleConstructDto {
    private Long id;                        // 1
    private String title;                   // 2
    private LocalDateTime lastModifiedDate; // 3
    private Long lastModifiedBy;            // 4
}
@DisplayName("Projections.constructor 사용")
@Test
public void projections_constructor() {
    Article savedArticle = articleRepository.save(Article.builder()
            .title("타이틀 테스트")
            .contents("내용 테스트")
            .build());

    ArticleConstructDto articleConstructDto = queryFactory
            .select(Projections.constructor(ArticleConstructDto.class,
                    article.id.as("id"),                            // 1
                    article.title.as("title"),                      // 2
                    article.lastModifiedDate.as("lastModifiedDate"),// 3
                    article.lastModifiedBy.as("lastModifiedBy")     // 4
            ))
            .from(article)
            .where(article.id.eq(savedArticle.getId()))
            .fetchOne();
    Assertions.assertThat(articleConstructDto.getId()).isEqualTo(savedArticle.getId());
    Assertions.assertThat(articleConstructDto.getTitle()).isEqualTo(savedArticle.getTitle());
    Assertions.assertThat(articleConstructDto.getLastModifiedBy()).isEqualTo(savedArticle.getLastModifiedBy());
}

 참고로 @AllArgsConstructor는 필드의 순서대로 생성자를 만들어줍니다. 따라서 @AllArgsConstructor를 사용 시 Dto의 필드 순서에 맞게 Projection을 구현해야 합니다.

 

 

4. @QueryProjection

 Dto의 Q-Type을 만들어 사용하는 방법입니다. Dto 생성자에 @QueryProjection 어노테이션을 사용하게 되면 Q-Type이 생성할 수 있으며 컴파일 시점에서 에러를 잡아낼 수 있다는 강력한 장점이 있습니다. 하지만 Dto도 Q-Type을 생성한다는 점과 Dto가 QueryDSL에 대한 의존성을 갖게 되기 때문에 좋은 방법이라고 생각하지 않습니다.

@Getter
public class ArticleQueryProjectionDto {
    private Long id;
    private String title;
    private LocalDateTime lastModifiedDate;
    private Long lastModifiedBy;

    @QueryProjection // QArticleQueryProjectionDto 생성
    public ArticleQueryProjectionDto(Long id, String title, LocalDateTime lastModifiedDate, Long lastModifiedBy) {
        this.id = id;
        this.title = title;
        this.lastModifiedDate = lastModifiedDate;
        this.lastModifiedBy = lastModifiedBy;
    }
}
@DisplayName("@QueryProjection 사용")
@Test
public void query_projection_annotation() {
    Article savedArticle = articleRepository.save(Article.builder()
            .title("타이틀 테스트")
            .contents("내용 테스트")
            .build());

    ArticleQueryProjectionDto articleQueryProjectionDto = queryFactory
            .select(new QArticleQueryProjectionDto(
                    article.id,
                    article.title,
                    article.lastModifiedDate,
                    article.lastModifiedBy)
            )
            .from(article)
            .where(article.id.eq(savedArticle.getId()))
            .fetchOne();
    Assertions.assertThat(articleQueryProjectionDto.getId()).isEqualTo(savedArticle.getId());
    Assertions.assertThat(articleQueryProjectionDto.getTitle()).isEqualTo(savedArticle.getTitle());
    Assertions.assertThat(articleQueryProjectionDto.getLastModifiedBy()).isEqualTo(savedArticle.getLastModifiedBy());
}

 

 

결론

 제가 생각하기에 가장 추천하는 방법은 Projections.fields(..)를 사용하는 방법입니다. Dto 객체를 불변 객체로 만들어 사용할 수 있고, 유지보수가 용이하다는 점 등에서 많은 장점이 있기 때문에 제가 실제로 실무에서 사용하는 방법입니다.

 

 Projections를 사용하는 다른 분들은 어떤 방법을 사용하고, 그 이유가 무엇인지 댓글로 의견을 남겨주시면 감사하겠습니다!

728x90