본문 바로가기
카테고리 없음

[feat] QueryDsl PageDto 동적경로 생성 PathBuilder 를 활용하여 정렬하기

by 진믈리 2024. 12. 17.

PageDto를 제대로 구현하기 위해 많은 고민을 하였다. PageDto를 구현하면서 스스로 건 제약 조건은

  1. 모든 곳에서 사용 가능할 것
  2. 동적으로 구현이 가능할 것(ex - 정렬, 키워드 검색 등)

PageDto 초기 Entity

첫번째 조건인 모든 곳에서 사용가능하게 하기 위해, PageDto에는 최소한의 데이터만 가지고 있도록 하였다. 데이터가 더 필요한 API에서는 PageDto를 상속받아 이용했다.

@Getter
public class PageDto {

    @Schema(description = "페이지 아이템 개수", example = "30", defaultValue = "10")
    private int pageSize = 10;

    @Schema(description = "현재 페이지 번호", example = "0", defaultValue = "0")
    private int page = 0;

    @Schema(description = "정렬 타입")
    private SortType sortType;

    @Schema(description = "오름차순 여부", example = "true", defaultValue = "true")
    @JsonProperty("isAscending")
    private boolean isAscending = true;
}

 

초기 SortType Enum

@Getter
@RequiredArgsConstructor
public enum SortType {

    CREATED_DATE("생성일 순", goods.createdDate),
    MODIFIED_DATE("수정일 순", goods.modifiedDate);

    private final String description;
    private final Expression<? extends Comparable<?>> expression;
}

 

초기에 SortType을 생성하였을 때에는 Expresson 필드를 생성하여, Querydsl에 바로 사용 할수 있도록 구현하였다.

 

하지만 이 코드의 문제점은

  1. 상품(goods) 에만 종속적이다.
  2. 그렇다면 SortType에 user.createdDate, user.modifiedDate....추가해 준다면?
    나중에 특정 API에서만 사용해야 하는 정렬이 있다면 valid검증이 까다로워진다. 
  3. 그렇다고 사용하는 API마다 SortType을 생성하여 제네릭타입으로 PageDto를 처리하기에는, 중복 코드도 늘어나고 추후 유지보수에서 굉장히 피곤해 질것 같았다.
  4. 그리고 enum에 Q파일 Importer가 생기는데 이게 RestAPI 적으로 옳은 일일까.. 

그렇다고 리포지토리에서 처리해주기에는 PageDto가 값을 다 가지고 있으니 왠지 PageDto에서 처리하고 싶은 고집이 있었다. 일관성도 있고 말이다.


PathBuilder 기능 구현

StackOverFlow를 찾아보다 키워드 하나가 눈에 들어왔다. "PathBuilder" 감사합니다.

 

개선 SortType

@Getter
@RequiredArgsConstructor
public enum SortType {

    CREATED_DATE("생성일 순", "createdDate"),
    MODIFIED_DATE("수정일 순", "modifiedDate"),

    private final String description;
    private final String fieldName;

}

 

먼저 SortType Enum을 수정했다. Q파일 을 제외하고 createdDate, modifiedDate만 남겨두어 동적으로 경로를 생성할 것이다.

 

 

PathBuilder 동적 경로 생성

 

PathBuilder는 여러 생성자가 있다. 이번 프로젝트에서 활용한 생성자는 Class타입과, String타입 두개의 인자를 받는 생성자를 활용했다. 

PathBuilder<Object> pathBuilder = new PathBuilder<>(Goods.class, "goods");
  • PathBuilder를 생성할 때 상위 생성자를 호출하여 QueryDSL이 자동으로 pathMetadataFactory.forVariable("goods") 를 사용해서 pathMetadata를 생성한다.
  • 따라서 필드에 "goods" 가 존재하지 않는다면 오류를 발생 시킨다.

 

 

PathMetadata pathMetadata = 
	PathMetadataFactory.forProperty(new PathBuilder<>(Sale.class, "sale"), "quantity");
PathBuilder<Object> customPathBuilder = 
	new PathBuilder<>(Sale.class, pathMetadata, PathBuilderValidator.DEFAULT);

 

