Skip to content

Commit 9e0c61c

Browse files
Merge pull request #55 from IT-Cotato/feature/17
[Feat] 검색 기능 (ElasticSearch, Redis)
2 parents 807d4bc + c5c5ca9 commit 9e0c61c

25 files changed

+693
-352
lines changed

src/main/java/com/ongil/backend/Application.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
55
import org.springframework.cloud.openfeign.EnableFeignClients;
66
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
7+
import org.springframework.scheduling.annotation.EnableAsync;
78

89
@EnableJpaAuditing
910
@SpringBootApplication
1011
@EnableFeignClients
12+
@EnableAsync
1113
public class Application {
1214

1315
public static void main(String[] args) {

src/main/java/com/ongil/backend/domain/auth/service/AuthService.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
import com.ongil.backend.global.config.redis.RedisRefreshTokenStore;
2020
import com.ongil.backend.global.security.jwt.JwtTokenProvider;
2121

22-
import jakarta.validation.Valid;
2322
import lombok.RequiredArgsConstructor;
2423

2524
@Service

src/main/java/com/ongil/backend/domain/product/controller/ProductController.java

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import com.ongil.backend.domain.product.dto.request.ProductSearchCondition;
1313
import com.ongil.backend.domain.product.dto.response.ProductDetailResponse;
14+
import com.ongil.backend.domain.product.dto.response.ProductSearchPageResDto;
1415
import com.ongil.backend.domain.product.dto.response.ProductSimpleResponse;
1516
import com.ongil.backend.domain.product.dto.response.SizeGuideResponse;
1617
import com.ongil.backend.domain.product.enums.ProductSortType;
@@ -41,15 +42,27 @@ public DataResponse<ProductDetailResponse> getProductDetail(@PathVariable Long p
4142
return DataResponse.from(productDetail);
4243
}
4344

44-
@Operation(summary = "상품 목록 조회", description = "조건에 맞는 상품들의 목록을 조회합니다.")
45+
@Operation(
46+
summary = "상품 목록 조회 (검색 포함)",
47+
description = """
48+
조건에 맞는 상품 목록을 조회합니다.
49+
- query가 없는 경우: 일반 상품 목록 조회(카테고리/브랜드/가격/사이즈 필터 + 정렬 + 페이징)
50+
- query가 있는 경우: Elasticsearch로 검색어에 매칭되는 상품 ID를 먼저 조회한 뒤,
51+
해당 ID 범위 내에서 필터/정렬/페이징을 적용하여 '검색 결과 목록'을 반환합니다.
52+
- 검색 결과가 0개인 경우: products는 빈 페이지로 반환하고, alternatives(최대 4개 대체 검색어)를 함께 반환합니다.
53+
- 로그인 사용자(userId 존재)인 경우: 검색 성공 시 최근검색어/검색 로그가 저장됩니다.
54+
"""
55+
)
4556
@GetMapping
46-
public DataResponse<Page<ProductSimpleResponse>> getProducts(
57+
public DataResponse<ProductSearchPageResDto> getProducts(
58+
@RequestParam(required = false) String query,
4759
@RequestParam(required = false) Long categoryId,
4860
@RequestParam(required = false) Long brandId,
4961
@RequestParam(required = false) String priceRange,
5062
@RequestParam(required = false) String clothingSize,
5163
@RequestParam(required = false, defaultValue = "POPULAR") ProductSortType sortType,
52-
@PageableDefault(size = 20) Pageable pageable
64+
@PageableDefault(size = 20) Pageable pageable,
65+
@AuthenticationPrincipal Long userId
5366
) {
5467
ProductSearchCondition condition = ProductSearchCondition.builder()
5568
.categoryId(categoryId)
@@ -58,9 +71,8 @@ public DataResponse<Page<ProductSimpleResponse>> getProducts(
5871
.size(clothingSize)
5972
.build();
6073

61-
Page<ProductSimpleResponse> products = productService.getProducts(condition, sortType, pageable);
62-
63-
return DataResponse.from(products);
74+
ProductSearchPageResDto res = productService.getProducts(condition, sortType, pageable, query, userId);
75+
return DataResponse.from(res);
6476
}
6577

6678
@Operation(summary = "특가 상품 조회", description = "할인율이 높은 특가 상품 TOP 10을 조회합니다.")
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.ongil.backend.domain.product.dto.response;
2+
3+
import java.util.List;
4+
5+
import org.springframework.data.domain.Page;
6+
7+
public record ProductSearchPageResDto(
8+
Page<ProductSimpleResponse> products,
9+
List<String> alternatives,
10+
boolean hasResult
11+
) {
12+
public static ProductSearchPageResDto of(Page<ProductSimpleResponse> products, List<String> alternatives) {
13+
return new ProductSearchPageResDto(products, alternatives, !products.isEmpty());
14+
}
15+
}

src/main/java/com/ongil/backend/domain/product/repository/ProductRepository.java

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,17 @@ public interface ProductRepository extends JpaRepository<Product, Long> {
2828
// 조건에 따른 상품 조회
2929
@EntityGraph(attributePaths = {"brand", "category"})
3030
@Query("""
31-
SELECT p FROM Product p
32-
WHERE (:categoryId IS NULL OR p.category.id = :categoryId)
33-
AND (:brandId IS NULL OR p.brand.id = :brandId)
34-
AND (:minPrice IS NULL OR p.price >= :minPrice)
35-
AND (:maxPrice IS NULL OR p.price <= :maxPrice)
36-
AND (:size IS NULL OR p.sizes LIKE CONCAT('%', :size, '%'))
37-
AND p.onSale = true
38-
""")
31+
SELECT p FROM Product p
32+
WHERE (:targetIds IS NULL OR p.id IN :targetIds)
33+
AND (:categoryId IS NULL OR p.category.id = :categoryId)
34+
AND (:brandId IS NULL OR p.brand.id = :brandId)
35+
AND (:minPrice IS NULL OR p.price >= :minPrice)
36+
AND (:maxPrice IS NULL OR p.price <= :maxPrice)
37+
AND (:size IS NULL OR p.sizes LIKE CONCAT('%', :size, '%'))
38+
AND p.onSale = true
39+
""")
3940
Page<Product> findAllByCondition(
41+
@Param("targetIds") List<Long> targetIds,
4042
@Param("categoryId") Long categoryId,
4143
@Param("brandId") Long brandId,
4244
@Param("minPrice") Integer minPrice,

src/main/java/com/ongil/backend/domain/product/service/ProductService.java

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import com.ongil.backend.domain.product.dto.request.ProductSearchCondition;
1515
import com.ongil.backend.domain.product.dto.response.AiMaterialDescriptionResponse;
1616
import com.ongil.backend.domain.product.dto.response.ProductDetailResponse;
17+
import com.ongil.backend.domain.product.dto.response.ProductSearchPageResDto;
1718
import com.ongil.backend.domain.product.dto.response.ProductSimpleResponse;
1819
import com.ongil.backend.domain.product.dto.response.SizeGuideResponse;
1920
import com.ongil.backend.domain.product.entity.Product;
@@ -22,16 +23,21 @@
2223
import com.ongil.backend.domain.product.enums.ProductType;
2324
import com.ongil.backend.domain.product.repository.ProductOptionRepository;
2425
import com.ongil.backend.domain.product.repository.ProductRepository;
26+
import com.ongil.backend.domain.search.service.RecentSearchService;
27+
import com.ongil.backend.domain.search.service.SearchService;
28+
import com.ongil.backend.domain.search.validator.SearchValidator;
2529
import com.ongil.backend.domain.user.entity.User;
2630
import com.ongil.backend.domain.user.repository.UserRepository;
2731
import com.ongil.backend.global.common.exception.EntityNotFoundException;
2832
import com.ongil.backend.global.common.exception.ErrorCode;
2933

3034
import lombok.RequiredArgsConstructor;
35+
import lombok.extern.slf4j.Slf4j;
3136

3237
@Transactional(readOnly = true)
3338
@Service
3439
@RequiredArgsConstructor
40+
@Slf4j
3541
public class ProductService {
3642

3743
private final ProductRepository productRepository;
@@ -40,6 +46,8 @@ public class ProductService {
4046
private final AiMaterialService aiMaterialService;
4147
private final UserRepository userRepository;
4248
private final SizeGuideConverter sizeGuideConverter;
49+
private final SearchService searchService;
50+
private final RecentSearchService recentSearchService;
4351

4452
private static final int SIMILAR_CUSTOMERS_LIMIT = 4;
4553

@@ -61,16 +69,28 @@ public ProductDetailResponse getProductDetail(Long productId) {
6169
}
6270

6371
// 조건에 따른 상품 조회
64-
public Page<ProductSimpleResponse> getProducts(
72+
public ProductSearchPageResDto getProducts(
6573
ProductSearchCondition condition,
6674
ProductSortType sortType,
67-
Pageable pageable
75+
Pageable pageable,
76+
String query,
77+
Long userId
6878
) {
79+
80+
// 검색어(query)가 있을 시 Elasticsearch 관련 동작
81+
boolean hasQuery = query != null && !query.isBlank();
82+
List<Long> targetIds = hasQuery ? searchService.getProductIdsByQuery(query) : null;
83+
84+
if (hasQuery && targetIds.isEmpty()) {
85+
String keyword = SearchValidator.normalize(query);
86+
List<String> alternatives = searchService.recommendAlternatives(keyword, 4);
87+
return ProductSearchPageResDto.of(Page.empty(pageable), alternatives);
88+
}
89+
6990
Integer[] priceRange = condition.parsePriceRange();
7091
Integer minPrice = priceRange != null ? priceRange[0] : null;
7192
Integer maxPrice = priceRange != null ? priceRange[1] : null;
7293

73-
// 정렬 조건 생성
7494
Sort sort = createSort(sortType);
7595
Pageable pageableWithSort = PageRequest.of(
7696
pageable.getPageNumber(),
@@ -79,6 +99,7 @@ public Page<ProductSimpleResponse> getProducts(
7999
);
80100

81101
Page<Product> products = productRepository.findAllByCondition(
102+
targetIds,
82103
condition.getCategoryId(),
83104
condition.getBrandId(),
84105
minPrice,
@@ -87,7 +108,40 @@ public Page<ProductSimpleResponse> getProducts(
87108
pageableWithSort
88109
);
89110

90-
return products.map(productConverter::toSimpleResponse);
111+
// 추천 검색어에 이용하기 위한 과정
112+
if (hasQuery && !products.isEmpty()) {
113+
Product firstProduct = products.getContent().get(0);
114+
String savedKeyword = null;
115+
116+
if (firstProduct.getBrand() != null && firstProduct.getBrand().getName() != null) {
117+
savedKeyword = firstProduct.getBrand().getName();
118+
}
119+
else if (firstProduct.getCategory() != null && firstProduct.getCategory().getName() != null) {
120+
savedKeyword = firstProduct.getCategory().getName();
121+
}
122+
123+
if (savedKeyword != null && !savedKeyword.isBlank()) {
124+
recordSearchSideEffects(savedKeyword, userId);
125+
}
126+
}
127+
128+
Page<ProductSimpleResponse> pageRes = products.map(productConverter::toSimpleResponse);
129+
return ProductSearchPageResDto.of(pageRes, List.of());
130+
}
131+
132+
// 로그인 유저) 최근 검색어 저장
133+
private void recordSearchSideEffects(String query, Long userId) {
134+
String keyword = SearchValidator.normalize(query);
135+
if (keyword.isEmpty()) return;
136+
137+
searchService.saveSearchLog(keyword, userId);
138+
if (userId != null) {
139+
try {
140+
recentSearchService.saveRecentSearch(userId, keyword);
141+
} catch (Exception e) {
142+
log.error("Redis 최근 검색어 저장 실패: {}", e.getMessage());
143+
}
144+
}
91145
}
92146

93147
// 특가 상품 조회
Lines changed: 74 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,92 @@
11
package com.ongil.backend.domain.search.controller;
22

3-
import com.ongil.backend.domain.search.dto.response.SearchAutocompleteResponse;
4-
import com.ongil.backend.domain.search.dto.response.SearchLogResponse;
3+
import java.util.Collections;
4+
import java.util.List;
5+
6+
import org.springframework.http.ResponseEntity;
7+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
8+
import org.springframework.web.bind.annotation.DeleteMapping;
9+
import org.springframework.web.bind.annotation.GetMapping;
10+
import org.springframework.web.bind.annotation.PostMapping;
11+
import org.springframework.web.bind.annotation.RequestMapping;
12+
import org.springframework.web.bind.annotation.RequestParam;
13+
import org.springframework.web.bind.annotation.RestController;
14+
15+
import com.ongil.backend.domain.search.service.RecentSearchService;
16+
import com.ongil.backend.domain.search.service.SearchIndexingService;
517
import com.ongil.backend.domain.search.service.SearchService;
6-
import com.ongil.backend.global.common.dto.DataResponse; // DataResponse 사용
18+
import com.ongil.backend.global.common.dto.DataResponse;
19+
720
import io.swagger.v3.oas.annotations.Operation;
821
import io.swagger.v3.oas.annotations.tags.Tag;
922
import lombok.RequiredArgsConstructor;
10-
import lombok.extern.slf4j.Slf4j;
11-
import org.springframework.security.core.annotation.AuthenticationPrincipal;
12-
import org.springframework.validation.annotation.Validated;
13-
import org.springframework.web.bind.annotation.*;
1423

15-
import java.util.List;
16-
17-
@Slf4j
18-
@Tag(name = "Search", description = "검색 관련 API")
19-
@Validated
2024
@RestController
21-
@RequestMapping("/api/v1/search")
2225
@RequiredArgsConstructor
26+
@RequestMapping("/api/search")
27+
@Tag(name = "Search", description = "검색 API")
2328
public class SearchController {
2429

25-
private final SearchService searchService;
26-
27-
@Operation(summary = "검색 초기 화면 데이터", description = "로그인: 최근 검색어(7개) / 비로그인: 추천 검색어(5개)")
28-
@GetMapping("/logs")
29-
public DataResponse<List<SearchLogResponse>> getSearchLogs(
30-
@AuthenticationPrincipal Long userId) { // UserDetails 대신 Long userId 바로 사용
31-
32-
List<SearchLogResponse> response = searchService.getInitialSearchLog(userId);
33-
return DataResponse.from(response);
34-
}
35-
36-
@Operation(summary = "검색어 기록 저장", description = "검색 버튼 클릭 시 호출 (상품 목록 이동 전)")
37-
@PostMapping("/log")
38-
public DataResponse<Void> saveSearchLog(
39-
@AuthenticationPrincipal Long userId,
40-
@RequestParam String keyword) {
30+
private final SearchService searchService;
31+
private final SearchIndexingService searchIndexingService;
32+
private final RecentSearchService recentSearchService;
4133

42-
searchService.saveSearchLog(userId, keyword);
43-
return DataResponse.from(null);
44-
}
34+
// 자동 완성
35+
@GetMapping("/autocomplete")
36+
public ResponseEntity<DataResponse<List<String>>> autocomplete(
37+
@RequestParam String query) {
38+
List<String> suggestions = searchService.getAutocomplete(query);
39+
return ResponseEntity.ok(DataResponse.from(suggestions));
40+
}
4541

46-
@Operation(summary = "최근 검색어 개별 삭제")
47-
@DeleteMapping("/log")
48-
public DataResponse<Void> deleteSearchLog(
49-
@AuthenticationPrincipal Long userId,
50-
@RequestParam String keyword) {
42+
// 추천 검색어
43+
@GetMapping("/recommend")
44+
public ResponseEntity<DataResponse<List<String>>> getRecommend() {
45+
return ResponseEntity.ok(DataResponse.from(searchService.getTopKeywords()));
46+
}
5147

52-
searchService.deleteRecentSearch(userId, keyword);
53-
return DataResponse.from(null);
54-
}
48+
// 최근 검색어
49+
@GetMapping("/recent")
50+
public ResponseEntity<DataResponse<List<String>>> getRecent(
51+
@AuthenticationPrincipal Long userId) {
52+
if (userId == null) {
53+
return ResponseEntity.ok(DataResponse.from(Collections.emptyList()));
54+
}
55+
List<String> recentSearches = recentSearchService.getRecentSearches(userId);
56+
return ResponseEntity.ok(DataResponse.from(recentSearches));
57+
}
5558

56-
@Operation(summary = "최근 검색어 전체 삭제")
57-
@DeleteMapping("/logs")
58-
public DataResponse<Void> deleteAllSearchLogs(
59-
@AuthenticationPrincipal Long userId) {
59+
// 최근 검색어 개별 삭제
60+
@DeleteMapping("/recent")
61+
public ResponseEntity<DataResponse<Void>> removeRecent(
62+
@AuthenticationPrincipal Long userId,
63+
@RequestParam String keyword) {
64+
if (userId != null) {
65+
recentSearchService.removeRecentSearch(userId, keyword);
66+
}
67+
return ResponseEntity.ok(DataResponse.from(null));
68+
}
6069

61-
searchService.deleteAllRecentSearch(userId);
62-
return DataResponse.from(null);
63-
}
70+
// 최근 검색어 전체 삭제
71+
@DeleteMapping("/recent/all")
72+
public ResponseEntity<DataResponse<Void>> clearAllRecent(
73+
@AuthenticationPrincipal Long userId) {
74+
if (userId != null) {
75+
recentSearchService.clearRecentSearches(userId);
76+
}
77+
return ResponseEntity.ok(DataResponse.from(null));
78+
}
6479

65-
@Operation(summary = "실시간 자동완성", description = "카테고리 & 브랜드 검색 (우선순위: 카테고리)")
66-
@GetMapping("/autocomplete")
67-
public DataResponse<List<SearchAutocompleteResponse>> getAutocomplete(@RequestParam String keyword) {
68-
List<SearchAutocompleteResponse> response = searchService.getAutocomplete(keyword);
69-
return DataResponse.from(response);
70-
}
80+
/**
81+
* [관리자용] 데이터 전체 동기화 API
82+
*/
83+
@PostMapping("/admin/reindex")
84+
@Operation(
85+
summary = "데이터 전체 동기화 (관리자용)",
86+
description = "데이터베이스의 모든 상품 정보를 Elasticsearch로 다시 색인합니다."
87+
)
88+
public ResponseEntity<String> reindex() {
89+
searchIndexingService.indexAllProducts();
90+
return ResponseEntity.ok("전체 데이터 색인이 완료되었습니다.");
91+
}
7192
}

0 commit comments

Comments
 (0)