본문 바로가기
프로젝트/DailycluB

검색/필터링 기능 (Querydsl)

by 넬준 2022. 10. 2.

 

 

프로젝트 기획 단계에서부터 검색/필터링은 꼭 필요한 기능이었다.

 

DailyClub 메인 페이지 중 일부

 메인 페이지를 보면 헤더에 검색어를 입력할 수 있고, 지역, 날짜, 친절도 등 다양한 조건을 입력해 모임을 필터링할 수 있는 기능이 있다.

 

 다양한 조건들이 사용자로부터 입력됐을 때 이를 서버 쪽에서, 자세히 말하면 Data Access 계층인 Repository 계층에서 이 조건들을 가지고 모임을 조회하는 Query가 필요하다. 
해당 Query는 단순한 Query가 아니라 조건값들의 존재여부에 따라서 동적으로 Query가 바뀌어야 하는 동적 Query를 작성해야 한다. 

 

 먼저, JPQL로 해당 조회 Query를 직접 작성해보자(일부분만)

  • 첫 번째 조건 : 검색어
  • 두 번째 조건 : 지역
  • ....
String query = "select p from Program p "; //뒤에 where절이 붙기 때문에 1칸 비워야 한다.
//첫번째 조건이면 where로 시작, 그 뒤 조건부터는 and로 시작
boolean isFirst = true; 

if(keyword != null) {
    if(isFirst) {
        query += "where "; //1칸 비워야 한다.
        isFirst = false;
    } else {
        query += "and "; //1칸 비워야 한다.
    }//if~else end

    query += "p.title like %"+keyword+"% or p.text like %"+keyword+"% "; //1칸 비워야 한다.
}//if end

//다음 조건
if(location != null) {
	if(isFirst) {
    	query += "where ";
        isFirst = false;
    } else {
    	query += "and ";
    }
    
    query += "p.location = "+location+" ";
}

//다음 조건...

 

 입력값의 존재 여부에 따라 동적으로 문자열을 일일이 작성해야 한다.

 

 한 눈에 보기에도 실수하기 쉽고(마지막 띄어쓰기 등), 만약 문법적으로 오류가 있더라도 컴파일 시점에서는 발견할 수 없고, 실제로 어플리케이션이 동작하는 런타임에서야 발견할 수 있다.

 

 이를 개선한 Query builder 중 하나인 JPA Criteria를 사용하면 문자가 아닌 Java 코드로 JPQL을 작성할 수 있기 때문에 많은 오류를 컴파일 단계에서 잡을 수 있다. 하지만 사용하기가 굉장히 복잡해 유지 보수가 힘들어 실용성이 없다는 단점이 있다.

 

 이러한 많은 단점을 개선하고 실무에서도 많이 사용하는 프레임워크가 있다. 바로 Querydsl이다.

 

 Querydsl는 다음과 같은 장점이 있다.

  • 단순 문자열이 아닌 Java 문법을 활용하므로 컴파일 단계에서 문법 오류를 체크할 수 있다.
  • JOIN, 서브쿼리 등 복잡한 Query 작성을 지원하며, 특히 동적 Query 작성이 편리하다.
  • 작성 코드가 SQL 모양과 비슷해 알아보기가 쉬워 유지보수에도 문제가 없다.

 

 그럼 검색/필터링 기능을 Querydsl을 활용해 어떤 식으로 구현했는지 처음부터 차근차근 알아보자.

 먼저, 검색/필터링을 위해 넘어올 입력값들을 모아 DTO를 만들었다.

 

SearchFilterDto.java

@Getter
@AllArgsConstructor
@Builder
public class SearchFilterDto {
    //검색 키워드
    private String keyword;

    //지역 : "서울", "경기", "강원", "인천", "대전/충청", "대구/경북", "부산/울산/경남", "광주/전라", "제주"
    @Location
    private String location;

    //최소 신청 가능 친절 %
    @Range(min = 0, max = 100)
    private Integer minKind;

    //프로그램 시작 날짜
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    @TodayOrAfter
    private LocalDate programDate;

