[Spring] QueryDSL 검색 기능 구현하기

easyone·2025년 11월 11일

Spring

목록 보기
13/18

UMC 시니어 미션 6주차 과제입니다.

미션 개요

프로젝트가 너무 잘되어서, PM님이 날뛰고 계십니다.

기존 기능에서, 가게를 검색하는 기능을 추가하신다고 합니다!!

사용자가 원하는 가게 정보를 쉽고 정확하게 찾을 수 있도록 검색 API를 설계하고 구현해야합니다.

검색 기능은 지역 필터, 이름 검색, 정렬 조건, 페이징을 지원해야 합니다.


1-1. 필터링

  • 지역(region) 기반 필터링 가능

  • 예: 강남구, 도봉구, 영등포구 등

    (원하시는 분들은 다중 선택 가능 기능도 추가해보세요!!)

1-2. 이름 검색

  • 검색어 띄어쓰기에 따라 검색 로직이 달라집니다.
    • 공백 포함 검색어: 각 단어가 포함된 가게의 합집합 조회
      • 예: '민트 초코''민트' 포함 가게 + '초코' 포함 가게
    • 공백 없는 검색어: 검색어 전체가 포함된 가게만 조회
      • 예: '민트초코''민트초코' 포함 가게만 조회

1-3. 정렬 조건

  • latest : 최신순
  • name : 이름순
    • 정렬 우선순위: 가나다 → 영어 대문자 → 영어 소문자 → 특수문자
    • 이름이 동일한 경우: 최신순으로 정렬

1-4. 페이징

  • 기본 페이징: page + size
  • 원하면 커서 기반 페이징도 지원

필요한 QueryDSL 문법 공부하기

queryDSL 세팅

두 가지 방법이 있다.

  • JpaQueryFactory를 bean으로 등록
  • Config class 따로 생성
    config class 따로 생성하기
@Configuration
public class QueryDslConfig {
    
    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager em) {
        return new JPAQueryFactory(em);
    }
}

// Repository에서 주입받아서 사용
private final JpaQueryFactory queryFactory;

생성없이 하기

JpaQueryFactory queryFactory = new JPAQueryFactory(em);

// Q클래스 정의 

queryFactory
				.selectFrom(QUser.user) // from절
				.where(QUser.user.age.gt(20)) // where절
				.fetch(); 실행 

where절 조건 생성 - BooleanBuilder

// 기본 형태
BooleanBuilder builder = new BooleanBuilder();

// and 조건 추가
builder.and(store.region.eq("강남구"));

// or 조건 추가
builder.or(store.region.eq("서초구"));

// not 조건
builder.and(store.active.eq(true).not());

// andAllOf() , 여러 조건을 한번에 or 
// orAllOf()도 마찬가지
builder.andAllOf(
	store.region.eq("강남구"),
	store.rating.goe(4.0)
	);
	
