해당 코드는 Github에서 확인할 수 있습니다.

Spring Data에서 Specification은 DB 쿼리의 조건을 Spec으로 작성해 Repository method에 적용하거나 몇가지 Spec을 조합해서 사용할 수 있게 도와줍니다. 간단한 예제와 함께 Specification 사용 방법을 소개해 보겠습니다.

먼저 Spcification을 모르는 상태에서 제목, 태그, 좋아요 수 필드를 가진 Post객체를 각 필드별로 검색하는 기능을 구현하기 위해 Entity, Repository, Controller를 작성해 보았습니다.

@Entity
public class Post {
    @Id @GeneratedValue
    private Long id;
    private String title;
    private String tag;
    private int likes;
...
}
public interface PostRepository extends JpaRepository<Post, Long> {
    List<Post> findAllByTitle(String title);
    List<Post> findAllByTag(String tag);
    List<Post> findAllByLikesGreaterThan(int likes);
}
@RestController
public class PostController {

    private final PostRepository postRepository;

    public PostController(PostRepository postRepository) {
        this.postRepository = postRepository;
    }

    @GetMapping("/post/list")
    public List<Post> getPostList(@RequestParam(required = false) String title,
                                  @RequestParam(required = false) String tag,
                                  @RequestParam(required = false) Integer likes) {
        if (title != null) {
            return postRepository.findByTitle(title);
        } else if (tag != null) {
            return postRepository.findByTag(tag);
        } else if (likes != null) {
            return postRepository.findByLikesGreaterThan(likes);
        } else {
            return postRepository.findAll();
        }
    }

}

쿼리 파라미터를 통해 검색 조건을 입력받고 조건이 없을 경우 전체 데이터를 받아오기 위해 @RequestParamrequired=false 조건을 추가했습니다.

위의 코드는 각 조건에 따라 실행하는 Repository 메소드를 추가로 작성해야 하고 if 조건문으로 검색할 내용을 구분해야 하기 때문에 코드가 지저분합니다. 또한 하나의 method에서 하나의 조건만 처리하기 때문에 titletag를 동시에 검색하는 기능을 구현하려면 또 하나의 메소드를 작성해야 합니다.

지금은 조건이 얼마 없어 괜찮아 보이지만 점점 조건이 추가될수록 코드를 유지 보수하기 어려워 질 것 같습니다. 하지만 Specification을 활용하면 이러한 문제점을 일부 해결할 수 있습니다.

Spcification

Specification을 적용하기 위해서는 Repository에 JpaSpecificationExecutor<T> 인터페이스를 추가로 상속받아야 합니다.

public interface PostRepository extends JpaRepository<Post, Long>, JpaSpecificationExecutor<Post> {
    ...
}

그런 다음 title로 Post를 검색하는 Specification을 작성해 보겠습니다.

public class PostSpecs {
    public static Specification<Post> withTitle(String title) {
        return (Specification<Post>) ((root, query, builder) -> 
                builder.equal(root.get("title"), title)
        );
    }
}

해당 람다식은 Specification의 toPredicate() 메소드를 구현한 것입니다.

@Nullable
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder);

root.get("title")을 통해 Post 인스턴스의 title 필드가 검색 조건으로 입력받은 매개변수 title과 일치하는지 확인하고 해당하는 Post 객체를 반환합니다.

이렇게 만들어진 Specification은 Repository의 매개변수에 적용할 수 있습니다.

@RestController
public class PostController {

    private final PostRepository postRepository;

    public PostController(PostRepository postRepository) {
        this.postRepository = postRepository;
    }

    @GetMapping("/post/list")
    public List<Post> getPostList(@RequestParam(required = false) String title,
                                  @RequestParam(required = false) String tag,
                                  @RequestParam(required = false) Integer likes) {
        if (title != null) {
            return postRepository.findAll(PostSpecs.withTitle(title));
        } else if (...) {
            ...
        }
    }

}

이렇게 하면 Repository는 findAll() 메소드 하나로 여러 조건에 해당하는 결과를 만들어 낼 수 있게 되었습니다. 하지만 여전히 조건의 수 만큼 @RequestParam이 추가되고 Controller의 조건문도 늘어날 것입니다.

이를 해결하기 위해 코드를 조금 추가해 보았습니다. 먼저 PostSpecs에서 지원할 검색 조건을 Enum으로 정의합니다.

public class PostSpecs {

