import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.ComponentScans;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.elasticsearch.client.ClientConfiguration;
import org.springframework.data.elasticsearch.client.elc.ElasticsearchConfiguration;
import org.springframework.data.elasticsearch.client.elc.ElasticsearchTemplate;
import org.springframework.data.elasticsearch.client.erhlc.AbstractElasticsearchConfiguration;
import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories;
@Configuration
public class ElasticConfig extends ElasticsearchConfiguration {
@Value("${spring.elastic.url}")
private String elasticUrl;
@Override
public ClientConfiguration clientConfiguration() {
return ClientConfiguration.builder()
.connectedTo(elasticUrl)
.build();
}
}
package com.yaloostore.shop.product.documents;
import com.yaloostore.shop.helper.Indices;
import lombok.*;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Date;
/**
* 엘라스틱 서치에 사용되는 인덱스 입니다.(관계형 디비 table - 엘라스틱서치 index)
* */
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToString
@Setting(settingPath = "/static/elastic/elastic-settings.json")
@Mapping(mappingPath = "/static/elastic/product-mappings.json")
@Document(indexName = Indices.PRODUCTS_INDEX)
public class SearchProduct {
@Id
@Field(name = "product_id", type = FieldType.Long)
private Long productId;
@Field(name = "product_name", type = FieldType.Text)
private String productName;
@Field(type = FieldType.Long)
private Long stock;
//ex) basic_date = 2023 01 11
@Field(name = "product_created_at", type=FieldType.Date, format = DateFormat.basic_date)
private LocalDate productCreatedAt;
@Field(type=FieldType.Text)
private String description;
@Field(name = "thumbnail_url",type=FieldType.Text)
private String thumbnailUrl;
@Field(name = "fixed_price", type=FieldType.Long)
private Long fixedPrice;
@Field(name = "raw_price", type=FieldType.Long)
private Long rawPrice;
@Field(name = "is_selled", type=FieldType.Boolean)
private Boolean isSelled;
@Field(name = "is_deleted", type=FieldType.Boolean)
private Boolean isDeleted;
@Field(name ="is_expose", type=FieldType.Boolean)
private Boolean isExpose;
@Field(name = "discount_percent", type=FieldType.Long)
private Long discountPercent;
}
package com.yaloostore.shop.helper;
/**
* 인덱스 이름을 모아둔 클래스입니다.
*/
public final class Indices {
public static final String PRODUCTS_INDEX = "yaloostore_products";
public static final String PRODUCTS_BOOKS_INDEX = "yaloostore_products_books";
}
{
"analysis": {
"analyzer": {
"korean": {
"type": "nori"
}
}
}
}
{
"properties": {
"productId": {
"type": "keyword"
},
"productName": {
"type": "text",
"analyzer": "korean"
},
"stock": {
"type": "long"
},
"productCreatedAt": {
"type": "date",
"format": "uuuuMMdd"
},
"description": {
"type": "text",
"analyzer": "korean"
},
"thumbnailUrl": {
"type": "text",
"analyzer": "korean"
},
"fixedPrice": {
"type": "long"
},
"rawPrice": {
"type": "long"
},
"isSelled": {
"type":"boolean"
},
"isDeleted": {
"type":"boolean"
},
"isExpose": {
"type":"boolean"
},
"discountPercent": {
"type": "long"
}
}
}
[settings]
[mappings]
ElasticsearchRepository
상속받은 레포지토리 사용import com.yaloostore.shop.product.repository.basic.SearchProductRepository;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import com.yaloostore.shop.product.documents.SearchProduct;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
import java.util.Optional;
/**
* 엘라스틱 서치를 사용해서 상품 검색을 위한 기본 레포지토리입니다.
* */
public interface ElasticCommonProductRepository extends ElasticsearchRepository<SearchProduct, Long> {
Page<SearchProduct> findByProductName(Pageable pageable, String productName);
Optional<SearchProduct> findById(Long productId);
}
ElasticsearchOperations
를 사용해서 원하는 조건으로 검색구현하기@RequiredArgsConstructor
@Repository
public class SearchProductRepositoryImpl implements SearchProductRepository {
// query를 받아서 elasticsearch에 요청을 보내는 역할을 한다.
private final ElasticsearchOperations elasticsearchOperations;
@Override
public Page<SearchProduct> searchProductsByProductName(String productName, Pageable pageable) {
Criteria criteria = Criteria.where("productName").contains(productName);
Query query = new CriteriaQuery(criteria).setPageable(pageable);
SearchHits<SearchProduct> search = elasticsearchOperations.search(query, SearchProduct.class);
List<SearchProduct> list = search.stream().map(SearchHit::getContent).collect(Collectors.toList());
return new PageImpl<>(list, pageable, search.getTotalHits());
}
}
import com.yaloostore.shop.product.dto.response.SearchProductResponseDto;
import com.yaloostore.shop.product.dto.transfer.SearchProductTransfer;
import com.yaloostore.shop.product.documents.SearchProduct;
import com.yaloostore.shop.product.repository.elasticSearch.common.ElasticCommonProductRepository;
import com.yaloostore.shop.product.repository.elasticSearch.impl.SearchProductRepositoryImpl;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ElasticProductServiceImpl implements ElasticProductService {
private final ElasticCommonProductRepository elasticCommonProductRepository;
private final SearchProductRepositoryImpl searchProductRepositoryImpl;
/**
* {@inheritDoc}
* */
@Override
public Page<SearchProductResponseDto> searchProductByProductName(Pageable pageable, String productName) {
Page<SearchProduct> searchProducts = searchProductRepositoryImpl.searchProductsByProductName(productName, pageable);
List<SearchProductResponseDto> response = searchProducts.stream()
.map(SearchProductTransfer::fromDocument).collect(Collectors.toList());
//cotent pageable, total
return new PageImpl<>(response, pageable, searchProducts.getTotalElements());
}
}
import com.yalooStore.common_utils.dto.ResponseDto;
import com.yaloostore.shop.common.dto.PaginationResponseDto;
import com.yaloostore.shop.product.dto.response.SearchProductResponseDto;
import com.yaloostore.shop.product.service.elasticSearch.ElasticProductService;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
/**
* 엘라스틱 서치를 사용해서 상품 검색을 하는 컨트롤러입니다.
* */
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/service/products/search")
public class ElasticProductRestController {
private final ElasticProductService elasticProductService;
/**
* [GET /api/service/products/search?productName={productName}]
* */
@GetMapping(params = "productName")
public ResponseDto<PaginationResponseDto<SearchProductResponseDto>> searchProductByProductNamePagination(@RequestParam @Size(max = 30) String productName,
@PageableDefault Pageable pageable){
Page<SearchProductResponseDto> page = elasticProductService.searchProductByProductName(pageable, productName);
return ResponseDto.<PaginationResponseDto<SearchProductResponseDto>>builder()
.success(true)
.status(HttpStatus.OK)
.data(PaginationResponseDto.<SearchProductResponseDto>builder()
.dataList(page.getContent())
.totalDataCount(page.getTotalElements())
.currentPage(page.getNumber())
.totalPage(page.getTotalPages())
.build())
.build();
}
}
@DisplayName("상품명 검색 - 성공 (단건 조회만 가능)")
@WithMockUser
@Test
void searchProductByProductNamePagination() throws Exception {
//given
Mockito.when(elasticProductService.searchProductByProductName(pageable,"test"))
.thenReturn(new PageImpl<>(List.of(responseDto), pageable, 2L));
//when, then
ResultActions result = mockMvc.perform(get("/api/service/products/search")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.param("productName", "test"));
result.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.status", equalTo(HttpStatus.OK.value())))
.andExpect(jsonPath("$.success", equalTo(true)))
.andExpect(jsonPath("$.errorMessages", equalTo(null)))
.andExpect(jsonPath("$.data.totalPage", equalTo(1)))
.andExpect(jsonPath("$.data.currentPage", equalTo(0)))
.andExpect(jsonPath("$.data.totalDataCount", equalTo(1)))
.andExpect(jsonPath("$.data.dataList[0].productId", equalTo(responseDto.getProductId().intValue())))
.andExpect(jsonPath("$.data.dataList[0].productName", equalTo(responseDto.getProductName())))
.andExpect(jsonPath("$.data.dataList[0].stock", equalTo(responseDto.getStock().intValue())))
.andExpect(jsonPath("$.data.dataList[0].productCreatedAt", equalTo(responseDto.getProductCreatedAt().toString())))
.andExpect(jsonPath("$.data.dataList[0].description", equalTo(responseDto.getDescription())))
.andExpect(jsonPath("$.data.dataList[0].thumbnailUrl", equalTo(responseDto.getThumbnailUrl())))
.andExpect(jsonPath("$.data.dataList[0].fixedPrice", equalTo(responseDto.getFixedPrice().intValue())))
.andExpect(jsonPath("$.data.dataList[0].rawPrice", equalTo(responseDto.getRawPrice().intValue())))
.andExpect(jsonPath("$.data.dataList[0].isSelled", equalTo(responseDto.getIsSelled())))
.andExpect(jsonPath("$.data.dataList[0].isDeleted", equalTo(responseDto.getIsDeleted())))
.andExpect(jsonPath("$.data.dataList[0].isExpose", equalTo(responseDto.getIsExpose())))
.andExpect(jsonPath("$.data.dataList[0].discountPercent", equalTo(responseDto.getDiscountPercent().intValue())));
//spring REST Docs 문서화 작업
result.andDo(document(
"search-product-success-product-name",
getDocumentRequest(),
getDocumentsResponse(),
queryParameters(
parameterWithName("productName").description("검색할 상품"),
parameterWithName("_csrf").description("csrf")
),
responseFields(
fieldWithPath("status").type(JsonFieldType.NUMBER).description("상태"),
fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("동작 성공 여부"),
//에러 메세지가 성공적으로 동작할 땐 Null값으로 넘어가기 때문에 이에 오류가 나지 않게 하기 위해서 optional을 붙여준다.
fieldWithPath("errorMessages").type(JsonFieldType.ARRAY)
.description("에러 메세지").optional(),
fieldWithPath("data.totalPage").type(JsonFieldType.NUMBER)
.description("검색 전체 페이지"),
fieldWithPath("data.currentPage").type(JsonFieldType.NUMBER)
.description("현재 페이지"),
fieldWithPath("data.totalDataCount").type(JsonFieldType.NUMBER)
.description("전체 데이터 갯수"),
fieldWithPath("data.dataList.[].productId").type(JsonFieldType.NUMBER).description("상품 Id"),
fieldWithPath("data.dataList.[].productName").type(JsonFieldType.STRING).description("상품명"),
fieldWithPath("data.dataList.[].stock").type(JsonFieldType.NUMBER).description("상품 수량"),
fieldWithPath("data.dataList.[].productCreatedAt").type(JsonFieldType.STRING).description("상품 등록시간"),
fieldWithPath("data.dataList.[].description").type(JsonFieldType.STRING).description("상품 상세설명"),
fieldWithPath("data.dataList.[].thumbnailUrl").type(JsonFieldType.STRING).description("상품 이미지 URL"),
fieldWithPath("data.dataList.[].fixedPrice").type(JsonFieldType.NUMBER).description("상품 할인 가격"),
fieldWithPath("data.dataList.[].rawPrice").type(JsonFieldType.NUMBER).description("상품 원가격"),
fieldWithPath("data.dataList.[].isSelled").type(JsonFieldType.BOOLEAN).description("상품 품절여부"),
fieldWithPath("data.dataList.[].isDeleted").type(JsonFieldType.BOOLEAN).description("상품 강제 삭제 여부"),
fieldWithPath("data.dataList.[].isExpose").type(JsonFieldType.BOOLEAN).description("상품 노출 여부"),
fieldWithPath("data.dataList.[].discountPercent").type(JsonFieldType.NUMBER).description("상품 할인율")
)
));
}
[해결방법]
queryParameters(
parameterWithName("productName").description("검색할 상품"),
parameterWithName("_csrf").description("csrf")
),
queryParameters
를 사용해서 쿼리스트링을 처리해야 한다.{
"success": true,
"status": 200,
"data": {
"totalPage": 1,
"currentPage": 0,
"totalDataCount": 3,
"dataList": [
{
"productId": 1,
"productName": "testtestests",
"stock": 10,
"productCreatedAt": "2023-04-06",
"description": "content",
"thumbnailUrl": "test url",
"fixedPrice": 900,
"rawPrice": 1000,
"isSelled": false,
"isDeleted": false,
"isExpose": null,
"discountPercent": 10
},
{
"productId": -1,
"productName": "test",
"stock": 10,
"productCreatedAt": "2023-04-07",
"description": "content",
"thumbnailUrl": "test url",
"fixedPrice": 900,
"rawPrice": 1000,
"isSelled": false,
"isDeleted": false,
"isExpose": null,
"discountPercent": 10
},
{
"productId": 325,
"productName": "test",
"stock": 100,
"productCreatedAt": "0022-11-04",
"description": "test 설명 주절주절 주절 주절",
"thumbnailUrl": "test url",
"fixedPrice": 1000,
"rawPrice": 1100,
"isSelled": false,
"isDeleted": false,
"isExpose": true,
"discountPercent": 10
}
]
},
"errorMessages": null
}
ElasticsearchOperations
를 사용해서 직접 만드는 경우나 다양하게 사용 가능하다.