// hasValue() - builder가 비어있지 않은지 확인
if (builder.hasValue()) {
    // 조건이 있음
}
// 일반적으로 사용방식: 조건이 입력되었으면 추가
if(condition.getRating() ! = null)
if(StringUtils.hasText(condition.getName()) 
// 이런식으로, 입력되어있으면 추가하는 방식으로 구현

// 초기값을 설정
BooleanBuilder builder = new BooleanBuilder(store.active.eq(true));

// 사용 방법
.where(builder)

공백 처리 방법

// 공백을 처리하기
BooleanBuilder nameBuilder = new BooleanBuilder();
// 먼저 name이 공백을 포함하는지를 확인하기
String[] keywords = name.split(" ");
for (String keyword : keywords) {
}

caseBuilder 사용해보기

SELECT문에서 CASE WHEN을 java에서 사용할 수 있게 해준다.

// 기본 사용방법
new CaseBuilder()
    .when(조건1).then(1)      // 조건1 만족 → 값1
    .when(조건2).then(2)      // 조건2 만족 → 값2
    .when(조건3).then(3)      // 조건3 만족 → 값3
    .otherwise(기본값)          // 아무 조건도 안 만족 → 기본값

// 반드시 SELECT에 포함되어야 함!

Expressions 사용하기

sql 문법을 querydsl에서 바로 사용할 수 없기 때문에, 사용할 수 있게 해준다.
Expressions.stringTemplate() 방식으로 사용한다.

// REPLACE 함수
Expressions.stringTemplate(
    "replace({0}, ' ', '')",
    store.name
)
// 의미: replace(name, ' ', '')

// CONCAT 함수
Expressions.stringTemplate(
    "concat({0}, {1})",
    store.region.name,
    store.name
)
// 의미: concat(region_name, name)

Projections.constructor

Sql 결과를 DTO로 반환한다. 자주 사용되는 방식이다.

Projections.constructor(Dto.class, ...)

 // 사용 방법

            .select(Projections.constructor(
                StoreSearchDto.class,
                store.id,
                store.name,
                store.region,
                store.rating,
                new CaseBuilder()  // CaseBuilder
                    .when(store.rating.goe(4.5)).then("최고")
                    .when(store.rating.goe(4.0)).then("좋음")
                    .when(store.rating.goe(3.0)).then("보통")
                    .otherwise("나쁨")
                    .as("ratingLabel")
            ))

정렬 구현하기

// 단순 정렬. 다양한 조건 추가 못함
.orderBy(store.createdAt.desc())

// OrderSpecifier 사용
OrderSpecifier<?> orderSpec = switch(sortBy) {
	case "name" -> new OrderSpecifier<>(order, sotre.name);
	default -> new OrderSpecifier<>(Order.DESC, store.createdAt);
};

// 다중 정렬해보기. 우선순위 순서대로 넣기
return queryFactory
		.selectFrom(store)
        .orderBy(
            new OrderSpecifier<>(Order.ASC, store.region),     // 지역순
            new OrderSpecifier<>(Order.DESC, store.rating),    // 그 다음 평점순
            new OrderSpecifier<>(Order.DESC, store.createdAt)  // 그 다음 최신순
        )
        .fetch();
}

// 적용하기
return queryFactory
		.selectFrom(store)
		.orderBy(orderSpec)
		.fetch();

page vs slice

정렬,조건설정 등등을 해줬으니.. 이제 페이징을 처리해줘야 한다.

page

page로 할 경우, 전체 개수를 포함해야 하기 때문에 Count(*) 별도 쿼리가 추가되어서, 성능이 낭비된다.
이런 식으로 fetchCount(), fetch() 총 두번의 쿼리가 실행된다.

// count 별도 쿼리가 추가됨
queryFactory
		.selectFrom(store)
		.where(...)
		.offset(0)
		.limit(20)
		.fetchCount();
slice

slice는 다음 페이지가 있는지 여부만을 체크함

  • limit을 size+1로 설정
  • count 쿼리를 별도 실행하지 않음
  • 전체 개수를 알 필요 없는 경우
List<store> stores = queryFactory
		.selectFrom(store)
		.where(...)
		.offset(0)
		.limit(21) // size+1개 조회

boolean hasNext = stores.size() > 20;
// 21개 이상이 조회됐다면, 다음 페이지가 있다는 뜻이므로 조회, 이하면 그대로 반환
List<Store> result = hasNext ? stores.subList(0,20) : stores;

fetch

// fetch() 결과 여러 개, List 반환
List<Store> stores = queryFactory
		.selectFrom(store)
		.fetch();

// fetchOne() 단일 객체만 반환
Store store = queryFactory
		.selectFrom(store)
		.where(store.id.eq(1))
		.fetchOne()
		
// fetchFirst() 첫 번째 객체만
// fetchCount() 개수만, long 타입

// exists() 존재 여부, boolean 타임
boolean exists = queryFactory
		.selectFrom(store)
		.where(store.region.eq("강남"))
		.select(Expressions.ONE)
		.fetchFirst() != null;

paging 관련 DTO 생성하기

offset 기반의 경우

@Data
@Builder
public class PagingResponse<T> {
	private List<T> content; // 데이터
	private int page; //현재 페이지
	private int size; // 페이지 크기
	private long total; // 전체 개수
	private long totalPages; // 전체 페이지
	private boolean hasNext; // 다음 페이지 존재 여부
}
	

cursor 기반

@Data
@Builder
public class SliceResponse<T> {
		private List<T> content;
		private boolean hasNext;
		private int currentPage;
		private int size;
}

최종 코드 작성

이제 필요한 문법을 찾아봤으니, 요구사항에 기반에 최종 코드를 작성해 보았다.
DTO 정의

// DTO
@Data
@AllArgsConstructor
public class StoreResponseDTO {
    private Long id;
    private String name;
    private String regionName;      
    private Double rating;
    private LocalDateTime createdAt;
}