PathMetadataFactory를 통해 더 복잡한 메타데이터를 생성하고 전달 할 수 있다. 예를 들어 상품의 판매수를 구하고 싶다면 새로운  "quantity" 이름을 가진 PathMetadata를 생성해 주면된다. 이 방식을 활용하여 추후 상품 판매량순에 대한 정렬을 구현할 수 있을 것 같다.

 

PathBuilder 의 getComparable()

 

QueryDSL의 PathBuilder 는 동적으로 Comparable 타입의 경로를 생성하는 역할을 한다.

 

파라미터

  • String property : 동적으로 접근하려는 필드의 이름이다. ex) - createdDate
  • Class<A> type : 해당 필드의 타입이다. 이 타입은 Comparable 인터페이스를 구현하는 타입만 들어올 수 있다.

리턴값

  • ComparablePath<A> QueryDSL에서 사용되는 Comparable 타입의 경로 객체(QueryDSL 의 표현식)를 반환한다.

내부 동작 

  • validate : property 이름과 type이 올바른지 검증한다. 만약 유효하지 않다면 예외를 던진다.
ComparableExpressionBase<?> sortExpression = 
	pathBuilder.getComparable(sortType.getFieldName(), Comparable.class);

 


개선된 PageDto

@Getter
public class PageDto {

    @Schema(description = "페이지 아이템 개수", example = "30", defaultValue = "10")
    private int pageSize = 10;

    @Schema(description = "현재 페이지 번호", example = "0", defaultValue = "0")
    private int page = 0;

    @Schema(description = "정렬 타입")
    private SortType sortType;

    @Schema(description = "오름차순 여부", example = "true", defaultValue = "true")
    @JsonProperty("isAscending")
    private boolean isAscending = true;

    public PageDto(int pageSize, int page, SortType sortType, boolean isAscending) {
        this.pageSize = pageSize;
        this.page = page;
        this.sortType = sortType;
        this.isAscending = isAscending;
    }

    public OrderSpecifier<?> getOrderSpecifier(Class<?> entityClass, String alias) {
        if (sortType == null) {
            throw new IllegalArgumentException("SortType cannot be null");
        }
        
        PathBuilder<Object> pathBuilder = new PathBuilder<>(entityClass, alias);
        
        ComparableExpressionBase<?> sortExpression = pathBuilder.getComparable(sortType.getFieldName(), Comparable.class);

        return new OrderSpecifier<>(
                isAscending ? Order.ASC : Order.DESC,
                sortExpression
        );
    }

    public Pageable toPageable() {
        return PageRequest.of(page, pageSize);
    }
}

 

 

사용방법

  • pathMetadata 를 생성할 alias이름 지정

 

  • Enum의 필드 이름도 경로를 잘 찾아 갈 수 있게 필드를 하나 생성해 준다.
ComparableExpressionBase<?> sortExpression = 
	pathBuilder.getComparable(sortType.getFieldName(), Comparable.class);
  • 생성된 goods의 pathBuilder에 QueryDSL에서 사용되는 .Comparable 클래스가 반환된다.
return new OrderSpecifier<>(
      isAscending ? Order.ASC : Order.DESC,
      sortExpression
);
  • 그후 isAscending을 검사하여 최종적으로 PageDto에서 OrderSpecifier을 반환한다.

 

이제 어느 곳에서든 PageDto를 활용하여

OrderSpecifier<?> orderSpecifier = pageDto.getOrderSpecifier(Goods.class, "goods");
OrderSpecifier<?> orderSpecifier = pageDto.getOrderSpecifier(User.class, "user");
OrderSpecifier<?> orderSpecifier = pageDto.getOrderSpecifier(Market,class, "market");

 

정렬로직을 생성할 수 있다. 

 


회고

아직까지 완벽하게 마음에 들지는 않는다. PathBuilder에 대한 정보가 많이 없어 계속 실험해 보고 있는 중이다. 아직 개발 초기 단계라 특이사항은 없지만, 나중에 문제가 될 일은 없는지, 더 좋은 방법은 없는지 계속 공부해 봐야겠다.