해당 코드는 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();
}
}
}
쿼리 파라미터를 통해 검색 조건을 입력받고 조건이 없을 경우 전체 데이터를 받아오기 위해 @RequestParam
에 required=false
조건을 추가했습니다.
위의 코드는 각 조건에 따라 실행하는 Repository 메소드를 추가로 작성해야 하고 if 조건문으로 검색할 내용을 구분해야 하기 때문에 코드가 지저분합니다. 또한 하나의 method에서 하나의 조건만 처리하기 때문에 title
과 tag
를 동시에 검색하는 기능을 구현하려면 또 하나의 메소드를 작성해야 합니다.
지금은 조건이 얼마 없어 괜찮아 보이지만 점점 조건이 추가될수록 코드를 유지 보수하기 어려워 질 것 같습니다. 하지만 Specification을 활용하면 이러한 문제점을 일부 해결할 수 있습니다.
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들에 대해 원하는 기능을 구현합니다. 여기서는 title과 tag의 경우 입력받은 값과 일치하는 결과를 반환하고 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)));
}
}
Spring Thymeleaf 환경에서 개발을 하다 간단한 에러를 겪어 이를 공유해보고자 합니다. 평소와 다름없이 필요한 Entity를 만들고 (문제 상황에 집중하기 위해 간단히 구현합니다.) 당연히 setLike() 핸들러에 like가 저장된 Post 객체가 맵핑될거라고 예상했지만 Bean property 'isLiked' is not read...
@RequestParam을 사용하며 겪은 실수를 공유해 보았습니다. @RequestParam 먼저 간단하게 @RequestParam에 대해 알아보자면 Spring MVC에서 쿼리 스트링 정보를 쉽게 가져오는데 사용할 수 있습니다. 예를 들어 /user?name=hellozin 이라는 요청에서 "hellozin" 이라는 값을 가져오기 위해 아래와...
해당 코드는 Github에서 확인할 수 있습니다. Spring Data에서 Specification은 DB 쿼리의 조건을 Spec으로 작성해 Repository method에 적용하거나 몇가지 Spec을 조합해서 사용할 수 있게 도와줍니다. 간단한 예제와 함께 Specification 사용 방법을 소개해 보겠습니다. 먼저 Spcification을 모르...
이번 포스트에서는 Spring boot 프로젝트에서 RabbitMQ를 사용하는 간단한 방법을 알아보겠습니다. Consumer 코드와 Producer 코드는 GitHub에 있습니다. 먼저 RabbitMQ 서버를 실행해야 하는데 Docker를 사용하면 쉽게 서버를 구성할 수 있습니다. 프로젝트 루트 폴더에 docker-compose.yml 파일을 생성하고 다...
Entity나 DTO를 검증하기 위해 @NotBlank, @Email 등 javax.validation.constraints.* validation을 사용하다 보면 아래와 같이 필드에 Enum 타입을 String으로 입력받는 경우가 있습니다. 이러한 경우 Enum 타입도 함께 validation 하는 방법을 정리해 보았습니다. Reference...