UMC 시니어 미션 6주차 과제입니다.
프로젝트가 너무 잘되어서, PM님이 날뛰고 계십니다.
기존 기능에서, 가게를 검색하는 기능을 추가하신다고 합니다!!
사용자가 원하는 가게 정보를 쉽고 정확하게 찾을 수 있도록 검색 API를 설계하고 구현해야합니다.
검색 기능은 지역 필터, 이름 검색, 정렬 조건, 페이징을 지원해야 합니다.
지역(region) 기반 필터링 가능
예: 강남구, 도봉구, 영등포구 등
(원하시는 분들은 다중 선택 가능 기능도 추가해보세요!!)
'민트 초코' → '민트' 포함 가게 + '초코' 포함 가게'민트초코' → '민트초코' 포함 가게만 조회latest : 최신순name : 이름순page + size두 가지 방법이 있다.
@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(); 실행
// 기본 형태
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) {
}
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에 포함되어야 함!
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)
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로 할 경우, 전체 개수를 포함해야 하기 때문에 Count(*) 별도 쿼리가 추가되어서, 성능이 낭비된다.
이런 식으로 fetchCount(), fetch() 총 두번의 쿼리가 실행된다.
// count 별도 쿼리가 추가됨
queryFactory
.selectFrom(store)
.where(...)
.offset(0)
.limit(20)
.fetchCount();
slice는 다음 페이지가 있는지 여부만을 체크함
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() 결과 여러 개, 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;
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 방식을 사용했다.