Skip to content

Feat/50/ingredient elastic search#51

Open
MODUGGAGI wants to merge 15 commits intoreleasefrom
feat/50/IngredientElasticSearch
Open

Feat/50/ingredient elastic search#51
MODUGGAGI wants to merge 15 commits intoreleasefrom
feat/50/IngredientElasticSearch

Conversation

@MODUGGAGI
Copy link
Contributor

@MODUGGAGI MODUGGAGI commented Sep 13, 2025

🚀 변경사항

재료 검색에 간단하게 ElasticSearch 적용

🔗 관련 이슈

✅ 체크리스트

  • 로컬에서 테스트 완료
  • 코드 리뷰 준비 완료

Summary by CodeRabbit

  • New Features

    • Ingredient search API with keyword-based, paginated results (name, category, image).
    • Admin endpoint to rebuild the search index.
  • Configuration

    • Elasticsearch integration with automatic index creation at startup.
    • Korean language analyzer and edge-ngram-based autocomplete for improved relevance.
  • Chores

    • Local dev stack extended with Elasticsearch and Kibana, custom image including Nori plugin, and persistent storage.

@MODUGGAGI MODUGGAGI self-assigned this Sep 13, 2025
@MODUGGAGI MODUGGAGI linked an issue Sep 13, 2025 that may be closed by this pull request
3 tasks
@coderabbitai
Copy link

coderabbitai bot commented Sep 13, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds Elasticsearch and Kibana to development infra, integrates Spring Data Elasticsearch, defines an IngredientDocument and repository, bootstraps index on startup, provides indexing and search services (with Korean nori analyzer), REST endpoints for reindex/search, and profile/config for local ES.

Changes

Cohort / File(s) Summary
Infra: Docker & Compose
Dockerfile.elasticsearch, compose-dev.yml
New Dockerfile builds ES 8.18.6 image with analysis-nori plugin; compose-dev adds elasticsearch (single-node, security disabled, ES_JAVA_OPTS, volumes, ports) and kibana:8.18.6, with es-data-dev volume.
Build Config
build.gradle
Adds spring-boot-starter-validation and spring-boot-starter-data-elasticsearch dependencies.
Application Startup / Wiring
src/main/java/.../GradprojApplication.java
Enables JPA and Elasticsearch repositories (explicit basePackages) and excludes ReactiveElasticsearchRepositoriesAutoConfiguration (keeps existing excludes).
ES Settings & Profiles
src/main/resources/application-es.yml, src/main/resources/application.yml, src/main/resources/es-setting/nori-setting.json
Adds es profile to application.yml; application-es.yml sets spring.elasticsearch.uris: http://localhost:9200; nori-setting.json defines korean_unified_analyzer, edge_ngram_filter and index settings.
ES Document & Repository
src/main/java/.../global/search/IngredientDocument.java, src/main/java/.../global/search/repository/IngredientSearchRepository.java
Adds IngredientDocument mapped to ingredients index with fields (id, ingredientName with Korean analyzer, keyword variants, category_name, image_url); repository extends ElasticsearchRepository<IngredientDocument, Long>.
Index Bootstrap
src/main/java/.../global/search/EsIndexBootstrap.java
Component that listens for ApplicationReadyEvent and creates the ingredients index and mapping if it does not exist.
Services: Indexing & Query
src/main/java/.../global/search/service/IngredientIndexingService.java, src/main/java/.../global/search/service/SearchQueryService.java
IngredientIndexingService reads DB ingredients and saves mapped IngredientDocuments to ES; SearchQueryService builds a NativeQuery (multi-match on ingredientName and ingredientNameKw with fuzziness and min score), executes via ElasticsearchOperations, paginates, and returns DTO list.
Web, DTOs & Converter
src/main/java/.../global/search/web/controller/SearchController.java, src/main/java/.../global/search/web/dto/SearchResponseDTO.java, src/main/java/.../global/search/converter/SearchConverter.java
Adds REST controller /api/search with POST /admin/reindex and GET /ingredients; DTOs for results and a converter mapping IngredientDocument → response DTOs.
Misc (config/boot)
src/main/resources/application-es.yml, src/main/resources/es-setting/nori-setting.json
New files added for ES connection and analyzer settings referenced by IngredientDocument.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant C as Client
  participant SC as SearchController
  participant SQS as SearchQueryService
  participant ESOp as ElasticsearchOperations
  participant ES as Elasticsearch

  rect rgba(230,245,255,0.6)
  note over C,SC: Ingredient Search Flow
  C->>SC: GET /api/search/ingredients?keyword=...&page=&size=
  alt keyword blank
    SC-->>C: ApiResponse(empty results)
  else keyword present
    SC->>SQS: searchIngredients(keyword,page,size)
    SQS->>ESOp: build NativeQuery (multi-match + fuzziness + min_score + pageable)
    ESOp->>ES: _search
    ES-->>ESOp: hits
    ESOp-->>SQS: IngredientDocument list
    SQS-->>SC: DTO (results)
    SC-->>C: ApiResponse(results)
  end
  end
Loading
sequenceDiagram
  autonumber
  participant Admin as Admin Client
  participant SC as SearchController
  participant IIS as IngredientIndexingService
  participant IR as IngredientRepository (DB)
  participant ISR as IngredientSearchRepository (ES)
  participant ES as Elasticsearch

  rect rgba(242,255,230,0.6)
  note over Admin,SC: Reindex Flow
  Admin->>SC: POST /api/search/admin/reindex
  SC->>IIS: indexIngredients()
  IIS->>IR: findAll()
  IR-->>IIS: Ingredient list
  IIS->>ISR: saveAll(mapped IngredientDocuments)
  ISR->>ES: bulk index
  ES-->>ISR: ack
  IIS-->>SC: done
  SC-->>Admin: ApiResponse("인덱싱 완료")
  end
Loading
sequenceDiagram
  autonumber
  participant App as Application
  participant EIB as EsIndexBootstrap
  participant ESOp as ElasticsearchOperations
  participant ES as Elasticsearch

  rect rgba(255,245,230,0.6)
  note over App,ES: Startup Index Bootstrap
  App->>EIB: ApplicationReadyEvent
  EIB->>ESOp: indexOps(IngredientDocument)
  ESOp->>ES: HEAD /ingredients
  alt not exists
    ESOp->>ES: PUT /ingredients (settings from nori-setting.json)
    ES-->>ESOp: created
    ESOp->>ES: PUT /ingredients/_mapping
    ES-->>ESOp: acknowledged
  else exists
    note over EIB: No action
  end
  EIB-->>App: ready
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

Thump-thump, I hop and hum a tune,
Nori winds and indices bloom soon—
Kibana glows, the search bell rings,
Ingredients sorted on soft spring wings.
Reindex hopped, results take flight—🥕✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Title Check ✅ Passed The title "Feat/50/ingredient elastic search" clearly identifies the primary change—adding Elasticsearch support for ingredient search—so it is relevant and concise for a quick scan, though the capitalization and inclusion of the issue number are slightly nonstandard.
Linked Issues Check ✅ Passed The changes implement the coding objectives of issue #50: compose-dev.yml adds Elasticsearch and Kibana services, Dockerfile.elasticsearch builds an ES image with the nori plugin, spring-data-elasticsearch is added and application-es.yml/profile configured, and code-level integration is present (document mapping, nori settings, index bootstrap, repository, indexing/query services, DTOs and controller endpoints). Kibana is used as an official image and Elasticsearch is built from the provided Dockerfile to include the analyzer, which satisfies the linked issue's goals for local ES/Kibana usage and feature integration.
Out of Scope Changes Check ✅ Passed Most changes are directly related to adding Elasticsearch support for ingredient search; the only minor out-of-scope item observed is the addition of spring-boot-starter-validation in build.gradle, which does not appear to be used by the new code in this PR.
Description Check ✅ Passed The PR description follows the repository template: it includes "🚀 변경사항" with a brief summary, "🔗 관련 이슈" with "Closes #50", and a completed checklist; the optional "특이사항" section is not present but is not required.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/50/IngredientElasticSearch

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@MODUGGAGI MODUGGAGI added the ✨ feature add feature label Sep 13, 2025
Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary of Changes

