Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
584bce8
✨feat: ElasticSearch μ˜μ‘΄μ„± μΆ”κ°€ 및 μ„€μ • μΆ”κ°€
MODUGGAGI Sep 12, 2025
e995197
✨feat: ElasticSearchλ₯Ό Docker둜 pull
MODUGGAGI Sep 12, 2025
57af20e
✨feat: Jpa와 ElasticSearch의 ν™˜κ²½ μ„€μ • 뢄리
MODUGGAGI Sep 12, 2025
3991229
✨feat: Nori Analysis ν”ŒλŸ¬κ·ΈμΈ μ„€μΉ˜λ₯Ό μœ„ν•΄ Elastic Search 이미지λ₯Ό Dockerfile둜 μ„€μΉ˜
MODUGGAGI Sep 12, 2025
2d1aca5
✨feat: Dockerfileμ—μ„œ λΉŒλ“œλœ 이미지 μ‚¬μš©
MODUGGAGI Sep 12, 2025
f037cc2
✨feat: Nori analysis μ„€μ • μΆ”κ°€
MODUGGAGI Sep 12, 2025
69a7249
✨feat: ElasticSearch 컨트둀러 μΆ”κ°€
MODUGGAGI Sep 12, 2025
e7d0573
✨feat: 검색 μ „μš© 컨버터 μΆ”κ°€
MODUGGAGI Sep 12, 2025
031c70a
✨feat: 검색 μ „μš© DTO μΆ”κ°€
MODUGGAGI Sep 12, 2025
38a743d
✨feat: ElasticSearch μ „μš© μ €μž₯μ†Œ μΆ”κ°€
MODUGGAGI Sep 12, 2025
09caa77
✨feat: ElasticSearch에 μ €μž₯ν•˜λŠ” μš©λ„μ˜ μ„œλΉ„μŠ€ μΆ”κ°€
MODUGGAGI Sep 12, 2025
224e149
✨feat: ElasticSearch에 μ €μž₯ν•  인덱슀 클래슀
MODUGGAGI Sep 12, 2025
705eae0
✨feat: ElasticSearch 인덱슀 μ΄ˆκΈ°ν™” κΈ°λŠ₯ μΆ”κ°€
MODUGGAGI Sep 12, 2025
7c4c5d5
✨feat: ElasticSearch ν™œμš©ν•΄μ„œ 검색 κΈ°λŠ₯ μΆ”κ°€
MODUGGAGI Sep 13, 2025
244a7de
✨feat: ElasticSearch ν™œμš©ν•΄μ„œ 검색 κΈ°λŠ₯ μΆ”κ°€
MODUGGAGI Sep 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Dockerfile.elasticsearch
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
FROM elasticsearch:8.18.6
RUN bin/elasticsearch-plugin install analysis-nori
Comment on lines +1 to +2
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`).

2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
30 changes: 29 additions & 1 deletion compose-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
redis_data:
es-data-dev:
10 changes: 9 additions & 1 deletion src/main/java/novaminds/gradproj/GradprojApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment on lines +22 to +25
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.

public class GradprojApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 λ§€ν•‘ 반영
}
Comment on lines +17 to +20
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 λ§€ν•‘ 반영

}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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 {

Comment on lines +8 to +9
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() {
        // μœ ν‹Έλ¦¬ν‹° ν΄λž˜μŠ€λŠ” μΈμŠ€ν„΄μŠ€ν™”ν•  수 μ—†μŠ΅λ‹ˆλ‹€.
    }

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<IngredientDocument> documents) {
List<SearchResponseDTO.IngredientSearchResult> results = documents.stream()
.map(SearchConverter::toIngredientSearchResultDTO)
.toList();

return SearchResponseDTO.IngredientSearchList.builder()
.results(results)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -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<IngredientDocument, Long> {
}
Original file line number Diff line number Diff line change
@@ -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<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());
}
}
Original file line number Diff line number Diff line change
@@ -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<IngredientDocument> hits = operations.search(query, IngredientDocument.class);
List<IngredientDocument> docs = hits.getSearchHits().stream()
.map(SearchHit::getContent).toList();

return SearchConverter.toIngredientSearchListDTO(docs);
}
}
Original file line number Diff line number Diff line change
@@ -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")
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')")κ³Ό 같은 μ–΄λ…Έν…Œμ΄μ…˜μ„ μΆ”κ°€ν•˜λŠ” 것을 κ³ λ €ν•΄ λ³΄μ„Έμš”.

public ApiResponse<String> reindexIngredients() {
ingredientIndexingService.indexIngredients();
return ApiResponse.onSuccess("인덱싱이 μ„±κ³΅μ μœΌλ‘œ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.");
Comment on lines +18 to +21

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 πŸ‘Β / πŸ‘Ž.

}
Comment on lines +18 to +22
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.


@GetMapping("/ingredients")
public ApiResponse<SearchResponseDTO.IngredientSearchList> searchIngredients(
@RequestParam("keyword") String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
Comment on lines +27 to +28
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

νŽ˜μ΄μ§€λ„€μ΄μ…˜ νŒŒλΌλ―Έν„°μΈ page와 size에 λŒ€ν•œ μœ νš¨μ„± 검사가 μ—†μŠ΅λ‹ˆλ‹€. μ‚¬μš©μžκ°€ λΉ„μ •μƒμ μœΌλ‘œ 큰 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

) {
SearchResponseDTO.IngredientSearchList searchResults = searchQueryService.searchIngredients(keyword, page, size);
return ApiResponse.onSuccess(searchResults);
}
Comment on lines +24 to +32
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.

}
Original file line number Diff line number Diff line change
@@ -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<IngredientSearchResult> results;
}

@Getter
@Builder
public static class IngredientSearchResult {
private Long id;
private String name;
private String category;
private String imageUrl;
}
}
3 changes: 3 additions & 0 deletions src/main/resources/application-es.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
spring:
elasticsearch:
uris: http://localhost:9200
3 changes: 2 additions & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ spring:
- jwt
- s3
- email
- redis
- redis
- es
17 changes: 17 additions & 0 deletions src/main/resources/es-setting/nori-setting.json
Original file line number Diff line number Diff line change
@@ -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" ]
}
}
}
}