본문 바로가기
Spring

Spring JPA Specification을 사용해 유연하게 조회 API 만들기

by setung 2021. 11. 8.

초기 프로젝트가 작았을 때, 조회 API를 만드는 것은 크게 어렵지 않았습니다.

하지만 기능이나 데이터가 추가되면서 조건식이 추가될 때마다 Repository 메서드를 만들어야 하는 게 너무 비효율적이었습니다.

혹시나 구글링을 해보니 JPA에서 Specification라는 것을 제공해주네요.

 

일단 Specification을 적용하기 전부터 보겠습니다.

Person, Team 엔터티와 Controller, Repository를 만들었습니다.

 

Person.class

@Entity
@Getter
public class Person {

    @Id
    @GeneratedValue
    @Column(name = "person_id")
    private Long id;

    private String firstName;
    private String lastName;
    private Integer age;

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
}

 

Team.class

@Entity
@Getter
public class Team {

    @Id
    @GeneratedValue
    @Column(name = "team_id")
    private Long id;

    private String name;
}

 

PersonRepository.class

public interface PersonRepository extends JpaRepository<Person, Long> {
}

 

TeamRepository.class

public interface TeamRepository extends JpaRepository<Team,Long> {
}

 

PersonController.class

@RestController
@RequestMapping("/people")
@RequiredArgsConstructor
public class PersonController {

    private final PersonRepository personRepository;

    @GetMapping
    public List<Person> findAll() {
        return personRepository.findAll();
    }
}

현재 Person 엔터티를 전체 조회하는 API만 존재하네요.

이제 firstName, lastName, age, Team으로 필터링을 통해 조회가 되도록 수정해보겠습니다.

 

PersonRepository.class

public interface PersonRepository extends JpaRepository<Person, Long> {
    
    List<Person> findByFirstName(String firstName);

    List<Person> findByLastName(String lastName);

    List<Person> findByAge(Integer age);

    List<Person> findByTeam(Team team);
}

 

PersonController.class

@RestController
@RequestMapping("/people")
@RequiredArgsConstructor
public class PersonController {

    private final PersonRepository personRepository;
    private final TeamRepository teamRepository;

    @GetMapping
    public List<Person> findAll(
            @RequestParam(required = false) String firstName,
            @RequestParam(required = false) String lastName,
            @RequestParam(required = false) Integer age,
            @RequestParam(required = false) Long teamId
    ) {
        if (firstName != null)
            return personRepository.findByFirstName(firstName);
        if (lastName != null)
            return personRepository.findByLastName(lastName);
        if (age != null)
            return personRepository.findByAge(age);
        if (teamId != null) {
            Team team = teamRepository.getById(teamId);
            return personRepository.findByTeam(team);
        }

        return personRepository.findAll();
    }
}

파라미터를 각각 받아 조건에 맞게 repository와 controller를 수정하였습니다.

그러나 다들 느끼셨겠지만 정말 문제가 많은 코드입니다.

 

일단 조건을 추가할 때마다 controller와 repository에 코드를 추가로 작성해줘야 합니다.

 

또한  /people?lastName=홍&age=10와 같이 파라미터가 여러 개라면 정상적으로 작동을 못합니다. 해결책은 파라미터가 두 개인 조회 메서드를 만들어야 합니다. 조합을 해보면 2개의 파라미터로 여러 메서드를 만들어야 합니다.  그렇다면 파라미터가 3개인 것은 어떨까요... 혹은 다른 파라미터가 추가가 된다면... 정말 복잡합니다. 

 

 

이제 Specification을 이용해 보겠습니다.

 

PersonRepository에 JpaSpecificationExcecutor를 상속해줍니다.

public interface PersonRepository extends JpaRepository<Person, Long>, JpaSpecificationExecutor<Person> {
}

 

JpaSpecificationExcecutor.class

public interface JpaSpecificationExecutor<T> {

	/**
	 * Returns a single entity matching the given {@link Specification} or {@link Optional#empty()} if none found.
	 *
	 * @param spec can be {@literal null}.
	 * @return never {@literal null}.
	 * @throws org.springframework.dao.IncorrectResultSizeDataAccessException if more than one entity found.
	 */
	Optional<T> findOne(@Nullable Specification<T> spec);

	/**
	 * Returns all entities matching the given {@link Specification}.
	 *
	 * @param spec can be {@literal null}.
	 * @return never {@literal null}.
	 */
	List<T> findAll(@Nullable Specification<T> spec);

	/**
	 * Returns a {@link Page} of entities matching the given {@link Specification}.
	 *
	 * @param spec can be {@literal null}.
	 * @param pageable must not be {@literal null}.
	 * @return never {@literal null}.
	 */
	Page<T> findAll(@Nullable Specification<T> spec, Pageable pageable);

	/**
	 * Returns all entities matching the given {@link Specification} and {@link Sort}.
	 *
	 * @param spec can be {@literal null}.
	 * @param sort must not be {@literal null}.
	 * @return never {@literal null}.
	 */
	List<T> findAll(@Nullable Specification<T> spec, Sort sort);

