유연한 필터링이 필요해
개인 프로젝트를 진행하던 중, [내가 쓴 글]과 [내가 좋아요 한 글]에 대한 검색 기능이 필요했다.
이미 전체 게시글에 대해서 검색하는 API가 있었는데, 이 API는 오직 "전체 게시글"을 바탕으로 응답해 주는 형태였다.
만약, 아래 그림과 같이 필요한 경우의 API를 일일이 작성하게 된다면 정말이지 골때리는 코드가 될 것이다.
추후에 카테고리를 통한 필터 기능도 추가할 예정이기 때문에, 그때가 되어서 저 3가지 API를 붙잡고 수정할 순 없는 노릇이다.
게시글 응답 API는 하나만 사용해보자
세 유형의 요청을 하나의 API로 요청받고, 내부적으로 쿼리 파라미터에 따라서 다른 응답을 하기로 했다.
쿼리 파라미터에 따라 다른 응답을 제공하기 위해서는 각 조건에 따라 Specification의 toPredicate를 적절하게 설정해 주면 된다.
따라서, [글 유형], [검색 조건], [검색 내용], [카테고리]... 등의 조건들을 각각 하나의 필터라고 생각하고,
다음 그림과 같은 계층구조를 통해 필터를 더해나가기로 했다.
(사실 필터의 순서는 상관없지만, 이해를 돕기 위해 계층구조라고 함)
이런 형태이다...
구현 코드 1
각 단계를 적용한 함수는 다음과 같다.
private Specification<Article> buildSpecification(Long userId, String type, String keyword, String displayType) {
return new Specification<Article>() {
@Override
public Predicate toPredicate(Root<Article> root, CriteriaQuery<?> query, CriteriaBuilder cb) {
List<Predicate> predicates = new ArrayList<>();
// 1. displayType 필터 적용
if (displayType.equals("myPosts")) {
predicates.add(cb.equal(root.get("user").get("id"), userId));
} else if (displayType.equals("myLikePosts")) {
Join<Article, Like> likesJoin = root.join("likes", JoinType.LEFT);
predicates.add(cb.equal(likesJoin.get("user").get("id"), userId));
}
// 2. type과 keyword 필터 적용
if (type != null && keyword != null) {
if (type.equals("title")) {
predicates.add(cb.like(root.get("title"), "%" + keyword + "%"));
} else if (type.equals("content")) {
predicates.add(cb.like(root.get("content"), "%" + keyword + "%"));
}
}
// 3. 카테고리 필터 적용 예정
query.distinct(true); // 중복 제거
return cb.and(predicates.toArray(new Predicate[0]));
}
};
}
각 필터의 중간 값을 List에 저장하고, 모든 단계를 거친 후 필터를 모아준다.
구현 코드 2
각 단계를 모듈화 하여 정리한 코드는 다음과 같다
private Specification<Article> buildSpec(Long userId, String keyword, SearchType type, DisplayType display) {
return new Specification<>() {
@Override
public Predicate toPredicate(Root<Article> root, CriteriaQuery<?> query, CriteriaBuilder cb) {
List<Predicate> predicates = new ArrayList<>();
query.distinct(true); // 중복 제거
// 1단계 : DisplayType에 따라서 필터를 추가한다
Optional<Predicate> displayFilter = filterByDisplayType(userId, display, root, cb);
displayFilter.ifPresent(predicates::add);
// 2단계 : Type and Keyword에 따라서 필터를 추가한다
Optional<Predicate> typeAndKeywordFilter = filterByTypeAndKeyword(keyword, type, root, cb);
typeAndKeywordFilter.ifPresent(predicates::add);
// 3단계 : 카테고리에 따라서 필터를 추가한다
// TODO 카테고리 기능 추가
return cb.and(predicates.toArray(new Predicate[0]));
}
};
}
private Optional<Predicate> filterByTypeAndKeyword(String keyword, SearchType type, Root<Article> root, CriteriaBuilder cb) {
if(type == null || keyword == null)
return Optional.empty();
if(type == SearchType.TITLE)
return Optional.of(cb.like(root.get("title"), "%" + keyword + "%"));
if(type == SearchType.CONTENT)
return Optional.of(cb.like(root.get("content"), "%" + keyword + "%"));
if(type == SearchType.USER) {
Join<User, Article> u1 = root.join("user", JoinType.LEFT);
return Optional.of(cb.like(u1.get("name"), "%" + keyword + "%"));
}
// 설정된 상수와 일치하지 않는 type이 들어오면 예외 발생
throw new IllegalArgumentException("잘못된 keyword 타입입니다");
}
private Optional<Predicate> filterByDisplayType(Long userId, DisplayType display, Root<Article> root, CriteriaBuilder cb) {
// DisplayType에 따라서 필터를 추가하는 함수
if(userId != null) {
if (display == DisplayType.LIKE) {
Join<Like, Article> l1 = root.join("like", JoinType.LEFT);
return Optional.of(cb.equal(l1.get("user").get("id"), userId));
}
if (display == DisplayType.MY_POST) {
return Optional.of(cb.equal(root.get("user").get("id"), userId));
}
}
// display가 ALL이거나, null인 경우 필터를 수행하지 않는다
return Optional.empty();
}
카테고리 분류나, 또 다른 분류 기능이 추가되어도 메서드를 작성하고, 중간에 추가만 하면 된다.
이렇게 함으로써, 하나의 API로 확장성 있고 유연한 필터링을 할 수 있게 되었다.
후기
개발 과정에서 어떻게 해야 앞으로의 변동사항에 대해 유연하게 코드를 작성할 수 있을지에 대해서 고민을 해보았다.
물론 위 방법이 정답은 아니고, 내 나름대로의 해결책을 모색한 결과이다.
더 좋은 방법이 당연히 있을 수 있고, 위 코드가 효율적이지 못할 수도 있다.
그럼에도 불구하고, 더 좋은 코드를 어떻게 작성할 수 있을까? 에 대한 고민을 하는 과정이 의미 있었고,
개인적으로는 만족스러운 코드라고 생각하여 뿌듯하기도 하다.
위 과정에서 잘못된 내용이나, 개선점이 보인다면 댓글로 의견 부탁드리겠습니다.