diff --git a/Dockerfile.elasticsearch b/Dockerfile.elasticsearch new file mode 100644 index 00000000..d96b326a --- /dev/null +++ b/Dockerfile.elasticsearch @@ -0,0 +1,2 @@ +FROM elasticsearch:8.18.6 +RUN bin/elasticsearch-plugin install analysis-nori \ No newline at end of file diff --git a/build.gradle b/build.gradle index 75a1c970..d44089fb 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,7 @@ repositories { dependencies { // ============= Spring Boot 스타터 ============= implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' @@ -34,6 +35,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' // ============= 개발 도구 ============= compileOnly 'org.projectlombok:lombok' diff --git a/compose-dev.yml b/compose-dev.yml index 7881f6e8..82889223 100644 --- a/compose-dev.yml +++ b/compose-dev.yml @@ -31,10 +31,38 @@ services: timeout: 3s retries: 10 + elasticsearch: + build: + context: . + dockerfile: Dockerfile.elasticsearch + environment: + - discovery.type=single-node # 단일 노드 모드 설정, 다른 노드 탐색하지 않아서 실행 시간 줄어듬 + - xpack.security.enabled=false # 개발용으로 보안 기능 비활성화 + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" # 메모리 사용량을 512MB로 제한 + ports: + - "9200:9200" + - "9300:9300" + volumes: + - es-data-dev:/usr/share/elasticsearch/data + networks: + - novaminds-network + + kibana: + image: kibana:8.18.6 + environment: + - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 + ports: + - "5601:5601" + depends_on: + - elasticsearch + networks: + - novaminds-network + networks: novaminds-network: driver: bridge volumes: #novaminds-mysql-data: - redis_data: \ No newline at end of file + redis_data: + es-data-dev: \ No newline at end of file diff --git a/src/main/java/novaminds/gradproj/GradprojApplication.java b/src/main/java/novaminds/gradproj/GradprojApplication.java index fa6511cc..17198a43 100644 --- a/src/main/java/novaminds/gradproj/GradprojApplication.java +++ b/src/main/java/novaminds/gradproj/GradprojApplication.java @@ -3,18 +3,26 @@ import io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.data.elasticsearch.ReactiveElasticsearchRepositoriesAutoConfiguration; import org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration; +import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.retry.annotation.EnableRetry; @SpringBootApplication( exclude = { RedisRepositoriesAutoConfiguration.class, - S3AutoConfiguration.class + S3AutoConfiguration.class, + ReactiveElasticsearchRepositoriesAutoConfiguration.class } ) @EnableJpaAuditing @EnableRetry +@EnableJpaRepositories( + basePackages = "novaminds.gradproj.domain" +) +@EnableElasticsearchRepositories(basePackages = "novaminds.gradproj.global.search.repository") public class GradprojApplication { public static void main(String[] args) { diff --git a/src/main/java/novaminds/gradproj/global/search/EsIndexBootstrap.java b/src/main/java/novaminds/gradproj/global/search/EsIndexBootstrap.java new file mode 100644 index 00000000..a541f4a3 --- /dev/null +++ b/src/main/java/novaminds/gradproj/global/search/EsIndexBootstrap.java @@ -0,0 +1,22 @@ +package novaminds.gradproj.global.search; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class EsIndexBootstrap { + private final ElasticsearchOperations operations; + + @EventListener(ApplicationReadyEvent.class) + public void init() { + var idx = operations.indexOps(IngredientDocument.class); + if (!idx.exists()) { + idx.create(); // @Setting(nori-setting.json) 반영 + idx.putMapping(idx.createMapping(IngredientDocument.class)); // @MultiField 매핑 반영 + } + } +} \ No newline at end of file diff --git a/src/main/java/novaminds/gradproj/global/search/IngredientDocument.java b/src/main/java/novaminds/gradproj/global/search/IngredientDocument.java new file mode 100644 index 00000000..de44e747 --- /dev/null +++ b/src/main/java/novaminds/gradproj/global/search/IngredientDocument.java @@ -0,0 +1,34 @@ +package novaminds.gradproj.global.search; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.*; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Document(indexName = "ingredients") // ingredients 라는 이름의 인덱스(서랍장)에 저장 +@Setting(settingPath = "es-setting/nori-setting.json") // 한글 형태소 분석기 설정 추가 +public class IngredientDocument { + + @Id + private Long id; // 원본 DB의 Ingredient ID + + // 모든 검색을 책임질 단 하나의 메인 필드 + @Field(type = FieldType.Text, analyzer = "korean_unified_analyzer") + private String ingredientName; + + // 정확 일치 및 정렬을 위한 키워드 필드 + @Field(type = FieldType.Keyword) + private String ingredientNameKw; + + @Field(type = FieldType.Keyword, name = "category_name") // 카테고리 이름은 정확히 일치해야 하므로 Keyword 타입 사용 + private String categoryName; + + @Field(type = FieldType.Keyword, name = "image_url", index = false) // 이미지 URL은 검색 대상이 아니므로 index=false + private String imageUrl; +} diff --git a/src/main/java/novaminds/gradproj/global/search/converter/SearchConverter.java b/src/main/java/novaminds/gradproj/global/search/converter/SearchConverter.java new file mode 100644 index 00000000..449f7d8b --- /dev/null +++ b/src/main/java/novaminds/gradproj/global/search/converter/SearchConverter.java @@ -0,0 +1,28 @@ +package novaminds.gradproj.global.search.converter; + +import novaminds.gradproj.global.search.IngredientDocument; +import novaminds.gradproj.global.search.web.dto.SearchResponseDTO; + +import java.util.List; + +public class SearchConverter { + + public static SearchResponseDTO.IngredientSearchResult toIngredientSearchResultDTO(IngredientDocument document) { + return SearchResponseDTO.IngredientSearchResult.builder() + .id(document.getId()) + .name(document.getIngredientName()) + .category(document.getCategoryName()) + .imageUrl(document.getImageUrl()) + .build(); + } + + public static SearchResponseDTO.IngredientSearchList toIngredientSearchListDTO(List documents) { + List results = documents.stream() + .map(SearchConverter::toIngredientSearchResultDTO) + .toList(); + + return SearchResponseDTO.IngredientSearchList.builder() + .results(results) + .build(); + } +} diff --git a/src/main/java/novaminds/gradproj/global/search/repository/IngredientSearchRepository.java b/src/main/java/novaminds/gradproj/global/search/repository/IngredientSearchRepository.java new file mode 100644 index 00000000..55ad7af2 --- /dev/null +++ b/src/main/java/novaminds/gradproj/global/search/repository/IngredientSearchRepository.java @@ -0,0 +1,7 @@ +package novaminds.gradproj.global.search.repository; + +import novaminds.gradproj.global.search.IngredientDocument; +import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; + +public interface IngredientSearchRepository extends ElasticsearchRepository { +} \ No newline at end of file diff --git a/src/main/java/novaminds/gradproj/global/search/service/IngredientIndexingService.java b/src/main/java/novaminds/gradproj/global/search/service/IngredientIndexingService.java new file mode 100644 index 00000000..85481b6a --- /dev/null +++ b/src/main/java/novaminds/gradproj/global/search/service/IngredientIndexingService.java @@ -0,0 +1,49 @@ +package novaminds.gradproj.global.search.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import novaminds.gradproj.domain.ingredient.entity.Ingredient; +import novaminds.gradproj.domain.ingredient.repository.IngredientRepository; +import novaminds.gradproj.global.search.IngredientDocument; +import novaminds.gradproj.global.search.repository.IngredientSearchRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class IngredientIndexingService { + + private final IngredientRepository ingredientRepository; // DB에서 데이터를 읽어올 JpaRepository + private final IngredientSearchRepository ingredientSearchRepository; // Elasticsearch에 데이터를 저장할 Repository + + @Transactional(readOnly = true) + public void indexIngredients() { + log.info(">>>> Ingredient 데이터 인덱싱 시작..."); + + // 1. DB에서 모든 재료 데이터를 조회 + List allIngredients = ingredientRepository.findAll(); + if (allIngredients.isEmpty()) { + log.info(">>>> 인덱싱할 재료 데이터가 없습니다."); + return; + } + + // 2. Ingredient 엔티티를 IngredientDocument로 변환 + List ingredientDocuments = allIngredients.stream() + .map(ingredient -> IngredientDocument.builder() + .id(ingredient.getId()) + .ingredientName(ingredient.getIngredientName()) + .ingredientNameKw(ingredient.getIngredientName()) + .categoryName(ingredient.getIngredientCategory().getIngredientCategoryName()) + .imageUrl(ingredient.getImageUrl()) + .build()) + .toList(); + + // 3. Elasticsearch에 변환된 데이터를 저장(인덱싱) + ingredientSearchRepository.saveAll(ingredientDocuments); + + log.info(">>>> Ingredient 데이터 인덱싱 완료 ({}개)", ingredientDocuments.size()); + } +} diff --git a/src/main/java/novaminds/gradproj/global/search/service/SearchQueryService.java b/src/main/java/novaminds/gradproj/global/search/service/SearchQueryService.java new file mode 100644 index 00000000..a78a42bb --- /dev/null +++ b/src/main/java/novaminds/gradproj/global/search/service/SearchQueryService.java @@ -0,0 +1,45 @@ +package novaminds.gradproj.global.search.service; + +import lombok.RequiredArgsConstructor; +import novaminds.gradproj.global.search.IngredientDocument; +import novaminds.gradproj.global.search.converter.SearchConverter; +import novaminds.gradproj.global.search.web.dto.SearchResponseDTO; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.elasticsearch.client.elc.NativeQuery; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.SearchHit; +import org.springframework.data.elasticsearch.core.SearchHits; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class SearchQueryService { + + private final ElasticsearchOperations operations; + + public SearchResponseDTO.IngredientSearchList searchIngredients(String keyword, int page, int size) { + if (keyword == null || keyword.isBlank()) { + return SearchConverter.toIngredientSearchListDTO(List.of()); + } + + NativeQuery query = NativeQuery.builder() + .withQuery(q -> q + .multiMatch(mm -> mm + .query(keyword) + .fields("ingredientName", "ingredientNameKw") + .fuzziness("AUTO") + ) + ) + .withMinScore(2.0f) + .withPageable(PageRequest.of(page, size)) + .build(); + + SearchHits hits = operations.search(query, IngredientDocument.class); + List docs = hits.getSearchHits().stream() + .map(SearchHit::getContent).toList(); + + return SearchConverter.toIngredientSearchListDTO(docs); + } +} \ No newline at end of file diff --git a/src/main/java/novaminds/gradproj/global/search/web/controller/SearchController.java b/src/main/java/novaminds/gradproj/global/search/web/controller/SearchController.java new file mode 100644 index 00000000..b6670928 --- /dev/null +++ b/src/main/java/novaminds/gradproj/global/search/web/controller/SearchController.java @@ -0,0 +1,33 @@ +package novaminds.gradproj.global.search.web.controller; + +import lombok.RequiredArgsConstructor; +import novaminds.gradproj.apiPayload.ApiResponse; +import novaminds.gradproj.global.search.service.IngredientIndexingService; +import novaminds.gradproj.global.search.service.SearchQueryService; +import novaminds.gradproj.global.search.web.dto.SearchResponseDTO; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/search") +public class SearchController { + + private final IngredientIndexingService ingredientIndexingService; + private final SearchQueryService searchQueryService; + + @PostMapping("/admin/reindex") + public ApiResponse reindexIngredients() { + ingredientIndexingService.indexIngredients(); + return ApiResponse.onSuccess("인덱싱이 성공적으로 완료되었습니다."); + } + + @GetMapping("/ingredients") + public ApiResponse searchIngredients( + @RequestParam("keyword") String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + SearchResponseDTO.IngredientSearchList searchResults = searchQueryService.searchIngredients(keyword, page, size); + return ApiResponse.onSuccess(searchResults); + } +} diff --git a/src/main/java/novaminds/gradproj/global/search/web/dto/SearchResponseDTO.java b/src/main/java/novaminds/gradproj/global/search/web/dto/SearchResponseDTO.java new file mode 100644 index 00000000..0b937ad3 --- /dev/null +++ b/src/main/java/novaminds/gradproj/global/search/web/dto/SearchResponseDTO.java @@ -0,0 +1,24 @@ +package novaminds.gradproj.global.search.web.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +public class SearchResponseDTO { + + @Getter + @Builder + public static class IngredientSearchList { + private List results; + } + + @Getter + @Builder + public static class IngredientSearchResult { + private Long id; + private String name; + private String category; + private String imageUrl; + } +} diff --git a/src/main/resources/application-es.yml b/src/main/resources/application-es.yml new file mode 100644 index 00000000..130e747d --- /dev/null +++ b/src/main/resources/application-es.yml @@ -0,0 +1,3 @@ +spring: + elasticsearch: + uris: http://localhost:9200 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e9e6cdf5..462a9d4b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,4 +10,5 @@ spring: - jwt - s3 - email - - redis \ No newline at end of file + - redis + - es \ No newline at end of file diff --git a/src/main/resources/es-setting/nori-setting.json b/src/main/resources/es-setting/nori-setting.json new file mode 100644 index 00000000..9f1410ae --- /dev/null +++ b/src/main/resources/es-setting/nori-setting.json @@ -0,0 +1,17 @@ +{ + "index": { + "max_ngram_diff": 20 + }, + "analysis": { + "filter": { + "edge_ngram_filter": { "type": "edge_ngram", "min_gram": 1, "max_gram": 20 } + }, + "analyzer": { + "korean_unified_analyzer": { + "type": "custom", + "tokenizer": "nori_tokenizer", + "filter": [ "lowercase", "nori_readingform", "edge_ngram_filter" ] + } + } + } +} \ No newline at end of file