    public enum SearchKey {
        TITLE("title"),
        TAG("tag"),
        LIKESGREATERTHAN("likes");

        private final String value;

        SearchKey(String value) {
            this.value = value;
        }

        public String getValue() {
            return value;
        }
    }
...
}

각 Enum의 값은 Post에서 참조할 필드명과 일치시키고 Specification을 정의하는 코드를 다음과 같이 변경합니다.

...
public static Specification<Post> searchWith(Map<SearchKey, Object> searchKeyword) {
    return (Specification<Post>) ((root, query, builder) -> {
        List<Predicate> predicate = getPredicateWithKeyword(searchKeyword, root, builder);
        return builder.and(predicate.toArray(new Predicate[0]));
    });
}

private static List<Predicate> getPredicateWithKeyword(Map<SearchKey, Object> searchKeyword, Root<Post> root, CriteriaBuilder builder) {
    List<Predicate> predicate = new ArrayList<>();
    for (SearchKey key : searchKeyword.keySet()) {
        switch (key) {
            case TITLE:
            case TAG:
                predicate.add(builder.equal(
                        root.get(key.value),searchKeyword.get(key)
                ));
                break;
            case LIKESGREATERTHAN:
                predicate.add(builder.greaterThan(
                        root.get(key.value), Integer.valueOf(searchKeyword.get(key).toString())
                ));
                break;
        }
    }
    return predicate;
}

List<Predicate>를 생성하고 입력받은 searchKey들에 대해 원하는 기능을 구현합니다. 여기서는 titletag의 경우 입력받은 값과 일치하는 결과를 반환하고 likes는 입력받은 값보다 큰 경우를 반환합니다.

case LIKESGREATERTHAN:에서 searchKeyword.get(key)toString을 적용한 뒤 Integer.parseInt를 하는 이유는 Integer에 Object를 지원하는 메소드가 없기 때문입니다.

컨트롤러에서는 DTO와 ArgumentResolver를 통해 간결하게 구현할 수 있지만 여기서는 그냥 Map을 사용해 구현해 보겠습니다.

@GetMapping("/post/list")
public List<Post> getPostList(@RequestParam(required = false) Map<String, Object> searchRequest) {
    Map<SearchKey, Object> searchKeys = new HashMap<>();
    for (String key : searchRequest.keySet()) {
        searchKeys.put(SearchKey.valueOf(key.toUpperCase()), searchRequest.get(key));
    }
    return searchKeys.isEmpty()
            ? postRepository.findAll()
            : postRepository.findAll(PostSpecs.searchWith(searchKeys));
}

@RequestParam으로 입력받은 쿼리 파라미터를 Map에 저장하고 String을 SearchKey로 변환해 새로운 Map에 저장합니다. 새로 저장한 Map에 값이 없을 경우 findAll(), 있을 경우 위에서 정의한 searchWith(Map<SearchKey, Object>) 메소드에 매개변수로 전달합니다.

확인을 위해 테스트를 수행해 보면 올바르게 동작하는 것을 확인할 수 있습니다. 하지만 모든 조건을 하나의 메소드로 불필요한 단계가 증가할 수 있기 때문에 상황에 따라 고민하는 것이 좋을 것 같습니다.

테스트

@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@AutoConfigureMockMvc
public class PostControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Autowired
    PostRepository postRepository;

    @BeforeAll
    public void 데이터_추가() {
        for (int i = 0; i < 10; i++) {
            postRepository.save(new Post("title"+i, "study"+(i%3), i));
        }
    }

    @Test
    public void 포스트_전체조회() throws Exception {
        mockMvc.perform(get("/post/list"))
                .andExpect(jsonPath("$", hasSize(10)));
    }

    @Test
    public void 포스트_제목으로_조회() throws Exception {
        mockMvc.perform(get("/post/list")
                .param("title", "title0")
        )
                .andExpect(jsonPath("$", hasSize(1)));
    }

    @Test
    public void 포스트_태그로_조회() throws Exception {
        mockMvc.perform(get("/post/list")
                .param("tag", "study0")
        )
                .andExpect(jsonPath("$", hasSize(4)));
    }

    @Test
    public void 포스트_좋아요수로_조회() throws Exception {
        mockMvc.perform(get("/post/list")
                .param("likesGreaterThan", "6")
        )
                .andExpect(jsonPath("$", hasSize(3)));
    }

}

Reference