SpringBoot 검색/정렬 조건 유연하게 받기

Kim Dong Kyun·2024년 6월 18일
1
post-thumbnail

문제 상황

List 조회하는 API를 만들 때(볼 때) 되게 마음에 안들었던 부분이 있다. 정렬 조건, 검색 조건 을 String 리터럴로 사용하는 것이다!

   @GetMapping("/test")
    public List<FooDto> foo(@RequestParam String searchType, // 검색 조건
                            @RequestParam Sring sortCondition, // 정렬 조건
                            // ...){
        // ...
    }

저렇게 관리하니 좀 불편하더라.


리터럴로 검색 조건을 관리 할 때의 문제점

  1. 검색/정렬 조건으로 어떤 리터럴이 들어 올 지 예측하기 힘들다
  • 세부 구현(Ex: 쿼리)을 보아야만 저 검색 조건으로 들어올 데이터가 무엇이 있는지 알 수 있다.
  1. 검색 조건이 실제 사용되는 곳과 너무 멀다
  • String 검색조건을 받아서(Controller layer)
  • Service 를 지나
  • Repository 레이어에서 사용된다
  • 따라서 이 녀석의 상세 구현은 Controller에서는 못 보고, Repository까지 가야한다.

상황 가정

일반적으로 List(Paging) 조회를 할 때, 정렬 조건은

  1. 좋아요순
  2. 조회수순
  3. 최신순
    ...

와 같고

검색 조건은 (경우에 따라 다르지만) 내가 지금 진행중인 사이드 프로젝트에서는 다음과 같은 검색 조건을 가지고 있다.

  1. 제목+내용
  2. 작성자 이름(닉네임)
  3. 지역+나라 (여행 커뮤니티 사이드라~)

1차 리팩토링

일단, 첫번째 문제(검색/정렬 조건으로 어떤 리터럴이 들어 올 지 예측하기 힘들다) 부터 해결해보자.

이런 경우 Java에서 무엇을 사용해야 할 지는 바로 생각나는 것이 있었다. Enum!

  1. SortCondition Enum
@Getter
public enum SortCondition {
    LIKES,
    VIEWS,
    RECENT
}
  • 위와 같이 간단한 enum을 정의했다.
  • 이제, 요청하는 측에서는 이미 정의된 상수들 (LIKES...) 을 가지고 요청하며, 모든 검색 조건을 이 클래스 하나에서 확인 할 수 있다.
  1. Post Controller
    @GetMapping("/posts")
    public List<PostListResponseDto> getAllPosts(
            @RequestParam PostSearchType postSearchType,
            @RequestParam(required = false) String searchString,
            @RequestParam SortCondition postSortCondition
    ) {
        return postReadService.getPostList(postSearchType, searchString, postSortCondition);
    }
  • 오케이, 나는 QueryDSL을 사용할거니까 이걸 어디서 쓸것이냐면
    private final JPAQueryFactory jpaQueryFactory;

    public OrderSpecifier<?>[] sortByVies(QPost post) {
        return new OrderSpecifier<?>[] { post.viewCount.desc(), post.createdAt.desc() };
    }

    public OrderSpecifier<?>[] sortByLikes(QPost post) {
        return new OrderSpecifier<?>[] { post.postLikeList.size().desc() , post.createdAt.desc()};
    }

    public OrderSpecifier<?>[] sortByRecent(QPost post) {
        return new OrderSpecifier<?>[] { post.createdAt.desc() };
    }

    public OrderSpecifier<?>[] getBySortType(PostSortCondition postSortCondition, QPost post) {
        if (postSortCondition == postSortCondition.RECENT){
            return sortByRecent(post);
        }
        
        // if (...)
    }
    public List<Post> findPostListBySearchTypeAndSortCondition(PostSearchType postSearchType, String searchString, PostSortCondition postSortCondition){

        return jpaQueryFactory.selectFrom(post)
                .leftJoin(post.author)
                // ...
                .orderBy(getBySortType(postSortCondition, post))
                .fetch();
    }
  • 요렇게 한번 써볼까?
  • 그런데, Enum 그 자체에서 저 OrderSpecifier 를 돌려준다면 더 좋겠다. 저 녀석은 오로지 OrderSpecifier 라는 QueryDSL Order절에 쓰이는 객체를 판별해주는 책임을 가지잖아?
  • 이 클래스에 존재하는 getBySortType() 이라는 팩토리 매서드를, Enum 클래스로 옮겨보자. 그리고, 한 가지 더 찝찝했던 부분을 개선해보자.

2차 리팩토링

두 번째 문제(검색 조건이 실제 사용되는 곳과 너무 멀다) 도 해결해보자.

