queryDsl 로 조회 검색 같이 처리하기

twonezero·2024년 9월 3일
0

queryDsl_image

Spring Boot 로 API 기능들을 개발하다보면 전체 조회 및 검색 기능을 어떻게 처리할까 고민이 된다.

많은 검색 파라미터, 권한 조건 처리(Security Filter 에서 1차로 거를 수 있지만 그렇지 않은 경우), 페이징 처리 등을 그냥 JPA 로 개발하는 것은 쉽지 않다.

수월하게 개발하기 위해 queryDsl 을 이용해서 조회 성능을 높이고 효율적으로 작성할 수 있다.

build.gradle

우선적으로, querydsl 을 사용하기 위한 dependency 를 설치하자.

ext {
	set('querydslVersion', "5.0.0")
}

dependencies {

	implementation "com.querydsl:querydsl-jpa:${querydslVersion}:jakarta"
	annotationProcessor "com.querydsl:querydsl-apt:${querydslVersion}:jakarta"
	annotationProcessor "jakarta.annotation:jakarta.annotation-api"
	annotationProcessor "jakarta.persistence:jakarta.persistence-api"

	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'

    // db
    runtimeOnly 'org.postgresql:postgresql'
}

ext 에 버전을 변수로 관리 할 수 있다.

QueryDslConfig

@Configuration
public class QueryDslConfig {

    @Bean
    JPAQueryFactory jpaQueryFactory(EntityManager em){
        return new JPAQueryFactory(em);
    }
}
  • querydsl 을 적용하며 repository 구현 클래스(아래 나옴)에서 사용할 queryFactory 를 JPAQueryFactory 로 지정하며 Bean으로 등록한다.

Entity 작성

도메인 요구사항에 맞는 엔티티를 작성해보자.

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@Getter
@Entity
@Table(name = "p_store")
public class Store extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    @Column(name = "store_id")
    private String storeId;

    @Setter
    @Column(length = 100, nullable = false)
    private String name;

    @Setter
    @Column(length = 200)
    private String address;

    @Setter
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Setter
    @Lob
    @Column
    private String description;

    @Setter
    @ManyToOne
    @JoinColumn(name = "region_id")
    private Region region;

    @Setter
    @ManyToOne
    @JoinColumn(name = "category_id")
    private Category category;

    @Setter
    @Column(name = "average_rating", nullable = false)
    @Builder.Default
    private float averageRating = 0;

    @Setter
    @Column(name = "review_count", nullable = false)
    @Builder.Default
    private int reviewCount = 0;

    @OneToMany(mappedBy = "store", cascade = CascadeType.ALL, orphanRemoval = true)
    @Builder.Default
    private Set<Menu> menus = new LinkedHashSet<>();

    public void deleteStore(LocalDateTime time, String userEmail){
        setIsPublic(false);
        setIsDeleted(true);
        setDeletedAt(time);
        setDeletedBy(userEmail);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Store store = (Store) o;
        return Objects.equals(storeId, store.storeId);
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(storeId);
    }
}
  • BaseEntity 는 Auditing Fields 를 위한 클래스이다. JPA Auditing 기능을 활용해 자동으로 생성일, 생성자, 수정일, 수정자 등을 업데이트 시켜줄 수 있다.
  • 연관관계가 있는 User, Region, Category, Menu 는 비슷하게 작성하면 되므로 생략하겠다.

엔티티를 작성했다면, 미리 컴파일을 통해 Q 클래스(QueryDSL 이 만들어 주는 클래스) 를 만들어놓자. 다른 로직을 작성 중에 컴파일을 하려고 하면 에러가 발생하여 코드를 되돌리거나 주석처리 하면서 진행할 수도 있기 때문이다.

Controller

조회 코드를 살펴보기 전에 컨트롤러 요청을 받는 컨트롤러 코드를 살펴보자.

	@GetMapping
    public CommonResponse<Page<StoreResponse>> getAll(
            @RequestParam(name = "regionId", required = false) String regionId,
            @RequestParam(name = "categoryId", required = false) String categoryId,
            @RequestParam(required = false,name = "searchType") StoreSearchType searchType,
            @RequestParam(required = false, name = "searchValue") String searchValue,
            @PageableDefault(
                    size = 10, sort = {"createdAt", "updatedAt"}, direction = Sort.Direction.DESC
            ) Pageable pageable,
            @AuthenticationPrincipal UserDetailsImpl userDetails
    ) {
        return CommonResponse.success(
                storeService.getAllStores(regionId, categoryId,searchType,searchValue,userDetails, pageable).map(StoreResponse::from)
        );
    }
  • regionId, categoryId 는 사용자가 웹 화면에서 지역이나 카테고리 버튼을 통해 선택적 필터링을 하는 것을 예상하여 넣었다.
  • SearchType 과 SearchValue 를 받아 검색하고자 하는 주제와 검색어를 선택적으로 받도록 하였다.

Repository

StoreRepository

