스프링/JPA

[Spring] JPA환경에서 검색 및 필터 기능 구현 1 - Specification 방법 이용

삼록이 2025. 6. 30. 00:45

먼저 JPA에서 쿼리를 날릴 수 있는 방식은 크게 아래 네 가지로 요약할 수 있겠다.

  • JPQL : JPA의 기본 쿼리 언어로 SQL과 유사한 문법이지만 엔티티 기준으로 작성된다. 테이블명 대신 클래스명, 컬럼 대신 필드명을 사용한다. @Query 어노테이션을 통해 사용할 수 있다.
  • Criteria API : JPQL을 자바 코드로 표현한 것이다. 코드가 복잡하며 가독성이 떨어진다는 단점이 있다.
  • Native Query : SQL 쿼리를 그대로 사용하는 방식으로 @Query어노테이션을 통해 사용할 수 있다.
  • Query DSL :SQL,JPQL을 코드로 작성할 수 있도록 해주는 오픈소스 프레임워크

이번 포스트에서는 Specification이라는 것을 활용해 검색 및 필터 기능을 구현하는 법을 알아보고자 한다.

Specification은 검색 조건이 있는 쿼리를 코드로 동적으로 구성할 수 있는 Spring Data JPA에서 제공하는 도구다.

Criteria API를 기반으로 동작하므로 위에서 언급한 네 가지 방식중 2번째에 해당하는 방법이다.

 

검색과 필터를 건다는 것은 WHERE절로 조건을 걸어 데이터를 가져와야함을 뜻한다.

다시말해, WHERE절 쿼리를 동적으로 자바코드를 구성할 수 있도록 하는 것이 Specification이다.

나는 이 Specification을 이용해 검색과 필터는 물론 전체 조회까지 하나의 API를 통해 구현했다.

따라서 아래 프론트 페이지에 처음 들어갈 때 전체 데이터 불러오기는 물론 체크박스에 체크할 때, 또는 검색버튼을 누를 때 

이 3가지에 하나의 API를 연결해 효율성을 높이기도 했다. 

 


0.우선 엔티티 클래스는 아래와 같이 설계되었다.

@Entity
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
public class Log {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long Id;
    private LocalDateTime timestamp;
    @Enumerated(EnumType.STRING)
    private Level level;
    @Enumerated(EnumType.STRING)
    private Service service;
    private String message;
    private String userId;
  

  //LogsResDto(조회용 DTO) 변환 메서드
    public LogsResDto toDtoFromEntity(){
       return LogsResDto.builder().timestamp(this.timestamp)
               .level(this.level)
               .service(this.service)
               .message(this.message)
               .userId(this.userId)
               .build();
    }
}

 

1.먼저 레포지토리단 코드다.

@Repository
public interface LogRepository extends JpaRepository<Log,Long> {
//  전체 로그 불러오기(검색/필터링)
    Page<Log> findAll(Specification<Log> specification, Pageable pageable);
}

 

동적 조건이 구성된 객체인 Specification과 Pageable을 매개변수로 함께 주어 페이징 처리도 수행하도록 했다.

원래 레포지토리가  JpaReporitory와  JpaSpecificationExecutor<T> 인터페이스를 함께 구현해야 specification과 관련한 JPA메서드들이 자동으로 제공되어 편리하게 사용할 수 있는데 여기서는 그냥 findAll 메서드 하나만 필요하므로 findAll(Specification<Log> specification, Pageable pageable)을 직접 명시했다.

 

2.컨트롤러단 코드.

@RestController
@RequestMapping("/monitoring")
public class LogController {

    private final LogService logService;

    public LogController(LogService logService) {
        this.logService = logService;
    }


    //1.로그리스트 불러오기 (검색,필터링까지 포함)
    @GetMapping("/logs")
    public ResponseEntity<?> bringLogs(LogSearchReqDto dto, @PageableDefault(size = 50) Pageable pageable) {
    
        Page<LogsResDto> searchedLogs = logService.bringSearchedLogs(dto, pageable);
        return new ResponseEntity<>(new CommonDto(HttpStatus.OK.value(), "logs you searched are uploaded successfully", searchedLogs),HttpStatus.OK);

    }

 

여기서 주목해야할 건 LogSearchReqDto다. 사용자로부터 어떤 검색을 하는지 어떤 필터링을 하는 지 받는 데이터이기 때문이다.

@AllArgsConstructor
@NoArgsConstructor
@Data
public class LogSearchReqDto {
    //검색 조건
    private String message;
    //검색 조건
    private String userId;
    //필터링 조건
    private List<String> levels;
    //필터링 조건
    private List<String> services;

}

 

3.서비스단 코드

실질적인 로직이 담긴 코드다. 여기서 Specification객체에 동적조건을 담아야한다.

그런데 Specification은 인터페이스이기 때문에 구현하는 클래스가 있어야 객체를 만들어야 하는데 아래 코드에서는 익명클래스로

바로 구현했다.

 

그리고 사용자가 검색하려하거나(message를 기준으로 검색한다거나 유저ID를 검색한다거나) 필터링하려는 조건(레벨이나 서비스명으로 필터링 건다거나)이 LogSearchReqDto 담겨있다. 

그런데 만약 프론트에서 사용자가 아무런 검색과 필터링을 하지않는다면 dto.각 필드값이 모두 ==null 이 되어 아무런 조건이 담기지 않게 되고 그러면 사실상 specification객체는 무용지물이 되어 그냥 전체 데이터를 조회하게 되는 것이다.

그래서 하나의 API로 데이터 조회,검색,필터링까지 한 번에 처리할 수 있게 된다.

 

먼저 아래 코드에  필요한 개념들이다

