package com.example.knittdaserver.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.ToString;
@Entity
@Getter
@ToString
public class Design {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Size(max=255)
private String title;
@Size(max=255)
private String designer;
@Size(max = 50)
@Column(name = "price", length = 50)
private String price;
@Size(max = 500)
@Column(name = "image_url", length = 500)
private String imageUrl;
@Size(max = 500)
@Column(name = "detail_url", length = 500, unique = true)
private String detailUrl;
@Size(max = 255)
private String categories;
@Size(max = 255)
private String tools;
@Lob
private String sizes;
@Lob
private String gauge;
@Lob
private String needles;
@Lob
@Column(name = "yarn_info", columnDefinition = "TEXT")
private String yarnInfo;
@Size(max = 50)
private String pages;
}
@GetMapping("/title")
public List<DesignDto> searchByTitle(@RequestParam String title) {
return designQueryService.searchByTitle(title);
}
@GetMapping("/designer")
public List<DesignDto> searchByDesigner(@RequestParam String designer) {
return designQueryService.searchByDesigner(designer);
}
@GetMapping
public List<DesignDto> searchByTitleAndDesigner(@RequestParam String title, @RequestParam String designer) {
return designQueryService.searchByTitleAndDesigner(title, designer);
}
title 검색, designer 검색, tilte + designer 검색 세 가지 경우마다 controller를 제작하였다.
public List<DesignDto> searchByTitle(String title) {
return designRepository.searchByTitle(title);
}
public List<DesignDto> searchByDesigner(String designer) {
return designRepository.searchByDesigner(designer);
}
public List<DesignDto> searchByTitleAndDesigner(String title, String designer) {
return designRepository.searchByTitleAndDesigner(title, designer);
}
@Query(
"select new com.example.knittdaserver.dto.DesignDto(d.title, d.designer, d.needles, d.yarnInfo)" +
"from Design d where d.title like %:title%"
)
List<DesignDto> searchByTitle(@Param("title") String title);
@Query(
"select new com.example.knittdaserver.dto.DesignDto(d.title, d.designer, d.needles, d.yarnInfo)" +
"from Design d where d.designer like %:designer%"
)
List<DesignDto> searchByDesigner(@Param("designer") String designer);
@Query(
"select new com.example.knittdaserver.dto.DesignDto(d.title, d.designer, d.needles, d.yarnInfo)" +
"from Design d where d.title like %:title% and d.designer like %:designer%"
)
List<DesignDto> searchByTitleAndDesigner(@Param("title") String title,
@Param("designer") String designer);
앞선 세 가지 유형의 메소드를 통합해 search()로 controller 단에서 받고, service에서 StringUtils.hasText()를 통해 Validation이 이루어지도록 설계를 변경하였다.
@Slf4j
@RestController
@RequestMapping("/v1/designs")
@RequiredArgsConstructor
public class DesignController {
private final DesignQueryService designQueryService;
@GetMapping
public List<DesignDto> search(@RequestParam(required = false) String title, @RequestParam(required = false) String designer) {
Long startTime = System.currentTimeMillis();
log.info("Searching designs with title {} and designer {}", title, designer);
List<DesignDto> result = designQueryService.search(title, designer);
log.info("Result count {}, Search completed in {} ms ", result.size() ,System.currentTimeMillis() - startTime);
return result;
}
}
@Service
@RequiredArgsConstructor
public class DesignQueryService {
private final DesignRepository designRepository;
public List<DesignDto> search(String title, String designer) {
if (StringUtils.hasText(title) && StringUtils.hasText(designer)) {
return designRepository.searchByTitleAndDesigner(title, designer);
}
if (StringUtils.hasText(title)) {
return designRepository.searchByTitle(title);
}
if (StringUtils.hasText(designer)) {
return designRepository.searchByDesigner(designer);
}
// 아무것도 입력하지 않은 경우, 전체 조회
return designRepository.searchAll();
}
}
package com.example.knittdaserver.repository;
import com.example.knittdaserver.dto.DesignDto;
import com.example.knittdaserver.entity.Design;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface DesignRepository extends JpaRepository<Design, Long> {
@Query(
"select new com.example.knittdaserver.dto.DesignDto(d.title, d.designer, d.needles, d.yarnInfo)" +
"from Design d where d.title like %:title%"
)
List<DesignDto> searchByTitle(@Param("title") String title);
@Query(
"select new com.example.knittdaserver.dto.DesignDto(d.title, d.designer, d.needles, d.yarnInfo)" +
"from Design d where d.designer like %:designer%"
)
List<DesignDto> searchByDesigner(@Param("designer") String designer);
@Query(
"select new com.example.knittdaserver.dto.DesignDto(d.title, d.designer, d.needles, d.yarnInfo)" +
"from Design d where d.title like %:title% and d.designer like %:designer%"
)
List<DesignDto> searchByTitleAndDesigner(@Param("title") String title,
@Param("designer") String designer);
@Query("select new com.example.knittdaserver.dto.DesignDto(d.title, d.designer, d.needles, d.yarnInfo) from Design d")
List<DesignDto> searchAll();
}
title, designer 두 개의 입력창을 받았던 v1.x 와 달리, 이번에는 입력창 하나로 검색을 동일하게 진행할 수 있도록 하였다.
단일 입력창 내에서 두 개의 키워드 (title + designer)의 조합으로 입력하도록 하였다.
@GetMapping
public List<DesignDto> searchV2(@RequestParam(required = false) String keyword) {
return designQueryService.searchV2(keyword);
}
public List<DesignDto> searchV2(String keyword) {
String[] keywords = keyword.trim().split("\\s+");
log.info("Searching for " + Arrays.toString(keywords) + " designs" );
if (keywords.length > 2) {
throw new IllegalArgumentException("최대 2개의 키워드만 입력 가능합니다.");
}
if (keywords.length == 1) {
return designRepository.searchSingleKeyword(keywords[0]);
}
if (keywords.length == 2) {
return designRepository.searchTwoKeywords(keywords[0], keywords[1]);
}
return designRepository.searchAll();
}
@Query("select new com.example.knittdaserver.dto.DesignDto(d.title, d.designer, d.needles, d.yarnInfo) from Design d")
List<DesignDto> searchAll();
@Query("""
select new com.example.knittdaserver.dto.DesignDto(d.title, d.designer, d.needles, d.yarnInfo)
from Design d where (d.title like %:keyword%) or (d.designer like %:keyword%)
""")
List<DesignDto> searchSingleKeyword(@Param("keyword") String keyword);
@Query("""
select distinct new com.example.knittdaserver.dto.DesignDto(d.title, d.designer, d.needles, d.yarnInfo)
from Design d
where
(d.title like %:kw1% and d.designer like %:kw2%)
or (d.title like %:kw2% and d.designer like %:kw1%)
or (d.title like %:kw1% or d.title like %:kw2%)
or (d.designer like %:kw1% or d.designer like %:kw2%)
""")
List<DesignDto> searchTwoKeywords(@Param("kw1") String kw1, @Param("kw2") String kw2 );
v2.0에서의 문제는 '스웨터 이지수'의 검색결과 내 우선순위가 없다는 점이다.
검색결과 (keyword: 스웨터 이지수)
(title, designer)
1. (스웨터, 이지수)
2. (스웨터, 정혜영)
3. (가디건, 이지수)
4. (브라운 스웨터, 정혜영)
해당 도안 내 우선순위를 정렬하였다.
1순위. kw1, kw2가 각각 title, designer인 경우
2순위. kw1 또는 kw2가 title에 속하거나 designer에 속한 경우
쿼리 상으로는 다음과 같다
select distinct new com.example.knittdaserver.dto.DesignDto(d.title, d.designer, d.needles, d.yarnInfo)
from Design d
where d.title like %:kw1% or d.designer like %:kw1% or d.title like %:kw2% or d.designer like %:kw2%
order by
case
when (d.title like %:kw1% and d.designer like %:kw2%) or (d.title like %:kw2% and d.designer like %:kw1%) then 1
when (d.title like %:kw1% or d.title like %:kw2%) or (d.designer like %:kw1% or d.designer like %:kw2%) then 2
else 3
end
@Query("""
select distinct new com.example.knittdaserver.dto.DesignDto(d.title, d.designer, d.needles, d.yarnInfo)
from Design d
where d.title like %:kw1% or d.designer like %:kw1% or d.title like %:kw2% or d.designer like %:kw2%
order by
case
when (d.title like %:kw1% and d.designer like %:kw2%) or (d.title like %:kw2% and d.designer like %:kw1%) then 1
when (d.title like %:kw1% or d.title like %:kw2%) or (d.designer like %:kw1% or d.designer like %:kw2%) then 2
else 3
end
""")
List<DesignDto> searchTwoKeywords(@Param("kw1") String kw1, @Param("kw2") String kw2 );
kw1(니트) kw2(김대리)로 검색하였을 때, title과 designer를 모두 만족하는 도안이 맨 앞에 위치하는 것을 알 수 있다.
키워드는 2개까지만 받을 수 있다는 솔루션의 문제 사항을 v3에서 수정해보겠다.
기존 방식대로 JPA를 사용한다면 N개의 키워드를 받았을 때
1. kw1이 title 또는 designer에 포함
2. kw2이 title 또는 designer에 포함
...
n. kwn이 title 또는 designer에 포함
정적 쿼리를 수행하는 총 n개의 searchByKeyword 메소드를 설정해야 한다.
뿐만 아니라 검색 결과 내 우선순위를 정하기 위해서는
1. kw1이 title, kw2이 designer에 포함
....
이렇게 많은 메소드가 Repository에 정의되어야 하는 것이다.
초기 build를 통해 Q모델 생성을 해야 한다는 게, 첫 진입 장벽이었으나 생각보다 수월히 진행되었다.
오히려 QueryDSL의 구조, 메소드 체이닝 문법을 잘 몰라서 헤매이곤 했다.
기존에는 DesignRepository interface에 작업하였으나,QueryDSL을 사용해 보다 복잡한 쿼리를 정의하기 위해
DesignRepositoryCustom interface, DesignRepositoryImpl class를 생성하였다.
Service 계층에서는 동일하게 DesignRepository를 사용하나, 그 안에 JPA에서 제공하는 메소드 + QueryDSL을 활용한 동적 쿼리가 합쳐진 형태이다.
public interface DesignRepositoryCustom {
List<DesignDto> searchByKeyword(List<String> keywords);
}
package com.example.knittdaserver.repository;
@RequiredArgsConstructor
public class DesignRepositoryImpl implements DesignRepositoryCustom{
private final JPAQueryFactory queryFactory;
@Override
public List<DesignDto> searchByKeyword(List<String> keywords) {
//....
}
QueryDSL에서는 JPAQueryFactory와 QDesign을 사용해야 한다.
package com.example.knittdaserver.repository;
//....
@Override
public List<DesignDto> searchByKeyword(List<String> keywords) {
QDesign design = QDesign.design;
BooleanBuilder builder = new BooleanBuilder();
CaseBuilder caseBuilder = new CaseBuilder();
for (String keyword : keywords) {
builder.or(design.title.contains(keyword))
.or(design.designer.contains(keyword));
}
// CASE WHEN으로 매칭된 키워드 수 계산
NumberExpression<Integer> score = Expressions.numberTemplate(Integer.class, "0");
for (String keyword : keywords) {
score = score.add(
caseBuilder
.when(design.title.contains(keyword))
.then(1)
.otherwise(0)
).add(
caseBuilder
.when(design.designer.contains(keyword))
.then(1)
.otherwise(0)
);
}
return queryFactory
.select(
Projections.constructor(
DesignDto.class,
design.title,
design.designer,
design.needles,
design.yarnInfo)
)
.distinct()
.from(design)
.where(builder)
.orderBy(score.desc())
.fetch();
}
}
@GetMapping("/v3")
public List<DesignDto> searchV3(@RequestParam(required = false) String keyword) {
Long startTime = System.currentTimeMillis();
List<DesignDto> result = designQueryService.searchV3(keyword);
log.info("completed in {}ms, size:{}", System.currentTimeMillis() - startTime, result.size());
return result;
}
public List<DesignDto> searchV3(String keyword) {
String[] keywords = keyword.trim().split("\\s+");
if (keywords.length == 0) {
return designRepository.searchAll();
}
return designRepository.searchByKeyword(List.of(keywords));
}
@RequiredArgsConstructor
public class DesignRepositoryImpl implements DesignRepositoryCustom{
private final JPAQueryFactory queryFactory;
@Override
public List<DesignDto> searchByKeyword(List<String> keywords) {
QDesign design = QDesign.design;
BooleanBuilder builder = new BooleanBuilder();
CaseBuilder caseBuilder = new CaseBuilder();
for (String keyword : keywords) {
builder.or(design.title.contains(keyword))
.or(design.designer.contains(keyword));
}
// CASE WHEN으로 매칭된 키워드 수 계산
NumberExpression<Integer> score = Expressions.numberTemplate(Integer.class, "0");
for (String keyword : keywords) {
score = score.add(
caseBuilder
.when(design.title.contains(keyword))
.then(1)
.otherwise(0)
).add(
caseBuilder
.when(design.designer.contains(keyword))
.then(1)
.otherwise(0)
);
}
return queryFactory
.select(
Projections.constructor(
DesignDto.class,
design.title,
design.designer,
design.needles,
design.yarnInfo)
)
.distinct()
.from(design)
.where(builder)
.orderBy(score.desc())
.fetch();
}
}
3개 이상의 keyword 상황에서도 정상적으로 조회된다.
그 중에서 김대리 가디건 도안 3가지의 keyword를 모두 만족시키는 도안이 가장 상단에 위치한다.
맨 처음 검색 기능을 시작하며 생각한 여러 버전을, 반 이상 완성해보았다.
기존에는 여러 버전 간 성능 비교를 진행하려 했으나, 키워드 갯수를 제한할 수 없기에 솔루션 간 비교가 불필요해 보인다.
현재 방식의 문제점을 떠올려보자면
1. 테이블 전체를 탐색 (비효율적)
2. 공백 기준 split 이기에 띄어쓰기가 없는 경우 처리 불가
! 이후 mysql의 Full-Text Index, 형태소 분석, elastic search를 통한 업데이트도 시도해 보아야겠다.