
DB의 데이터를 조회할 때 Spring Data JPA를 이용해 원하는 데이터를 가져온다. 이 때 검색 조건이 많아질수록 쿼리 메소드가 길어져 가독성이 떨어지는 문제가 있다. 오늘은 이 문제를 해결한 과정에 대해 포스팅 하려고 한다.
JPA Specification은 JPA에서 제공하는 API 중 하나로 쿼리를 동적으로 구성할 수 있게 해준다.
Spring Data JPA로 데이터 접근 레이어(Repository)를 구현할 때 일반적으로 아래와 같이 쿼리 메소드(JPA Query Methods)를 이용한다.
// repository/RecipeLikeRepository.java
public interface RecipeLikeRepository extends JpaRepository<RecipeLike, Long> {
RecipeLike findByRecipeLikeNo(Long recipeLikeNo);
List<RecipeLike> findAllByRecipe_RecipeNo(Long recipeNo);
Long countAllByRecipe_RecipeNo(Long recipeNo);
Boolean existsRecipeLikeByRecipe_RecipeNoAndUser_UserNo(Long recipeNo, String userNo);
}
쿼리 메소드를 이용하면 (find를 예시로 들었을 때) find + By + 컬럼명 과 같이 메소드 이름을 작성함으로써 간단하게 쿼리를 생성할 수 있다는 장점이 있다.
💡Tip) 외래키로 조회할 때는 메소드를 "findBy" + "외래키를 관리하는 엔티티의 필드명(첫 글자를 대문자로)" + "_" + "외래키가 참조하는 엔티티의 기본키 필드명(첫글자를 대문자로)" 로 작성하면 조회가 가능하다.
4번째 메소드와 같이 조건이 여러 개가 된다면 And 혹은 Or를 메소드에 작성함으로써 적용시킬 수 있지만 메소드명이 길어지면서 가독성이 떨어진다는 문제점이 있다. 또한 이 메소드들은 수행하는 기능이 고정되어 있기 때문에 어플리케이션이 커지면서 작성해야할 쿼리 메소드의 개수가 증가할 수도 있다는 문제점이 있다.
이러한 문제점들을 해결하기 위한 것이 JPA Specification이다.
JPA Specification을 사용하여 4번째 메소드를 간단하게 변경해보려 한다.
사용 방법은 간단하다. 우선 Specification 클래스를 생성 후 사용할 쿼리 조건들을 추가해주면 된다.
// repository/RecipeLikeSpecification.java
public class RecipeLikeSpecification {
public static Specification<RecipeLike> equalRecipeNo(Long recipeNo) {
return (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("recipe").get("recipeNo"), recipeNo);
}
public static Specification<RecipeLike> equalUserNo(Long userNo) {
return (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("user").get("userNo"), userNo);
}
}
Specification<Entity Name> Method Name(Parameter)로 메소드를 만들고 사용하고자 하는 용도에 맞게 위와 같이 메소드를 정의하면 된다. 필자는 인자로 받은 recipeNo, userNo와 일치하는 RecipeLike를 찾기 위해 equal을 사용하였다. JPA Specification은 criteria API를 기반으로 만들어졌기 때문에 equal외에도 between, like 등의 다양한 메소드를 제공한다.
RecipeLike 엔티티는 Recipe 엔티티와 User 엔티티를 외래키로 사용하기에 각각 get("recipe").get("recipeNo") / get("user").get("userNo")로 작성하였다. 위의 코드에서 root는 RecipeLike를 의미한다.
Specification을 사용하는 Repository에서는 JpaSpecificationExecutor를 상속해주면 된다.
// repository/RecipeLikeRepository.java
public interface RecipeLikeRepository extends JpaRepository<RecipeLike, Long>, JpaSpecificationExecutor<RecipeLike> {
.
.
.
}
JpaSpecificationExecutor는 JpaRepository와 비슷한 메소드를 제공하지만 인자로 Specification을 사용한다.
public interface JpaSpecificationExecutor<T> {
Optional<T> findOne(Specification<T> spec);
List<T> findAll(Specification<T> spec);
Page<T> findAll(Specification<T> spec, Pageable pageable);
List<T> findAll(Specification<T> spec, Sort sort);
long count(Specification<T> spec);
boolean exists(Specification<T> spec);
long delete(Specification<T> spec);
<S extends T, R> R findBy(Specification<T> spec, Function<FluentQuery.FetchableFluentQuery<S>, R> queryFunction);
}
실제 사용은 로직을 담당하는 Service단에서 사용하면 된다. 필자는 레시피 상세 조회에서 사용자가 상세 조회하는 레시피의 북마크 여부를 판별하는데 사용하였다.
Specification<RecipeLike> spec = (root, query, criteriaBuilder) -> null;
우선 Specification을 선언하고 null로 초기화해준다.
spec = RecipeLikeSpecification.equalRecipeNo(recipeNo).and(RecipeLikeSpecification.equalUserNo(userNo));
그 후 RecipeLikeSpecification 클래스에서 작성한 메소드들을 호출하면 된다.
이 때 Specification은 and와 or 같이 다른 Specification 인스턴스를 결합할 수 있는 default method를 제공한다.
and()로 이어주게 되면 A조건을 만족하면서 B조건을 만족하는 데이터를,
or()로 이어주게 되면 A조건을 만족하거나 B조건을 만족하는 데이터를 찾을 수 있다.
GetRecipeDto.builder()
.recipe_like_status(recipeLikeRepository.findOne(spec).isPresent())
.build();
마지막으로 Repository의 메소드를 호출하고 위에서 만든 Specification을 인자로 전달하면 된다.
사용자가 상세 조회하는 레시피를 북마크 했다면 DB에 데이터가 있을 것이고 북마크 하지 않았다면 DB에 데이터가 없기 때문에 isPresent()로 해당 여부를 파악하였다.
여담으로
JpaSpecificationExecutor는exists()라는 메소드를 제공하기에 해당 메소드를 사용하면 됐지만 포스팅을 작성하는 지금에서야 해당 메소드가 있다는 것을 알았다.
// service/implement/RecipeServiceImpl.java
@Service
@Slf4j
@RequiredArgsConstructor
public class RecipeServiceImpl implements RecipeService {
private final RecipeRepository recipeRepository;
private final RecipeLikeRepository recipeLikeRepository;
private final UserRepository userRepository;
@Override
public ResponseEntity<? extends RecipeResponseDto> getRecipe(Long recipe_no, String userId) throws Exception {
try {
User user = userRepository.findByUserId(userId);
if (user == null) {
throw new UserNotFoundException();
}
Long userNo = user.getUserNo();
Recipe recipe = recipeRepository.findByRecipeNo(recipe_no).orElseThrow(RecipeNotFoundException::new);
Long recipeNo = recipe.getRecipeNo();
Specification<RecipeLike> spec = (root, query, criteriaBuilder) -> null;
if (Objects.nonNull(recipeNo) && Objects.nonNull(userNo)) {
spec = RecipeLikeSpecification.equalRecipeNo(recipeNo).and(RecipeLikeSpecification.equalUserNo(userNo));
}
GetRecipeDto newDto = GetRecipeDto.builder()
.
.
.
// 다중 조건 검색에 따라 즐겨찾기 여부를 지정
.recipe_like_status(recipeLikeRepository.findOne(spec).isPresent())
.build();
GetRecipeSuResDto responseBody = new GetRecipeSuResDto(ResponseCode.SUCCESS, ResponseMessage.SUCCESS, newDto);
return ResponseEntity.status(HttpStatus.OK).body(responseBody);
}
.
.
.
}
}
전체 코드는 이러하다. 길이상 관계없는 코드는 생략하였다.
DB에 다음과 같이 데이터를 넣어주었다.
| recipe_no | recipe_category | ... | recipe_title | ... | user_no |
|---|---|---|---|---|---|
| 2 | MEAT | ... | 북마크 한 레시피 | ... | 1 |
| 3 | MEAT | ... | 북마크 하지 않은 레시피 | ... | 1 |
그리고 2번 레시피에 북마크를 해준다.

이제 1번 사용자가 2번 레시피, 3번 레시피를 각각 조회했을 때 recipe_like_status 값을 확인한다.
2번 레시피를 조회했을 때는 recipe_like_status의 값이 true가, 3번 레시피를 조회했을 때는 false가 나와야 한다.

먼저 2번 레시피를 조회했을 때이다. 예상대로 recipe_like_status의 값이 true로 나왔다.

다음은 3번 레시피를 조회했을 때이다. 역시 예상대로 recipe_like_status의 값이 false로 나왔다. 정상적으로 작동하는 것을 확인할 수 있다.
https://docs.spring.io/spring-data/jpa/reference/jpa/specifications.html
https://bsssss.tistory.com/1280
https://dev-setung.tistory.com/20
https://www.youtube.com/watch?v=-znDGy-BQJk
https://spring.io/blog/2011/04/26/advanced-spring-data-jpa-specifications-and-querydsl