Hello @MODUGGAGI, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이 PR은 애플리케이션에 Elasticsearch 기반의 재료 검색 기능을 도입하여 사용자 경험을 향상시키는 것을 목표로 합니다. 기존 데이터베이스 검색의 한계를 극복하고, 한국어 형태소 분석을 통해 더욱 정확하고 효율적인 검색 결과를 제공하기 위한 인프라 및 서비스 계층의 변경사항을 포함합니다. 이를 통해 재료 검색 시 오타 보정, 자동 완성 등 고급 검색 기능을 지원할 수 있는 기반을 마련했습니다.

Highlights

  • Elasticsearch 통합: 재료 검색 기능을 위해 Elasticsearch를 시스템에 통합했습니다. 이를 통해 더 빠르고 유연한 검색 기능을 제공할 수 있습니다.
  • Docker Compose 환경 설정: 개발 환경에서 Elasticsearch와 Kibana를 쉽게 배포하고 관리할 수 있도록 compose-dev.yml 파일에 관련 서비스를 추가했습니다.
  • 한글 형태소 분석기(Nori) 적용: 한국어 검색의 정확도를 높이기 위해 Elasticsearch에 Nori 형태소 분석기 플러그인을 설치하고, 이를 활용한 인덱스 설정을 추가했습니다.
  • 재료 데이터 인덱싱 및 검색 API: 기존 DB의 재료 데이터를 Elasticsearch로 인덱싱하는 서비스와, 인덱싱된 데이터를 활용하여 재료를 검색하는 API 엔드포인트를 구현했습니다.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point in your pull request via creating an issue comment (i.e. comment on the pull request page) using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in issue comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary of Changes

Hello @MODUGGAGI, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a new ingredient search feature powered by Elasticsearch. It sets up the necessary infrastructure, including Dockerized Elasticsearch and Kibana, integrates Spring Boot with Elasticsearch, and provides a robust search API. The implementation focuses on enhancing search relevance for Korean ingredient names through specialized analyzers and offers an administrative endpoint for index management.

Highlights

  • Elasticsearch Integration: Implemented Elasticsearch for ingredient search, enabling keyword-based and paginated results with details like name, category, and image.
  • Korean Language Support: Configured Elasticsearch with the Nori analyzer for advanced Korean text analysis and autocomplete functionality to improve search relevance.
  • Development Environment Setup: Extended the local development environment with Docker Compose services for Elasticsearch and Kibana, including a custom Elasticsearch image with the Nori plugin and persistent storage.
  • Search Index Management: Added an administrative API endpoint to manually trigger the re-indexing of ingredient data from the primary database into Elasticsearch.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point in your pull request via creating an issue comment (i.e. comment on the pull request page) using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in issue comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex Review: Here are some suggestions.

Reply with @codex fix comments to fix any unresolved comments.

About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you open a pull request for review, mark a draft as ready, or comment "@codex review". If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex fix this CI failure" or "@codex address that feedback".