기존 JPA 를 통해 조회 등을 개발하면 쿼리 메소드를 통해 아래와 같이 선언하기만 해도 될 것이다.

public interface StoreRepository extends JpaRepository<Store, String>, StoreRepositoryCustom {
    Optional<Store> findByStoreIdAndUser(String storeId, User user);

    Optional<Store> findByUser(User user);

}

하지만, Store 조회에는 검색 파라미터와 권한 검증도 하고 있기에 위와 같이 개발하면 쿼리메소드를 여러 개로 작성하여 서비스 코드 단에서 분기처리를 해야 할 것이다.

StoreRepositoryCustom

public interface StoreRepositoryCustom {
    Page<Store> searchStore(String regionId,
                            String categoryId,
                            StoreSearchType searchType,
                            String searchValue,
                            UserDetailsImpl userDetails,
                            Pageable pageable);
}
  • StoreRepository 가 상속받고 있는 인터페이스로, queryDsl 을 적용해서 쿼리를 작성하기 위해 함수 시그니처에 맞게 선언을 한다.

StoreRepositoryCustomImpl

@RequiredArgsConstructor
public class StoreRepositoryCustomImpl implements StoreRepositoryCustom{

    private final JPAQueryFactory queryFactory;
    QStore store = QStore.store;
    QMenu menu = QMenu.menu;

    @Override
    public Page<Store> searchStore(String regionId, String categoryId, StoreSearchType searchType, String searchValue, UserDetailsImpl userDetails, Pageable pageable) {
        BooleanExpression regionCondition = regionId != null ? store.region.regionId.eq(regionId) : null;
        BooleanExpression categoryCondition = categoryId != null ? store.category.categoryId.eq(categoryId) : null;

        // 입력 검색 조건
        BooleanExpression searchCondition = getSearchCondition(searchType, searchValue);
        BooleanExpression visibilityCondition = getVisibilityByUserRoleCondition(userDetails);

        JPAQuery<Store> query = queryFactory.selectFrom(store)
                .where(regionCondition, categoryCondition, visibilityCondition, searchCondition)
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize());

        // 메뉴 이름으로 검색할 때만 조인 추가
        if (searchType == StoreSearchType.MENU) {
            query.leftJoin(store.menus, menu);
        }

        List<Store> stores = query.fetch();

        return new PageImpl<>(stores, pageable, stores.size());
    }

    // UserRole에 따른 조건 설정
    private BooleanExpression getVisibilityByUserRoleCondition(UserDetailsImpl userDetails) {
        if (userDetails.getUser().getRole() == UserRoleEnum.MASTER) {
            // MASTER 권한인 경우 모든 Store를 조회
            return null;
        }
        // 일반 사용자 권한인 경우 필터링 조건 적용
        return store.isPublic.isTrue().and(store.isDeleted.isFalse());
    }

    private BooleanExpression getSearchCondition(StoreSearchType searchType, String searchValue) {
        if (searchValue == null || searchType == null) {
            return null;
        }
        return switch (searchType) {
            case NAME -> store.name.containsIgnoreCase(searchValue);
            case DESCRIPTION -> store.description.containsIgnoreCase(searchValue);
            case MENU -> menu.name.containsIgnoreCase(searchValue);
        };
    }
}

interface에 선언한 searchStore 을 implementaion 하고 그에 맞는 쿼리를 작성한다.

searchType, 유저 권한, 삭제 여부 등의 조건을 함수로 작성하여 where 조건절에 동적으로 적용할 수 있다.

  • queryDsl 을 사용하면, 컴파일 타임에 타입 체크를 할 수 있고, 직접적인 SQL 을 작성하지 않아 코드 실수가 줄어든다.
  • 중요한 점은, 각 조건을 BooleanExpression 을 반환하는 함수로 만들어, 동적으로 조건을 처리할 수 있다는 것이다.
    • 즉, 입력되지 않은 조건은 null 처리를 통해 where 절에서 넘어갈 수 있게 된다.
  • menu 검색을 할 때도 leftJoin 등도 미리 적지 않고 if 절에서 선택적으로 적용하는 것을 볼 수 있다.

Store 엔티티 필드들을 검색하고 싶을 때도 해당 필드의 조건을 함수로 작성하여 where 절에 추가만 하면 손쉽게 필드 검색이 가능할 것이다.

다만, 위 코드는 성능을 높이기 위해 SearchType 을 하나로 지정하고 그에 맞는 검색어를 작성해야 한다는 것이 아쉬운 점이다.

모든 필드에 대해 한 번에 검색하는 것도 좋지만, 요구사항에 맞는 조회 쿼리 작성도 중요한 것 같아 위와 같이 작성했다.

추후, 모든 필드에 대해 검색을 해야 하는 수요가 생기면 모든 필드에 대한 파라미터를 optional 로 선언하고, 입력 파라미터 객체를 생성하여 위와 같이 queryDsl 을 활용한 조건 처리를 하면 될 것 같다.

profile
소소한 행복을 즐기는 백엔드 개발자입니다😉

0개의 댓글