Skip to content

Commit 7201df1

Browse files
authored
[Feat]: Elasticsearch 정렬 개선 및 유사어 검색 기능 구현 (#181)
* [Refactor]: 회원 기반 상품 목록 조회는 elasticsearch 미사용 # Conflicts: # src/main/java/com/backend/domain/product/service/ProductSearchService.java # src/test/java/com/backend/domain/product/service/ProductSearchServiceTest.java * [Refactor]: 정렬 로직 개선 * [Fix]: 테스트 오류 수정 * [Test]: default 정렬 test * [Feat]: 유사어 검색 * [Refactor]: 복구, synonyms.txt 개선 * [Feat]: 사용자 사전 * [Feat]: reload search analyzers � Conflicts: � src/main/java/com/backend/domain/product/service/ProductSearchService.java * [Test]: test에 유사어 검색 적용 * [Feat]: 배포 설정 # Conflicts: # .github/workflows/deploy.yml # Conflicts: # .github/workflows/deploy.yml * [Docs]: swagger 적용 * [Docs]: swagger 적용 * [Fix]: ReviewControllerTest import 경로 수정 * [Rename]: 재인덱싱을 인덱싱으로 네이밍 수정 * [Fix]: 배포 반영되지 않게 수정
1 parent 42af388 commit 7201df1

24 files changed

+459
-577
lines changed

.github/workflows/deploy.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,14 @@ jobs:
239239
-e JWT_SECRET="${JWT_SECRET}" \
240240
-e SPRING_DATASOURCE_URL___DB_NAME="${SPRING_DATASOURCE_URL___DB_NAME}" \
241241
-v /bid_data:/data \
242+
# -v /bid_es_config/analysis:/es-dict \
242243
"${IMAGE}"
244+
245+
# 컨테이너에서 호스트로 사전 파일 복사
246+
# docker cp "${GREEN}":/es-dict/user_dictionary.txt /bid_es_config/analysis/user_dictionary.txt
247+
# docker cp "${GREEN}":/es-dict/synonyms.txt /bid_es_config/analysis/synonyms.txt
248+
# chmod 644 /bid_es_config/analysis/*.txt
249+
# chown 1000:1000 /bid_es_config/analysis/*.txt
243250
244251
# ---------------------------------------------------------
245252
# 5) 헬스체크 (/actuator/health 200 OK까지 대기)

Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ WORKDIR /app
2828
COPY --from=builder /app/build/libs/*.jar app.jar
2929
COPY --from=builder /app/.env .env
3030

31+
# Elasticsearch 사전 파일을 별도 경로로 복사
32+
# COPY --from=builder /app/src/main/resources/elasticsearch/user_dictionary.txt /es-dict/user_dictionary.txt
33+
# COPY --from=builder /app/src/main/resources/elasticsearch/synonyms.txt /es-dict/synonyms.txt
34+
3135
RUN mkdir /data
3236

3337
# 실행할 JAR 파일 지정

docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ services:
2525
- "9300:9300"
2626
volumes:
2727
- es_data:/usr/share/elasticsearch/data
28+
- ./src/main/resources/elasticsearch/synonyms.txt:/usr/share/elasticsearch/config/analysis/synonyms.txt:ro
29+
- ./src/main/resources/elasticsearch/user_dictionary.txt:/usr/share/elasticsearch/config/analysis/user_dictionary.txt:ro
2830
restart: unless-stopped
2931
networks:
3032
- elastic

src/main/java/com/backend/domain/product/controller/ApiV1ProductController.java

Lines changed: 9 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@
66
import com.backend.domain.product.dto.ProductSearchDto;
77
import com.backend.domain.product.dto.request.ProductCreateRequest;
88
import com.backend.domain.product.dto.request.ProductModifyRequest;
9-
import com.backend.domain.product.dto.response.MyProductListItemDto;
10-
import com.backend.domain.product.dto.response.ProductListByMemberItemDto;
11-
import com.backend.domain.product.dto.response.ProductListItemDto;
12-
import com.backend.domain.product.dto.response.ProductResponse;
9+
import com.backend.domain.product.dto.response.*;
1310
import com.backend.domain.product.entity.Product;
1411
import com.backend.domain.product.enums.AuctionStatus;
1512
import com.backend.domain.product.enums.ProductSearchSortType;
@@ -121,7 +118,7 @@ public RsData<PageDto<ProductListItemDto>> getProductsByElasticsearch(
121118
@RequestParam(required = false) String[] location,
122119
@RequestParam(required = false) Boolean isDelivery,
123120
@RequestParam(defaultValue = "BIDDING") AuctionStatus status,
124-
@RequestParam(defaultValue = "LATEST") ProductSearchSortType sort
121+
@RequestParam(required = false) ProductSearchSortType sort
125122
) {
126123
ProductSearchDto search = new ProductSearchDto(keyword, category, location, isDelivery, status);
127124
Page<ProductDocument> products = productSearchService.searchProducts(page, size, sort, search);
@@ -229,27 +226,6 @@ public RsData<PageDto<MyProductListItemDto>> getMyProducts(
229226
return RsData.ok("내 상품 목록이 조회되었습니다", response);
230227
}
231228

232-
/**
233-
* 내 상품 목록 조회 (Elasticsearch 기반)
234-
* - Elasticsearch를 활용한 빠른 조회
235-
* - 낙찰자 및 리뷰 정보는 RDB에서 별도 조회
236-
*/
237-
@GetMapping("/es/me")
238-
@Transactional(readOnly = true)
239-
public RsData<PageDto<MyProductListItemDto>> getMyProductsByElasticsearch(
240-
@RequestParam(defaultValue = "1") int page,
241-
@RequestParam(defaultValue = "20") int size,
242-
@RequestParam(defaultValue = "SELLING") SaleStatus status,
243-
@RequestParam(defaultValue = "LATEST") ProductSearchSortType sort,
244-
@AuthenticationPrincipal User user
245-
) {
246-
Member actor = memberService.findMemberByEmail(user.getUsername());
247-
Page<ProductDocument> products = productSearchService.searchProductsByMember(page, size, sort, actor, status);
248-
249-
PageDto<MyProductListItemDto> response = productMapper.toMyListResponseFromDocument(products);
250-
return RsData.ok("내 상품 목록이 조회되었습니다", response);
251-
}
252-
253229
/**
254230
* 특정 회원의 상품 목록 조회 (RDB 기반)
255231
* - 다른 회원이 등록한 상품 목록 조회
@@ -277,24 +253,13 @@ public RsData<PageDto<ProductListByMemberItemDto>> getProductsByMember(
277253
}
278254

279255
/**
280-
* 특정 회원의 상품 목록 조회 (Elasticsearch 기반)
281-
* - Elasticsearch를 활용한 빠른 조회
282-
* - 리뷰 정보는 RDB에서 별도 조회
256+
* Elasticsearch 검색 분석기 재로드
257+
* 사용자 사전, 동의어 사전 변경 후 호출 필요
258+
* TODO: 관리자만 접근 가능하도록 변경 필요
283259
*/
284-
@GetMapping("/es/members/{memberId}")
285-
@Transactional(readOnly = true)
286-
public RsData<PageDto<ProductListByMemberItemDto>> getProductsByMemberAndElasticsearch(
287-
@PathVariable Long memberId,
288-
@RequestParam(defaultValue = "1") int page,
289-
@RequestParam(defaultValue = "20") int size,
290-
@RequestParam(defaultValue = "SELLING") SaleStatus status,
291-
@RequestParam(defaultValue = "LATEST") ProductSearchSortType sort
292-
) {
293-
Member actor = memberService.findById(memberId).orElseThrow(ProductException::memberNotFound);
294-
295-
Page<ProductDocument> products = productSearchService.searchProductsByMember(page, size, sort, actor, status);
296-
297-
PageDto<ProductListByMemberItemDto> response = productMapper.toListByMemberResponseFromDocument(products);
298-
return RsData.ok("%d번 회원 상품 목록이 조회되었습니다".formatted(memberId), response);
260+
@PostMapping("/reload-analyzers")
261+
// @PreAuthorize("hasRole('ADMIN')")
262+
public RsData<ReloadAnalyzersResponse> reloadSearchAnalyzers() {
263+
return productSearchService.reloadSearchAnalyzers();
299264
}
300265
}

src/main/java/com/backend/domain/product/controller/ApiV1ProductControllerDocs.java

Lines changed: 6 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,12 @@
22

33
import com.backend.domain.product.dto.request.ProductCreateRequest;
44
import com.backend.domain.product.dto.request.ProductModifyRequest;
5-
import com.backend.domain.product.dto.response.MyProductListItemDto;
6-
import com.backend.domain.product.dto.response.ProductListByMemberItemDto;
7-
import com.backend.domain.product.dto.response.ProductListItemDto;
8-
import com.backend.domain.product.dto.response.ProductResponse;
5+
import com.backend.domain.product.dto.response.*;
96
import com.backend.domain.product.enums.AuctionStatus;
107
import com.backend.domain.product.enums.ProductSearchSortType;
118
import com.backend.domain.product.enums.SaleStatus;
12-
import com.backend.global.response.RsData;
139
import com.backend.global.page.dto.PageDto;
10+
import com.backend.global.response.RsData;
1411
import io.swagger.v3.oas.annotations.Operation;
1512
import io.swagger.v3.oas.annotations.Parameter;
1613
import io.swagger.v3.oas.annotations.media.Content;
@@ -151,22 +148,6 @@ RsData<PageDto<MyProductListItemDto>> getMyProducts(
151148
);
152149

153150

154-
@Operation(summary = "내 상품 조회 (Elasticsearch)", description = "Elasticsearch를 사용하여 내가 올린 상품들을 조회합니다.")
155-
@ApiResponses(value = {
156-
@ApiResponse(responseCode = "200", description = "내 상품 조회 성공",
157-
content = @Content(schema = @Schema(implementation = RsData.class))),
158-
@ApiResponse(responseCode = "401", description = "인증 실패",
159-
content = @Content(schema = @Schema(implementation = RsData.class)))
160-
})
161-
RsData<PageDto<MyProductListItemDto>> getMyProductsByElasticsearch(
162-
@Parameter(description = "페이지 번호 (1부터 시작)") @RequestParam(defaultValue = "1") int page,
163-
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size,
164-
@Parameter(description = "판매 상태") @RequestParam(defaultValue = "SELLING") SaleStatus status,
165-
@Parameter(description = "정렬 기준") @RequestParam(defaultValue = "LATEST") ProductSearchSortType sort,
166-
@Parameter(description = "로그인 회원") @AuthenticationPrincipal User user
167-
);
168-
169-
170151
@Operation(summary = "특정 회원 상품 조회", description = "특정 회원이 올린 상품들을 조회합니다.")
171152
@ApiResponses(value = {
172153
@ApiResponse(responseCode = "200", description = "특정 회원 상품 조회 성공",
@@ -183,18 +164,12 @@ RsData<PageDto<ProductListByMemberItemDto>> getProductsByMember(
183164
);
184165

185166

186-
@Operation(summary = "특정 회원 상품 조회 (Elasticsearch)", description = "Elasticsearch를 사용하여 특정 회원이 올린 상품들을 조회합니다.")
167+
@Operation(summary = "Elasticsearch 검색 분석기 재로드", description = "Elasticsearch 검색 분석기를 재로드합니다.")
187168
@ApiResponses(value = {
188-
@ApiResponse(responseCode = "200", description = "특정 회원 상품 조회 성공",
169+
@ApiResponse(responseCode = "200", description = "검색 분석기 재로드 성공",
189170
content = @Content(schema = @Schema(implementation = RsData.class))),
190-
@ApiResponse(responseCode = "404", description = "회원을 찾을 수 없음",
171+
@ApiResponse(responseCode = "500", description = "검색 분석기 재로드 실패",
191172
content = @Content(schema = @Schema(implementation = RsData.class)))
192173
})
193-
RsData<PageDto<ProductListByMemberItemDto>> getProductsByMemberAndElasticsearch(
194-
@Parameter(description = "회원 ID", required = true) @PathVariable Long memberId,
195-
@Parameter(description = "페이지 번호 (1부터 시작)") @RequestParam(defaultValue = "1") int page,
196-
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size,
197-
@Parameter(description = "판매 상태") @RequestParam(defaultValue = "SELLING") SaleStatus status,
198-
@Parameter(description = "정렬 기준") @RequestParam(defaultValue = "LATEST") ProductSearchSortType sort
199-
);
174+
RsData<ReloadAnalyzersResponse> reloadSearchAnalyzers();
200175
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.backend.domain.product.dto.response;
2+
3+
import co.elastic.clients.elasticsearch.indices.reload_search_analyzers.ReloadDetails;
4+
import jakarta.validation.constraints.NotNull;
5+
6+
import java.time.LocalDateTime;
7+
import java.util.List;
8+
9+
public record ReloadAnalyzersResponse(
10+
@NotNull Boolean success,
11+
@NotNull List<ReloadDetails> reloadedNodes,
12+
@NotNull LocalDateTime timestamp
13+
) {}

src/main/java/com/backend/domain/product/mapper/ProductMapper.java

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,8 @@
66
import com.backend.domain.product.dto.response.ProductListByMemberItemDto;
77
import com.backend.domain.product.dto.response.ProductListItemDto;
88
import com.backend.domain.product.dto.response.ProductResponse;
9-
import com.backend.domain.product.dto.response.component.BidderDto;
10-
import com.backend.domain.product.dto.response.component.ReviewDto;
119
import com.backend.domain.product.dto.response.component.SellerDto;
1210
import com.backend.domain.product.entity.Product;
13-
import com.backend.domain.product.service.ProductService;
1411
import com.backend.global.page.dto.PageDto;
1512
import lombok.RequiredArgsConstructor;
1613
import org.springframework.data.domain.Page;
@@ -28,7 +25,6 @@
2825
@RequiredArgsConstructor
2926
public class ProductMapper {
3027
private final MemberService memberService;
31-
private final ProductService productService;
3228

3329
// ======================================= Entity → Response 변환 ======================================= //
3430
// Product -> ProductResponse (상품 생성, 상세 조회, 수정)
@@ -56,16 +52,6 @@ public PageDto<ProductListItemDto> toListResponseFromDocument(Page<ProductDocume
5652
return mapToPageDtoFromDocuments(products, doc -> ProductListItemDto.fromDocument(doc, getSellerDto(doc)));
5753
}
5854

59-
// Page<ProductDocument> -> PageDto<MyProductListItemDto> (내 상품 목록 조회 - ElasticSearch)
60-
public PageDto<MyProductListItemDto> toMyListResponseFromDocument(Page<ProductDocument> products) {
61-
return mapToPageDtoFromDocuments(products, doc -> MyProductListItemDto.fromDocument(doc, getBidderDto(doc), getReviewDto(doc)));
62-
}
63-
64-
// Page<ProductDocument> -> PageDto<ProductListByMemberItemDto> (특정 회원의 상품 목록 조회 - ElasticSearch)
65-
public PageDto<ProductListByMemberItemDto> toListByMemberResponseFromDocument(Page<ProductDocument> products) {
66-
return mapToPageDtoFromDocuments(products, doc -> ProductListByMemberItemDto.fromDocument(doc, getReviewDto(doc)));
67-
}
68-
6955

7056
// ======================================= 헬퍼 메서드 ======================================= //
7157
// Page<Product> -> PageDto<T>
@@ -84,16 +70,4 @@ private SellerDto getSellerDto(ProductDocument document) {
8470
.map(SellerDto::fromEntity)
8571
.orElse(null);
8672
}
87-
88-
// ProductDocument -> BidderDto (내 상품 목록 조회 - Elasticsearch)
89-
private BidderDto getBidderDto(ProductDocument document) {
90-
Product product = productService.findById(document.getProductId()).get();
91-
return BidderDto.fromEntity(product.getBidder());
92-
}
93-
94-
// ProductDocument -> ReviewDto (내 상품 목록 조회, 특정 회원의 상품 목록 조회 - Elasticsearch)
95-
private ReviewDto getReviewDto(ProductDocument document) {
96-
Product product = productService.findById(document.getProductId()).get();
97-
return ReviewDto.fromEntity(product.getReview());
98-
}
9973
}

src/main/java/com/backend/domain/product/repository/elasticsearch/ProductElasticRepositoryCustom.java

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,9 @@
22

33
import com.backend.domain.product.document.ProductDocument;
44
import com.backend.domain.product.dto.ProductSearchDto;
5-
import com.backend.domain.product.enums.SaleStatus;
65
import org.springframework.data.domain.Page;
76
import org.springframework.data.domain.Pageable;
87

98
public interface ProductElasticRepositoryCustom {
109
Page<ProductDocument> searchProducts(Pageable pageable, ProductSearchDto search);
11-
12-
Page<ProductDocument> searchProductsByMember(Pageable pageable, Long actorId, SaleStatus status);
1310
}

src/main/java/com/backend/domain/product/repository/elasticsearch/ProductElasticRepositoryImpl.java

Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import com.backend.domain.product.dto.ProductSearchDto;
99
import com.backend.domain.product.enums.DeliveryMethod;
1010
import com.backend.domain.product.enums.ProductCategory;
11-
import com.backend.domain.product.enums.SaleStatus;
1211
import lombok.RequiredArgsConstructor;
1312
import org.springframework.data.domain.Page;
1413
import org.springframework.data.domain.PageImpl;
@@ -46,18 +45,8 @@ public Page<ProductDocument> searchProducts(Pageable pageable, ProductSearchDto
4645
// 필터 적용
4746
applyFilters(boolQuery, search);
4847

49-
return createPagedQuery(boolQuery, pageable);
50-
}
51-
52-
@Override
53-
public Page<ProductDocument> searchProductsByMember(Pageable pageable, Long actorId, SaleStatus status) {
54-
BoolQuery.Builder boolQuery = new BoolQuery.Builder();
55-
56-
// 필터 적용
57-
if (actorId != null) boolQuery.filter(f -> f.term(t -> t.field("sellerId").value(actorId)));
58-
if (status != null) boolQuery.filter(f -> f.term(t -> t.field("status").value(status.getDisplayName())));
59-
60-
return createPagedQuery(boolQuery, pageable);
48+
boolean hasKeyword = search.keyword() != null && !search.keyword().isBlank();
49+
return createPagedQuery(boolQuery, pageable, hasKeyword);
6150
}
6251

6352

@@ -72,9 +61,9 @@ public Page<ProductDocument> searchProductsByMember(Pageable pageable, Long acto
7261
* @param pageable 페이징 정보
7362
* @return 페이징된 검색 결과
7463
*/
75-
private Page<ProductDocument> createPagedQuery(BoolQuery.Builder boolQuery, Pageable pageable) {
64+
private Page<ProductDocument> createPagedQuery(BoolQuery.Builder boolQuery, Pageable pageable, boolean hasKeyword) {
7665
// 정렬 적용
77-
List<SortOptions> sortOptions = applySorting(pageable.getSort());
66+
List<SortOptions> sortOptions = applySorting(pageable.getSort(), hasKeyword);
7867

7968
// 검색 쿼리 생성
8069
Query query = NativeQuery.builder()
@@ -158,30 +147,33 @@ private void applyFilters(BoolQuery.Builder boolQuery, ProductSearchDto search)
158147
* @param sort Spring Data Sort
159148
* @return Elasticsearch SortOptions 리스트
160149
*/
161-
private List<SortOptions> applySorting(Sort sort) {
150+
private List<SortOptions> applySorting(Sort sort, boolean hasKeyword) {
162151
List<SortOptions> sortOptions = new ArrayList<>();
163152

164-
// Sort의 각 Order를 SortOptions로 변환
165-
for (Sort.Order order : sort) {
166-
SortOptions sortOption = SortOptions.of(s -> s
167-
.field(f -> f
168-
.field(order.getProperty())
169-
.order(order.isAscending() ? SortOrder.Asc : SortOrder.Desc)
170-
)
171-
);
172-
sortOptions.add(sortOption);
153+
// 1. 사용자가 명시적으로 정렬을 지정한 경우
154+
if (sort.isSorted()) {
155+
for (Sort.Order order : sort) {
156+
sortOptions.add(SortOptions.of(s -> s
157+
.field(f -> f
158+
.field(order.getProperty())
159+
.order(order.isAscending() ? SortOrder.Asc : SortOrder.Desc)
160+
)
161+
));
162+
}
173163
}
174-
175-
// 정렬이 있을 때만 productId 타이브레이커 추가
176-
if (!sortOptions.isEmpty()) {
177-
sortOptions.add(SortOptions.of(s -> s
178-
.field(f -> f
179-
.field("productId")
180-
.order(SortOrder.Desc)
181-
)
182-
));
164+
// 2. 정렬 지정 안했지만 키워드 검색이 있는 경우 -> score 정렬
165+
else if (hasKeyword) {
166+
sortOptions.add(SortOptions.of(s -> s.score(sc -> sc.order(SortOrder.Desc))));
183167
}
184168

169+
// 타이브레이커 추가 (키워드도 업고, 정렬 기준도 없으면 최신순 정렬)
170+
sortOptions.add(SortOptions.of(s -> s
171+
.field(f -> f
172+
.field("productId")
173+
.order(SortOrder.Desc)
174+
)
175+
));
176+
185177
return sortOptions;
186178
}
187179

0 commit comments

Comments
 (0)