    //프로그램 상태 : "모집중", "마감임박", "마감"
    @ProgramStatus
    private String programStatus;
}

 

 각 조건에 대해서 필드를 선언했고, ``@Annotation`/`을 추가해 각 필드에 대한 유효성 검사를 진행했다.

(``@TodayAfter`/`과 ``@ProgramStatus`/`는 custom annotation)

 

ProgramController.java

 유저가 검색/필터링 조건을 입력하고 조회할 때 해당 입력값을 받는 method를 선언했다.

 

@GetMapping
public ResponseEntity<MultiResponseDto<ProgramDto.Response>> getPrograms(@Parameter(description = "페이지 번호") @RequestParam int page,
                                                                         @Parameter(description = "한 페이지당 프로그램 수") @RequestParam int size,
                                                                         @ParameterObject @Validated @ModelAttribute SearchFilterDto searchFilterDto,
                                                                         @Parameter(hidden = true) @AuthenticationPrincipal AuthDetails authDetails) {
    //Service 계층 호출
    Page<Program> pagePrograms = programService.findPrograms(page-1, size, searchFilterDto);
    List<Program> programs = pagePrograms.getContent();
    //Entity -> DTO 변환
    List<ProgramDto.Response> responses = programMapper.programListToProgramResponseDtoList(programs);

    ...

    return new ResponseEntity<>(new MultiResponseDto<>(responses, pagePrograms), HttpStatus.OK);
}

 

 GET 요청이라 조회 조건을 Request body에 담지 않고 Query string으로 보내기로 팀원들과 합의했다.
