Spring Boot 로 API 기능들을 개발하다보면 전체 조회 및 검색 기능을 어떻게 처리할까 고민이 된다.
많은 검색 파라미터, 권한 조건 처리(Security Filter 에서 1차로 거를 수 있지만 그렇지 않은 경우), 페이징 처리 등을 그냥 JPA 로 개발하는 것은 쉽지 않다.
수월하게 개발하기 위해 queryDsl 을 이용해서 조회 성능을 높이고 효율적으로 작성할 수 있다.
우선적으로, 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 에 버전을 변수로 관리 할 수 있다.
@Configuration
public class QueryDslConfig {
@Bean
JPAQueryFactory jpaQueryFactory(EntityManager em){
return new JPAQueryFactory(em);
}
}
- querydsl 을 적용하며 repository 구현 클래스(아래 나옴)에서 사용할 queryFactory 를 JPAQueryFactory 로 지정하며 Bean으로 등록한다.
도메인 요구사항에 맞는 엔티티를 작성해보자.
@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 이 만들어 주는 클래스) 를 만들어놓자. 다른 로직을 작성 중에 컴파일을 하려고 하면 에러가 발생하여 코드를 되돌리거나 주석처리 하면서 진행할 수도 있기 때문이다.
조회 코드를 살펴보기 전에 컨트롤러 요청을 받는 컨트롤러 코드를 살펴보자.
@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 를 받아 검색하고자 하는 주제와 검색어를 선택적으로 받도록 하였다.
기존 JPA 를 통해 조회 등을 개발하면 쿼리 메소드를 통해 아래와 같이 선언하기만 해도 될 것이다.
public interface StoreRepository extends JpaRepository<Store, String>, StoreRepositoryCustom {
Optional<Store> findByStoreIdAndUser(String storeId, User user);
Optional<Store> findByUser(User user);
}
하지만, Store 조회에는 검색 파라미터와 권한 검증도 하고 있기에 위와 같이 개발하면 쿼리메소드를 여러 개로 작성하여 서비스 코드 단에서 분기처리를 해야 할 것이다.
public interface StoreRepositoryCustom {
Page<Store> searchStore(String regionId,
String categoryId,
StoreSearchType searchType,
String searchValue,
UserDetailsImpl userDetails,
Pageable pageable);
}
@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 을 활용한 조건 처리를 하면 될 것 같다.