	/**
	 * Returns the number of instances that the given {@link Specification} will return.
	 *
	 * @param spec the {@link Specification} to count instances for. Can be {@literal null}.
	 * @return the number of instances.
	 */
	long count(@Nullable Specification<T> spec);
}

JpaSpecificationExcecutor 소스 내부에는 기존의 JpaRepository에 있는 메서드와 비슷하지만 인자로 Specification 있다는 것이 다릅니다.

 

 

실제 적용을 해보겠습니다.

PersonSpecification.class

public class PersonSpecification {

    public static Specification<Person> equalFirstName(String firstName) {
        return (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("firstName"), firstName);
    }

    public static Specification<Person> equalLastName(String lastName) {
        return (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("lastName"), lastName);
    }

    public static Specification<Person> equalAge(Integer age) {
        return (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("age"), age);
    }

    public static Specification<Person> equalTeam(Team team) {
        return (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("team"), team);
    }
}

PersonSpecification이라는 클래스를 만들었습니다.

간단하게 설명을 드리자면 criteriaBuilder.equal(root.get("firstName"), firstName)을 통해 Person의 firstName과 파라미터의 firstName을 비교하는 쿼리문이 추가가 됩니다.

 

criteriaBuilder는 equal 말고도 like, lessThanOrEqualTo, greaterThanOrEqualT 등 다양한 메서드를 지원합니다.

 

PersonController.class

@RestController
@RequestMapping("/people")
@RequiredArgsConstructor
public class PersonController {

    private final PersonRepository personRepository;
    private final TeamRepository teamRepository;

    @GetMapping
    public List<Person> findAll(
            @RequestParam(required = false) String firstName,
            @RequestParam(required = false) String lastName,
            @RequestParam(required = false) Integer age,
            @RequestParam(required = false) Long teamId
    ) {
        Specification<Person> spec = (root, query, criteriaBuilder) -> null;

        if (firstName != null)
            spec = spec.and(PersonSpecification.equalFirstName(firstName));
        if (lastName != null)
            spec = spec.and(PersonSpecification.equalLastName(lastName));
        if (age != null)
            spec = spec.and(PersonSpecification.equalAge(age));
        if (teamId != null) {
            Team team = teamRepository.getById(teamId);
            spec = spec.and(PersonSpecification.equalTeam(team));
        }

        return personRepository.findAll(spec);
    }
}

처음 Specification<Person> spec을 null로 초기화합니다. 

그리고 Controller가 받아온 파라미터가 null이 아니면 PersonSpecification에서 작성한 Specification<Person>을 반환 값을 and() 메서드에 넣어줍니다. 

 

and() 메서드를 호출하면 쿼리의 Where 문에 AND로 조건식이 붙게 됩니다.

or() 메서드도 지원하는데 비슷하게 Where 문에 OR가 붙게 됩니다.

 

이것으로 제가 원하는 조회 API를 만들 수 있게 되었습니다.

 

 

추가로 파라미터의 age 보다 크거나 같은 사람을 조회하도록 변경해보겠습니다.

각각 Controller와 PersonSpecification 코드를 수정했습니다.

if (age != null)
            spec = spec.and(PersonSpecification.geAge(age));
 public static Specification<Person> geAge(Integer age) {
        return (root, query, criteriaBuilder) -> criteriaBuilder.greaterThanOrEqualTo(root.get("age"), age);
    }

 

마지막으로 team의 name으로 조회를 해보겠습니다.

 

PersonController.class

@RestController
@RequestMapping("/people")
@RequiredArgsConstructor
public class PersonController {

    private final PersonRepository personRepository;
    private final TeamRepository teamRepository;

    @GetMapping
    public List<Person> findAll(
            @RequestParam(required = false) String firstName,
            @RequestParam(required = false) String lastName,
            @RequestParam(required = false) Integer age,
            @RequestParam(required = false) Long teamId,
            @RequestParam(required = false) String teamName
    ) {
        Specification<Person> spec = (root, query, criteriaBuilder) -> null;

        if (firstName != null) spec = spec.and(PersonSpecification.equalFirstName(firstName));
        if (lastName != null) spec = spec.and(PersonSpecification.equalLastName(lastName));
        if (age != null) spec = spec.and(PersonSpecification.geAge(age));
        if (teamId != null) {
            Team team = teamRepository.getById(teamId);
            spec = spec.and(PersonSpecification.equalTeam(team));
        }
        if (teamName != null) {
            spec = spec.and(PersonSpecification.equalTeamName(teamName));
        }

        return personRepository.findAll(spec);
    }
}

 

PersonSpecification.class

public static Specification<Person> equalTeamName(String teamName) {
        return (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("team").get("name"), teamName);
    }

Person의 Team의 name을 비교를 해야 하는데 root.get("team").get("name")와 같이 get을 연달아 호출에 접근이 하시면 됩니다.

'Spring' 카테고리의 다른 글

Spring의 @Transactional  (0) 2021.12.15
JDK Dynamic Proxy와 CGLIB  (0) 2021.12.07
Spring AOP  (0) 2021.12.02
Spring Boot에 Redis Cache 적용해보기  (0) 2021.11.25
@RestControllerAdvice 예외 처리  (0) 2021.10.31

댓글