2014년 이후  RFC 7230-7237에 의하면 GET method에 body값을 담아 보내도 크게 문제될 것은 없어 보이지만 (ElasticSearch에서도 GET method에 body값을 담는다) 캐싱, 호환성 등을 고려했을 때, 조회 조건이 query string으로 담지 못할 만큼 복잡하지 않다고 생각했다. (참고 : https://brunch.co.kr/@kd4/158

 

 따라서 ``@ModelAttribute`/`를 ``SearchFilterDto`/` parameter에 포함했다. 

paging처리를 위해 ``page`/`, ``size`/` 또한 query string으로 받았고, 로그인한 유저 정보를 위한 처리를 위해 ``AuthDetails`/` 객체도 parameter에 포함했다.

 

ProgramService.java

public Page<Program> findPrograms(int page, int size, SearchFilterDto searchFilterDto) {
    Pageable pageable = PageRequest.of(page, size, Sort.by("id").descending());
    return programRepository.searchAndFilter(pageable, searchFilterDto);
}

 

Repository

 Spring Data JPA에서 Repository 계층은 interface만으로 구현한다. 하지만 위와 같은 복잡한 상황은 Spring Data JPA에서 제공하는 Query method만으로 구현할 수 없다.

 따라서 개발자가 직접 Repository 인터페이스를 정의하고, 해당 인터페이스 구현 클래스에 우리가 구현하고자 하는 메소드를 만드는 방식을 사용한다.

 

 

 

ProgramRepositoryCustom.java (Custom Repository Interface)

 

public interface ProgramRepositoryCustom {
    Page<Program> searchAndFilter(Pageable pageable, SearchFilterDto searchFilterDto);
}

 

ProgramRepositoryImpl.java (Custom Repository Interface 구현 클래스)

 

@RequiredArgsConstructor
public class ProgramRepositoryImpl implements ProgramRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<Program> searchAndFilter(Pageable pageable, SearchFilterDto searchFilterDto) {
        //데이터 조회와 totalCount 조회 분리

        //데이터 조회
        List<Program> content = queryFactory
            .selectFrom(program)
            .where(
                keywordContains(searchFilterDto.getKeyword()),
                locationEq(searchFilterDto.getLocation()),
                minKindGoe(searchFilterDto.getMinKind()),
                programDateEq(searchFilterDto.getProgramDate()),
                programStatusEq(searchFilterDto.getProgramStatus())
            )
            .orderBy(program.id.desc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

        //totalCount Query
        JPAQuery<Long> countQuery = queryFactory
            .select(program.count())
            .from(program)
            .where(
                keywordContains(searchFilterDto.getKeyword()),
                locationEq(searchFilterDto.getLocation()),
                minKindGoe(searchFilterDto.getMinKind()),
                programDateEq(searchFilterDto.getProgramDate()),
                programStatusEq(searchFilterDto.getProgramStatus())
            );

        /**
         * total count query 필요없는 경우
         * 1. 첫 페이지, 데이터 수가 사이즈보다 작을 때
         * 2. 마지막 페이지일 때
         * -> PageableExecutionUtils를 사용하면 위 경우일 때 total count query를 실행하지 않는다.
         */
        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

    //title이나 text에 keyword 있는지 여부
    private BooleanExpression keywordContains(String keyword) {
        return StringUtils.hasText(keyword) ?
            program.title.contains(keyword).or(program.text.contains(keyword)) : null;
    }
    //같은 지역인지 여부
    private BooleanExpression locationEq(String location) {
        return StringUtils.hasText(location) ?
            program.location.eq(EnumValueConvertUtils.ofDescription(Program.Location.class, location)) : null;
    }
    //더 높은 최소 신청 가능 친절 퍼센트인지
    private BooleanExpression minKindGoe(Integer minKind) {
        return (minKind != null) ?
            program.minKind.goe(minKind) : null;
    }
    //해당 날짜에 프로그램이 시작하는지
    //조건 없으면 오늘 날짜 포함 그 이후 프로그램으로 검색
    private BooleanExpression programDateEq(LocalDate programDate) {
        return (programDate != null) ?
            program.programDate.eq(programDate) : (program.programDate.eq(LocalDate.now()).or(program.programDate.after(LocalDate.now())));
    }
    //[모집중, 마감임박, 마감] 상태
    private BooleanExpression programStatusEq(String programStatus) {
        return (programStatus != null) ?
            program.programStatus.eq(EnumValueConvertUtils.ofDescription(Program.ProgramStatus.class, programStatus)) : null;
    }

}

 

 이 클래스에서 바로 Querydsl을 이용해 동적 Query를 작성했다. 

 

 (페이징 처리에서 Count Query와 내용 Query를 따로 분리하고, Spring Data Library가 제공하는 ``PageableExecutionUtils.getPage()`/`를 이용해 Count Query를 최적화했다)

 

 

 여기서 우리가 중점적으로 볼 부분은 이 ``where()`/`이다.

 

.where(
    keywordContains(searchFilterDto.getKeyword()),
    locationEq(searchFilterDto.getLocation()),
    minKindGoe(searchFilterDto.getMinKind()),
    programDateEq(searchFilterDto.getProgramDate()),
    programStatusEq(searchFilterDto.getProgramStatus())
)

 

 메소드명에서 보면 알 수 있듯이 각 입력값에 따라 WHERE절을 만드는 부분이다.

``where()`/`에 parameter로 조건을 추가하면 and로 자동 연결이 된다. 만일 입력값이 없다면, 해당 조건값을 null로 처리하면 ``where()`/` 조건에서 무시하기 때문에 동적 Query 문제를 쉽게 해결할 수 있다.

 

 위 ``where()`/`을 보면 알 수 있듯이, 철저하게 Java 문법을 사용해서 작성하기 때문에 문법 오류를 컴파일 단계에서 바로 찾을 수 있다.

 또한, 해당 조건 메소드(``keywordContains()`/`, ``locationEq()`/` 등)들은 말 그대로 메소드이므로 다른 Query를 작성할 때 재사용할 수 있다.

 게다가, 메소드명만 보더라도 대략 어떤 조건식인지 알 수 있다. 가독성이 좋기 때문에 유지/보수 관점에서도 이점이 많다.

 

 최종적으로 ``JpaRepository`/`, ``ProgramRepositoryCustom`/` interface를 우리의 ``ProgramRepository`/` interface에서 상속해서 메소드를 활용할 수 있다.

 

ProgramRepository.java (최종 Repository)

 

public interface ProgramRepository extends JpaRepository <Program, Long>, 
                                           ProgramRepositoryCustom {
	...
}

댓글