서론

 

이전 포스팅에서 다룬 예제를 확장하여 진행하겠습니다. 이번 포스팅에서는 JPA Sort에 대해서 다루도록 하겠습니다.

 


 

문제상황

 

 

 

 

http://localhost:8080/customers?page=3&size=10&sort=customerId,asc

 

예제 프로그램 수행후 다음과 같은 URL을 호출하면 page, size, sort 파라미터가 Pageable 객체로 컨버팅 되어 정상적으로 프로그램이 페이징과 정렬이 수행되는 것을 확인할 수 있습니다.

 

http://localhost:8080/customers?page=3&size=10&sort=customerId,asc&sort=customerName,desc

 

만약 여러 Column의 정렬을 수행하고 싶다면, 위와 같이 여러개의 sort 파라미터를 추가하면 정상 수행되는 것을 확인할 수 있습니다.

 

하지만 위 sort 조건은 @Entity 클래스에 정의한 필드명과 정확히 동일해야합니다. 만약 예제와 같이 동일 컬럼에 대하여 리턴 값은 SnakeCase인 customer_id 일경우 Client 로직에서는 전달받은 값 이외에 @Entitiy 클래스 필드명을 관리해야합니다. 만약 Entitiy에 정의되어있지 않은 필드명을 전달하게된다면, 에러가 발생합니다.

 

Client 로직에서 도메인 객체 필드명에 의존하는 것은 바람직해 보이지 않습니다. 따라서 Enum과 DTO를 활용해서 Sort 조건이 도메인 객체와 직접연관되지 않도록 설정해봅시다.

 


 

Pageable 구현

 

기존에는 파라미터 객체를 Pageable로 Controller 파라미터로 주입받았습니다. 이때 도메인 객체 필드명을 Sort 인자에 정확히 명시해야하는 문제가 있었습니다. 따라서 방법을 바꾸어 별도 PageableDTO에 파라미터 인자를 주입받고 이를 처리하겠습니다.

 


1. Pair 클래스

 

@Getter
@Builder(access = AccessLevel.PRIVATE)
@ToString
public class Pair<T, U> {
    private T first;
    private U second;

    public static <T, U> Pair of(T first, U second) {
        return Pair.builder()
                .first(first)
                .second(second)
                .build();
    }
}

 

먼저 컬럼명과 정렬 상태를 담을 Pair 클래스를 생성합니다.

 


 

2. Sort 상태를 표현하는 Enum 구현

 

public enum SortOption {
    ASC,
    DESC
}

 


 

3. PageableDTO

 

@Getter
@Builder
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class PageableDTO {
    private Integer page;
    private Integer size;
    private Integer totalElements;
    private List<Pair<String, SortOption>> sorts;
}

 

Page 관련 정보와 Sort 정보를 담을 DTO를 선언합니다.


 

4.  HandlerMethodArgumentResolver 클래스 구현

 

public class PageableArgumentResolver implements HandlerMethodArgumentResolver {
    private final String PAGE = "page";
    private final String SIZE = "size";
    private final String TOTAL_ELEMENTS = "total_elements";

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterType().equals(PageableDTO.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        List<Pair<String, SortOption>> sorts = new ArrayList<>();
        final var sortArr = webRequest.getParameterMap().get("sort");

        if(sortArr != null){
            for(var v : sortArr){
                String[] keywords = v.split(",");
                sorts.add(Pair.of(keywords[0], (keywords.length < 2 || keywords[1].equals("asc")) ? SortOption.ASC : SortOption.DESC));
            }
        }

        return PageableDTO.builder()
                .page(getValue(webRequest.getParameter(PAGE)))
                .size(getValue(webRequest.getParameter(SIZE)))
                .totalElements(getValue(webRequest.getParameter(TOTAL_ELEMENTS)))
                .sorts(sorts)
                .build();
    }

    private Integer getValue(String param) {
        return param != null ? Integer.parseInt(param) : null;
    }
}

 

@Configuration
public class AppConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new PageableArgumentResolver());
    }
}

 