public enum SortCondition {
    LIKES() {
        @Override
        public OrderSpecifier<?>[] getSpecifier(QPost post) {
            return new OrderSpecifier<?>[] { post.postLikeList.size().desc() , post.createdAt.desc()};
        }
    },
    VIEWS() {
        @Override
        public OrderSpecifier<?>[] getSpecifier(QPost post) {
            return new OrderSpecifier<?>[] { post.viewCount.desc(), post.createdAt.desc() };
        }
    },
    RECENT() {
        @Override
        public OrderSpecifier<?>[] getSpecifier(QPost post) {
            return new OrderSpecifier<?>[] { post.createdAt.desc() };
        }
    };

    public abstract OrderSpecifier<?>[] getSpecifier(QPost post);
}
  • 이렇게 한번 바꿔보면 어떨까?
  • Enum 에 있던 팩토리 매서드를 SortCondition 에서 가져왔다. 이제 이 녀석들은 getSpecifier 추상 매서드를 통해서 각각의 정렬 조건을 동적으로 리턴 할 수 있다.
  • 아직도 조금 찝찝한 구석이 남아있다. 좀 더 리팩토링 해보자

3차 리팩토링

  • Enum 클래스 안에 있는 추상매서드가 좀 신경쓰였다. Post가 아닌 다른 녀석들에게는 저 팩토리 매서드를 사용하게 할 수 없는걸까?
  • 당연히 있다. 우리는 이미 알고 있는, Function 타입의 무언가! 바로 FunctionalInterface 를 직접 정의 후 사용해보자.
@FunctionalInterface
public interface SortCondition<T> {
    OrderSpecifier<?>[] getSpecifier(T targetQEntity);
}

public enum PostSortCondition implements SortCondition<QPost> {
    LIKES() {
        @Override
        public OrderSpecifier<?>[] getSpecifier(QPost qPost) {
            return new OrderSpecifier<?>[] { qPost.postLikeList.size().desc() , qPost.createdAt.desc()};
        }
    },
    VIEWS() {
        @Override
        public OrderSpecifier<?>[] getSpecifier(QPost qPost) {
            return new OrderSpecifier<?>[] { qPost.viewCount.desc(), qPost.createdAt.desc() };
        }
    },
    RECENT() {
        @Override
        public OrderSpecifier<?>[] getSpecifier(QPost qPost) {
            return new OrderSpecifier<?>[] { qPost.createdAt.desc() };
        }
    };
}
  • 좀 더 나아진 것 같다.
  • 이제 'T' 라는 제네릭 타입을 통해, QEntity 를 특정해서 구현(implements)하는 무언가를 만들 수 있게 되었다.
  • 여기서는 PostSearchType 라는 enum 이 SearchType<'QPost> 를 구현하므로, 저 구현체 enum은 Post라는 엔티티에 대한 정렬 조건만을 핸들한다.
  • 조금만 더 손봐보자. 제네릭 <`T> 타입에 제한을 둬야 하는 것 아닐까? 저 T 자리엔 QueryDSL의 QEntity 타입만 오면 좋겠다.

4차 리팩토링

@FunctionalInterface
public interface SortCondition<T extends EntityPathBase<?>> {
    OrderSpecifier<?>[] getSpecifier(T targetQEntity);
}


public enum PostSortCondition implements SortCondition<QPost> {
    LIKES() {
        @Override
        public OrderSpecifier<?>[] getSpecifier(QPost qPost) {
            return new OrderSpecifier<?>[] { qPost.postLikeList.size().desc() , qPost.createdAt.desc()};
        }
    },
    VIEWS() {
        @Override
        public OrderSpecifier<?>[] getSpecifier(QPost qPost) {
            return new OrderSpecifier<?>[] { qPost.viewCount.desc(), qPost.createdAt.desc() };
        }
    },
    RECENT() {
        @Override
        public OrderSpecifier<?>[] getSpecifier(QPost qPost) {
            return new OrderSpecifier<?>[] { qPost.createdAt.desc() };
        }
    };
  • 이제 T 타입은 QEntity 타입을 가지는 무엇인가만 들어 올 수 있다.

리팩토링 후 얻게 된 것

  1. Enum 타입으로 관리하므로, 어떤 데이터가 request 로 올 지 특정 할 수 있다.

리팩토링 중의 고민

  1. 이제 저 녀석들은 말하자면 Util 클래스가 되었다. (레이어 상관없이{도메인 제외} 사용하게 되니까)
  2. 이부분이 좀 마음에 안든다.

결론

  1. QueryDSL 쓸 땐 이런 방법도 나쁘지 않은 것 같다.
  2. 리터럴 쓰지 말자.
  3. 유틸클래스는 꼭 필요할때만 쓰자.

...


번외

패키지 어따두었는고?

좀 맘에 안들긴 한다...특히 base 라는 이름. (이건 정말로 고치고싶다. 근디 내가 한 게 아니라...)

1개의 댓글

comment-user-thumbnail
2024년 6월 18일

TMI : 사실 1와 3차는 없었다. 2차에서 4차로의 리팩토링 전후에 저런 과정이 있으면 더 읽기 쉬울 것 같아서 넣어봄

답글 달기