  • Root<T> : 쿼리에서 기준이 되는 엔티티.  SQL의 FROM절이라고 보면 된다.
  • Predicate :SQL의 WHERE 조건절을 자바로 표현한 것. 조건 하나하나를 Predicate로 표현함
  • CriteriaBuilder: 조건(Predicate),정렬,그룹 등을 만들  때 사용하는 팩토리 역할.

 

//1.로그리스트 불러오기(검색,필터링 까지)
public Page<LogsResDto> bringSearchedLogs(LogSearchReqDto dto, Pageable pageable) {

    //Specification 익명 클래스로 구현
    Specification<Log> specification = new Specification<Log>() {
        @Override
        public Predicate toPredicate(Root<Log> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
            List<Predicate> predicates = new ArrayList<>(); //여러 검색 및 필터 조건을 담아야하니까 리스트 생성
           //검색조건
            if (dto.getMessage() != null) {
                predicates.add(criteriaBuilder.like(root.get("message"), "%" + dto.getMessage() + "%"));
            } //->criteriaBuilder를 통해 predicate객체를 생성. 여기서는 .like메서드를 통해 SQL의 LIKE조건을 가진 predicate 조건객체를 만든 것(.like 대신 equal,and,or이 올 수도 있음)
            if (dto.getUserId() != null) {
                predicates.add(criteriaBuilder.like(root.get("userId"), "%" + dto.getUserId() + "%"));
            } //root는 여기서 Log엔티티를 가르키고 .get("필드이름") 즉 로그 엔티티의 userId필드 접근. 다시 말해서, WHERE userId LIKE "%dto.getUserId%"
            //필터 조건
            if (dto.getLevels() != null && !dto.getLevels().isEmpty()){
                CriteriaBuilder.In<Level> inClause = criteriaBuilder.in(root.get("level")); //필터에 해당하는 IN절
                for(String level : dto.getLevels()){
                    Level levelEnum = Level.valueOf(level.toUpperCase()); //프론트에서 문자열로 받아서 enum타입으로 변환해준 것
                    inClause.value(levelEnum);
                }
                predicates.add(inClause);
            }
            if (dto.getServices() != null){
                CriteriaBuilder.In<com.example.log.monitoring.domain.Service> inClause2 = criteriaBuilder.in(root.get("service"));
                for(String service : dto.getServices()){
                    com.example.log.monitoring.domain.Service serviceEnum = com.example.log.monitoring.domain.Service.valueOf(service.toUpperCase());
                    inClause2.value(serviceEnum);
                }
                predicates.add(inClause2);
            }
            //제일 처음에 만들었던 Predicates를 배열로 변환해준다(criteriaBuilder.and 혹은 .or은 배열을 매개변수로 요구하기 때문)
            //처음엔 여러 조건 객체를 갯수에 제한받지 않고 자유롭게 받기 위해 리스트로 만들었던 거임(배열을 생성하려면 처음부터 크기를 고정해놓아야하니까)
            Predicate[] predicateArr = new Predicate[predicates.size()];
            for (int i = 0; i < predicates.size(); i++) {
                predicateArr[i] = predicates.get(i);
            }
            Predicate predicate = criteriaBuilder.and(predicateArr);
            return predicate;
          }
    	};
     	Page<Log> originalSearchedLogs = logRepository.findAll(specification,pageable);
            return originalSearchedLogs.map(e->e.toDtoFromEntity());
        }

 


그러나 이러한 Specification을 통한 조건 기능 구현은 코드가 길고 복잡해 가독성이 떨어지고 유지보수가 쉽지않다. 이러한 한계 때문에 실무에서는 JPA에 QueryDSL을 혼합하는 방식으로 구현하는 경우가 많다고 한다.

그래서 다음 게시물에서는 이 코드를 QueryDSL기반으로 리팩토링 해보려한다.

 

(QueryDSL 리팩토링 게시물 보러가기 )

 

[Spring] JPA환경에서 검색 및 필터 기능 구현 2 - QueryDSL 방법 이용

이전 게시물에서 Specification을 통해, 즉 CriteriaAPI를 통해 검색 및 필터 기능을 구현했다면 이번에는 같은 기능을 QueryDSL을 통해 구현하도록 리팩토링 해보려한다. (이전 게시물) [JPA] JPA환경에서

gotopm.tistory.com