위 클래스는 Custom HandlerMethodArgumentResolver입니다. 별도로 이를 구현한 이유는 PageableDTO에 정렬 정보를 담기 위해서는 배열형태로 넘어오는 Sort 정보를 @ModelAttribute를 통해 주입받을 수 없기 때문입니다. 

 

따라서, 별도 HandlerMethodArgumentResolver를 구현하여 파라미터 값에서 관심있는 값들을 가져와 가공하여 DTO를 생성받아 주입할 수 있도록 구현하여 기존 resolver 목록에 추가했습니다.

 

만약 HandlerMethodArgumentResolver를 사용하고 싶지 않다면, MultiValueMap을 통해 주입받은 후 별도 Factory 메소드를 사용해 DTO를 코드상에서 변경시키는 것도 한 방법입니다.

 

 


 

5. 도메인 컬럼 매핑 Layer 구현

 

public class MetaModelUtil {
    public static String getColumn(Path<?> path){
        return path.getMetadata().getName();
    }
}

 

public enum CustomerMetaType {
    CUSTOMER_ID(getColumn(customer.customerId)),
    CUSTOMER_NAME(getColumn(customer.customerName));

    private String name;
    CustomerMetaType(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

 

Client에서 존재하는 SnakeCase의 컬럼명과 실제 @Entity 클래스 컬럼명을 매핑하기 위하여 Enum을 사용했습니다. @Entity로 부터 생성되는 컬럼명을 얻어오기 위해서는 QueryDsl의 Q클래스를 활용하거나 JPA Meta모델을 활용하는 방법이 있습니다.

 

대개 JPA를 쓰면서 QueryDsl을 같이 쓰기 때문에 별도 JPA Meta모델을 추출하기 보다는 QueryDsl의 MetaModel을 활용하여 매핑하였습니다.

 


 

6. DomainSpec 클래스 구현

 

6-1 Sort 전략 클래스 생성

 

public interface SortStrategy<T extends Enum<T>> {
    Sort.Order getSortOrder(T domainKey, SortOption order);
}
public class CustomerSortStrategy implements SortStrategy<CustomerMetaType> {
    @Override
    public Sort.Order getSortOrder(CustomerMetaType domainKey, SortOption order) {
        Sort.Order sortOrder = null;
        switch (domainKey){
            case CUSTOMER_ID:
            case CUSTOMER_NAME:
                final var column = domainKey.getName();
                sortOrder = (order == SortOption.ASC) ? Sort.Order.asc(column) : Sort.Order.desc(column);
        }
        return sortOrder;
    }
}

 

엔티티별로 정렬을 구현할 전략을 설정할 수 있는 객체를 생성합니다. 위 switch 구문에서 CUSTOMER_ID를 제거하면 Client로부터 넘어오는 customer_id는 해당되지 않으므로 Null이 반환되고 에러가 발생하게 될 것입니다. 따라서 Strategy에 지정한 구문여부에 따라서 정렬 대상을 한정할 수 있습니다.

 

 

 

6-2. DomainSpec 클래스 생성

 


public class DomainSpec<T extends Enum<T>, U> {
    private final Class<T> clazz;
    @Getter @Setter
    private int defaultPage = 0;
    @Getter @Setter
    private int defaultSize = 20;
    private SortStrategy<T> sortStrategy;

    public DomainSpec(Class<T> clazz, SortStrategy<T> sortStrategy) {
        this.clazz = clazz;
        this.sortStrategy = sortStrategy;
    }

    public DomainSpec(Class<T> clazz, SortStrategy<T> sortStrategy, int defaultPage, int defaultSize) {
        this.clazz = clazz;
        this.defaultPage = defaultPage;
        this.defaultSize = defaultSize;
        this.sortStrategy = sortStrategy;
    }

    public void changeSortStrategy(SortStrategy<T> sortStrategy) {
        this.sortStrategy = sortStrategy;
    }

    public Pageable getPageable(PageableDTO dto) {
        Integer page = dto.getPage();
        Integer size = dto.getSize();
        Pageable pageable;

        if (dto.getSorts().size() < 1) {
            pageable = PageRequest.of(page == null ? defaultPage : page, size == null ? defaultSize : size);
        } else {
            List<Sort.Order> sorts = getSortOrders(dto.getSorts());
            pageable = PageRequest.of(page == null ? defaultPage : page, size == null ? defaultSize : size, Sort.by(sorts));
        }

        return pageable;
    }

    private List<Sort.Order> getSortOrders(List<Pair<String, SortOption>> sorts) {
        Assert.notNull(this.sortStrategy,"There is no sort strategy");

        List<Sort.Order> orders = new ArrayList<>();
        for (var o : sorts) {
            T column;
            try {
                column = Enum.valueOf(this.clazz, o.getFirst().toUpperCase());
            } catch (IllegalArgumentException e) {
                throw new IllegalArgumentException("Sort option error");
            }

            final Sort.Order order = sortStrategy.getSortOrder(column, o.getSecond());
            Assert.notNull(order, "sort option error");

            orders.add(order);
        }
        return orders;
    }
}

 

정렬을 포함한 Pageable 객체를 얻어오기 위한 클래스입니다. SortStrategy를 통해 전달받은 구현체에 정렬 방법을 위임합니다.

 


 

7. Service 구현

 

@Service
public class CustomerService {
    private final CustomerRepository repository;
    private DomainSpec<CustomerMetaType, Customer> spec;

    public CustomerService(CustomerRepository repository) {
        this.repository = repository;
        this.spec = new DomainSpec<>(CustomerMetaType.class, new CustomerSortStrategy());
    }

    public Page<CustomerDTO> getCustomer(PageableDTO pageableDTO) {
        final var pageable = spec.getPageable(pageableDTO);

        final var customers = repository.findAll(pageable, pageableDTO.getTotalElements());
        return customers.map(CustomerDTO::of);
    }
}

 

정렬 조건을 포함한 Pageable 객체를 CustomerSpec에서 가져올 수 있도록 구성하였습니다.

 


 

8. Controller 구현

 

@RestController
@RequiredArgsConstructor
public class CustomerController {
    private final CustomerService service;

    @GetMapping("/customers")
    public ResponseEntity<Page<CustomerDTO>> getCustomer(PageableDTO pageableDTO){
        Page<CustomerDTO> customers = service.getCustomer(pageableDTO);
        return ResponseEntity.ok(customers);
    }
}

 

Pageable 객체를 바로 전달받는 것이 아니라, HandlerMethodArgumentResolver를 통해 PageableDTO를 주입받아 이를 서비스 객체로 넘기도록 코드를 수정하였습니다.

 

 


 

9. 실행 결과

 

 

프로그램을 실행시키면, 기존과는 다르게 customer_name과 같이 도메인 엔티티 컬럼명에 종속적이지 않고 컬럼명을 지정할 수 있습니다. 또한, 컬럼 정렬 대상을 제한하여 보다 무분별한 정렬이 발생되지 않도록 강제할 수 있습니다. 

 

 


 

마치며

 

이번 포스팅에서는 JPA에서 정렬 조건을 제한하는 방법과 엔티티 컬럼명에 의존하지 않도록 매핑 Layer를 두는 방법에 대해서 알아봤습니다.

 

위 구조를 조금 확장하면, Specification이나 BooleanBuilder 등을 활용하여 SQL Where 조건절에 추가하는 도메인 코드 로직을 구성할 수 있습니다.

 

hellozin님 블로그님 포스팅에 이와 관련된 내용이 있으니 참고하면 좋을 것 같습니다.

 

JPA Specification으로 쿼리 조건 처리하기

해당 코드는 Github에서 확인할 수 있습니다. Spring Data에서 Specification은 DB 쿼리의 조건을 Spec으로 작성해 Repository method에 적용하거나 몇가지 Spec을 조합해서 사용할 수 있게 도와줍니다. 간단한 예

velog.io

 

'JAVA > JPA' 카테고리의 다른 글

4. JPA Cache 적용하기  (0) 2020.08.05
3. JPA Hateoas 적용하기  (8) 2020.08.01
1. JPA 페이징 쿼리 개선  (0) 2020.07.29

+ Recent posts