Develop/spring-data

Querydsl Join Table Sort 적용 ( 번외로 Pageable와 비슷한 것을 구현해보자! )

에디개발자 2021. 3. 14. 07:00
반응형

Front-End에서 데이터 테이블을 이용하여 데이터를 보여주고 있었습니다. 이 때 각 컬럼의 Sort를 설정하여 데이터를 정렬하는 Querydsl을 사용하기 위해 적용한 내용을 정리한 글입니다.

 

모든 소스는 Github에 있습니다.

 

나를 닮았다고 한다...

 

사용하게 된 이유

화면에서 넘겨주는 Sort관련 정보는 N개의 테이블에 대한 정보가 넘어올 수 있습니다.

예를 들어 Store, Staff 2개의 테이블이 존재하고 같은 field의 이름이 존재합니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Store {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String name;   // 동일
    private String address;

    @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
    private List<Staff> staffs = new ArrayList<>();

    @Builder
    public Store(Long id, String name, String address, List<Staff> staffs) {
        this.id = id;
        this.name = name;
        this.address = address;
        this.staffs = staffs;
    }

    public void addStaff(Staff staff) {
        if (this.staffs == null) {
            this.staffs = new ArrayList<>();
        }

        staffs.add(staff);
        staff.setStore(this);
    }
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Staff {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String name;    // 동일
    private Integer age;
    private String lastName;

    @ManyToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @JoinColumn(name = "store_id", foreignKey = @ForeignKey(name = "fk_staff_store_id"))
    private Store store;

    @Builder
    public Staff(Long id, String name, Integer age, String lastName, Store store) {
        this.id = id;
        this.name = name;
        this.age = age;
        this.lastName = lastName;
        this.store = store;
    }

    public void changeName(final String name) {
        this.name = name;
    }

    public void setStore(Store store) {
        this.store = store;
    }
}

 

Store, Staff Entity 모두 name이라는 field를 가지고 있습니다.

이런 경우 store의 name과 staff의 name으로 정렬하려면 어떻게 해야할까?

queryFactory
    .selectFrom(store)
    .join(store.staffs, staff)

 

Pageable로 테스트

위와 같은 쿼리라고 했을 때 Sort 설정을 할 때 join의 정보를 명시하면 됩니다.

@ActiveProfiles("h2")
@ExtendWith(SpringExtension.class)
@SpringBootTest
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
class StaffRepositoryTest {

    private final StaffRepository staffRepository;
    private final StoreRepository storeRepository;

    public StaffRepositoryTest(StaffRepository staffRepository, StoreRepository storeRepository) {
        this.staffRepository = staffRepository;
        this.storeRepository = storeRepository;
    }

    @Test
    @DisplayName("querydsl join 시 paging 테스트 - pageable 사용 경우 ")
    void findStaffJoinOrderPaging_test_if_use_pageable() {
        // when
        Sort.Order storeNameOrder = Sort.Order.asc("name");          // store
        Sort.Order staffNameOrder = Sort.Order.asc("staffs.name");   // staff

        Sort sort = Sort.by(storeNameOrder, staffNameOrder);
        Pageable pageable = PageRequest.of(0, 10, sort);

		// then
        PageImpl<Store> pageImplStaff = staffRepository.findStoreJoinOrderPaging(pageable);  // test
    }
}
테이블을 명시하지 않고 필드명만 입력하는 경우 from 절에 있는 table의 필드로 적용됩니다.

Order 객체를 생성할 때 파라미터로 필드명을 명시하고 Pageable을 생성하여 테스트합니다.

 

public PageImpl<Store> findStoreJoinOrderPaging(Pageable pageable) {
    JPAQuery<Store> staffQuery = queryFactory
            .selectFrom(store)
            .join(store.staffs, staff);

    return pagingUtil.getPageImpl(pageable, staffQuery, Store.class);
}
@Component
@RequiredArgsConstructor
public class PagingUtil {

    private final EntityManager entityManager;

    private Querydsl getQuerydsl(Class clazz) {
        PathBuilder<Staff> builder = new PathBuilderFactory().create(clazz);
        return new Querydsl(entityManager, builder);
    }

    public <T> PageImpl<T> getPageImpl(Pageable pageable, JPQLQuery<T> query, Class clazz) {
        long totalCount = query.fetchCount();
        List<T> results = getQuerydsl(clazz).applyPagination(pageable, query).fetch();  // pageable 적용하여 query 실행
        return new PageImpl<>(results, pageable, totalCount);
    }

}

생성된 쿼리에 Pageable을 적용시켜 쿼리를 실행시킵니다.

 

select
    store0_.id as id1_3_,
    store0_.address as address2_3_,
    store0_.name as name3_3_ 
from
    store store0_ 
inner join
    staff staffs1_ 
        on store0_.id=staffs1_.store_id 
order by
    store0_.name asc,
    staffs1_.name asc limit 10

예상했던 그림의 query가 나오는 것을 알 수 있습니다.

 

 

여기서 부터는 위 기능이 지원되지 않는 줄 알고 기능을 만들었습니다.... 자세히 알아볼껄.. 그래서 기능을 만들었으니 블로그에는 남겨놓자.. ㅠ

원래 이런 기능이 지원되지 않는 줄 알고 다른 방법으로 구현을 하고 정리하려했던 글입니다... ㅠ

블로그 작성을 위해 다시 자세히 알아보던 중 되네? 이것도 되네? 다 되네?? 하다가 글의 방향을 틀었습니다.

 

위 기능을 Sort가 아닌 제가 만든 객체로 사용하여 동일하게 구현하겠습니다.

Pageable 과 비슷한 Vo

@ToString
@Getter @Setter
public class PageOrderVo {
    private Integer page;
    private Integer pageSize;
    private List<SortVo> sorts;
}

 

Sort와 비슷한 Vo

@Getter @Setter
public class SortVo {
    private String key;
    private OrderSort orderSort;

    public boolean equalsAsc() {
        return OrderSort.ASC.equals(this.orderSort);
    }

    public boolean equalsDesc() {
        return OrderSort.DESC.equals(this.orderSort);
    }
}

 

 

asc, desc 열거형

@Getter
public enum OrderSort {
    ASC("asc"),
    DESC("desc");

    private String name;

    OrderSort(String name) {
        this.name = name;
    }
}

 

테스트 코드

@Test
@DisplayName("querydsl join 시 paging 테스트 - (성공)")
void findStaffJoinOrderPaging_test() {
    //given
    PageOrderVo pageOrderVo = new PageOrderVo();
    SortVo storeNameSort = new SortVo();    // store
    storeNameSort.setKey("name");           // store name
    storeNameSort.setOrderSort(OrderSort.DESC);

    SortVo staffNameSort = new SortVo();    // staff
    staffNameSort.setKey("staffs.name");    // staff name
    staffNameSort.setOrderSort(OrderSort.DESC);

    pageOrderVo.setPage(0);
    pageOrderVo.setPageSize(10);
    pageOrderVo.setSorts(Arrays.asList(storeNameSort, staffNameSort));


    //when
    PageImpl<Store> pageImplStaff = staffRepository.findAllJoinDynamicOrder(pageOrderVo);

    //then
    assertAll(
            () -> assertThat(pageImplStaff.getTotalPages()).isEqualTo(1),
            () -> assertThat(pageImplStaff.getNumber()).isEqualTo(0),
            () -> assertThat(pageImplStaff.getContent()).hasSize(10)
    );
}

 

Querydsl로 생성된 Query에 Page, Order 적용하여 실행하는 메서드

@Override
public PageImpl<Store> findAllJoinDynamicOrder(PageOrderVo pageOrderVo) {
    JPAQuery<Store> query = queryFactory
            .selectFrom(store)
            .join(store.staffs, staff);

    OrderPage orderPage = new StoreOrderPage(entityManager, Store.class);
    return orderPage.getPageImpl(query, pageOrderVo);
}

 

Query에 Page와 Order를 적용하는 추상화 클래스

StoreOrderPage라는 클래스를 만들었습니다. 이 클래스는 OrderPage 클래스를 상속받는 클래스입니다. 먼저 OrderPage 클래스는 추상화 클래스입니다. 

public abstract class OrderPage {

    protected final List<OrderSpecifier<?>> orderSpecifiers;  // order 정보를 담고 있는 리스트
    private final EntityManager entityManager;
    private final Class<?> returnTypeClass;

    // constructor
    protected OrderPage(EntityManager entityManager, Class<?> returnTypeClass) {
        this.orderSpecifiers = new ArrayList<>();
        this.entityManager = entityManager;
        this.returnTypeClass = returnTypeClass;
    }

    // pageImp 객체를 리턴합니다.
    public <T> PageImpl<T> getPageImpl(JPQLQuery<T> query, PageOrderVo pageOrderVo) {
        long totalCount = query.fetchCount();

        JPQLQuery<T> applyOrderQuery = applyOrderQuery(query, pageOrderVo.getSorts());
        return getPageImplByPage(applyOrderQuery, pageOrderVo, totalCount);
    }

    // 쿼리에 Order를 적용합니다.
    private <T> JPQLQuery<T> applyOrderQuery(JPQLQuery<T> query, List<SortVo> sorts) {
        if (sorts == null || sorts.isEmpty()) {
            return query;
        }

        return query.orderBy(getOrder(sorts));
    }

    // Pageable 객체를 생성하고 리턴합니다.
    private Optional<Pageable> getPageable(Integer page, Integer pageSize) {
        if (page == null || pageSize == null) {
            return Optional.empty();
        }

        return Optional.of(PageRequest.of(page, pageSize));
    }

    // Pageable을 적용한 pageImpl 객체를 리턴합니다.
    private <T> PageImpl<T> getPageImplByPage(JPQLQuery<T> query, PageOrderVo pageOrderVo, long totalCount) {
        Optional<Pageable> optionalPageable = getPageable(pageOrderVo.getPage(), pageOrderVo.getPageSize());
        if (optionalPageable.isPresent()) {
            List<T> queryResults = getQuerydsl(returnTypeClass).applyPagination(optionalPageable.get(), query).fetch();
            return new PageImpl<>(queryResults, optionalPageable.get(), totalCount);
        }

        return new PageImpl<>(query.fetch());
    }

    // Pageable을 적용하기 위한 Querydsl 객체를 리턴합니다.
    private Querydsl getQuerydsl(Class<?> clazz) {
        PathBuilder<?> builder = new PathBuilderFactory().create(clazz);
        return new Querydsl(entityManager, builder);
    }

    / ** 
      * 자식 클래스에서 사용하는 메서드
      * 내림차순, 오름차순 Order를 적용
      * /
    protected void addOrderSpecifier(SortVo sortVo, ComparableExpressionBase<?> comparableExpressionBase) {
        if (sortVo.equalsAsc()) {
            this.orderSpecifiers.add(comparableExpressionBase.asc());
        } else if (sortVo.equalsDesc()) {
            this.orderSpecifiers.add(comparableExpressionBase.desc());
        }
    }

    // 추상클래스
    protected abstract OrderSpecifier[] getOrder(List<SortVo> sorts);
}

 

메서드마다 Order 적용하는 클래스

조회하는 쿼리마다 생성해야하는 클래스입니다. OrderPage를 상속받고 getOrder 메서드를 구현합니다.

public class StoreOrderPage extends OrderPage {

    public StoreOrderPage(EntityManager entityManager, Class returnTypeClass) {
        super(entityManager, returnTypeClass);
    }

    @Override
    public OrderSpecifier<?>[] getOrder(List<SortVo> sorts) {
        for (SortVo sort : sorts) {
            StoreOrder.getComparableExpressionBaseByName(sort.getKey()).ifPresent(
                    comparableExpressionBase -> super.addOrderSpecifier(sort, comparableExpressionBase)
            );
        }

        return this.orderSpecifiers.toArray(new OrderSpecifier[0]);
    }
}

파라미터로 넘어오는 SortVo 리스트에서 StoreOrder Enum에서 찾아서 생성된 쿼리에 orderBy절을 생성하여 붙혀줍니다.

QuerydslOrder는 Order 관련 Enum을 그룹화하기 위한 인터페이스입니다.

 

Order 할 수 있는 필드 열거형

public enum StoreOrder implements QuerydslOrder {
    STORE_NAME("name", store.name),
    STAFF_NAME("staffs.name", staff.name);

    private final String name;
    private final ComparableExpressionBase<?> comparableExpressionBase;

    StoreOrder(String name, ComparableExpressionBase<?> comparableExpressionBase) {
        this.name = name;
        this.comparableExpressionBase = comparableExpressionBase;
    }

    @Override
    public String getName() {
        return this.name;
    }

    @Override
    public ComparableExpressionBase<?> getComparableExpressionBase() {
        return this.comparableExpressionBase;
    }

    public static Optional<ComparableExpressionBase<?>> getComparableExpressionBaseByName(String name) {
        for (StoreOrder value : StoreOrder.values()) {
            if (value.name.equals(name)) {
                return Optional.of(value.comparableExpressionBase);
            }
        }
        return Optional.empty();
    }
}
public interface QuerydslOrder {
    String getName();

    ComparableExpressionBase<?> getComparableExpressionBase();
}

 

결과

다시 돌아와서 테스트하는 메서드를 실행합니다.

@Override
public PageImpl<Store> findAllJoinDynamicOrder(PageOrderVo pageOrderVo) {
    JPAQuery<Store> query = queryFactory
            .selectFrom(store)
            .join(store.staffs, staff);

    OrderPage orderPage = new StoreOrderPage(entityManager, Store.class);
    return orderPage.getPageImpl(query, pageOrderVo);
}
select
    store0_.id as id1_3_,
    store0_.address as address2_3_,
    store0_.name as name3_3_ 
from
    store store0_ 
inner join
    staff staffs1_ 
        on store0_.id=staffs1_.store_id 
order by
    store0_.name desc,
    staffs1_.name desc limit 10

결과는 Querydsl에서 제공하는 Pageable과 동일합니다.

 

결론

구현되어있는 것을 사용하자! Pageable을 자세히 파고 했다면 위와같은 엄청난 코드를 추가할 필요가 없었습니다. ㅠㅠ 반성.. 

반응형