쇼핑몰 만들기 프로젝트 - 엘라스틱 서치(elasticsearch)와 스프링부트 연동해보자

yeom yaloo·2023년 4월 4일
1


이전의 포스팅


스프링 환경에서 사용하는 엘라스틱 서치

1. elasticsearch configuration

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();
    }


}
  • application.properties에 저장해준 elastic url을 @Value 애노테이션으로 불러와서 설정해줍니다.

@document

  • Document는
  • 기존 Entity를 사용해서 검색 작업을 진행하게 되면 충돌 문제가 생긴다고 한다. 이를 방지하기 위해서 해당 검색에 사용될 document를 만들자.
  • Entity를 엘라스틱 서치에 사용할 예정이 아니라면 똑같이 그대로 @Document 애노테이션만 달고 진행해주면 된다.
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;


}
  • 여기서 주의해야 할 점은 indexName에 절대 대문자가 들어가서는 안 된다는 점이다.
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";
}
  • 인덱스 네임은 꼭 소문자로만 작성하고 카멜케이스 대신 스네이크 케이스를 써야하는듯하다!

Setting


{
  "analysis": {
    "analyzer": {
      "korean": {
        "type": "nori"
      }
    }
  }
}
  • nori 설정을 이곳에서 진행

mapping

{
  "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"
    }
  }

}
  • analyzer은 위의 setting.json 파일에서 진행
  • Keyword의 경우엔 노리 적용 불가(text만 가능)
  • setting & maaping 파일은 실행 후에 수정 불가

제대로 세팅, 매핑 됐는지 확인하기

[settings]

[mappings]

엘라스틱 서치를 사용한 레이어드 아키텍처 구현하기(repository, service, controller)

Repository

  • 엘라스틱서치 관련 repository 구현 방식 두가지를 소개하고자 한다.
  1. 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);

}
  • JpaRepository를 상속받아 사용함과 똑같이 정해준 메소드명 규칙만 지키면 해당하는 기능을 사용할 수 있다.
  1. 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());
    }


}
  • Criteria, NativeQuery.. 등으로 쿼리문 작성해서 넘겨주면 해당하는 검색 정보를 캡슐화해서 SearchHits라는 객체로 돌려준다. 이를 이용해서 해당 데이터를 가져오고 하는 방식으로 진행하며 된다.

Service


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());
    }

}

Controller


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();

    }
}

Test

 @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("상품 할인율")
                )
        ));

    }

Test 작업 중 있던 에러

  1. org.springframework.restdocs.snippet.SnippetException: Query parameters with the following names were not documented: [_csrf]: 스프링시큐리티는 csrf 방어를 위해서 테스트 작업에도 csrf 토큰을 필요로한다.
    이때 ResultAction 설정에 with(csrf())를 해주었다면 _csrf가 파라미터로 넘어간다.
    문서화 작업할 때 이를 파라미터로 같이 넘겨주면 해결 된다.
    파라미터로 넘어가는 _csrf 토큰

[해결방법]

queryParameters(
	parameterWithName("productName").description("검색할 상품"),
    parameterWithName("_csrf").description("csrf")
),
  1. requestParameter을 대신해서 queryParameter로 requestParam으로 받은 매개변수를 처리하자
  • 요청 매개변수 문서화가 3.0.0 버전부턴 조금 달라졌다 requestParameters로 쿼리스트링을 처리했다면 3.0.0버전에는 queryParameters를 사용해서 쿼리스트링을 처리해야 한다.
    본인은 컨트롤러에서 RequestParam으로 넘겨받아서 queryParamters를 사용한 것이다.
  • pathVariable의 경우라면 pathParameters()를 사용하자

추가사항

  • 23.4.8 - 해당 레포지토리에서 검색하는 기능 구현을 했는데 이는 완전하게 일치하는 것 뿐만 아니라 해당 검색어를 포함한 모든 내용이 출력됩니다.
{
    "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
}
profile
즐겁고 괴로운 개발😎

0개의 댓글