// PageResponse
@Data
@Builder
public class CursorPageResponse<T> {
    private List<T> content;      // 데이터
    private String nextCursor;    // 다음 조회 시 사용할 커서
    private boolean hasNext;      // 다음 페이지가 있는지
}

레포지토리 코드 작성하기(Controller 코드는 생략)

// 검색 조건 설정
@Getter
@Builder
public class SearchCondition {
	private List<Long> regions;
	private String query;
	private String sort;
}

public class StoreCustomRepositoryImpl extends StoreCustomRepository {

private final JpaQueryFactory queryFactory;

public PageResponse<StoreResponseDTO> searchStoreByCondition(SearchCondition condition, Long cursor, int size) {
	
	QRegion region = QRegion.Region;
	QStore store = QStore.Store;
	
	
	BooleanBuilder builder = new BooleanBuilder();
	
	// 지역 ID 다중 필터링
	if(condition.getRegions!=null && !condition.getRegions().isEmpty()) {
			builder.and(store.region.id.in(condition.getRegions());
	}
	
	if(StringUtils.hasText(condition.getQuery()) {
			// 앞뒤 공백 제거
			String query = condition.getQuery().trim();
			
			BooleanBuilder nameBuilder = new BooleanBuilder();
			
			// 전체 공백을 제거해서 검색
			String withOutSpace = query.replace(" ","");
			nameBuilder.or(
				Expresstions.stringTemplate(
					"replace({0},' ', '')",
					store.name // name에 공백을 제거하는 함수
				).contains(withOutSpace) // LIKE 조건 추가,%검색어%
			);
			// 공백으로 구분된 단어를 각각 검색
			if(query.contains(" ")) {
					// 공백 기준으로 키워드를 나눠서 저장, or 조건으로 저장
					String[] keywords = query.split(" ");
					for (String keyword : keywords) {
						nameBuilder.or(store.name.contains(keyword));
					} 
			}
			else {
				// 공백이 없으면 그냥 검색
				nameBuilder.or(store.name.contains(name));
			}
			
				builder.and(nameBuilder);
		}
		
		
		// 커서 기반 페이징 : cursor보다 큰 ID만 조회
		if(cursor != null) {
			builder.and(store.id.gt(cursor));
		}
		
		// 정렬 우선순위: 가나다 → 영어 대문자 → 영어 소문자 → 특수문자
		OrderSpecifier<?> typeSort = new OrderSpecifier<>(
			Order.ASC,
			new CaseBuilder()
				.when(store.name.mathes("[가-힣].*")).then(0)
				.when(store.name.matches("[A-Z].*")).then(1)
				.when(store.name.matches("[a-z].*")).then(2)
				.otherwise(3)  // 특수문자
			);
		// size+1을 조회하는 쿼리
		List<StoreResponseDTO> stores = queryFactory
			.select(Projections.constructor(
				StoreResponseDTO.class,
				store.id,
				store.name,
				store.rating,
				store.createdAt))
				.from(store)
				.innerJoin(store.region, region) // 지역이 있는 가게만 join
				.where(builder)
				.orderBy( // 가나다순, 최신순 순서의 우선순위로 조회
					typeSort,
					new OrderSpecifier<>(Order.ASC,store.name),
					new OrderSpecifier<>(Order.DESC,store.createdAt)
				)
				.limit(size + 1)
				.fetch();
				
				
		boolean hasNext = stores.size() > size;
		String nextCursor = null;
		if(hasNext) {
			stores = stores.subList(0,size);
			nextCurosr = stores.get(stores.size() - 1).getId().toString();
		}
		
		return CursorPageResponse.<StoreResponseDTO>builder()
			   .content(stores)
        .nextCursor(nextCursor)
        .hasNext(hasNext)
        .build();
	}

느낀 점? 고민한 점

정렬 방식에 대한 고민이 있었는데,

CaseBuilder를 사용할지, Java 정규표현식으로 거를지..

java에서 할 경우, CaseBuilder 없이 SearchCondition 클래스 안에 먼저 넣고 검색을 진행할 수가 있다. 그렇게 하면 OrderSpecifier를 sort조건에 따라 간단하게 설정할 수 있다. CaseBuilder를 다양하게 사용해보기 위해 CaseBuilder 방식을 사용했다.

profile
백엔드 개발자 지망 대학생

0개의 댓글