Comment on lines +18 to +21
@PostMapping("/admin/reindex")
public ApiResponse<String> reindexIngredients() {
ingredientIndexingService.indexIngredients();
return ApiResponse.onSuccess("인덱싱이 성공적으로 완료되었습니다.");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Restrict reindex endpoint to admin role

The reindex endpoint is mapped to /api/search/admin/reindex but does not enforce any role check, and SecurityConfig only guards paths starting with /admin/**. As written, any authenticated user can trigger a costly reindex operation. Add explicit authorization on this controller (e.g., @PreAuthorize("hasRole('ADMIN')")) or extend the security matcher so only administrators can call it.

Useful? React with 👍 / 👎.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

이번 PR은 재료 검색 기능에 Elasticsearch를 도입하는 중요한 변경사항을 담고 있습니다. 전반적으로 Elasticsearch 설정, 인덱싱, 검색 쿼리 구현이 잘 이루어졌습니다. 특히 한글 형태소 분석기(nori)와 다중 필드 검색을 활용하여 검색 품질을 높이려는 노력이 돋보입니다. 다만, 몇 가지 부분에서 성능, 유지보수성, 보안을 개선할 수 있는 여지가 있어 리뷰 의견을 남깁니다. 주로 대용량 데이터 처리 시 발생할 수 있는 성능 문제, 관리자 API 보안, 코드의 유지보수성 향상에 대한 제안을 포함했습니다.

Comment on lines 28 to 47
List<Ingredient> allIngredients = ingredientRepository.findAll();
if (allIngredients.isEmpty()) {
log.info(">>>> 인덱싱할 재료 데이터가 없습니다.");
return;
}

// 2. Ingredient 엔티티를 IngredientDocument로 변환
List<IngredientDocument> ingredientDocuments = allIngredients.stream()
.map(ingredient -> IngredientDocument.builder()
.id(ingredient.getId())
.ingredientName(ingredient.getIngredientName())
.categoryName(ingredient.getIngredientCategory().getIngredientCategoryName())
.imageUrl(ingredient.getImageUrl())
.build())
.toList();

// 3. Elasticsearch에 변환된 데이터를 저장(인덱싱)
ingredientSearchRepository.saveAll(ingredientDocuments);

log.info(">>>> Ingredient 데이터 인덱싱 완료 ({}개)", ingredientDocuments.size());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

ingredientRepository.findAll()을 사용하여 모든 재료 데이터를 한 번에 메모리로 불러오고 있습니다. 재료 데이터가 많아질 경우 OutOfMemoryError가 발생할 수 있는 잠재적인 성능 문제가 있습니다. 데이터를 일정한 크기의 배치(batch)로 나누어 처리하는 것이 안전합니다. Spring Data JPA의 페이징 기능을 활용하여 이 문제를 해결할 수 있습니다. 아래와 같이 수정하는 것을 제안합니다. (참고: org.springframework.data.domain.Pageorg.springframework.data.domain.PageRequest 임포트가 필요합니다.)

        final int BATCH_SIZE = 1000;
        int pageNumber = 0;
        long totalIndexed = 0;

        while (true) {
            // 1. DB에서 배치 크기만큼 재료 데이터를 조회
            org.springframework.data.domain.Page<Ingredient> page = ingredientRepository.findAll(org.springframework.data.domain.PageRequest.of(pageNumber, BATCH_SIZE));
            List<Ingredient> ingredients = page.getContent();

            if (ingredients.isEmpty()) {
                break;
            }

            // 2. Ingredient 엔티티를 IngredientDocument로 변환
            List<IngredientDocument> ingredientDocuments = ingredients.stream()
                    .map(ingredient -> IngredientDocument.builder()
                            .id(ingredient.getId())
                            .ingredientName(ingredient.getIngredientName())
                            .categoryName(ingredient.getIngredientCategory().getIngredientCategoryName())
                            .imageUrl(ingredient.getImageUrl())
                            .build())
                    .toList();

            // 3. Elasticsearch에 변환된 데이터를 저장(인덱싱)
            ingredientSearchRepository.saveAll(ingredientDocuments);
            totalIndexed += ingredients.size();
            pageNumber++;
        }

        if (totalIndexed == 0) {
            log.info(">>>> 인덱싱할 재료 데이터가 없습니다.");
            return;
        }

        log.info(">>>> Ingredient 데이터 인덱싱 완료 ({}개)", totalIndexed);

private final IngredientIndexingService ingredientIndexingService;
private final SearchQueryService searchQueryService;

@PostMapping("/admin/reindex")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

/admin/reindex 엔드포인트는 전체 재료 데이터를 다시 인덱싱하는 무거운 작업이며, 시스템에 부하를 줄 수 있습니다. 따라서 아무나 호출할 수 없도록 관리자(Admin) 권한을 가진 사용자만 접근할 수 있도록 보안을 강화해야 합니다. Spring Security의 메서드 시큐리티를 사용하고 있다면 @PreAuthorize("hasRole('ADMIN')")과 같은 어노테이션을 추가하는 것을 고려해 보세요.

Comment on lines +17 to +20
if (!idx.exists()) {
idx.create(); // @Setting(nori-setting.json) 반영
idx.putMapping(idx.createMapping(IngredientDocument.class)); // @MultiField 매핑 반영
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 구현은 애플리케이션 시작 시 인덱스가 존재하지 않을 때만 생성하도록 되어 있습니다. 만약 IngredientDocument의 매핑이 변경될 경우, 기존 인덱스가 삭제되지 않아 변경사항이 반영되지 않는 문제가 발생할 수 있습니다. 개발 환경에서는 애플리케이션 재시작 시 항상 최신 매핑을 보장하기 위해 기존 인덱스를 삭제하고 다시 생성하는 것이 더 안정적일 수 있습니다. 운영 환경에서는 무중단 인덱스 업데이트를 위해 Blue-Green 배포와 유사한 인덱스 alias를 활용하는 전략을 고려해야 합니다.

        if (idx.exists()) {
            idx.delete();
        }
        idx.create(); // @Setting(nori-setting.json) 반영
        idx.putMapping(idx.createMapping(IngredientDocument.class)); // @MultiField 매핑 반영

Comment on lines +8 to +9
public class SearchConverter {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

이 클래스는 정적 유틸리티 메서드만 포함하고 있습니다. 이런 유틸리티 클래스는 인스턴스화되거나 상속될 필요가 없으므로, final로 선언하고 private 생성자를 추가하여 의도를 명확히 하는 것이 좋습니다.

public final class SearchConverter {

    private SearchConverter() {
        // 유틸리티 클래스는 인스턴스화할 수 없습니다.
    }

Comment on lines 33 to 56
.should(s -> s
.match(m -> m
.field("ingredient_name")
.query(keyword)
.fuzziness("AUTO") // 오타 허용
.boost(3.0f) // 가중치 3배
)
)
// 2-2) 자동완성 필드: 부분 일치 검색
.should(s -> s
.match(m -> m
.field("ingredient_name.auto")
.query(keyword)
.boost(2.0f) // 가중치 2배
)
)
// 2-3) 키워드 필드: 정확한 일치 (가장 높은 점수)
.should(s -> s
.term(t -> t
.field("ingredient_name.kw")
.value(keyword)
.boost(50.0f) // 가중치 50배
)
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

검색 쿼리 내에 필드 이름("ingredient_name", "ingredient_name.auto" 등)과 가중치(boost) 값들이 하드코딩되어 있습니다. 이러한 값들을 상수로 정의하면 오타를 방지하고, 가중치 조절과 같은 유지보수 작업을 더 쉽게 할 수 있습니다. 예를 들어, IngredientDocument 클래스나 별도의 상수 클래스에 관련 상수들을 정의하여 사용하는 것을 권장합니다.

Comment on lines +27 to +28
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

페이지네이션 파라미터인 pagesize에 대한 유효성 검사가 없습니다. 사용자가 비정상적으로 큰 size 값을 요청할 경우 서버에 과도한 부하를 줄 수 있습니다. page는 0 이상, size는 적절한 범위(예: 1~100) 내의 값만 허용하도록 유효성 검사를 추가하는 것이 좋습니다. 아래와 같이 수정하고, 컨트롤러 클래스에 @org.springframework.validation.annotation.Validated 어노테이션을 추가하는 것을 권장합니다.

Suggested change
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
@RequestParam(defaultValue = "0") @jakarta.validation.constraints.Min(0) int page,
@RequestParam(defaultValue = "20") @jakarta.validation.constraints.Min(1) @jakarta.validation.constraints.Max(100) int size

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (28)
build.gradle (1)

38-38: Elasticsearch starter added — consider tests and timeouts.

  • Add ES Testcontainers for integration tests.
  • Configure client timeouts to avoid long hangs if ES is down (properties in application-es.yml).

Example properties to add:

spring:
  elasticsearch:
    connection-timeout: 2s
    socket-timeout: 5s
src/main/resources/application-es.yml (2)

1-3: Confirm host depending on where the app runs.

http://localhost:9200 is correct when running the app on the host. If you run the app in Docker on the same compose network, use http://elasticsearch:9200. Also consider adding connection/socket timeouts (see build.gradle comment).


3-3: Add a newline at EOF.

-    uris: http://localhost:9200
+    uris: http://localhost:9200
+
src/main/resources/application.yml (2)

13-14: Including the es profile globally may force ES config in all environments.

If that’s not intended, gate ES to a dev/local profile (e.g., via profile groups) instead of always including it.

Option A (quick): remove - es here and activate es only when needed.

Option B (groups):

spring:
  profiles:
    include:
      - redis
    group:
      local: redis,es

Run with SPRING_PROFILES_ACTIVE=local during local dev.


14-14: Add a newline at EOF.

-      - es
+      - es
+
src/main/resources/es-setting/nori-setting.json (3)

5-5: Edge n-gram min_gram=1 can explode token count and memory.

For autocomplete, min_gram: 2 (and a slightly smaller max_gram) is usually sufficient and far cheaper.

-      "edge_ngram_filter": { "type": "edge_ngram", "min_gram": 1, "max_gram": 20 }
+      "edge_ngram_filter": { "type": "edge_ngram", "min_gram": 2, "max_gram": 15 }

8-17: Ensure search_analyzer is non-ngram to avoid noisy queries.

Verify your field mappings use analyzer: korean_autocomplete and search_analyzer: korean_nori (e.g., on the name field). If not, autocomplete will also n-gram user queries.

Example mapping snippet:

{
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "korean_autocomplete",
        "search_analyzer": "korean_nori"
      }
    }
  }
}

3-3: Remove unused empty tokenizer block.

nori_tokenizer is built-in; the empty "tokenizer": {} section is unnecessary.

-    "tokenizer": {},
src/main/java/novaminds/gradproj/GradprojApplication.java (1)

13-18: Excluding Reactive ES repos is likely redundant.

Spring Boot won’t auto-configure reactive repositories if the reactive stack isn’t on the classpath. Safe to keep, but you can remove the exclude to reduce noise.

src/main/java/novaminds/gradproj/global/search/web/dto/SearchResponseDTO.java (2)

16-23: Consider Java records for DTOs (Java 21).

Records reduce boilerplate and work well with Jackson for response-only DTOs.

Example:

public record IngredientSearchResult(Long id, String name, String category, String imageUrl) {}
public record IngredientSearchList(List<IngredientSearchResult> results) {}

10-14: Avoid DTO duplication with existing IngredientResponseDTO.

If shapes match, reuse or map to the existing DTO to keep the API surface consistent.

compose-dev.yml (3)

34-49: Dev-only flags acknowledged; avoid carrying into prod.

xpack.security.enabled=false and low heap are fine for local dev. Ensure prod compose overrides these.

Optional: add memlock ulimit to prevent bootstrap warnings if you later enable security:

   elasticsearch:
     build:
       context: .
       dockerfile: Dockerfile.elasticsearch
+    ulimits:
+      memlock:
+        soft: -1
+        hard: -1

50-59: Kibana depends_on is okay; consider readiness if you script automation.

If you later add healthchecks, switch to depends_on: condition: service_healthy so Kibana waits for ES readiness.


68-68: Add a newline at EOF.

   es-data-dev:
+
src/main/java/novaminds/gradproj/global/search/IngredientDocument.java (3)

30-31: Consider a lowercase normalizer for category exact matches.

Keyword is case-sensitive. If you need case-insensitive equality, attach a normalizer (e.g., lowercase) defined in your settings.

-    @Field(type = FieldType.Keyword, name = "category_name") // 카테고리 이름은 정확히 일치해야 하므로 Keyword 타입 사용
+    @Field(type = FieldType.Keyword, name = "category_name", normalizer = "lowercase")

Ensure "lowercase" normalizer is defined in nori-setting.json; otherwise, add it or skip this change if strict case-sensitive matching is intended.


33-34: Disable doc_values for non-indexed imageUrl to save storage.

Since image_url is not searchable, disabling doc_values reduces heap/disk.

-    @Field(type = FieldType.Keyword, name = "image_url", index = false) // 이미지 URL은 검색 대상이 아니므로 index=false
+    @Field(type = FieldType.Keyword, name = "image_url", index = false, docValues = false) // 검색 비대상 + doc_values 비활성화

15-15: Optional: make @setting path explicit (use classpath:)

Resource found at src/main/resources/es-setting/nori-setting.json — current annotation will resolve, but using an explicit classpath: prefix is safer across runtimes.

-@Setting(settingPath = "es-setting/nori-setting.json") // 한글 형태소 분석기 설정 추가
+@Setting(settingPath = "classpath:es-setting/nori-setting.json") // 한글 형태소 분석기 설정 추가
src/main/java/novaminds/gradproj/global/search/EsIndexBootstrap.java (2)

14-21: Harden startup against ES unavailability and add logging.

A transient ES outage during startup will bubble an exception from the event listener and can crash the app. Wrap with try/catch and log; optionally fail-fast only on specific profiles.

     @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 매핑 반영
-        }
+        try {
+            var idx = operations.indexOps(IngredientDocument.class);
+            if (!idx.exists()) {
+                idx.create(); // @Setting(nori-setting.json) 반영
+                idx.putMapping(idx.createMapping(IngredientDocument.class)); // @MultiField 매핑 반영
+            }
+        } catch (Exception e) {
+            // TODO: replace with your logger
+            System.err.println("[EsIndexBootstrap] Failed to init index 'ingredients': " + e.getMessage());
+            // Option A: rethrow to fail-fast on non-dev
+            // Option B: swallow on local/dev
+        }
     }

Confirm desired behavior: fail-fast vs. best-effort on local/dev versus prod.


16-20: Plan for mapping changes using versioned indices + aliases.

putMapping won’t reconcile incompatible mapping updates on existing indices. Adopt versioned indices (e.g., ingredients_v{n}) and a write/search alias (ingredients) for zero-downtime reindex/migrations.

I can sketch a rollout plan (create v2 -> reindex -> atomically switch alias -> delete v1) if helpful.

src/main/java/novaminds/gradproj/global/search/web/controller/SearchController.java (2)

24-32: Avoid API duplication with existing DB-backed search.

You now have /api/search/ingredients (ES) and /api/ingredients (DB). Decide the canonical endpoint or attach a feature flag/profile to prevent diverging behaviors for clients.

Confirm which endpoint FE will call; we can route /api/ingredients to ES under a profile or toggle.


24-32: Consider returning pagination metadata.

Clients typically need total, page, size. Extend IngredientSearchList to include these.

I can draft DTO and service changes if you want.

src/main/java/novaminds/gradproj/global/search/service/SearchQueryService.java (3)

22-27: Trim and short-circuit early for blank input.

Normalize whitespace to reduce accidental empty queries.

-    public SearchResponseDTO.IngredientSearchList searchIngredients(String keyword, int page, int size) {
-        // 1. 검색어가 비어있으면 빈 결과 반환
-        if (keyword == null || keyword.isBlank()) {
+    public SearchResponseDTO.IngredientSearchList searchIngredients(String keyword, int page, int size) {
+        // 1. 검색어가 비어있으면 빈 결과 반환
+        if (keyword == null || (keyword = keyword.strip()).isBlank()) {
             return SearchConverter.toIngredientSearchListDTO(List.of());
         }

29-60: Exact term on ingredient_name.kw is case-sensitive; align with mapping or adjust query.

If you want case-insensitive exact matches, ensure the kw subfield uses a lowercase normalizer (mapping change), or add a second normalized keyword subfield and target that here.

Please confirm ingredient_name.kw has a normalizer in nori-setting.json. If not, matches will be strictly case-sensitive.


29-60: Optional: improve autocomplete relevance.

For the auto field, match_phrase_prefix or search_as_you_type can improve prefix matches over match.

I can provide an alternative query using multi_match with type: "bool_prefix" if desired.

src/main/java/novaminds/gradproj/global/search/converter/SearchConverter.java (1)

8-8: Make the utility class non-instantiable.

Add a private constructor (or Lombok’s @UtilityClass) to prevent accidental instantiation.

 public class SearchConverter {
+    private SearchConverter() {}
src/main/java/novaminds/gradproj/global/search/service/IngredientIndexingService.java (3)

23-24: Avoid readOnly = true when the method performs writes (to Elasticsearch).

While JPA writes aren’t happening, the method still mutates external state. Drop readOnly for clarity.

-    @Transactional(readOnly = true)
+    @Transactional
     public void indexIngredients() {

44-46: Wrap ES bulk save in error handling with context.

Add try/catch to surface failures without losing which page failed.

-            ingredientSearchRepository.saveAll(docs);
+            try {
+                ingredientSearchRepository.saveAll(docs);
+            } catch (Exception e) {
+                log.error(">>>> ES 인덱싱 실패: page={}, size={}, firstId={}", 
+                          pageable.getPageNumber(), docs.size(), 
+                          docs.isEmpty() ? null : docs.get(0).getId(), e);
+                throw e;
+            }

44-47: Plan for zero-downtime reindex using aliases.

Consider indexing into a versioned index (ingredients_vN), then atomically swap an alias to avoid partial results during reindex.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 214895a and 7c4c5d5.

📒 Files selected for processing (15)
  • Dockerfile.elasticsearch (1 hunks)
  • build.gradle (2 hunks)
  • compose-dev.yml (1 hunks)
  • src/main/java/novaminds/gradproj/GradprojApplication.java (1 hunks)
  • src/main/java/novaminds/gradproj/global/search/EsIndexBootstrap.java (1 hunks)
  • src/main/java/novaminds/gradproj/global/search/IngredientDocument.java (1 hunks)
  • src/main/java/novaminds/gradproj/global/search/converter/SearchConverter.java (1 hunks)
  • src/main/java/novaminds/gradproj/global/search/repository/IngredientSearchRepository.java (1 hunks)
  • src/main/java/novaminds/gradproj/global/search/service/IngredientIndexingService.java (1 hunks)
  • src/main/java/novaminds/gradproj/global/search/service/SearchQueryService.java (1 hunks)
  • src/main/java/novaminds/gradproj/global/search/web/controller/SearchController.java (1 hunks)
  • src/main/java/novaminds/gradproj/global/search/web/dto/SearchResponseDTO.java (1 hunks)
  • src/main/resources/application-es.yml (1 hunks)
  • src/main/resources/application.yml (1 hunks)
  • src/main/resources/es-setting/nori-setting.json (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: MODUGGAGI
PR: TeamNovaMinds/BE#36
File: src/main/resources/application-redis.yml:7-7
Timestamp: 2025-08-29T02:33:01.862Z
Learning: MODUGGAGI is willing to use hardcoded passwords for local development/testing phases before deployment, prioritizing development convenience over strict security practices during the development phase.
Learnt from: MODUGGAGI
PR: TeamNovaMinds/BE#34
File: src/main/java/novaminds/gradproj/config/S3Config.java:22-29
Timestamp: 2025-08-28T04:53:41.474Z
Learning: MODUGGAGI prefers explicit credential configuration through environment variables and properties files rather than using AWS default credential chain fallbacks in their Spring Boot S3Config setup.
🧬 Code graph analysis (9)
src/main/resources/application-es.yml (1)
src/test/java/novaminds/gradproj/GradprojApplicationTests.java (1)
  • SpringBootTest (6-13)
src/main/java/novaminds/gradproj/global/search/repository/IngredientSearchRepository.java (3)
src/main/java/novaminds/gradproj/domain/ingredient/repository/IngredientRepository.java (1)
  • IngredientRepository (9-16)
src/main/java/novaminds/gradproj/domain/ingredient/repository/IngredientRepositoryCustom.java (2)
  • IngredientRepositoryCustom (7-10)
  • searchIngredientsByKeyword (8-8)
src/main/java/novaminds/gradproj/domain/ingredient/repository/IngredientRepositoryCustomImpl.java (2)
  • Repository (18-41)
  • Override (24-30)
src/main/java/novaminds/gradproj/GradprojApplication.java (6)
src/main/java/novaminds/gradproj/domain/ingredient/repository/IngredientRepository.java (1)
  • IngredientRepository (9-16)
src/main/java/novaminds/gradproj/domain/userdevice/repository/UserDeviceRepository.java (1)
  • UserDeviceRepository (7-9)
src/main/java/novaminds/gradproj/domain/recipe/repository/RecipeRepository.java (1)
  • RecipeRepository (10-15)
src/main/java/novaminds/gradproj/domain/notification/repository/NotificationRepository.java (1)
  • NotificationRepository (7-8)
src/main/java/novaminds/gradproj/domain/notification/repository/NotificationSettingsRepository.java (1)
  • NotificationSettingsRepository (7-8)
src/main/java/novaminds/gradproj/domain/member/repository/MemberRepository.java (1)
  • MemberRepository (14-26)
src/main/java/novaminds/gradproj/global/search/web/dto/SearchResponseDTO.java (1)
src/main/java/novaminds/gradproj/domain/ingredient/web/dto/IngredientResponseDTO.java (4)
  • IngredientResponseDTO (10-52)
  • Builder (23-29)
  • Builder (12-21)
  • Builder (31-41)
src/main/java/novaminds/gradproj/global/search/service/SearchQueryService.java (2)
src/main/java/novaminds/gradproj/global/search/converter/SearchConverter.java (1)
  • SearchConverter (8-28)
src/main/java/novaminds/gradproj/global/search/web/dto/SearchResponseDTO.java (1)
  • SearchResponseDTO (8-24)
src/main/java/novaminds/gradproj/global/search/converter/SearchConverter.java (3)
src/main/java/novaminds/gradproj/global/search/web/dto/SearchResponseDTO.java (1)
  • SearchResponseDTO (8-24)
src/main/java/novaminds/gradproj/domain/ingredient/converter/IngredientConverter.java (4)
  • IngredientConverter (10-49)
  • toIngredientListDTO (21-30)
  • toIngredientDTO (12-19)
  • toIngredientDetailResponse (32-40)
src/main/java/novaminds/gradproj/domain/ingredient/web/dto/IngredientResponseDTO.java (2)
  • IngredientResponseDTO (10-52)
  • Builder (23-29)
src/main/java/novaminds/gradproj/global/search/IngredientDocument.java (5)
src/main/java/novaminds/gradproj/domain/ingredient/entity/Ingredient.java (1)
  • Getter (15-63)
src/main/java/novaminds/gradproj/domain/ingredient/entity/IngredientCategory.java (1)
  • NoArgsConstructor (27-54)
src/main/java/novaminds/gradproj/domain/ingredient/web/dto/IngredientResponseDTO.java (2)
  • IngredientResponseDTO (10-52)
  • Builder (12-21)
src/main/java/novaminds/gradproj/domain/ingredient/repository/IngredientRepositoryCustomImpl.java (1)
  • Repository (18-41)
src/main/java/novaminds/gradproj/domain/ingredient/converter/IngredientConverter.java (1)
  • IngredientConverter (10-49)
src/main/java/novaminds/gradproj/global/search/service/IngredientIndexingService.java (2)
src/main/java/novaminds/gradproj/domain/ingredient/repository/IngredientRepository.java (1)
  • IngredientRepository (9-16)
src/main/java/novaminds/gradproj/domain/ingredient/entity/Ingredient.java (1)
  • Getter (15-63)
src/main/java/novaminds/gradproj/global/search/web/controller/SearchController.java (3)
src/main/java/novaminds/gradproj/global/search/web/dto/SearchResponseDTO.java (1)
  • SearchResponseDTO (8-24)
src/main/java/novaminds/gradproj/domain/ingredient/web/controller/IngredientController.java (3)
  • RestController (16-42)
  • Operation (25-32)
  • Operation (34-41)
src/main/java/novaminds/gradproj/domain/ingredient/service/query/IngredientQueryService.java (1)
  • Service (17-47)
🪛 YAMLlint (1.37.1)
src/main/resources/application-es.yml

[error] 3-3: no new line character at the end of file

(new-line-at-end-of-file)

compose-dev.yml

[error] 68-68: no new line character at the end of file

(new-line-at-end-of-file)

src/main/resources/application.yml

[error] 14-14: no new line character at the end of file

(new-line-at-end-of-file)

🔇 Additional comments (10)
build.gradle (1)

29-29: Validation starter addition looks good.

src/main/java/novaminds/gradproj/global/search/IngredientDocument.java (2)

21-28: LGTM: MultiField mapping aligns with query usage.

The main field and auto/kw subfields match the query fields in SearchQueryService.


18-20: Ensure id is stored in _source but not indexed

Annotate the id field so it is present in _source while preventing indexing/search and docValues (example for a Long):
@field(type = FieldType.Long, index = false, docValues = false)
private Long id;
(If id is stored as String, use FieldType.Keyword instead.) Confirm current ES search hits include id in _source; if they do not, update the mapping or explicitly add the id into _source at indexing time.

Location: src/main/java/novaminds/gradproj/global/search/IngredientDocument.java (reported lines 18–20) — file not found in the repo during verification; unable to confirm mapping.

src/main/java/novaminds/gradproj/global/search/EsIndexBootstrap.java (1)

16-16: No action required — toolchain targets Java 21.

build.gradle sets languageVersion = JavaLanguageVersion.of(21), so local-variable type inference ('var') is supported (Java 10+).

src/main/java/novaminds/gradproj/global/search/repository/IngredientSearchRepository.java (1)

6-7: LGTM: Repository wiring is minimal and correct.

Extends ElasticsearchRepository<IngredientDocument, Long> with correct generic types. No issues.

src/main/java/novaminds/gradproj/global/search/service/SearchQueryService.java (1)

64-70: Preserve ID even if not present in _source.

If IngredientDocument.id may be null (not stored in _source), getContent().getId() can be null — use SearchHit.getId() as a fallback when mapping hits to DTOs, or persist id into _source (add @field on IngredientDocument.id).

File: src/main/java/novaminds/gradproj/global/search/service/SearchQueryService.java (lines 64–70)

  • Suggested mapping: id = doc.getId() != null ? doc.getId() : Long.parseLong(hit.getId()); set this id on the DTO.
src/main/java/novaminds/gradproj/global/search/converter/SearchConverter.java (2)

10-17: DTO mapping looks correct and consistent.

Field-to-field mapping aligns with SearchResponseDTO and IngredientDocument.


20-22: Resolved — keep Stream#toList() (project targets Java 21).
build.gradle sets java.toolchain.languageVersion = JavaLanguageVersion.of(21), so Stream.toList() is supported; no change required.

src/main/java/novaminds/gradproj/global/search/service/IngredientIndexingService.java (2)

35-41: Confirm filtering policy (e.g., only APPROVED ingredients).

If search should exclude 미승인/비공개 항목, filter at query (DB) level to avoid indexing unwanted documents.

Example (outside this file):

// IngredientRepository.java
@EntityGraph(attributePaths = "ingredientCategory")
@Query("select i from Ingredient i where i.status = novaminds.gradproj.domain.ingredient.entity.RegistrationStatus.APPROVED")
Page<Ingredient> findApprovedWithCategory(Pageable pageable);

And call findApprovedWithCategory(...) here.


15-22: Overall structure and dependencies are sound.

Service wiring and logging are clear; once paging/fetch tweaks land, this should scale.

Comment on lines +1 to +2
FROM elasticsearch:8.18.6
RUN bin/elasticsearch-plugin install analysis-nori No newline at end of file
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Make plugin install non-interactive to prevent CI hangs.

Without --batch, elasticsearch-plugin install can block waiting for confirmation during docker build.

Apply this diff:

-FROM elasticsearch:8.18.6
-RUN bin/elasticsearch-plugin install analysis-nori
+FROM elasticsearch:8.18.6
+RUN bin/elasticsearch-plugin install --batch analysis-nori
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
FROM elasticsearch:8.18.6
RUN bin/elasticsearch-plugin install analysis-nori
FROM elasticsearch:8.18.6
RUN bin/elasticsearch-plugin install --batch analysis-nori
🤖 Prompt for AI Agents
In Dockerfile.elasticsearch around lines 1 to 2, the RUN command uses
`elasticsearch-plugin install analysis-nori` which can prompt interactively
during docker builds and hang CI; modify the RUN invocation to pass the
non-interactive flag `--batch` to `elasticsearch-plugin install` so the plugin
installs without prompting (i.e., run the install with `--batch`).

Comment on lines 27 to 46
// 1. DB에서 모든 재료 데이터를 조회
List<Ingredient> allIngredients = ingredientRepository.findAll();
if (allIngredients.isEmpty()) {
log.info(">>>> 인덱싱할 재료 데이터가 없습니다.");
return;
}

// 2. Ingredient 엔티티를 IngredientDocument로 변환
List<IngredientDocument> ingredientDocuments = allIngredients.stream()
.map(ingredient -> IngredientDocument.builder()
.id(ingredient.getId())
.ingredientName(ingredient.getIngredientName())
.categoryName(ingredient.getIngredientCategory().getIngredientCategoryName())
.imageUrl(ingredient.getImageUrl())
.build())
.toList();

// 3. Elasticsearch에 변환된 데이터를 저장(인덱싱)
ingredientSearchRepository.saveAll(ingredientDocuments);

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Eliminate full-table load and N+1 on ingredientCategory; index in pages with a fetch strategy.

findAll() loads everything into memory and accessing ingredient.getIngredientCategory() (LAZY) risks N+1. Page through results and fetch the category to cut memory and queries.

-        // 1. DB에서 모든 재료 데이터를 조회
-        List<Ingredient> allIngredients = ingredientRepository.findAll();
-        if (allIngredients.isEmpty()) {
-            log.info(">>>> 인덱싱할 재료 데이터가 없습니다.");
-            return;
-        }
-
-        // 2. Ingredient 엔티티를 IngredientDocument로 변환
-        List<IngredientDocument> ingredientDocuments = allIngredients.stream()
-                .map(ingredient -> IngredientDocument.builder()
-                        .id(ingredient.getId())
-                        .ingredientName(ingredient.getIngredientName())
-                        .categoryName(ingredient.getIngredientCategory().getIngredientCategoryName())
-                        .imageUrl(ingredient.getImageUrl())
-                        .build())
-                .toList();
-
-        // 3. Elasticsearch에 변환된 데이터를 저장(인덱싱)
-        ingredientSearchRepository.saveAll(ingredientDocuments);
-
-        log.info(">>>> Ingredient 데이터 인덱싱 완료 ({}개)", ingredientDocuments.size());
+        int total = 0;
+        int pageSize = 1000;
+        var pageable = org.springframework.data.domain.PageRequest.of(0, pageSize);
+        while (true) {
+            var page = ingredientRepository.findAllWithCategory(pageable); // fetch category to avoid N+1
+            if (page.isEmpty()) break;
+
+            var docs = page.getContent().stream()
+                .map(ingredient -> IngredientDocument.builder()
+                    .id(ingredient.getId())
+                    .ingredientName(ingredient.getIngredientName())
+                    .categoryName(ingredient.getIngredientCategory().getIngredientCategoryName())
+                    .imageUrl(ingredient.getImageUrl())
+                    .build())
+                .toList();
+
+            ingredientSearchRepository.saveAll(docs);
+            total += docs.size();
+
+            if (!page.hasNext()) break;
+            pageable = page.nextPageable();
+        }
+
+        if (total == 0) {
+            log.info(">>>> 인덱싱할 재료 데이터가 없습니다.");
+            return;
+        }
+        log.info(">>>> Ingredient 데이터 인덱싱 완료 ({}개)", total);

Outside-this-file support (repository):

// IngredientRepository.java
@EntityGraph(attributePaths = "ingredientCategory")
Page<Ingredient> findAllWithCategory(Pageable pageable);

Comment on lines 60 to 61
.withPageable(PageRequest.of(page, size)) // 페이징 설정
.build();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Clamp pageable inputs to avoid ES heavy queries and negative values.

PageRequest.of(page, size) throws on negatives and large sizes can be harmful.

-                .withPageable(PageRequest.of(page, size)) // 페이징 설정
+                .withPageable(PageRequest.of(Math.max(0, page), Math.max(1, Math.min(100, size)))) // 페이징 설정(클램프)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.withPageable(PageRequest.of(page, size)) // 페이징 설정
.build();
.withPageable(PageRequest.of(Math.max(0, page), Math.max(1, Math.min(100, size)))) // 페이징 설정(클램프)
.build();
🤖 Prompt for AI Agents
In
src/main/java/novaminds/gradproj/global/search/service/SearchQueryService.java
around lines 60-61, PageRequest.of(page, size) can throw for negative values and
allow excessively large sizes that stress Elasticsearch; clamp inputs by
ensuring page >= 0 and size between 1 and a defined MAX_PAGE_SIZE (e.g., 100),
applying Math.max/Math.min or equivalent to compute safePage and safeSize, then
pass PageRequest.of(safePage, safeSize) so negatives are avoided and size is
capped to a reasonable maximum.

Comment on lines +18 to +22
@PostMapping("/admin/reindex")
public ApiResponse<String> reindexIngredients() {
ingredientIndexingService.indexIngredients();
return ApiResponse.onSuccess("인덱싱이 성공적으로 완료되었습니다.");
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Protect the reindex endpoint (authorization + environment gating).

Unauthenticated/unauthorized reindexing is a security and operational risk. Restrict to admins and optionally expose only on local/dev.

+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.context.annotation.Profile;
 ...
-    @PostMapping("/admin/reindex")
+    @PostMapping("/admin/reindex")
+    @PreAuthorize("hasRole('ADMIN')")
+    @Profile({"local","dev"})
     public ApiResponse<String> reindexIngredients() {
         ingredientIndexingService.indexIngredients();
         return ApiResponse.onSuccess("인덱싱이 성공적으로 완료되었습니다.");
     }

If Spring Security isn’t configured, at minimum add a shared secret header check or remove this endpoint from release.

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines +24 to +32
@GetMapping("/ingredients")
public ApiResponse<SearchResponseDTO.IngredientSearchList> 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);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Validate and clamp pagination parameters.

Unbounded size can hurt ES. Guard against negative page and large sizes (e.g., max 100).

-    public ApiResponse<SearchResponseDTO.IngredientSearchList> searchIngredients(
-            @RequestParam("keyword") String keyword,
-            @RequestParam(defaultValue = "0") int page,
-            @RequestParam(defaultValue = "20") int size
-    ) {
-        SearchResponseDTO.IngredientSearchList searchResults = searchQueryService.searchIngredients(keyword, page, size);
+    public ApiResponse<SearchResponseDTO.IngredientSearchList> searchIngredients(
+            @RequestParam("keyword") String keyword,
+            @RequestParam(defaultValue = "0") int page,
+            @RequestParam(defaultValue = "20") int size
+    ) {
+        page = Math.max(0, page);
+        size = Math.max(1, Math.min(100, size));
+        var searchResults = searchQueryService.searchIngredients(keyword, page, size);
         return ApiResponse.onSuccess(searchResults);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@GetMapping("/ingredients")
public ApiResponse<SearchResponseDTO.IngredientSearchList> 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);
}
@GetMapping("/ingredients")
public ApiResponse<SearchResponseDTO.IngredientSearchList> searchIngredients(
@RequestParam("keyword") String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
page = Math.max(0, page);
size = Math.max(1, Math.min(100, size));
var searchResults = searchQueryService.searchIngredients(keyword, page, size);
return ApiResponse.onSuccess(searchResults);
}
🤖 Prompt for AI Agents
In
src/main/java/novaminds/gradproj/global/search/web/controller/SearchController.java
around lines 24-32, the pagination params are unvalidated; ensure page is
non-negative and size is clamped to a safe range (e.g., 1..100) before calling
searchQueryService. At the start of the method, normalize page = Math.max(0,
page) (or return 400 for negative if you prefer strictness) and size =
Math.max(1, Math.min(size, 100)); then pass the normalized values to
searchQueryService and return the response.

Comment on lines +22 to +25
@EnableJpaRepositories(
basePackages = "novaminds.gradproj.domain"
)
@EnableElasticsearchRepositories(basePackages = "novaminds.gradproj.global.search.repository")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Gate Elasticsearch repositories behind the es profile to prevent unintended startup coupling.

If ES is unavailable (CI, prod without ES), repository/bootstrapping beans may cause startup or runtime failures. Prefer enabling ES repos only when es is active.

Proposed approach (move to a dedicated config):

@Configuration
@Profile("es")
@EnableElasticsearchRepositories(basePackages = "novaminds.gradproj.global.search.repository")
class EsRepositoriesConfig {}

Verification script to check ES beans are profile-scoped:


🏁 Script executed:

#!/bin/bash
rg -n -C2 '@EnableElasticsearchRepositories|EsIndexBootstrap|IngredientIndexingService|SearchQueryService' src/main/java
rg -n '@Profile\("es"\)' -C2 src/main/java/novaminds/gradproj/global/search

Length of output: 3715


Gate Elasticsearch behind the "es" profile (make ES repos and ES-only beans conditional).

@EnableElasticsearchRepositories is enabled unconditionally in src/main/java/novaminds/gradproj/GradprojApplication.java (line 25). ES-related beans are unprofiled and will attempt ES wiring at startup: src/main/java/novaminds/gradproj/global/search/EsIndexBootstrap.java (class ~line 11), SearchQueryService.java (line 18), IngredientIndexingService.java (line 18), SearchController.java (lines 15–16).

Action: move @EnableElasticsearchRepositories into a @configuration annotated with @Profile("es") and make ES bootstrap/services conditional (either annotate them with @Profile("es") or use @ConditionalOnBean(ElasticsearchOperations.class) / @ConditionalOnProperty). Example:

@Configuration
@Profile("es")
@EnableElasticsearchRepositories(basePackages = "novaminds.gradproj.global.search.repository")
class EsRepositoriesConfig {}
🤖 Prompt for AI Agents
In src/main/java/novaminds/gradproj/GradprojApplication.java around lines 22–25,
@EnableElasticsearchRepositories is declared unconditionally which causes ES
wiring to be attempted even when ES is not enabled; move the annotation into a
new @Configuration class annotated with @Profile("es") (e.g., create an
EsRepositoriesConfig with @Configuration, @Profile("es") and
@EnableElasticsearchRepositories(basePackages =
"novaminds.gradproj.global.search.repository")). Also make ES-related beans
conditional: update
src/main/java/novaminds/gradproj/global/search/EsIndexBootstrap.java,
SearchQueryService.java, IngredientIndexingService.java, and
SearchController.java to be either annotated with @Profile("es") or use
@ConditionalOnBean(ElasticsearchOperations.class) /
@ConditionalOnProperty("es.enabled") so they are only created when ES is active.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (6)
src/main/resources/es-setting/nori-setting.json (2)

9-15: Split index/search analyzers to avoid n‑gramming queries.

Using edge_ngram at search-time hurts relevance and can be heavy. Define a search analyzer without the n‑gram filter and wire it via searchAnalyzer in mapping.

Apply this diff:

       "analyzer": {
         "korean_unified_analyzer": {
           "type": "custom",
           "tokenizer": "nori_tokenizer",
           "filter": [ "lowercase", "nori_readingform", "edge_ngram_filter" ]
-      }
+        }
+        , "korean_unified_search_analyzer": {
+          "type": "custom",
+          "tokenizer": "nori_tokenizer",
+          "filter": [ "lowercase", "nori_readingform" ]
+        }
       }

7-7: Consider raising min_gram to reduce index bloat.

min_gram: 1 explodes term count and storage. 2 or 3 often balances UX and size for prefix search.

-      "edge_ngram_filter": { "type": "edge_ngram", "min_gram": 1, "max_gram": 20 }
+      "edge_ngram_filter": { "type": "edge_ngram", "min_gram": 2, "max_gram": 20 }
src/main/java/novaminds/gradproj/global/search/service/SearchQueryService.java (1)

35-35: Make min_score configurable.

2.0f can easily filter out valid results depending on analyzer tuning; prefer an application property.

src/main/java/novaminds/gradproj/global/search/service/IngredientIndexingService.java (1)

44-45: Consider bulk indexing via ElasticsearchOperations for throughput.

saveAll is fine for small sets; bulk operations improve speed on large batches.

src/main/java/novaminds/gradproj/global/search/IngredientDocument.java (2)

21-23: Use dedicated search analyzer (no n‑gram) for queries.

Pair with the new korean_unified_search_analyzer in settings.

-    @Field(type = FieldType.Text, analyzer = "korean_unified_analyzer")
+    @Field(type = FieldType.Text, analyzer = "korean_unified_analyzer", searchAnalyzer = "korean_unified_search_analyzer")
     private String ingredientName;

25-28: Optional: model keyword as a multi-field instead of a separate property.

A multi-field on ingredientName (adding a .kw inner Keyword) simplifies queries and keeps fields cohesive. Keep current approach if you prefer explicit fields.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7c4c5d5 and 244a7de.

📒 Files selected for processing (4)
  • src/main/java/novaminds/gradproj/global/search/IngredientDocument.java (1 hunks)
  • src/main/java/novaminds/gradproj/global/search/service/IngredientIndexingService.java (1 hunks)
  • src/main/java/novaminds/gradproj/global/search/service/SearchQueryService.java (1 hunks)
  • src/main/resources/es-setting/nori-setting.json (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: MODUGGAGI
PR: TeamNovaMinds/BE#36
File: src/main/resources/application-redis.yml:7-7
Timestamp: 2025-08-29T02:33:01.862Z
Learning: MODUGGAGI is willing to use hardcoded passwords for local development/testing phases before deployment, prioritizing development convenience over strict security practices during the development phase.
Learnt from: MODUGGAGI
PR: TeamNovaMinds/BE#34
File: src/main/java/novaminds/gradproj/config/S3Config.java:22-29
Timestamp: 2025-08-28T04:53:41.474Z
Learning: MODUGGAGI prefers explicit credential configuration through environment variables and properties files rather than using AWS default credential chain fallbacks in their Spring Boot S3Config setup.
🧬 Code graph analysis (3)
src/main/java/novaminds/gradproj/global/search/service/IngredientIndexingService.java (2)
src/main/java/novaminds/gradproj/domain/ingredient/repository/IngredientRepository.java (1)
  • IngredientRepository (9-16)
src/main/java/novaminds/gradproj/domain/ingredient/entity/Ingredient.java (1)
  • Getter (15-63)
src/main/java/novaminds/gradproj/global/search/IngredientDocument.java (4)
src/main/java/novaminds/gradproj/domain/ingredient/entity/Ingredient.java (1)
  • Getter (15-63)
src/main/java/novaminds/gradproj/domain/ingredient/web/dto/IngredientResponseDTO.java (3)
  • IngredientResponseDTO (10-52)
  • Builder (12-21)
  • Builder (23-29)
src/main/java/novaminds/gradproj/domain/ingredient/entity/IngredientCategory.java (1)
  • NoArgsConstructor (27-54)
src/main/java/novaminds/gradproj/domain/ingredient/converter/IngredientConverter.java (1)
  • IngredientConverter (10-49)
src/main/java/novaminds/gradproj/global/search/service/SearchQueryService.java (2)
src/main/java/novaminds/gradproj/domain/ingredient/service/query/IngredientQueryService.java (2)
  • Service (17-47)
  • getIngredients (25-39)
src/main/java/novaminds/gradproj/domain/ingredient/repository/IngredientRepositoryCustomImpl.java (2)
  • Repository (18-41)
  • Override (24-30)
🔇 Additional comments (3)
src/main/java/novaminds/gradproj/global/search/service/SearchQueryService.java (2)

31-33: Extract field names into constants.

Avoid hardcoded strings for "ingredientName" and "ingredientNameKw" to reduce typos and ease maintenance.


27-37: Avoid multi_match on a Keyword field; use match + term and clamp pagination.

multiMatch with ingredientNameKw (Keyword) is suboptimal and may error with fuzziness. Match on the Text field and add a boosting exact term on the Keyword field. Also clamp page/size.

-        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();
+        NativeQuery query = NativeQuery.builder()
+                .withQuery(q -> q
+                        .bool(b -> b
+                                .must(m -> m.match(mm -> mm
+                                        .field("ingredientName")
+                                        .query(keyword)
+                                        .fuzziness("AUTO")
+                                ))
+                                .should(s -> s.term(t -> t
+                                        .field("ingredientNameKw")
+                                        .value(keyword)
+                                ))
+                        )
+                )
+                .withMinScore(2.0f)
+                .withPageable(PageRequest.of(Math.max(0, page), Math.max(1, Math.min(100, size))))
+                .build();
src/main/java/novaminds/gradproj/global/search/service/IngredientIndexingService.java (1)

22-48: Batch index to avoid OOM and N+1; also index normalized name for exact matches.

findAll() loads everything and dereferencing ingredientCategory risks N+1. Page through with a fetch strategy and use the normalized name for the Keyword field.

-    @Transactional(readOnly = true)
-    public void indexIngredients() {
-        log.info(">>>> Ingredient 데이터 인덱싱 시작...");
-
-        // 1. DB에서 모든 재료 데이터를 조회
-        List<Ingredient> allIngredients = ingredientRepository.findAll();
-        if (allIngredients.isEmpty()) {
-            log.info(">>>> 인덱싱할 재료 데이터가 없습니다.");
-            return;
-        }
-
-        // 2. Ingredient 엔티티를 IngredientDocument로 변환
-        List<IngredientDocument> 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());
-    }
+    @Transactional(readOnly = true)
+    public void indexIngredients() {
+        log.info(">>>> Ingredient 데이터 인덱싱 시작...");
+
+        int pageSize = 1000;
+        var pageable = org.springframework.data.domain.PageRequest.of(0, pageSize);
+        long total = 0;
+
+        while (true) {
+            var page = ingredientRepository.findAllWithCategory(pageable); // fetch join to avoid N+1
+            if (page.isEmpty()) break;
+
+            List<IngredientDocument> docs = page.getContent().stream()
+                    .map(ingredient -> IngredientDocument.builder()
+                            .id(ingredient.getId())
+                            .ingredientName(ingredient.getIngredientName())
+                            .ingredientNameKw(ingredient.getIngredientNameNormalized())
+                            .categoryName(ingredient.getIngredientCategory().getIngredientCategoryName())
+                            .imageUrl(ingredient.getImageUrl())
+                            .build())
+                    .toList();
+
+            ingredientSearchRepository.saveAll(docs);
+            total += docs.size();
+
+            if (!page.hasNext()) break;
+            pageable = page.nextPageable();
+        }
+
+        if (total == 0) {
+            log.info(">>>> 인덱싱할 재료 데이터가 없습니다.");
+            return;
+        }
+
+        log.info(">>>> Ingredient 데이터 인덱싱 완료 ({}개)", total);
+    }

Outside-this-file support:

// IngredientRepository.java
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;

@EntityGraph(attributePaths = "ingredientCategory")
Page<Ingredient> findAllWithCategory(Pageable pageable);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ feature add feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] 재료 검색에 기초 ElasticSearch 적용해보기

1 participant