diff --git a/RECOMMENDED_SEARCH_MIGRATION.md b/RECOMMENDED_SEARCH_MIGRATION.md new file mode 100644 index 00000000..15d1cf75 --- /dev/null +++ b/RECOMMENDED_SEARCH_MIGRATION.md @@ -0,0 +1,232 @@ +# 추천 레시피 검색 + 재료 동의어 DB 이전 마이그레이션 가이드 + +## 배경 + +기존 추천 검색 (`findRecommendedRecipesByUserFridge`) 의 두 가지 한계: + +1. **재료 매칭이 `ingredientName` exact equality** — 케이스/공백 차이 흡수 못함. 한 RecipeIngredient 가 "양파 1/4개" 같이 단어 + 단위 형태일 때 "양파" fridge 와 매치 안 됨. +2. **동의어 매핑이 Java `if/else` 체인** (`Ingredient.getIngredientNameWithSimilar()`) — 운영 중 동의어 추가 시 코드 변경 + 배포 필요. 14개 그룹 33개 단어가 엔티티 메서드 안에 박혀있던 상태. + +이번 변경: +- 동의어를 **`IngredientSynonym` DB 테이블** + 부팅 시 캐시 로드로 분리 +- 매칭을 `RecipeIngredient.searchTokens` (lowercase+trim 정규화) **FULLTEXT MATCH** 기반으로 전환 +- 3-way 동의어 그룹 (새싹채소-어린잎채소-무순, 조개-조갯살-바지락) 의 transitive 매칭으로 통일 + +--- + +## 작업 완료 내역 (코드 측) + +### 추가된 커밋 + +``` +ed4e08f refactor: 추천 레시피 매칭을 RecipeIngredient.searchTokens FULLTEXT 기반으로 변경 +1e19022 refactor: 재료 동의어를 Java if/else 에서 IngredientSynonym DB 테이블로 이전 +5b931f3 test: 검색 Repository 통합 테스트의 키워드 인자를 SearchQuery 로 갱신 + 1글자 ExactToken 케이스 추가 +``` + +### 핵심 설계 결정 + +| 결정 | 선택 | 이유 | +|---|---|---| +| 동의어 저장 | DB 테이블 (`IngredientSynonym`) | 운영 중 코드 배포 없이 추가 가능 | +| 동의어 모델 | `(groupId, name)` 페어 — 같은 groupId = 동의어 그룹 | 단순. 3-way 이상 자연스럽게 표현. 양방향/단방향 고민 불필요 | +| 매칭 transitive | 그룹 안 모든 단어가 서로 동의어 | 기존 if/else 의 비대칭 (어린잎채소→새싹채소만) 보다 자연스러움 | +| 캐시 전략 | `@PostConstruct` 부팅 시 1회 로드 (HashMap 기반) | 동의어 추가 빈도 낮음. TTL/무효화 매커니즘 불필요. DB 추가 시 인스턴스 재시작 필요 | +| 매칭 컬럼 | `RecipeIngredient.searchTokens` (lowercase + trim) | 케이스/공백 흡수. nori 미적용 (도메인 단어 보존) | +| 매칭 식 | `MATCH(searchTokens) AGAINST('... ... ...' IN BOOLEAN MODE)` (OR) | FULLTEXT 인덱스 사용. fridge 모든 동의어를 한 쿼리로 OR 매칭 | +| `hasInFridge` 시그니처 | `List` → `Set normalizedFridgeNames` | 정규화 1회 + 도메인 메서드 의도 명확화 | +| `calculateIngredientMatchRate` 시그니처 | 동일하게 `Set` | 일관성 | +| 정규화 위치 | DTO (`RecipeDetailResponse`, `RecommendedRecipesResponse`) 진입부 | fridge 리스트 흐름 중 한 번만 정규화 | + +### 추가된 / 변경된 파일 + +**신규** +- `src/main/java/com/recipe/app/src/ingredient/domain/IngredientSynonym.java` — 동의어 엔티티 (groupId + name) +- `src/main/java/com/recipe/app/src/ingredient/infra/IngredientSynonymRepository.java` — JpaRepository +- `src/main/java/com/recipe/app/src/ingredient/application/IngredientSynonymCache.java` — 부팅 시 로드 + `expand(Collection)` API +- `src/test/groovy/com/recipe/app/src/ingredient/application/IngredientSynonymCacheTest.groovy` — 동의어 그룹 / transitive / 미등록 / 빈 입력 케이스 + +**변경** +- `src/main/java/com/recipe/app/src/ingredient/domain/Ingredient.java` — `getIngredientNameWithSimilar()` 통째 제거 (54줄) +- `src/main/java/com/recipe/app/src/fridge/application/FridgeService.java` — `findIngredientNamesInFridge` 가 cache.expand 호출 +- `src/main/java/com/recipe/app/src/recipe/domain/Recipe.java` — `calculateIngredientMatchRate(Set)`. 빈 ingredients 처리 +- `src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java` — `hasInFridge(Set)` searchTokens 토큰 교집합 매칭. 생성자에서 searchTokens 즉시 채움 (단위 테스트 호환) +- `src/main/java/com/recipe/app/src/recipe/application/dto/RecipeDetailResponse.java` — 진입 시 fridge 이름 정규화 Set 변환 +- `src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipesResponse.java` — 동일 +- `src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java` — `findRecipesInFridge` 가 FULLTEXT MATCH OR 매칭 +- `src/test/groovy/com/recipe/app/src/recipe/domain/RecipeIngredientTest.groovy` — Set 시그니처 +- `src/test/groovy/com/recipe/app/src/recipe/domain/RecipeTest.groovy` — Set 시그니처 +- `src/test/groovy/com/recipe/app/src/fridge/application/FridgeServiceTest.groovy` — IngredientSynonymCache mock 추가 +- `src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy` — SearchQuery 시그니처 + ExactToken 케이스 추가 +- `src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy` — 동일 +- `src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy` — 동일 + +### 자동 검증된 것 +- `./gradlew compileJava compileTestGroovy` — 통과 +- 단위 테스트 + - `IngredientSynonymCacheTest` — 동의어 그룹 매칭 / 3-way transitive / 미등록 / 빈 입력 + - `RecipeIngredientTest` — `hasInFridge(Set)` searchTokens 매칭 + - `RecipeTest` — `calculateIngredientMatchRate(Set)` 일치율 계산 +- mock 기반 서비스 테스트 + - `FridgeServiceTest` — cache 주입 후 `findIngredientNamesInFridge` 동작 + - `RecipeSearchServiceTest` — 추천/상세 케이스 (matchRate 100/33 검증 포함) + +### 변경 후 동의어 동작 비교 + +| 입력 | 기존 (if/else) | 새 (DB groupId) | +|---|---|---| +| 새싹채소 | [어린잎채소, 무순, 새싹채소] | [어린잎채소, 무순, 새싹채소] | +| 어린잎채소 | [새싹채소, 어린잎채소] (무순 X) | [새싹채소, 어린잎채소, **무순**] | +| 무순 | [새싹채소, 무순] (어린잎채소 X) | [새싹채소, **어린잎채소**, 무순] | +| 조갯살 | [조개, 조갯살] (바지락 X) | [조개, **바지락**, 조갯살] | +| 바지락 | [조개, 바지락] (조갯살 X) | [조개, **조갯살**, 바지락] | +| 새우 / 대하 | 양방향 OK | 양방향 OK (변동 없음) | +| 그 외 14 그룹 | 양방향 OK | 양방향 OK (변동 없음) | + +→ 3-way 그룹 2개 (새싹채소·조개) 의 비대칭이 transitive 로 통일됨. + +--- + +## 본인이 직접 해야 하는 것 + +### Step 1 — DB 스키마 + 초기 데이터 + +```sql +CREATE TABLE IngredientSynonym ( + synonymId BIGINT PRIMARY KEY AUTO_INCREMENT, + groupId BIGINT NOT NULL, + name VARCHAR(64) NOT NULL, + UNIQUE KEY uk_synonym_name (name), + KEY idx_synonym_group (groupId) +); + +-- 기존 if/else 그대로 이전 (14 그룹 / 29 단어) +INSERT INTO IngredientSynonym (groupId, name) VALUES + (1, '새우'), (1, '대하'), + (2, '계란'), (2, '달걀'), + (3, '소고기'), (3, '쇠고기'), + (4, '후추'), (4, '후춧가루'), + (5, '간마늘'), (5, '다진마늘'), + (6, '새싹채소'), (6, '어린잎채소'), (6, '무순'), + (7, '조개'), (7, '조갯살'), (7, '바지락'), + (8, '케찹'), (8, '케첩'), + (9, '소면'), (9, '국수'), + (10, '김치'), (10, '김칫잎'), + (11, '고춧가루'), (11, '고추가루'), + (12, '올리브유'), (12, '올리브오일'), + (13, '파스타'), (13, '스파게티'), + (14, '포도씨유'), (14, '식용유'); +``` + +### Step 2 — 검증 + +#### 2-A. 부팅 로그 +``` +IngredientSynonymCache loaded - groups=14, words=29 +``` +이 라인이 `INFO` 레벨로 찍히는지 확인. + +#### 2-B. 동의어 확장 동작 +```sql +SELECT name, groupId FROM IngredientSynonym ORDER BY groupId, name; +``` +14 그룹 29 row 가 정확히 들어왔는지. + +#### 2-C. 추천 검색 동작 +- 냉장고에 "어린잎채소" 만 등록 → 추천 호출 → "무순" 사용한 레시피도 매치되는지 (transitive 동작) +- 냉장고에 "양파" 등록 → 추천 호출 → 결과에 "양파" 들어간 레시피 + matchRate 가 정상 계산되는지 +- 냉장고에 "Onion" 등록 (대문자) → 추천 호출 → "onion" / "Onion" / "ONION" 들어간 레시피 모두 매치되는지 (정규화 동작) + +#### 2-D. 단위 테스트 +``` +./gradlew test +``` +모두 통과 확인. 만약 `RecipeCustomRepositoryTest` / `BlogRecipeCustomRepositoryTest` / `YoutubeRecipeCustomRepositoryTest` 의 ExactToken 케이스가 깨지면 **테스트 DB 의 FULLTEXT 인덱스 + searchTokens 백필 적용 여부** 확인 (`SEARCH_NORI_MIGRATION.md` 참조). + +--- + +### Step 3 — 운영 환경 적용 + +``` +1. 운영 DB 에 Step 1 의 DDL + INSERT 적용 +2. 코드 배포 +3. Step 2 의 검증 절차 수행 +4. 모니터링: matchRate 분포 / 추천 결과 변화 빈도 +``` + +#### 롤백 시나리오 + +문제 발견 시: +- **코드 롤백**: 이번 시리즈의 첫 커밋 (`5b931f3`) 직전인 `5168081 feat: 1글자 검색어는 정확 매칭...` 으로 revert +- **DB 롤백 불필요**: `IngredientSynonym` 테이블이 남아있어도 무영향 (이전 코드는 안 읽음) + +--- + +### Step 4 — 운영 중 동의어 추가/수정 + +새 동의어 그룹 추가: +```sql +-- 다음 사용 가능한 groupId 확인 +SELECT MAX(groupId) FROM IngredientSynonym; + +-- 새 그룹 +INSERT INTO IngredientSynonym (groupId, name) VALUES + (15, '아보카도'), (15, 'avocado'); +``` + +기존 그룹에 단어 추가: +```sql +INSERT INTO IngredientSynonym (groupId, name) VALUES (1, '왕새우'); +``` + +⚠️ **반영 절차**: +- 캐시는 부팅 시 1회 로드. DB 만 수정하면 적용 안 됨 +- **인스턴스 재시작** 필요 (다운타임 약 1분) +- 또는 **수동 reload endpoint 추가** (선택, 운영 부담이 커지면 도입) + +--- + +### Step 5 — 후속 검토 (선택) + +#### Public 추천 API 의 동의어 확장 ✅ 완료 + +`findPublicRecommendedRecipesByIngredients` 도 사용자 냉장고 추천과 동일하게 `IngredientSynonymCache.expand` 후 FULLTEXT 매칭하도록 통일. matchRate 도 expanded ingredients 기반으로 계산. + +- `RecipeSearchService` 에 `IngredientSynonymCache` 의존성 추가 +- `findPublicRecommendedRecipesByIngredients`: `expand(input)` → `findRecipesInFridge(expanded)` → `RecommendedRecipesResponse.from(..., expanded, ...)` +- 테스트: `RecipeSearchServiceTest` 에 cache mock 주입 + Public 추천 케이스 2개 (expand 인자 전달 검증 / 빈 입력) + +#### 수동 cache reload endpoint +운영자가 동의어 추가 후 재시작 없이 적용하려면: +```java +@PostMapping("/admin/ingredient-synonyms/reload") +public void reload() { + ingredientSynonymCache.reload(); +} +``` +인증/권한 정책 결정 후 도입. + +#### 사용자 정의 사전 (운영 데이터로 확장) +운영해보면서 자주 다른 표현으로 등록되는 재료명 패턴이 보이면 동의어 그룹 추가: + +```sql +-- 운영 데이터에서 비슷한 재료명 패턴 찾기 +SELECT ingredientName, COUNT(*) +FROM RecipeIngredient +GROUP BY ingredientName +ORDER BY COUNT(*) DESC +LIMIT 100; +``` + +상위 100개에서 변종 (예: "양파 1/2개" vs "양파" vs "어니언") 보이면 후보. 단 자유 입력 ingredientName 자체는 동의어 테이블이 매핑 못 하므로 (마스터 단위 매핑이라), 이 케이스는 클라이언트에서 정규화하거나 별도 자유텍스트 매핑 테이블 검토가 필요할 수 있음. + +#### 디버그 코드 정리 +세션 중 `JwtFilter.java` 에 추가된 임시 `System.out.println(jwtUtil.createAccessToken(23988L));` 라인은 운영 배포 전 제거 권장 (이번 작업 범위 밖이라 commit 안 함). + +--- + +## 참고 + +- 동의어 그룹 모델은 transitive 가 항상 true. 비대칭이 필요한 케이스는 현재 모델로 표현 불가 +- 캐시는 단순 in-memory `Map>`. Caffeine 등 별도 캐시 인프라 사용 안 함 (재시작 시 자동 새로 로드) +- `IngredientSynonym.name` 에 unique 제약 — 같은 단어가 여러 그룹에 못 속함. 도메인상 자연스러운 제약 diff --git a/build.gradle b/build.gradle index 2b685f44..3efdf6ed 100644 --- a/build.gradle +++ b/build.gradle @@ -71,6 +71,9 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + // 한국어 형태소 분석기 (검색 토큰화 용) + implementation 'org.apache.lucene:lucene-analysis-nori:9.11.1' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.spockframework:spock-core:2.4-M1-groovy-4.0' diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/com/recipe/app/src/common/config/MysqlFulltextFunctionContributor.java b/src/main/java/com/recipe/app/src/common/config/MysqlFulltextFunctionContributor.java new file mode 100644 index 00000000..0066116d --- /dev/null +++ b/src/main/java/com/recipe/app/src/common/config/MysqlFulltextFunctionContributor.java @@ -0,0 +1,25 @@ +package com.recipe.app.src.common.config; + +import org.hibernate.boot.model.FunctionContributions; +import org.hibernate.boot.model.FunctionContributor; +import org.hibernate.type.StandardBasicTypes; + +/** + * Hibernate 에 MySQL FULLTEXT MATCH AGAINST 함수를 등록한다. + * QueryDSL 등에서 function('match_against', col, query) 로 호출 가능. + */ +public class MysqlFulltextFunctionContributor implements FunctionContributor { + + @Override + public void contributeFunctions(FunctionContributions functionContributions) { + + functionContributions.getFunctionRegistry() + .registerPattern( + "match_against", + "MATCH(?1) AGAINST(?2 IN BOOLEAN MODE)", + functionContributions.getTypeConfiguration() + .getBasicTypeRegistry() + .resolve(StandardBasicTypes.DOUBLE) + ); + } +} diff --git a/src/main/java/com/recipe/app/src/common/utils/KoreanTokenizer.java b/src/main/java/com/recipe/app/src/common/utils/KoreanTokenizer.java new file mode 100644 index 00000000..4351d8fb --- /dev/null +++ b/src/main/java/com/recipe/app/src/common/utils/KoreanTokenizer.java @@ -0,0 +1,44 @@ +package com.recipe.app.src.common.utils; + +import org.apache.lucene.analysis.TokenStream; +import org.apache.lucene.analysis.ko.KoreanAnalyzer; +import org.apache.lucene.analysis.ko.KoreanPartOfSpeechStopFilter; +import org.apache.lucene.analysis.ko.KoreanTokenizer.DecompoundMode; +import org.apache.lucene.analysis.tokenattributes.CharTermAttribute; + +import java.io.IOException; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; + +public final class KoreanTokenizer { + + private static final KoreanAnalyzer ANALYZER = new KoreanAnalyzer( + null, + DecompoundMode.NONE, + KoreanPartOfSpeechStopFilter.DEFAULT_STOP_TAGS, + false + ); + + private KoreanTokenizer() { + } + + public static String tokenize(String text) { + + if (text == null || text.isBlank()) return ""; + + List tokens = new ArrayList<>(); + try (TokenStream ts = ANALYZER.tokenStream(null, new StringReader(text))) { + CharTermAttribute attr = ts.addAttribute(CharTermAttribute.class); + ts.reset(); + while (ts.incrementToken()) { + String token = attr.toString(); + if (!token.isEmpty()) tokens.add(token); + } + ts.end(); + } catch (IOException e) { + throw new IllegalStateException("Failed to tokenize: " + text, e); + } + return String.join(" ", tokens); + } +} diff --git a/src/main/java/com/recipe/app/src/common/utils/QueryUtils.java b/src/main/java/com/recipe/app/src/common/utils/QueryUtils.java index e852b4b9..29598047 100644 --- a/src/main/java/com/recipe/app/src/common/utils/QueryUtils.java +++ b/src/main/java/com/recipe/app/src/common/utils/QueryUtils.java @@ -1,6 +1,8 @@ package com.recipe.app.src.common.utils; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.StringPath; import java.util.function.BiFunction; import java.util.function.Function; @@ -14,4 +16,36 @@ public static BooleanExpression ifIdIsNotNullAndGreaterThanZero(BiFunction function, Long id) { return id != null && id > 0 ? function.apply(id) : null; } + + /** + * MySQL FULLTEXT BOOLEAN MODE 매칭 조건. SearchKeywordNormalizer 가 만든 BOOLEAN 모드 쿼리 문자열을 받는다. + */ + public static BooleanExpression matchAgainst(StringPath column, String booleanQuery) { + return Expressions.numberTemplate(Double.class, + "function('match_against', {0}, {1})", column, booleanQuery).gt(0); + } + + /** + * 1글자 토큰 정확 매칭. FULLTEXT 인덱스의 ngram_token_size 제약 때문에 1글자는 BOOLEAN 모드로 잡지 못해 + * 공백 구분 단어 경계 LIKE 로 대체. 풀스캔이라 비용 있지만 1글자 검색 빈도 자체가 낮다고 가정. + * 매칭 예: searchTokens="갓" 또는 "오 갓 김치" -> "갓" 매치, "갓김치"는 매치 안 됨. + */ + public static BooleanExpression exactTokenMatch(StringPath column, String token) { + return Expressions.booleanTemplate( + "concat(' ', {0}, ' ') like concat('% ', {1}, ' %')", + column, token); + } + + /** + * SearchQuery 타입에 맞춰 적절한 매칭 식을 반환. Empty 는 호출 직전 서비스에서 걸러져야 정상. + */ + public static BooleanExpression matchSearchQuery(StringPath column, SearchKeywordNormalizer.SearchQuery query) { + if (query instanceof SearchKeywordNormalizer.SearchQuery.BooleanQuery b) { + return matchAgainst(column, b.query()); + } + if (query instanceof SearchKeywordNormalizer.SearchQuery.ExactToken e) { + return exactTokenMatch(column, e.token()); + } + return Expressions.asBoolean(false).isTrue(); + } } diff --git a/src/main/java/com/recipe/app/src/common/utils/SearchKeywordNormalizer.java b/src/main/java/com/recipe/app/src/common/utils/SearchKeywordNormalizer.java new file mode 100644 index 00000000..9300aae1 --- /dev/null +++ b/src/main/java/com/recipe/app/src/common/utils/SearchKeywordNormalizer.java @@ -0,0 +1,51 @@ +package com.recipe.app.src.common.utils; + +import java.util.Arrays; +import java.util.stream.Collectors; + +public final class SearchKeywordNormalizer { + + private SearchKeywordNormalizer() { + } + + /** + * 사용자 입력을 검색 의도에 맞는 쿼리 형태로 변환한다. + *
    + *
  • {@link SearchQuery.Empty}: 입력이 비었거나 토큰화 결과가 빈 경우. 호출자는 검색 자체를 스킵
  • + *
  • {@link SearchQuery.ExactToken}: 1글자 입력. FULLTEXT BOOLEAN 모드는 토큰 최소 길이 제약 때문에 + * 사용 불가하므로 단어 경계 정확 매칭으로 처리 (예: "갓","닭","감")
  • + *
  • {@link SearchQuery.BooleanQuery}: 2글자 이상. nori 토큰화 후 +token AND BOOLEAN 모드
  • + *
+ */ + public static SearchQuery normalize(String rawKeyword) { + + if (rawKeyword == null) return new SearchQuery.Empty(); + + String stripped = rawKeyword + .replaceAll("[+\\-><()~*\"@]", " ") + .trim() + .replaceAll("\\s+", " ") + .toLowerCase(); + + if (stripped.isEmpty()) return new SearchQuery.Empty(); + + if (stripped.length() == 1) return new SearchQuery.ExactToken(stripped); + + String tokenized = KoreanTokenizer.tokenize(stripped); + if (tokenized.isBlank()) return new SearchQuery.Empty(); + + String[] tokens = tokenized.split(" "); + String boolQuery = Arrays.stream(tokens) + .filter(t -> !t.isBlank()) + .map(t -> "+" + t) + .collect(Collectors.joining(" ")); + + return boolQuery.isBlank() ? new SearchQuery.Empty() : new SearchQuery.BooleanQuery(boolQuery); + } + + public sealed interface SearchQuery { + record Empty() implements SearchQuery {} + record ExactToken(String token) implements SearchQuery {} + record BooleanQuery(String query) implements SearchQuery {} + } +} diff --git a/src/main/java/com/recipe/app/src/fridge/application/FridgeService.java b/src/main/java/com/recipe/app/src/fridge/application/FridgeService.java index 66f62fe9..2df9aa69 100644 --- a/src/main/java/com/recipe/app/src/fridge/application/FridgeService.java +++ b/src/main/java/com/recipe/app/src/fridge/application/FridgeService.java @@ -10,6 +10,7 @@ import com.recipe.app.src.fridgeBasket.domain.FridgeBasket; import com.recipe.app.src.ingredient.application.IngredientCategoryService; import com.recipe.app.src.ingredient.application.IngredientService; +import com.recipe.app.src.ingredient.application.IngredientSynonymCache; import com.recipe.app.src.ingredient.domain.Ingredient; import com.recipe.app.src.ingredient.domain.IngredientCategory; import com.recipe.app.src.user.domain.User; @@ -29,12 +30,14 @@ public class FridgeService { private final FridgeBasketService fridgeBasketService; private final IngredientService ingredientService; private final IngredientCategoryService ingredientCategoryService; + private final IngredientSynonymCache ingredientSynonymCache; - public FridgeService(FridgeRepository fridgeRepository, FridgeBasketService fridgeBasketService, IngredientService ingredientService, IngredientCategoryService ingredientCategoryService) { + public FridgeService(FridgeRepository fridgeRepository, FridgeBasketService fridgeBasketService, IngredientService ingredientService, IngredientCategoryService ingredientCategoryService, IngredientSynonymCache ingredientSynonymCache) { this.fridgeRepository = fridgeRepository; this.fridgeBasketService = fridgeBasketService; this.ingredientService = ingredientService; this.ingredientCategoryService = ingredientCategoryService; + this.ingredientSynonymCache = ingredientSynonymCache; } @Transactional @@ -140,9 +143,11 @@ public List findIngredientNamesInFridge(Long userId) { List ingredients = ingredientService.findByIngredientIds(ingredientIds); - return ingredients.stream() - .flatMap(ingredient -> ingredient.getIngredientNameWithSimilar().stream()) + List rawNames = ingredients.stream() + .map(Ingredient::getIngredientName) .collect(Collectors.toList()); + + return List.copyOf(ingredientSynonymCache.expand(rawNames)); } private List getIngredientIdsInFridges(Collection fridges) { diff --git a/src/main/java/com/recipe/app/src/ingredient/application/IngredientSynonymCache.java b/src/main/java/com/recipe/app/src/ingredient/application/IngredientSynonymCache.java new file mode 100644 index 00000000..0cef37e3 --- /dev/null +++ b/src/main/java/com/recipe/app/src/ingredient/application/IngredientSynonymCache.java @@ -0,0 +1,70 @@ +package com.recipe.app.src.ingredient.application; + +import com.recipe.app.src.ingredient.domain.IngredientSynonym; +import com.recipe.app.src.ingredient.infra.IngredientSynonymRepository; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * IngredientSynonym 테이블의 동의어 그룹을 메모리 캐시로 들고있다가 단어 -> 그룹 전체 단어 집합을 반환한다. + * 운영 중 DB 에 새 동의어를 추가했다면 인스턴스 재시작 또는 reload() 호출 필요. + */ +@Slf4j +@Component +public class IngredientSynonymCache { + + private final IngredientSynonymRepository ingredientSynonymRepository; + private volatile Map> expansionMap = Collections.emptyMap(); + + public IngredientSynonymCache(IngredientSynonymRepository ingredientSynonymRepository) { + this.ingredientSynonymRepository = ingredientSynonymRepository; + } + + @PostConstruct + public void reload() { + + List all = ingredientSynonymRepository.findAll(); + Map> byGroup = all.stream() + .collect(Collectors.groupingBy( + IngredientSynonym::getGroupId, + Collectors.mapping(IngredientSynonym::getName, Collectors.toUnmodifiableSet()) + )); + + Map> map = new HashMap<>(); + for (Set group : byGroup.values()) { + for (String name : group) { + map.put(name, group); + } + } + this.expansionMap = Map.copyOf(map); + + log.info("IngredientSynonymCache loaded - groups={}, words={}", byGroup.size(), all.size()); + } + + /** + * 입력 단어들과 그 동의어들을 모두 합친 Set 반환. 동의어 그룹에 없는 단어는 자기 자신만 들어감. + */ + public Set expand(Collection names) { + + if (names == null || names.isEmpty()) return Set.of(); + + Set result = new HashSet<>(); + for (String name : names) { + if (name == null) continue; + result.add(name); + Set group = expansionMap.get(name); + if (group != null) result.addAll(group); + } + return result; + } +} diff --git a/src/main/java/com/recipe/app/src/ingredient/domain/Ingredient.java b/src/main/java/com/recipe/app/src/ingredient/domain/Ingredient.java index 046d0316..395853af 100644 --- a/src/main/java/com/recipe/app/src/ingredient/domain/Ingredient.java +++ b/src/main/java/com/recipe/app/src/ingredient/domain/Ingredient.java @@ -14,7 +14,6 @@ import lombok.NoArgsConstructor; import org.springframework.util.StringUtils; -import java.util.List; import java.util.Objects; @Getter @@ -52,67 +51,4 @@ public Ingredient(Long ingredientId, Long ingredientCategoryId, String ingredien this.userId = userId; } - public List getIngredientNameWithSimilar() { - if (ingredientName.equals("새우")) - return List.of("대하", ingredientName); - if (ingredientName.equals("대하")) - return List.of("새우", ingredientName); - if (ingredientName.equals("계란")) - return List.of("달걀", ingredientName); - if (ingredientName.equals("달걀")) - return List.of("계란", ingredientName); - if (ingredientName.equals("소고기")) - return List.of("쇠고기", ingredientName); - if (ingredientName.equals("쇠고기")) - return List.of("소고기", ingredientName); - if (ingredientName.equals("후추")) - return List.of("후춧가루", ingredientName); - if (ingredientName.equals("후춧가루")) - return List.of("후추", ingredientName); - if (ingredientName.equals("간마늘")) - return List.of("다진마늘", ingredientName); - if (ingredientName.equals("다진마늘")) - return List.of("간마늘", ingredientName); - if (ingredientName.equals("새싹채소")) - return List.of("어린잎채소", "무순", ingredientName); - if (ingredientName.equals("어린잎채소")) - return List.of("새싹채소", ingredientName); - if (ingredientName.equals("무순")) - return List.of("새싹채소", ingredientName); - if (ingredientName.equals("조개")) - return List.of("조갯살", "바지락", ingredientName); - if (ingredientName.equals("조갯살")) - return List.of("조개", ingredientName); - if (ingredientName.equals("바지락")) - return List.of("조개", ingredientName); - if (ingredientName.equals("케찹")) - return List.of("케첩", ingredientName); - if (ingredientName.equals("케첩")) - return List.of("케찹", ingredientName); - if (ingredientName.equals("소면")) - return List.of("국수", ingredientName); - if (ingredientName.equals("국수")) - return List.of("소면", ingredientName); - if (ingredientName.equals("김치")) - return List.of("김칫잎", ingredientName); - if (ingredientName.equals("김칫잎")) - return List.of("김치", ingredientName); - if (ingredientName.equals("고춧가루")) - return List.of("고추가루", ingredientName); - if (ingredientName.equals("고추가루")) - return List.of("고춧가루", ingredientName); - if (ingredientName.equals("올리브유")) - return List.of("올리브오일", ingredientName); - if (ingredientName.equals("올리브오일")) - return List.of("올리브유", ingredientName); - if (ingredientName.equals("파스타")) - return List.of("스파게티", ingredientName); - if (ingredientName.equals("스파게티")) - return List.of("파스타", ingredientName); - if (ingredientName.equals("포도씨유")) - return List.of("식용유", ingredientName); - if (ingredientName.equals("식용유")) - return List.of("포도씨유", ingredientName); - return List.of(ingredientName); - } } \ No newline at end of file diff --git a/src/main/java/com/recipe/app/src/ingredient/domain/IngredientSynonym.java b/src/main/java/com/recipe/app/src/ingredient/domain/IngredientSynonym.java new file mode 100644 index 00000000..dcd99f67 --- /dev/null +++ b/src/main/java/com/recipe/app/src/ingredient/domain/IngredientSynonym.java @@ -0,0 +1,43 @@ +package com.recipe.app.src.ingredient.domain; + +import com.google.common.base.Preconditions; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.util.StringUtils; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "IngredientSynonym") +public class IngredientSynonym { + + @Id + @Column(name = "synonymId", nullable = false, updatable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long synonymId; + + @Column(name = "groupId", nullable = false) + private Long groupId; + + @Column(name = "name", nullable = false, length = 64, unique = true) + private String name; + + @Builder + public IngredientSynonym(Long synonymId, Long groupId, String name) { + + Preconditions.checkNotNull(groupId, "동의어 그룹 아이디를 입력해주세요."); + Preconditions.checkArgument(StringUtils.hasText(name), "동의어 단어를 입력해주세요."); + + this.synonymId = synonymId; + this.groupId = groupId; + this.name = name; + } +} diff --git a/src/main/java/com/recipe/app/src/ingredient/infra/IngredientSynonymRepository.java b/src/main/java/com/recipe/app/src/ingredient/infra/IngredientSynonymRepository.java new file mode 100644 index 00000000..6099c262 --- /dev/null +++ b/src/main/java/com/recipe/app/src/ingredient/infra/IngredientSynonymRepository.java @@ -0,0 +1,9 @@ +package com.recipe.app.src.ingredient.infra; + +import com.recipe.app.src.ingredient.domain.IngredientSynonym; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface IngredientSynonymRepository extends JpaRepository { +} diff --git a/src/main/java/com/recipe/app/src/recipe/api/YoutubeRecipeController.java b/src/main/java/com/recipe/app/src/recipe/api/YoutubeRecipeController.java index 05090930..9f1b1030 100644 --- a/src/main/java/com/recipe/app/src/recipe/api/YoutubeRecipeController.java +++ b/src/main/java/com/recipe/app/src/recipe/api/YoutubeRecipeController.java @@ -15,8 +15,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.io.IOException; - @Tag(name = "유튜브 레시피 Controller") @RestController @RequestMapping("/recipes/youtube") @@ -39,7 +37,7 @@ public RecipesResponse getYoutubeRecipes(@Parameter(hidden = true) User user, @Parameter(example = "20", name = "사이즈") @RequestParam(value = "size") int size, @Parameter(example = "조회수순(views) / 좋아요순(scraps) / 최신순(newest) = 기본값", name = "정렬") - @RequestParam(value = "sort") String sort) throws IOException { + @RequestParam(value = "sort") String sort) { return youtubeRecipeService.findYoutubeRecipesByKeyword(user, keyword, startAfter, size, sort); } diff --git a/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java b/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java index 29315ae6..bab685b3 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/RecipeSearchService.java @@ -1,7 +1,10 @@ package com.recipe.app.src.recipe.application; import com.recipe.app.src.common.utils.BadWordFiltering; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.fridge.application.FridgeService; +import com.recipe.app.src.ingredient.application.IngredientSynonymCache; import com.recipe.app.src.recipe.application.dto.RecipeDetailResponse; import com.recipe.app.src.recipe.application.dto.RecipesResponse; import com.recipe.app.src.recipe.application.dto.RecommendedRecipesResponse; @@ -26,15 +29,17 @@ public class RecipeSearchService { private final BadWordFiltering badWordFiltering; private final RecipeScrapService recipeScrapService; private final RecipeViewService recipeViewService; + private final IngredientSynonymCache ingredientSynonymCache; public RecipeSearchService(RecipeRepository recipeRepository, FridgeService fridgeService, UserService userService, BadWordFiltering badWordFiltering, - RecipeScrapService recipeScrapService, RecipeViewService recipeViewService) { + RecipeScrapService recipeScrapService, RecipeViewService recipeViewService, IngredientSynonymCache ingredientSynonymCache) { this.recipeRepository = recipeRepository; this.fridgeService = fridgeService; this.userService = userService; this.badWordFiltering = badWordFiltering; this.recipeScrapService = recipeScrapService; this.recipeViewService = recipeViewService; + this.ingredientSynonymCache = ingredientSynonymCache; } @Transactional(readOnly = true) @@ -42,39 +47,44 @@ public RecipesResponse findRecipesByKeywordOrderBy(User user, String keyword, lo badWordFiltering.check(keyword); - long totalCnt = recipeRepository.countByKeyword(keyword); + SearchQuery query = SearchKeywordNormalizer.normalize(keyword); + if (query instanceof SearchQuery.Empty) { + return getRecipes(user, 0L, new Recipes(List.of())); + } + + long totalCnt = recipeRepository.countByKeyword(query); List recipes; if (sort.equals("scraps")) { - recipes = findByKeywordOrderByRecipeScrapCnt(keyword, lastRecipeId, size); + recipes = findByKeywordOrderByRecipeScrapCnt(query, lastRecipeId, size); } else if (sort.equals("views")) { - recipes = findByKeywordOrderByRecipeViewCnt(keyword, lastRecipeId, size); + recipes = findByKeywordOrderByRecipeViewCnt(query, lastRecipeId, size); } else { - recipes = findByKeywordOrderByCreatedAt(keyword, lastRecipeId, size); + recipes = findByKeywordOrderByCreatedAt(query, lastRecipeId, size); } return getRecipes(user, totalCnt, new Recipes(recipes)); } - private List findByKeywordOrderByRecipeScrapCnt(String keyword, long lastRecipeId, int size) { + private List findByKeywordOrderByRecipeScrapCnt(SearchQuery query, long lastRecipeId, int size) { long recipeScrapCnt = recipeScrapService.countByRecipeId(lastRecipeId); - return recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(keyword, lastRecipeId, recipeScrapCnt, size); + return recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(query, lastRecipeId, recipeScrapCnt, size); } - private List findByKeywordOrderByRecipeViewCnt(String keyword, long lastRecipeId, int size) { + private List findByKeywordOrderByRecipeViewCnt(SearchQuery query, long lastRecipeId, int size) { long recipeViewCnt = recipeViewService.countByRecipeId(lastRecipeId); - return recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(keyword, lastRecipeId, recipeViewCnt, size); + return recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(query, lastRecipeId, recipeViewCnt, size); } - private List findByKeywordOrderByCreatedAt(String keyword, long lastRecipeId, int size) { + private List findByKeywordOrderByCreatedAt(SearchQuery query, long lastRecipeId, int size) { Recipe recipe = recipeRepository.findById(lastRecipeId).orElse(null); - return recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(keyword, lastRecipeId, recipe != null ? recipe.getCreatedAt() : null, size); + return recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(query, lastRecipeId, recipe != null ? recipe.getCreatedAt() : null, size); } @Transactional(readOnly = true) @@ -171,7 +181,9 @@ public RecipeDetailResponse findPublicRecipeDetail(long recipeId) { @Transactional(readOnly = true) public RecommendedRecipesResponse findPublicRecommendedRecipesByIngredients(List ingredientNames, long lastRecipeId, int size) { - Recipes recipes = new Recipes(recipeRepository.findRecipesInFridge(ingredientNames)); + List expandedIngredientNames = List.copyOf(ingredientSynonymCache.expand(ingredientNames)); + + Recipes recipes = new Recipes(recipeRepository.findRecipesInFridge(expandedIngredientNames)); List recipePostUsers = userService.findByUserIds(recipes.getUserIds()); @@ -181,6 +193,6 @@ public RecommendedRecipesResponse findPublicRecommendedRecipesByIngredients(List User anonymousUser = new User(); - return RecommendedRecipesResponse.from(recipes, recipePostUsers, recipeScraps, anonymousUser, ingredientNames, lastRecipe, size); + return RecommendedRecipesResponse.from(recipes, recipePostUsers, recipeScraps, anonymousUser, expandedIngredientNames, lastRecipe, size); } } diff --git a/src/main/java/com/recipe/app/src/recipe/application/SearchTokensBackfillRunner.java b/src/main/java/com/recipe/app/src/recipe/application/SearchTokensBackfillRunner.java new file mode 100644 index 00000000..5f4df9ea --- /dev/null +++ b/src/main/java/com/recipe/app/src/recipe/application/SearchTokensBackfillRunner.java @@ -0,0 +1,115 @@ +package com.recipe.app.src.recipe.application; + +import com.recipe.app.src.common.utils.KoreanTokenizer; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; +import java.util.function.Function; + +@Slf4j +@Component +@ConditionalOnProperty(value = "recipe.migration.search-tokens.enabled", havingValue = "true") +public class SearchTokensBackfillRunner implements ApplicationRunner { + + private static final int BATCH_SIZE = 500; + private static final Function NORI_TOKENIZE = KoreanTokenizer::tokenize; + private static final Function SIMPLE_NORMALIZE = text -> text == null ? "" : text.toLowerCase().trim(); + + @PersistenceContext + private EntityManager em; + + private final TransactionTemplate transactionTemplate; + + public SearchTokensBackfillRunner(PlatformTransactionManager transactionManager) { + this.transactionTemplate = new TransactionTemplate(transactionManager); + } + + @Override + public void run(ApplicationArguments args) { + + log.info("SearchTokens backfill started"); + backfill("Recipe", "recipeId", new String[]{"recipeNm", "introduction"}, NORI_TOKENIZE); + // RecipeIngredient 는 단일 명사 위주라 nori stopword 정책이 도메인 단어를 제거해버림 (예: "갓","다시다"). + // 단순 정규화로 처리. + backfill("RecipeIngredient", "recipeIngredientId", new String[]{"ingredientName"}, SIMPLE_NORMALIZE); + backfill("BlogRecipe", "blogRecipeId", new String[]{"title", "description"}, NORI_TOKENIZE); + backfill("YoutubeRecipe", "youtubeRecipeId", new String[]{"title", "description"}, NORI_TOKENIZE); + log.info("SearchTokens backfill done"); + } + + private void backfill(String table, String pk, String[] sourceColumns, Function tokenizer) { + + log.info("[{}] backfill started", table); + long lastId = 0L; + long total = 0L; + while (true) { + long startId = lastId; + BatchResult r = transactionTemplate.execute(status -> processBatch(table, pk, sourceColumns, startId, tokenizer)); + if (r == null || r.processed == 0) break; + lastId = r.lastId; + total += r.updated; + log.info("[{}] progress lastId={} updatedSoFar={}", table, lastId, total); + } + log.info("[{}] backfill done. updated={}", table, total); + } + + private BatchResult processBatch(String table, String pk, String[] sourceColumns, long startId, Function tokenizer) { + + String columnList = String.join(", ", sourceColumns); + String selectSql = "SELECT " + pk + ", " + columnList + + " FROM " + table + + " WHERE " + pk + " > :startId" + + " ORDER BY " + pk + " ASC" + + " LIMIT :batchSize"; + + @SuppressWarnings("unchecked") + List rows = em.createNativeQuery(selectSql) + .setParameter("startId", startId) + .setParameter("batchSize", BATCH_SIZE) + .getResultList(); + + if (rows.isEmpty()) return new BatchResult(0, 0, startId); + + String updateSql = "UPDATE " + table + " SET searchTokens = :tokens WHERE " + pk + " = :id"; + + int updated = 0; + long lastId = startId; + for (Object[] row : rows) { + Long id = ((Number) row[0]).longValue(); + StringBuilder text = new StringBuilder(); + for (int i = 1; i < row.length; i++) { + String s = row[i] == null ? "" : row[i].toString(); + if (text.length() > 0) text.append(' '); + text.append(s); + } + String tokens = tokenizer.apply(text.toString()); + em.createNativeQuery(updateSql) + .setParameter("tokens", tokens) + .setParameter("id", id) + .executeUpdate(); + lastId = id; + updated++; + } + return new BatchResult(rows.size(), updated, lastId); + } + + private static class BatchResult { + final int processed; + final int updated; + final long lastId; + + BatchResult(int processed, int updated, long lastId) { + this.processed = processed; + this.updated = updated; + this.lastId = lastId; + } + } +} diff --git a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeClientSearchService.java b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeClientSearchService.java index 788dcc75..721ffa88 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeClientSearchService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeClientSearchService.java @@ -48,25 +48,23 @@ public void searchNaverBlogRecipes(String keyword) { NAVER_BLOG_SEARCH_SORT, keyword + " 레시피").toEntity(); - createBlogRecipes(blogRecipes); + List newlyInserted = createBlogRecipes(blogRecipes); - blogRecipeThumbnailCrawlingService.saveThumbnails(blogRecipes); + blogRecipeThumbnailCrawlingService.saveThumbnails(newlyInserted); } - public List fallback(String keyword, int size, Exception e) { + public void fallback(String keyword, Throwable e) { - log.info("fallback call - " + e.getMessage()); - - return blogRecipeRepository.findByKeywordLimit(keyword, size); + log.warn("naver blog search fallback - keyword={}, cause={}", keyword, e.getMessage()); } - private void createBlogRecipes(List blogRecipes) { + private List createBlogRecipes(List blogRecipes) { List blogUrls = blogRecipes.stream().map(BlogRecipe::getBlogUrl).collect(Collectors.toList()); List existBlogRecipes = blogRecipeRepository.findByBlogUrlIn(blogUrls); Map existBlogRecipeMapByBlogUrl = existBlogRecipes.stream().collect(Collectors.toMap(BlogRecipe::getBlogUrl, Function.identity(), (o1, o2) -> o1)); - blogRecipeRepository.saveAll(blogRecipes.stream() + return blogRecipeRepository.saveAll(blogRecipes.stream() .filter(blogRecipe -> !existBlogRecipeMapByBlogUrl.containsKey(blogRecipe.getBlogUrl())) .collect(Collectors.toList())); } diff --git a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeService.java b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeService.java index 6029e89c..4789375f 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeService.java @@ -1,6 +1,8 @@ package com.recipe.app.src.recipe.application.blog; import com.recipe.app.src.common.utils.BadWordFiltering; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.recipe.application.dto.RecipesResponse; import com.recipe.app.src.recipe.domain.blog.BlogRecipe; import com.recipe.app.src.recipe.domain.blog.BlogRecipes; @@ -38,49 +40,53 @@ public RecipesResponse findBlogRecipesByKeyword(User user, String keyword, long badWordFiltering.check(keyword); - long totalCnt = blogRecipeRepository.countByKeyword(keyword); + SearchQuery query = SearchKeywordNormalizer.normalize(keyword); + if (query instanceof SearchQuery.Empty) { + return getRecipes(user, 0L, new BlogRecipes(List.of())); + } + + long totalCnt = blogRecipeRepository.countByKeyword(query); - List blogRecipes; if (totalCnt < MIN_RECIPE_CNT) { blogRecipeClientSearchService.searchNaverBlogRecipes(keyword); } - blogRecipes = findByKeywordOrderBy(keyword, lastBlogRecipeId, size, sort); - totalCnt = blogRecipeRepository.countByKeyword(keyword); + List blogRecipes = findByKeywordOrderBy(query, lastBlogRecipeId, size, sort); + totalCnt = blogRecipeRepository.countByKeyword(query); return getRecipes(user, totalCnt, new BlogRecipes(blogRecipes)); } - private List findByKeywordOrderBy(String keyword, long lastBlogRecipeId, int size, String sort) { + private List findByKeywordOrderBy(SearchQuery query, long lastBlogRecipeId, int size, String sort) { if (sort.equals("scraps")) { - return findByKeywordOrderByBlogScrapCnt(keyword, lastBlogRecipeId, size); + return findByKeywordOrderByBlogScrapCnt(query, lastBlogRecipeId, size); } else if (sort.equals("views")) { - return findByKeywordOrderByBlogViewCnt(keyword, lastBlogRecipeId, size); + return findByKeywordOrderByBlogViewCnt(query, lastBlogRecipeId, size); } else { - return findByKeywordOrderByPublishedAt(keyword, lastBlogRecipeId, size); + return findByKeywordOrderByPublishedAt(query, lastBlogRecipeId, size); } } - private List findByKeywordOrderByBlogScrapCnt(String keyword, long lastBlogRecipeId, int size) { + private List findByKeywordOrderByBlogScrapCnt(SearchQuery query, long lastBlogRecipeId, int size) { long lastBlogScrapCnt = blogScrapService.countByBlogRecipeId(lastBlogRecipeId); - return blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(keyword, lastBlogRecipeId, lastBlogScrapCnt, size); + return blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(query, lastBlogRecipeId, lastBlogScrapCnt, size); } - private List findByKeywordOrderByBlogViewCnt(String keyword, long lastBlogRecipeId, int size) { + private List findByKeywordOrderByBlogViewCnt(SearchQuery query, long lastBlogRecipeId, int size) { long lastBlogViewCnt = blogViewService.countByBlogRecipeId(lastBlogRecipeId); - return blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(keyword, lastBlogRecipeId, lastBlogViewCnt, size); + return blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(query, lastBlogRecipeId, lastBlogViewCnt, size); } - private List findByKeywordOrderByPublishedAt(String keyword, long lastBlogRecipeId, int size) { + private List findByKeywordOrderByPublishedAt(SearchQuery query, long lastBlogRecipeId, int size) { BlogRecipe blogRecipe = blogRecipeRepository.findById(lastBlogRecipeId).orElse(null); - return blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(keyword, lastBlogRecipeId, blogRecipe == null ? null : blogRecipe.getPublishedAt(), size); + return blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(query, lastBlogRecipeId, blogRecipe == null ? null : blogRecipe.getPublishedAt(), size); } @Transactional(readOnly = true) diff --git a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeThumbnailCrawlingService.java b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeThumbnailCrawlingService.java index f243e45d..7e856862 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeThumbnailCrawlingService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/blog/BlogRecipeThumbnailCrawlingService.java @@ -27,8 +27,6 @@ public BlogRecipeThumbnailCrawlingService(BlogRecipeRepository blogRecipeReposit @Transactional(propagation = Propagation.REQUIRES_NEW) public void saveThumbnails(List blogRecipes) { - System.out.println("thumbnail save"); - for (BlogRecipe blogRecipe : blogRecipes) { blogRecipe.changeThumbnail(getBlogThumbnailUrl(blogRecipe.getBlogUrl())); } diff --git a/src/main/java/com/recipe/app/src/recipe/application/dto/RecipeDetailResponse.java b/src/main/java/com/recipe/app/src/recipe/application/dto/RecipeDetailResponse.java index 3bcfc947..d59e756a 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/dto/RecipeDetailResponse.java +++ b/src/main/java/com/recipe/app/src/recipe/application/dto/RecipeDetailResponse.java @@ -7,6 +7,8 @@ import lombok.Getter; import java.util.List; +import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; @Schema(description = "레시피 상세 응답 DTO") @@ -72,6 +74,12 @@ public RecipeDetailResponse(Long recipeId, String recipeName, String introductio public static RecipeDetailResponse from(Recipe recipe, boolean isUserScrap, User postUser, List ingredientNamesInFridge) { + Set normalizedFridge = ingredientNamesInFridge.stream() + .filter(Objects::nonNull) + .map(s -> s.toLowerCase().trim()) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + return RecipeDetailResponse.builder() .recipeId(recipe.getRecipeId()) .recipeName(recipe.getRecipeNm()) @@ -82,7 +90,7 @@ public static RecipeDetailResponse from(Recipe recipe, boolean isUserScrap, User .recipeIngredients(recipe.getIngredients().stream() .map(ingredient -> RecipeIngredientResponse.from( ingredient, - ingredient.hasInFridge(ingredientNamesInFridge))) + ingredient.hasInFridge(normalizedFridge))) .collect(Collectors.toList())) .recipeProcesses(recipe.getProcesses().stream() .map(RecipeProcessResponse::from) diff --git a/src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipesResponse.java b/src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipesResponse.java index e048d4d2..496ca81b 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipesResponse.java +++ b/src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipesResponse.java @@ -11,6 +11,8 @@ import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -33,6 +35,12 @@ public RecommendedRecipesResponse(long totalCnt, List public static RecommendedRecipesResponse from(Recipes recipes, List recipePostUsers, List recipeScraps, User user, List ingredientNamesInFridge, Recipe lastRecipe, int size) { + Set normalizedFridge = ingredientNamesInFridge.stream() + .filter(Objects::nonNull) + .map(s -> s.toLowerCase().trim()) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + Map recipePostUserMapByUserId = recipePostUsers.stream() .collect(Collectors.toMap(User::getUserId, Function.identity())); @@ -41,7 +49,7 @@ public static RecommendedRecipesResponse from(Recipes recipes, List recipe .recipes(recipes.getRecipes().stream() .map((recipe) -> RecommendedRecipeResponse.from(recipe, recipePostUserMapByUserId.get(recipe.getUserId()), - recipe.calculateIngredientMatchRate(ingredientNamesInFridge), + recipe.calculateIngredientMatchRate(normalizedFridge), recipeScraps, user)) .sorted(Comparator.comparing(RecommendedRecipeResponse::getIngredientsMatchRate).thenComparing(RecommendedRecipeResponse::getRecipeId).reversed()) @@ -51,7 +59,7 @@ public static RecommendedRecipesResponse from(Recipes recipes, List recipe } return recommendedRecipe.getRecipeId() < lastRecipe.getRecipeId() - && recommendedRecipe.getIngredientsMatchRate() <= lastRecipe.calculateIngredientMatchRate(ingredientNamesInFridge); + && recommendedRecipe.getIngredientsMatchRate() <= lastRecipe.calculateIngredientMatchRate(normalizedFridge); }) .limit(size) .collect(Collectors.toList())) diff --git a/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeClientSearchService.java b/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeClientSearchService.java index 50071e01..8075e1eb 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeClientSearchService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeClientSearchService.java @@ -44,49 +44,50 @@ public YoutubeRecipeClientSearchService(YoutubeRecipeRepository youtubeRecipeRep } @CircuitBreaker(name = "recipe-youtube-search", fallbackMethod = "fallback") - public void searchYoutube(String keyword) throws IOException { + public void searchYoutube(String keyword) { log.info("youtube search api call"); - YouTube youtube = new YouTube.Builder(HTTP_TRANSPORT, JSON_FACTORY, new HttpRequestInitializer() { - public void initialize(HttpRequest request) throws IOException { + try { + YouTube youtube = new YouTube.Builder(HTTP_TRANSPORT, JSON_FACTORY, new HttpRequestInitializer() { + public void initialize(HttpRequest request) { + } + }).setApplicationName("youtube-cmdline-search-sample").build(); + + YouTube.Search.List search = youtube.search().list("id,snippet"); + + search.setKey(youtubeApiKey); + search.setQ(keyword + " 레시피"); + search.setType("video"); + search.setMaxResults(NUMBER_OF_VIDEOS_RETURNED); + search.setFields(YOUTUBE_SEARCH_FIELDS); + + SearchListResponse searchResponse = search.execute(); + List searchResultList = searchResponse.getItems(); + + List youtubeRecipes = new ArrayList<>(); + if (searchResultList != null) { + for (SearchResult rid : searchResultList) { + youtubeRecipes.add(YoutubeRecipe.builder() + .title(rid.getSnippet().getTitle()) + .description(rid.getSnippet().getDescription()) + .thumbnailImgUrl(rid.getSnippet().getThumbnails().getDefault().getUrl()) + .postDate(LocalDate.ofInstant(Instant.ofEpochMilli(rid.getSnippet().getPublishedAt().getValue()), ZoneId.systemDefault())) + .channelName(rid.getSnippet().getChannelTitle()) + .youtubeId(rid.getId().getVideoId()) + .build()); + } } - }).setApplicationName("youtube-cmdline-search-sample").build(); - - // Define the API request for retrieving search results. - YouTube.Search.List search = youtube.search().list("id,snippet"); - - search.setKey(youtubeApiKey); - search.setQ(keyword + " 레시피"); - search.setType("video"); - search.setMaxResults(NUMBER_OF_VIDEOS_RETURNED); - search.setFields(YOUTUBE_SEARCH_FIELDS); - - SearchListResponse searchResponse = search.execute(); - List searchResultList = searchResponse.getItems(); - - List youtubeRecipes = new ArrayList<>(); - if (searchResultList != null) { - for (SearchResult rid : searchResultList) { - youtubeRecipes.add(YoutubeRecipe.builder() - .title(rid.getSnippet().getTitle()) - .description(rid.getSnippet().getDescription()) - .thumbnailImgUrl(rid.getSnippet().getThumbnails().getDefault().getUrl()) - .postDate(LocalDate.ofInstant(Instant.ofEpochMilli(rid.getSnippet().getPublishedAt().getValue()), ZoneId.systemDefault())) - .channelName(rid.getSnippet().getChannelTitle()) - .youtubeId(rid.getId().getVideoId()) - .build()); - } - } - createYoutubeRecipes(youtubeRecipes); + createYoutubeRecipes(youtubeRecipes); + } catch (IOException e) { + throw new IllegalStateException("youtube search api failed", e); + } } - public List fallback(String keyword, int size, Exception e) { - - log.info("fallback call - " + e.getMessage()); + public void fallback(String keyword, Throwable e) { - return youtubeRecipeRepository.findByKeywordLimit(keyword, size); + log.warn("youtube search fallback - keyword={}, cause={}", keyword, e.getMessage()); } private void createYoutubeRecipes(List youtubeRecipes) { diff --git a/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java b/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java index 82947ee6..80fc7ddf 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeService.java @@ -1,6 +1,8 @@ package com.recipe.app.src.recipe.application.youtube; import com.recipe.app.src.common.utils.BadWordFiltering; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.recipe.application.dto.RecipesResponse; import com.recipe.app.src.recipe.domain.youtube.YoutubeRecipe; import com.recipe.app.src.recipe.domain.youtube.YoutubeRecipes; @@ -10,7 +12,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.io.IOException; import java.util.List; @Service @@ -33,53 +34,57 @@ public YoutubeRecipeService(YoutubeRecipeRepository youtubeRecipeRepository, You } @Transactional - public RecipesResponse findYoutubeRecipesByKeyword(User user, String keyword, long lastYoutubeRecipeId, int size, String sort) throws IOException { + public RecipesResponse findYoutubeRecipesByKeyword(User user, String keyword, long lastYoutubeRecipeId, int size, String sort) { badWordFiltering.check(keyword); - long totalCnt = youtubeRecipeRepository.countByKeyword(keyword); + SearchQuery query = SearchKeywordNormalizer.normalize(keyword); + if (query instanceof SearchQuery.Empty) { + return getRecipes(user, 0L, new YoutubeRecipes(List.of())); + } + + long totalCnt = youtubeRecipeRepository.countByKeyword(query); - List youtubeRecipes; if (totalCnt < MIN_RECIPE_CNT) { youtubeRecipeClientSearchService.searchYoutube(keyword); } - youtubeRecipes = findByKeywordOrderBy(keyword, lastYoutubeRecipeId, size, sort); - totalCnt = youtubeRecipeRepository.countByKeyword(keyword); + List youtubeRecipes = findByKeywordOrderBy(query, lastYoutubeRecipeId, size, sort); + totalCnt = youtubeRecipeRepository.countByKeyword(query); return getRecipes(user, totalCnt, new YoutubeRecipes(youtubeRecipes)); } - private List findByKeywordOrderBy(String keyword, long lastYoutubeRecipeId, int size, String sort) { + private List findByKeywordOrderBy(SearchQuery query, long lastYoutubeRecipeId, int size, String sort) { if (sort.equals("scraps")) { - return findByKeywordOrderByYoutubeScrapCnt(keyword, lastYoutubeRecipeId, size); + return findByKeywordOrderByYoutubeScrapCnt(query, lastYoutubeRecipeId, size); } else if (sort.equals("views")) { - return findByKeywordOrderByYoutubeViewCnt(keyword, lastYoutubeRecipeId, size); + return findByKeywordOrderByYoutubeViewCnt(query, lastYoutubeRecipeId, size); } else { - return findByKeywordOrderByPostDate(keyword, lastYoutubeRecipeId, size); + return findByKeywordOrderByPostDate(query, lastYoutubeRecipeId, size); } } - private List findByKeywordOrderByYoutubeScrapCnt(String keyword, long lastYoutubeRecipeId, int size) { + private List findByKeywordOrderByYoutubeScrapCnt(SearchQuery query, long lastYoutubeRecipeId, int size) { long youtubeScrapCnt = youtubeScrapService.countByYoutubeRecipeId(lastYoutubeRecipeId); - return youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(keyword, lastYoutubeRecipeId, youtubeScrapCnt, size); + return youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(query, lastYoutubeRecipeId, youtubeScrapCnt, size); } - private List findByKeywordOrderByYoutubeViewCnt(String keyword, long lastYoutubeRecipeId, int size) { + private List findByKeywordOrderByYoutubeViewCnt(SearchQuery query, long lastYoutubeRecipeId, int size) { long youtubeViewCnt = youtubeViewService.countByYoutubeRecipeId(lastYoutubeRecipeId); - return youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(keyword, lastYoutubeRecipeId, youtubeViewCnt, size); + return youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(query, lastYoutubeRecipeId, youtubeViewCnt, size); } - private List findByKeywordOrderByPostDate(String keyword, long lastYoutubeRecipeId, int size) { + private List findByKeywordOrderByPostDate(SearchQuery query, long lastYoutubeRecipeId, int size) { YoutubeRecipe youtubeRecipe = youtubeRecipeRepository.findById(lastYoutubeRecipeId).orElse(null); - return youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(keyword, lastYoutubeRecipeId, youtubeRecipe != null ? youtubeRecipe.getPostDate() : null, size); + return youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(query, lastYoutubeRecipeId, youtubeRecipe != null ? youtubeRecipe.getPostDate() : null, size); } @Transactional(readOnly = true) diff --git a/src/main/java/com/recipe/app/src/recipe/domain/Recipe.java b/src/main/java/com/recipe/app/src/recipe/domain/Recipe.java index e5252918..3b539791 100644 --- a/src/main/java/com/recipe/app/src/recipe/domain/Recipe.java +++ b/src/main/java/com/recipe/app/src/recipe/domain/Recipe.java @@ -2,6 +2,7 @@ import com.google.common.base.Preconditions; import com.recipe.app.src.common.entity.BaseEntity; +import com.recipe.app.src.common.utils.KoreanTokenizer; import com.recipe.app.src.recipe.infra.RecipeLevelPersistConverter; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; @@ -11,6 +12,8 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; @@ -21,6 +24,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Set; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -70,6 +74,9 @@ public class Recipe extends BaseEntity { @Column(name = "reportYn", nullable = false) private String reportYn = "N"; + @Column(name = "searchTokens", columnDefinition = "TEXT") + private String searchTokens; + @OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true) List ingredients = new ArrayList<>(); @@ -152,12 +159,21 @@ public void report() { this.hiddenYn = "Y"; } - public long calculateIngredientMatchRate(List ingredientNamesInFridge) { + public long calculateIngredientMatchRate(Set normalizedFridgeNames) { + + if (ingredients.isEmpty()) return 0; long ingredientMatchCnt = ingredients.stream() - .filter(ingredient -> ingredient.hasInFridge(ingredientNamesInFridge)) + .filter(ingredient -> ingredient.hasInFridge(normalizedFridgeNames)) .count(); return Math.round((double) ingredientMatchCnt / ingredients.size() * 100); } + + @PrePersist + @PreUpdate + private void refreshSearchTokens() { + String source = (recipeNm != null ? recipeNm : "") + " " + (introduction != null ? introduction : ""); + this.searchTokens = KoreanTokenizer.tokenize(source); + } } diff --git a/src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java b/src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java index f716fa58..17f1032c 100644 --- a/src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java +++ b/src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java @@ -10,6 +10,8 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; @@ -17,7 +19,7 @@ import lombok.NoArgsConstructor; import org.springframework.util.StringUtils; -import java.util.List; +import java.util.Set; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -37,6 +39,9 @@ public class RecipeIngredient extends BaseEntity { @Column(name = "ingredientName", nullable = false, length = 64) private String ingredientName; + @Column(name = "searchTokens", length = 128) + private String searchTokens; + @Column(name = "ingredientIconId") private Long ingredientIconId; @@ -57,16 +62,33 @@ public RecipeIngredient(Long recipeIngredientId, Recipe recipe, String ingredien recipe.ingredients.add(this); } this.ingredientName = ingredientName; + this.searchTokens = normalize(ingredientName); this.ingredientIconId = ingredientIconId; this.quantity = quantity; this.unit = unit; } + private static String normalize(String name) { + return name == null ? "" : name.toLowerCase().trim(); + } + void setRecipe(Recipe recipe) { this.recipe = recipe; } - public boolean hasInFridge(List ingredientNames) { - return ingredientNames.contains(ingredientName); + public boolean hasInFridge(Set normalizedFridgeNames) { + if (searchTokens == null || searchTokens.isBlank()) return false; + for (String token : searchTokens.split(" ")) { + if (!token.isEmpty() && normalizedFridgeNames.contains(token)) return true; + } + return false; + } + + @PrePersist + @PreUpdate + private void refreshSearchTokens() { + // 재료명은 짧고 단일 명사가 대부분이라 nori stopword 정책이 오히려 + // 도메인 단어("갓", "다시다" 등)를 제거해버린다. 단순 정규화로 대체. + this.searchTokens = normalize(ingredientName); } } diff --git a/src/main/java/com/recipe/app/src/recipe/domain/blog/BlogRecipe.java b/src/main/java/com/recipe/app/src/recipe/domain/blog/BlogRecipe.java index f265760e..fd6a46b3 100644 --- a/src/main/java/com/recipe/app/src/recipe/domain/blog/BlogRecipe.java +++ b/src/main/java/com/recipe/app/src/recipe/domain/blog/BlogRecipe.java @@ -2,11 +2,14 @@ import com.google.common.base.Preconditions; import com.recipe.app.src.common.entity.BaseEntity; +import com.recipe.app.src.common.utils.KoreanTokenizer; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; @@ -52,6 +55,9 @@ public class BlogRecipe extends BaseEntity { @Column(name = "viewCnt", nullable = false) private long viewCnt; + @Column(name = "searchTokens", columnDefinition = "TEXT") + private String searchTokens; + @Builder public BlogRecipe(Long blogRecipeId, String blogUrl, String blogThumbnailImgUrl, String title, String description, LocalDate publishedAt, String blogName, long scrapCnt, long viewCnt) { @@ -87,4 +93,11 @@ public void plusViewCnt() { public void changeThumbnail(String blogThumbnailUrl) { this.blogThumbnailImgUrl = blogThumbnailUrl; } + + @PrePersist + @PreUpdate + private void refreshSearchTokens() { + String source = (title != null ? title : "") + " " + (description != null ? description : ""); + this.searchTokens = KoreanTokenizer.tokenize(source); + } } diff --git a/src/main/java/com/recipe/app/src/recipe/domain/youtube/YoutubeRecipe.java b/src/main/java/com/recipe/app/src/recipe/domain/youtube/YoutubeRecipe.java index 23a71318..bece1f8e 100644 --- a/src/main/java/com/recipe/app/src/recipe/domain/youtube/YoutubeRecipe.java +++ b/src/main/java/com/recipe/app/src/recipe/domain/youtube/YoutubeRecipe.java @@ -2,11 +2,14 @@ import com.google.common.base.Preconditions; import com.recipe.app.src.common.entity.BaseEntity; +import com.recipe.app.src.common.utils.KoreanTokenizer; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; @@ -52,6 +55,9 @@ public class YoutubeRecipe extends BaseEntity { @Column(name = "viewCnt", nullable = false) private long viewCnt; + @Column(name = "searchTokens", columnDefinition = "TEXT") + private String searchTokens; + @Builder public YoutubeRecipe(Long youtubeRecipeId, String title, String description, String thumbnailImgUrl, LocalDate postDate, String channelName, String youtubeId, long scrapCnt, long viewCnt) { @@ -83,4 +89,11 @@ public void minusScrapCnt() { public void plusViewCnt() { this.viewCnt++; } + + @PrePersist + @PreUpdate + private void refreshSearchTokens() { + String source = (title != null ? title : "") + " " + (description != null ? description : ""); + this.searchTokens = KoreanTokenizer.tokenize(source); + } } diff --git a/src/main/java/com/recipe/app/src/recipe/infra/RecipeCustomRepository.java b/src/main/java/com/recipe/app/src/recipe/infra/RecipeCustomRepository.java index 91872a16..83b3c843 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/RecipeCustomRepository.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/RecipeCustomRepository.java @@ -1,5 +1,6 @@ package com.recipe.app.src.recipe.infra; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.recipe.domain.Recipe; import java.time.LocalDateTime; @@ -11,13 +12,13 @@ public interface RecipeCustomRepository { Optional findRecipeDetail(Long recipeId, Long userId); - Long countByKeyword(String keyword); + Long countByKeyword(SearchQuery query); - List findByKeywordLimitOrderByCreatedAtDesc(String keyword, Long lastRecipeId, LocalDateTime createdAt, int size); + List findByKeywordLimitOrderByCreatedAtDesc(SearchQuery query, Long lastRecipeId, LocalDateTime createdAt, int size); - List findByKeywordLimitOrderByRecipeScrapCntDesc(String keyword, Long lastRecipeId, long recipeScrapCnt, int size); + List findByKeywordLimitOrderByRecipeScrapCntDesc(SearchQuery query, Long lastRecipeId, long recipeScrapCnt, int size); - List findByKeywordLimitOrderByRecipeViewCntDesc(String keyword, Long lastRecipeId, long recipeViewCnt, int size); + List findByKeywordLimitOrderByRecipeViewCntDesc(SearchQuery query, Long lastRecipeId, long recipeViewCnt, int size); List findUserScrapRecipesLimit(Long userId, Long lastRecipeId, LocalDateTime scrapCreatedAt, int size); diff --git a/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java b/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java index 935ff352..41f9786e 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java @@ -1,5 +1,6 @@ package com.recipe.app.src.recipe.infra; +import com.querydsl.jpa.JPAExpressions; import com.recipe.app.src.common.infra.BaseRepositoryImpl; import com.recipe.app.src.recipe.domain.Recipe; import jakarta.persistence.EntityManager; @@ -7,9 +8,16 @@ import java.time.LocalDateTime; import java.util.Collection; import java.util.List; +import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; + +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; +import com.querydsl.core.types.dsl.BooleanExpression; import static com.recipe.app.src.common.utils.QueryUtils.ifIdIsNotNullAndGreaterThanZero; +import static com.recipe.app.src.common.utils.QueryUtils.matchAgainst; +import static com.recipe.app.src.common.utils.QueryUtils.matchSearchQuery; import static com.recipe.app.src.recipe.domain.QRecipe.recipe; import static com.recipe.app.src.recipe.domain.QRecipeIngredient.recipeIngredient; import static com.recipe.app.src.recipe.domain.QRecipeScrap.recipeScrap; @@ -32,81 +40,80 @@ public Optional findRecipeDetail(Long recipeId, Long userId) { } @Override - public Long countByKeyword(String keyword) { - return (long) queryFactory - .select(recipe.recipeId, recipe.recipeNm, recipe.introduction) + public Long countByKeyword(SearchQuery query) { + return queryFactory + .select(recipe.recipeId.countDistinct()) .from(recipe) - .leftJoin(recipe.ingredients, recipeIngredient).on(recipeIngredient.ingredientName.contains(keyword)) - .where(recipe.hiddenYn.eq("N")) - .groupBy(recipe.recipeId) - .having(recipe.recipeNm.contains(keyword) - .or(recipe.introduction.contains(keyword)) - .or(recipeIngredient.count().gt(0))) - .fetch().size(); + .where( + recipe.hiddenYn.eq("N"), + keywordMatch(query) + ) + .fetchOne(); } @Override - public List findByKeywordLimitOrderByCreatedAtDesc(String keyword, Long lastRecipeId, LocalDateTime lastCreatedAt, int size) { + public List findByKeywordLimitOrderByCreatedAtDesc(SearchQuery query, Long lastRecipeId, LocalDateTime lastCreatedAt, int size) { return queryFactory .selectFrom(recipe) - .leftJoin(recipe.ingredients, recipeIngredient).on(recipeIngredient.ingredientName.contains(keyword)) .where( + recipe.hiddenYn.eq("N"), + keywordMatch(query), ifIdIsNotNullAndGreaterThanZero((recipeId, createdAt) -> recipe.createdAt.lt(createdAt) .or(recipe.createdAt.eq(createdAt) .and(recipe.recipeId.lt(recipeId))), - lastRecipeId, lastCreatedAt), - recipe.hiddenYn.eq("N") + lastRecipeId, lastCreatedAt) ) - .groupBy(recipe.recipeId) - .having(recipe.recipeNm.contains(keyword) - .or(recipe.introduction.contains(keyword)) - .or(recipeIngredient.count().gt(0))) .orderBy(recipe.createdAt.desc(), recipe.recipeId.desc()) .limit(size) .fetch(); } @Override - public List findByKeywordLimitOrderByRecipeScrapCntDesc(String keyword, Long lastRecipeId, long lastRecipeScrapCnt, int size) { + public List findByKeywordLimitOrderByRecipeScrapCntDesc(SearchQuery query, Long lastRecipeId, long lastRecipeScrapCnt, int size) { return queryFactory .selectFrom(recipe) - .leftJoin(recipe.ingredients, recipeIngredient).on(recipeIngredient.ingredientName.contains(keyword)) - .where(recipe.hiddenYn.eq("N"), + .where( + recipe.hiddenYn.eq("N"), + keywordMatch(query), ifIdIsNotNullAndGreaterThanZero((recipeId, recipeScrapCnt) -> recipe.scrapCnt.lt(recipeScrapCnt) .or(recipe.scrapCnt.eq(recipeScrapCnt) .and(recipe.recipeId.lt(recipeId))), - lastRecipeId, lastRecipeScrapCnt)) - .groupBy(recipe.recipeId) - .having(recipe.recipeNm.contains(keyword) - .or(recipe.introduction.contains(keyword)) - .or(recipeIngredient.count().gt(0))) + lastRecipeId, lastRecipeScrapCnt) + ) .orderBy(recipe.scrapCnt.desc(), recipe.recipeId.desc()) .limit(size) .fetch(); } @Override - public List findByKeywordLimitOrderByRecipeViewCntDesc(String keyword, Long lastRecipeId, long lastRecipeViewCnt, int size) { + public List findByKeywordLimitOrderByRecipeViewCntDesc(SearchQuery query, Long lastRecipeId, long lastRecipeViewCnt, int size) { return queryFactory .selectFrom(recipe) - .leftJoin(recipe.ingredients, recipeIngredient).on(recipeIngredient.ingredientName.contains(keyword)) - .where(recipe.hiddenYn.eq("N"), + .where( + recipe.hiddenYn.eq("N"), + keywordMatch(query), ifIdIsNotNullAndGreaterThanZero((recipeId, recipeViewCnt) -> recipe.viewCnt.lt(recipeViewCnt) .or(recipe.viewCnt.eq(recipeViewCnt) .and(recipe.recipeId.lt(recipeId))), - lastRecipeId, lastRecipeViewCnt)) - .groupBy(recipe.recipeId) - .having(recipe.recipeNm.contains(keyword) - .or(recipe.introduction.contains(keyword)) - .or(recipeIngredient.count().gt(0))) + lastRecipeId, lastRecipeViewCnt) + ) .orderBy(recipe.viewCnt.desc(), recipe.recipeId.desc()) .limit(size) .fetch(); } + private BooleanExpression keywordMatch(SearchQuery query) { + return matchSearchQuery(recipe.searchTokens, query) + .or(JPAExpressions.selectOne() + .from(recipeIngredient) + .where(recipeIngredient.recipe.recipeId.eq(recipe.recipeId) + .and(matchSearchQuery(recipeIngredient.searchTokens, query))) + .exists()); + } + @Override public List findUserScrapRecipesLimit(Long userId, Long lastRecipeId, LocalDateTime lastScrapCreatedAt, int size) { @@ -142,11 +149,23 @@ public List findLimitByUserId(Long userId, Long lastRecipeId, int size) @Override public List findRecipesInFridge(Collection ingredientNames) { + if (ingredientNames == null || ingredientNames.isEmpty()) return List.of(); + + // OR BOOLEAN MODE 쿼리. "+" 없이 공백 구분이면 토큰 중 하나만 매치되어도 hit. + String boolQuery = ingredientNames.stream() + .filter(Objects::nonNull) + .map(s -> s.toLowerCase().trim()) + .filter(s -> !s.isEmpty()) + .distinct() + .collect(Collectors.joining(" ")); + + if (boolQuery.isEmpty()) return List.of(); + return queryFactory .selectFrom(recipe) .join(recipeIngredient).on(recipe.recipeId.eq(recipeIngredient.recipe.recipeId)) .where( - (recipeIngredient.ingredientName.in(ingredientNames)), + matchAgainst(recipeIngredient.searchTokens, boolQuery), recipe.hiddenYn.eq("N") ) .groupBy(recipe.recipeId) diff --git a/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepository.java b/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepository.java index 5bbc7cd0..64214172 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepository.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepository.java @@ -1,5 +1,6 @@ package com.recipe.app.src.recipe.infra.blog; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.recipe.domain.blog.BlogRecipe; import java.time.LocalDate; @@ -8,15 +9,15 @@ public interface BlogRecipeCustomRepository { - Long countByKeyword(String keyword); + Long countByKeyword(SearchQuery query); List findByKeywordLimit(String keyword, int size); - List findByKeywordLimitOrderByPublishedAtDesc(String keyword, Long lastBlogRecipeId, LocalDate lastBlogRecipePublishedAt, int size); + List findByKeywordLimitOrderByPublishedAtDesc(SearchQuery query, Long lastBlogRecipeId, LocalDate lastBlogRecipePublishedAt, int size); - List findByKeywordLimitOrderByBlogScrapCntDesc(String keyword, Long lastBlogRecipeId, long lastBlogScrapCnt, int size); + List findByKeywordLimitOrderByBlogScrapCntDesc(SearchQuery query, Long lastBlogRecipeId, long lastBlogScrapCnt, int size); - List findByKeywordLimitOrderByBlogViewCntDesc(String keyword, Long lastBlogRecipeId, long lastBlogViewCnt, int size); + List findByKeywordLimitOrderByBlogViewCntDesc(SearchQuery query, Long lastBlogRecipeId, long lastBlogViewCnt, int size); List findUserScrapBlogRecipesLimit(Long userId, Long lastBlogRecipeId, LocalDateTime scrapCreatedAt, int size); } diff --git a/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeRepositoryImpl.java b/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeRepositoryImpl.java index c4a2ff9c..02fb2637 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeRepositoryImpl.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/blog/BlogRecipeRepositoryImpl.java @@ -1,7 +1,7 @@ package com.recipe.app.src.recipe.infra.blog; import com.recipe.app.src.common.infra.BaseRepositoryImpl; -import com.recipe.app.src.common.utils.QueryUtils; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.recipe.domain.blog.BlogRecipe; import jakarta.persistence.EntityManager; @@ -9,7 +9,9 @@ import java.time.LocalDateTime; import java.util.List; -import static com.recipe.app.src.common.utils.QueryUtils.*; +import static com.recipe.app.src.common.utils.QueryUtils.ifIdIsNotNullAndGreaterThanZero; +import static com.recipe.app.src.common.utils.QueryUtils.matchAgainst; +import static com.recipe.app.src.common.utils.QueryUtils.matchSearchQuery; import static com.recipe.app.src.recipe.domain.blog.QBlogRecipe.blogRecipe; import static com.recipe.app.src.recipe.domain.blog.QBlogScrap.blogScrap; @@ -20,14 +22,13 @@ public BlogRecipeRepositoryImpl(EntityManager em) { } @Override - public Long countByKeyword(String keyword) { + public Long countByKeyword(SearchQuery query) { return queryFactory .select(blogRecipe.count()) .from(blogRecipe) .where( - blogRecipe.title.contains(keyword) - .or(blogRecipe.description.contains(keyword)) + matchSearchQuery(blogRecipe.searchTokens, query) ) .fetchOne(); } @@ -38,21 +39,19 @@ public List findByKeywordLimit(String keyword, int size) { return queryFactory .selectFrom(blogRecipe) .where( - blogRecipe.title.contains(keyword) - .or(blogRecipe.description.contains(keyword)) + matchAgainst(blogRecipe.searchTokens, keyword) ) .limit(size) .fetch(); } @Override - public List findByKeywordLimitOrderByPublishedAtDesc(String keyword, Long lastBlogRecipeId, LocalDate lastBlogRecipePublishedAt, int size) { + public List findByKeywordLimitOrderByPublishedAtDesc(SearchQuery query, Long lastBlogRecipeId, LocalDate lastBlogRecipePublishedAt, int size) { return queryFactory .selectFrom(blogRecipe) .where( - blogRecipe.title.contains(keyword) - .or(blogRecipe.description.contains(keyword)), + matchSearchQuery(blogRecipe.searchTokens, query), ifIdIsNotNullAndGreaterThanZero((blogRecipeId, publishedAt) -> blogRecipe.publishedAt.lt(publishedAt) .or(blogRecipe.publishedAt.eq(publishedAt) .and(blogRecipe.blogRecipeId.lt(blogRecipeId))), @@ -64,13 +63,12 @@ public List findByKeywordLimitOrderByPublishedAtDesc(String keyword, } @Override - public List findByKeywordLimitOrderByBlogScrapCntDesc(String keyword, Long lastBlogRecipeId, long lastBlogScrapCnt, int size) { + public List findByKeywordLimitOrderByBlogScrapCntDesc(SearchQuery query, Long lastBlogRecipeId, long lastBlogScrapCnt, int size) { return queryFactory .selectFrom(blogRecipe) .where( - blogRecipe.title.contains(keyword) - .or(blogRecipe.description.contains(keyword)), + matchSearchQuery(blogRecipe.searchTokens, query), ifIdIsNotNullAndGreaterThanZero((blogRecipeId, blogScrapCnt) -> blogRecipe.scrapCnt.lt(blogScrapCnt) .or(blogRecipe.scrapCnt.eq(blogScrapCnt) .and(blogRecipe.blogRecipeId.lt(blogRecipeId))), @@ -82,13 +80,12 @@ public List findByKeywordLimitOrderByBlogScrapCntDesc(String keyword } @Override - public List findByKeywordLimitOrderByBlogViewCntDesc(String keyword, Long lastBlogRecipeId, long lastBlogViewCnt, int size) { + public List findByKeywordLimitOrderByBlogViewCntDesc(SearchQuery query, Long lastBlogRecipeId, long lastBlogViewCnt, int size) { return queryFactory .selectFrom(blogRecipe) .where( - blogRecipe.title.contains(keyword) - .or(blogRecipe.description.contains(keyword)), + matchSearchQuery(blogRecipe.searchTokens, query), ifIdIsNotNullAndGreaterThanZero((blogRecipeId, blogViewCnt) -> blogRecipe.viewCnt.lt(blogViewCnt) .or(blogRecipe.viewCnt.eq(blogViewCnt) .and(blogRecipe.blogRecipeId.lt(blogRecipeId))), diff --git a/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepository.java b/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepository.java index dedfb6b2..2797e41d 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepository.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepository.java @@ -1,5 +1,6 @@ package com.recipe.app.src.recipe.infra.youtube; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.recipe.domain.youtube.YoutubeRecipe; import java.time.LocalDate; @@ -8,15 +9,15 @@ public interface YoutubeRecipeCustomRepository { - Long countByKeyword(String keyword); + Long countByKeyword(SearchQuery query); List findByKeywordLimit(String keyword, int size); - List findByKeywordLimitOrderByPostDateDesc(String keyword, Long lastYoutubeRecipeId, LocalDate lastYoutubeRecipePostDate, int size); + List findByKeywordLimitOrderByPostDateDesc(SearchQuery query, Long lastYoutubeRecipeId, LocalDate lastYoutubeRecipePostDate, int size); - List findByKeywordLimitOrderByYoutubeScrapCntDesc(String keyword, Long lastYoutubeRecipeId, long youtubeScrapCnt, int size); + List findByKeywordLimitOrderByYoutubeScrapCntDesc(SearchQuery query, Long lastYoutubeRecipeId, long youtubeScrapCnt, int size); - List findByKeywordLimitOrderByYoutubeViewCntDesc(String keyword, Long lastYoutubeRecipeId, long youtubeViewCnt, int size); + List findByKeywordLimitOrderByYoutubeViewCntDesc(SearchQuery query, Long lastYoutubeRecipeId, long youtubeViewCnt, int size); List findUserScrapYoutubeRecipesLimit(Long userId, Long lastYoutubeRecipeId, LocalDateTime scrapCreatedAt, int size); } diff --git a/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeRepositoryImpl.java b/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeRepositoryImpl.java index f5c827a5..1dee14c4 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeRepositoryImpl.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeRepositoryImpl.java @@ -1,6 +1,7 @@ package com.recipe.app.src.recipe.infra.youtube; import com.recipe.app.src.common.infra.BaseRepositoryImpl; +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery; import com.recipe.app.src.recipe.domain.youtube.YoutubeRecipe; import jakarta.persistence.EntityManager; @@ -9,6 +10,8 @@ import java.util.List; import static com.recipe.app.src.common.utils.QueryUtils.ifIdIsNotNullAndGreaterThanZero; +import static com.recipe.app.src.common.utils.QueryUtils.matchAgainst; +import static com.recipe.app.src.common.utils.QueryUtils.matchSearchQuery; import static com.recipe.app.src.recipe.domain.youtube.QYoutubeRecipe.youtubeRecipe; import static com.recipe.app.src.recipe.domain.youtube.QYoutubeScrap.youtubeScrap; @@ -19,14 +22,13 @@ public YoutubeRecipeRepositoryImpl(EntityManager em) { } @Override - public Long countByKeyword(String keyword) { + public Long countByKeyword(SearchQuery query) { return queryFactory .select(youtubeRecipe.count()) .from(youtubeRecipe) .where( - youtubeRecipe.title.contains(keyword) - .or(youtubeRecipe.description.contains(keyword)) + matchSearchQuery(youtubeRecipe.searchTokens, query) ) .fetchOne(); } @@ -37,21 +39,19 @@ public List findByKeywordLimit(String keyword, int size) { return queryFactory .selectFrom(youtubeRecipe) .where( - youtubeRecipe.title.contains(keyword) - .or(youtubeRecipe.description.contains(keyword)) + matchAgainst(youtubeRecipe.searchTokens, keyword) ) .limit(size) .fetch(); } @Override - public List findByKeywordLimitOrderByPostDateDesc(String keyword, Long lastYoutubeRecipeId, LocalDate lastYoutubeRecipePostDate, int size) { + public List findByKeywordLimitOrderByPostDateDesc(SearchQuery query, Long lastYoutubeRecipeId, LocalDate lastYoutubeRecipePostDate, int size) { return queryFactory .selectFrom(youtubeRecipe) .where( - youtubeRecipe.title.contains(keyword) - .or(youtubeRecipe.description.contains(keyword)), + matchSearchQuery(youtubeRecipe.searchTokens, query), ifIdIsNotNullAndGreaterThanZero((youtubeRecipeId, postDate) -> youtubeRecipe.postDate.lt(postDate) .or(youtubeRecipe.postDate.eq(postDate) .and(youtubeRecipe.youtubeRecipeId.lt(youtubeRecipeId))), @@ -63,13 +63,12 @@ public List findByKeywordLimitOrderByPostDateDesc(String keyword, } @Override - public List findByKeywordLimitOrderByYoutubeScrapCntDesc(String keyword, Long lastYoutubeRecipeId, long lastYoutubeScrapCnt, int size) { + public List findByKeywordLimitOrderByYoutubeScrapCntDesc(SearchQuery query, Long lastYoutubeRecipeId, long lastYoutubeScrapCnt, int size) { return queryFactory .selectFrom(youtubeRecipe) .where( - youtubeRecipe.title.contains(keyword) - .or(youtubeRecipe.description.contains(keyword)), + matchSearchQuery(youtubeRecipe.searchTokens, query), ifIdIsNotNullAndGreaterThanZero((youtubeRecipeId, youtubeScrapCnt) -> youtubeRecipe.scrapCnt.lt(youtubeScrapCnt) .or(youtubeRecipe.scrapCnt.eq(youtubeScrapCnt) .and(youtubeRecipe.youtubeRecipeId.lt(youtubeRecipeId))), @@ -81,13 +80,12 @@ public List findByKeywordLimitOrderByYoutubeScrapCntDesc(String k } @Override - public List findByKeywordLimitOrderByYoutubeViewCntDesc(String keyword, Long lastYoutubeRecipeId, long lastYoutubeViewCnt, int size) { + public List findByKeywordLimitOrderByYoutubeViewCntDesc(SearchQuery query, Long lastYoutubeRecipeId, long lastYoutubeViewCnt, int size) { return queryFactory .selectFrom(youtubeRecipe) .where( - youtubeRecipe.title.contains(keyword) - .or(youtubeRecipe.description.contains(keyword)), + matchSearchQuery(youtubeRecipe.searchTokens, query), ifIdIsNotNullAndGreaterThanZero((youtubeRecipeId, youtubeViewCnt) -> youtubeRecipe.viewCnt.lt(youtubeViewCnt) .or(youtubeRecipe.viewCnt.eq(youtubeViewCnt) .and(youtubeRecipe.youtubeRecipeId.lt(youtubeRecipeId))), diff --git a/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor b/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor new file mode 100644 index 00000000..45348957 --- /dev/null +++ b/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor @@ -0,0 +1 @@ +com.recipe.app.src.common.config.MysqlFulltextFunctionContributor diff --git a/src/test/groovy/com/recipe/app/src/common/utils/KoreanTokenizerTest.groovy b/src/test/groovy/com/recipe/app/src/common/utils/KoreanTokenizerTest.groovy new file mode 100644 index 00000000..0cc284c1 --- /dev/null +++ b/src/test/groovy/com/recipe/app/src/common/utils/KoreanTokenizerTest.groovy @@ -0,0 +1,80 @@ +package com.recipe.app.src.common.utils + +import spock.lang.Specification + +class KoreanTokenizerTest extends Specification { + + def "null/blank 입력은 빈 문자열을 반환한다"() { + + expect: + KoreanTokenizer.tokenize(input) == "" + + where: + input << [null, "", " ", "\t\n"] + } + + def "동일 입력은 동일 토큰 결과를 반환한다 (idempotency)"() { + + expect: + KoreanTokenizer.tokenize(input) == KoreanTokenizer.tokenize(input) + + where: + input << ["소면", "감자전", "김치 찌개 만들기", "에어프라이어"] + } + + def "한국어 합성어는 NONE 모드로 토큰이 분리되지 않는다"() { + + expect: + KoreanTokenizer.tokenize(input) == expected + + where: + input || expected + "소면" || "소면" + "감자" || "감자" + "양파" || "양파" + "감자전" || "감자전" + "김치찌개" || "김치찌개" + "닭볶음탕" || "닭볶음탕" + "고추장" || "고추장" + "고춧가루" || "고춧가루" + } + + def "조사가 붙은 단어는 명사만 추출된다"() { + + when: + String tokens = KoreanTokenizer.tokenize("감자를 샀다") + + then: + tokens.contains("감자") + !tokens.contains("를") + } + + def "사전에 없는 외래어도 토큰화된다 (분할되더라도 빈 결과는 아님)"() { + + when: + String tokens = KoreanTokenizer.tokenize("에어프라이어") + + then: + !tokens.isEmpty() + } + + def "영어/숫자도 토큰화된다"() { + + when: + String tokens = KoreanTokenizer.tokenize("Pasta 2024") + + then: + tokens.toLowerCase().contains("pasta") + tokens.contains("2024") + } + + def "단어 경계가 다른 텍스트는 토큰이 겹치지 않는다 (소면 vs 소고기 미역국)"() { + + given: + Set soMyeon = KoreanTokenizer.tokenize("소면").split(" ") as Set + Set soGoGiSoup = KoreanTokenizer.tokenize("소고기 미역국").split(" ") as Set + + expect: "소면 검색이 소고기 글의 토큰과 겹치지 않아야 함 (false positive 방지의 핵심 invariant)" + soMyeon.intersect(soGoGiSoup).isEmpty() + } +} diff --git a/src/test/groovy/com/recipe/app/src/common/utils/SearchKeywordNormalizerTest.groovy b/src/test/groovy/com/recipe/app/src/common/utils/SearchKeywordNormalizerTest.groovy new file mode 100644 index 00000000..f1b3dbea --- /dev/null +++ b/src/test/groovy/com/recipe/app/src/common/utils/SearchKeywordNormalizerTest.groovy @@ -0,0 +1,90 @@ +package com.recipe.app.src.common.utils + +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery +import spock.lang.Specification + +class SearchKeywordNormalizerTest extends Specification { + + def "null/empty/blank 입력은 Empty 를 반환한다"() { + + expect: + SearchKeywordNormalizer.normalize(input) instanceof SearchQuery.Empty + + where: + input << [null, "", " ", "\t"] + } + + def "1글자 입력은 ExactToken 으로 반환된다"() { + + when: + SearchQuery query = SearchKeywordNormalizer.normalize(input) + + then: + query instanceof SearchQuery.ExactToken + ((SearchQuery.ExactToken) query).token() == expectedToken + + where: + input || expectedToken + "감" || "감" + "갓" || "갓" + "닭" || "닭" + "B" || "b" + } + + def "한글 합성어는 BooleanQuery 로 +token 형태가 된다"() { + + when: + SearchQuery query = SearchKeywordNormalizer.normalize(input) + + then: + query instanceof SearchQuery.BooleanQuery + ((SearchQuery.BooleanQuery) query).query() == expected + + where: + input || expected + "소면" || "+소면" + "양파" || "+양파" + "감자전" || "+감자전" + "김치찌개" || "+김치찌개" + } + + def "복수 토큰은 +token1 +token2 형식으로 AND 매칭된다"() { + + when: + SearchQuery query = SearchKeywordNormalizer.normalize("감자 양파") + + then: + query instanceof SearchQuery.BooleanQuery + ((SearchQuery.BooleanQuery) query).query() == "+감자 +양파" + } + + def "BOOLEAN MODE 특수문자는 제거된다"() { + + when: + SearchQuery query = SearchKeywordNormalizer.normalize("+감자 -양파*") + + then: + query instanceof SearchQuery.BooleanQuery + ((SearchQuery.BooleanQuery) query).query() == "+감자 +양파" + } + + def "조사가 붙은 입력은 명사만 추출되어 반영된다"() { + + when: + SearchQuery query = SearchKeywordNormalizer.normalize("감자를") + + then: + query instanceof SearchQuery.BooleanQuery + ((SearchQuery.BooleanQuery) query).query() == "+감자" + } + + def "연속 공백/탭은 단일 공백으로 정리된다"() { + + when: + SearchQuery query = SearchKeywordNormalizer.normalize(" 감자 \t양파 ") + + then: + query instanceof SearchQuery.BooleanQuery + ((SearchQuery.BooleanQuery) query).query() == "+감자 +양파" + } +} diff --git a/src/test/groovy/com/recipe/app/src/fridge/application/FridgeServiceTest.groovy b/src/test/groovy/com/recipe/app/src/fridge/application/FridgeServiceTest.groovy index cbb8c996..d9ecc208 100644 --- a/src/test/groovy/com/recipe/app/src/fridge/application/FridgeServiceTest.groovy +++ b/src/test/groovy/com/recipe/app/src/fridge/application/FridgeServiceTest.groovy @@ -11,6 +11,7 @@ import com.recipe.app.src.fridgeBasket.application.FridgeBasketService import com.recipe.app.src.fridgeBasket.domain.FridgeBasket import com.recipe.app.src.ingredient.application.IngredientCategoryService import com.recipe.app.src.ingredient.application.IngredientService +import com.recipe.app.src.ingredient.application.IngredientSynonymCache import com.recipe.app.src.ingredient.domain.Ingredient import com.recipe.app.src.ingredient.domain.IngredientCategory import com.recipe.app.src.user.domain.User @@ -24,7 +25,8 @@ class FridgeServiceTest extends Specification { private FridgeBasketService fridgeBasketService = Mock() private IngredientService ingredientService = Mock() private IngredientCategoryService ingredientCategoryService = Mock() - private FridgeService fridgeService = new FridgeService(fridgeRepository, fridgeBasketService, ingredientService, ingredientCategoryService) + private IngredientSynonymCache ingredientSynonymCache = Mock() + private FridgeService fridgeService = new FridgeService(fridgeRepository, fridgeBasketService, ingredientService, ingredientCategoryService, ingredientSynonymCache) def "냉장고 생성"() { @@ -585,11 +587,14 @@ class FridgeServiceTest extends Specification { ingredientService.findByIngredientIds(fridges.ingredientId) >> ingredients + ingredientSynonymCache.expand(_) >> (["재료1", "재료2"] as Set) + when: List result = fridgeService.findIngredientNamesInFridge(userId) then: - result == ["재료1", "재료2"] + result.size() == 2 + result.containsAll(["재료1", "재료2"]) } def "유저 아이디와 재료 아이디로 냉장고 삭제"() { diff --git a/src/test/groovy/com/recipe/app/src/ingredient/application/IngredientSynonymCacheTest.groovy b/src/test/groovy/com/recipe/app/src/ingredient/application/IngredientSynonymCacheTest.groovy new file mode 100644 index 00000000..a581a148 --- /dev/null +++ b/src/test/groovy/com/recipe/app/src/ingredient/application/IngredientSynonymCacheTest.groovy @@ -0,0 +1,91 @@ +package com.recipe.app.src.ingredient.application + +import com.recipe.app.src.ingredient.domain.IngredientSynonym +import com.recipe.app.src.ingredient.infra.IngredientSynonymRepository +import spock.lang.Specification + +class IngredientSynonymCacheTest extends Specification { + + private IngredientSynonymRepository repository = Mock() + private IngredientSynonymCache cache = new IngredientSynonymCache(repository) + + def "동의어 그룹 안의 단어를 입력하면 그룹 전체를 반환한다"() { + + given: + repository.findAll() >> [ + IngredientSynonym.builder().synonymId(1L).groupId(1L).name("새우").build(), + IngredientSynonym.builder().synonymId(2L).groupId(1L).name("대하").build(), + IngredientSynonym.builder().synonymId(3L).groupId(2L).name("계란").build(), + IngredientSynonym.builder().synonymId(4L).groupId(2L).name("달걀").build(), + ] + cache.reload() + + expect: + cache.expand([input]) as Set == expected as Set + + where: + input || expected + "새우" || ["새우", "대하"] + "대하" || ["새우", "대하"] + "계란" || ["계란", "달걀"] + "달걀" || ["계란", "달걀"] + } + + def "3-way 그룹은 transitive 하게 모두 반환한다 (새싹채소-어린잎채소-무순)"() { + + given: + repository.findAll() >> [ + IngredientSynonym.builder().synonymId(1L).groupId(10L).name("새싹채소").build(), + IngredientSynonym.builder().synonymId(2L).groupId(10L).name("어린잎채소").build(), + IngredientSynonym.builder().synonymId(3L).groupId(10L).name("무순").build(), + ] + cache.reload() + + expect: "그룹 안의 어떤 단어로 들어와도 셋 다 반환" + cache.expand([input]) as Set == ["새싹채소", "어린잎채소", "무순"] as Set + + where: + input << ["새싹채소", "어린잎채소", "무순"] + } + + def "그룹에 없는 단어는 그 단어만 반환한다"() { + + given: + repository.findAll() >> [ + IngredientSynonym.builder().synonymId(1L).groupId(1L).name("새우").build(), + IngredientSynonym.builder().synonymId(2L).groupId(1L).name("대하").build(), + ] + cache.reload() + + expect: + cache.expand(["감자"]) == ["감자"] as Set + } + + def "여러 단어 입력 시 모두 확장해서 합친다"() { + + given: + repository.findAll() >> [ + IngredientSynonym.builder().synonymId(1L).groupId(1L).name("새우").build(), + IngredientSynonym.builder().synonymId(2L).groupId(1L).name("대하").build(), + IngredientSynonym.builder().synonymId(3L).groupId(2L).name("계란").build(), + IngredientSynonym.builder().synonymId(4L).groupId(2L).name("달걀").build(), + ] + cache.reload() + + expect: + cache.expand(["새우", "계란", "감자"]) as Set == ["새우", "대하", "계란", "달걀", "감자"] as Set + } + + def "null/빈 입력은 빈 Set 을 반환한다"() { + + given: + repository.findAll() >> [] + cache.reload() + + expect: + cache.expand(input).isEmpty() + + where: + input << [null, []] + } +} diff --git a/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy index 840ee4d5..2a0fb0ee 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/application/RecipeSearchServiceTest.groovy @@ -3,6 +3,7 @@ package com.recipe.app.src.recipe.application import com.recipe.app.src.common.utils.BadWordFiltering import com.recipe.app.src.fridge.application.FridgeService +import com.recipe.app.src.ingredient.application.IngredientSynonymCache import com.recipe.app.src.recipe.application.dto.RecipeDetailResponse import com.recipe.app.src.recipe.application.dto.RecipesResponse import com.recipe.app.src.recipe.application.dto.RecommendedRecipesResponse @@ -23,8 +24,9 @@ class RecipeSearchServiceTest extends Specification { private BadWordFiltering badWordService = Mock() private RecipeScrapService recipeScrapService = Mock() private RecipeViewService recipeViewService = Mock() + private IngredientSynonymCache ingredientSynonymCache = Mock() private RecipeSearchService recipeSearchService = new RecipeSearchService(recipeRepository, fridgeService, userService, badWordService, - recipeScrapService, recipeViewService) + recipeScrapService, recipeViewService, ingredientSynonymCache) def "레시피 키워드 검색 - 스크랩 수 정렬"() { @@ -46,7 +48,7 @@ class RecipeSearchServiceTest extends Specification { int size = 10 String sort = "scraps" - recipeRepository.countByKeyword(keyword) >> 2 + recipeRepository.countByKeyword(_) >> 2 recipeScrapService.countByRecipeId(lastRecipeId) >> 0 @@ -69,7 +71,7 @@ class RecipeSearchServiceTest extends Specification { .build(), ] - recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(keyword, lastRecipeId, 0, size) >> recipes + recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(_, lastRecipeId, 0, size) >> recipes userService.findByUserIds(users.userId) >> users @@ -120,7 +122,7 @@ class RecipeSearchServiceTest extends Specification { int size = 10 String sort = "views" - recipeRepository.countByKeyword(keyword) >> 2 + recipeRepository.countByKeyword(_) >> 2 recipeViewService.countByRecipeId(lastRecipeId) >> 0 @@ -143,7 +145,7 @@ class RecipeSearchServiceTest extends Specification { .build(), ] - recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(keyword, lastRecipeId, 0, size) >> recipes + recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(_, lastRecipeId, 0, size) >> recipes userService.findByUserIds(users.userId) >> users @@ -194,7 +196,7 @@ class RecipeSearchServiceTest extends Specification { int size = 10 String sort = "newest" - recipeRepository.countByKeyword(keyword) >> 2 + recipeRepository.countByKeyword(_) >> 2 recipeRepository.findById(lastRecipeId) >> Optional.empty() @@ -219,7 +221,7 @@ class RecipeSearchServiceTest extends Specification { recipeRepository.findById(lastRecipeId) >> Optional.empty() - recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(keyword, lastRecipeId, null, size) >> recipes + recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(_, lastRecipeId, null, size) >> recipes userService.findByUserIds(users.userId) >> users @@ -588,4 +590,77 @@ class RecipeSearchServiceTest extends Specification { result.recipes.viewCnt == recipes.viewCnt result.recipes.ingredientsMatchRate == [100, 33] } + + def "Public 추천 - 입력 재료를 동의어 확장 후 FULLTEXT 매칭한다"() { + + given: + long lastRecipeId = 0 + int size = 10 + List inputIngredientNames = ["새우"] + Set expandedSet = ["새우", "대하"] as Set + + List recipes = [ + Recipe.builder() + .recipeId(1L) + .recipeNm("대하구이") + .introduction("대하 들어간 레시피") + .level(RecipeLevel.NORMAL) + .userId(1L) + .isHidden(false) + .build() + ] + + ingredientSynonymCache.expand(inputIngredientNames) >> expandedSet + userService.findByUserIds(_) >> [] + recipeScrapService.findByRecipeIds(_) >> [] + recipeRepository.findById(lastRecipeId) >> Optional.empty() + + when: + RecommendedRecipesResponse result = recipeSearchService.findPublicRecommendedRecipesByIngredients(inputIngredientNames, lastRecipeId, size) + + then: "동의어 expand 결과(새우+대하)가 그대로 검색 인자로 전달된다" + 1 * recipeRepository.findRecipesInFridge({ Collection arg -> arg as Set == expandedSet }) >> recipes + result.totalCnt == 1 + result.recipes.recipeId == [1L] + } + + def "Public 추천 - 빈 입력은 빈 결과를 반환한다"() { + + given: + long lastRecipeId = 0 + int size = 10 + + ingredientSynonymCache.expand([]) >> ([] as Set) + recipeRepository.findRecipesInFridge(_) >> [] + userService.findByUserIds(_) >> [] + recipeScrapService.findByRecipeIds(_) >> [] + recipeRepository.findById(lastRecipeId) >> Optional.empty() + + when: + RecommendedRecipesResponse result = recipeSearchService.findPublicRecommendedRecipesByIngredients([], lastRecipeId, size) + + then: + result.totalCnt == 0 + result.recipes.isEmpty() + } + + def "레시피 키워드 검색 - 빈/공백 입력은 빈 결과를 반환한다"() { + + given: + User user = User.builder().userId(1).socialId("naver_1").nickname("테스터1").build() + userService.findByUserIds(_) >> [] + recipeScrapService.findByRecipeIds(_) >> [] + + when: + RecipesResponse result = recipeSearchService.findRecipesByKeywordOrderBy(user, input, 0L, 10, "newest") + + then: + result.totalCnt == 0 + result.recipes.isEmpty() + 0 * recipeRepository.countByKeyword(_) + 0 * recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(_, _, _, _) + + where: + input << ["", " ", "\t"] + } } diff --git a/src/test/groovy/com/recipe/app/src/recipe/application/blog/BlogRecipeServiceTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/application/blog/BlogRecipeServiceTest.groovy index 3e158245..89b1d161 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/application/blog/BlogRecipeServiceTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/application/blog/BlogRecipeServiceTest.groovy @@ -34,7 +34,7 @@ class BlogRecipeServiceTest extends Specification { int size = 2 String sort = "scraps" - blogRecipeRepository.countByKeyword(keyword) >> 20 + blogRecipeRepository.countByKeyword(_) >> 20 blogScrapService.countByBlogRecipeId(lastBlogRecipeId) >> 0 @@ -63,7 +63,7 @@ class BlogRecipeServiceTest extends Specification { .build(), ] - blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(keyword, lastBlogRecipeId, 0, size) >> blogRecipes + blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(_, lastBlogRecipeId, 0, size) >> blogRecipes List blogScraps = [ BlogScrap.builder() @@ -104,7 +104,7 @@ class BlogRecipeServiceTest extends Specification { int size = 2 String sort = "views" - blogRecipeRepository.countByKeyword(keyword) >> 20 + blogRecipeRepository.countByKeyword(_) >> 20 blogViewService.countByBlogRecipeId(lastBlogRecipeId) >> 0 @@ -133,7 +133,7 @@ class BlogRecipeServiceTest extends Specification { .build(), ] - blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(keyword, lastBlogRecipeId, 0, size) >> blogRecipes + blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(_, lastBlogRecipeId, 0, size) >> blogRecipes List blogScraps = [ BlogScrap.builder() @@ -174,7 +174,7 @@ class BlogRecipeServiceTest extends Specification { int size = 2 String sort = "newest" - blogRecipeRepository.countByKeyword(keyword) >> 20 + blogRecipeRepository.countByKeyword(_) >> 20 List blogRecipes = [ BlogRecipe.builder() @@ -203,7 +203,7 @@ class BlogRecipeServiceTest extends Specification { blogRecipeRepository.findById(lastBlogRecipeId) >> Optional.empty() - blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(keyword, lastBlogRecipeId, null, size) >> blogRecipes + blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(_, lastBlogRecipeId, null, size) >> blogRecipes List blogScraps = [ BlogScrap.builder() diff --git a/src/test/groovy/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeServiceTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeServiceTest.groovy index 26f1eaab..c4f397d0 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeServiceTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/application/youtube/YoutubeRecipeServiceTest.groovy @@ -35,7 +35,7 @@ class YoutubeRecipeServiceTest extends Specification { int size = 2 String sort = "scraps" - youtubeRecipeRepository.countByKeyword(keyword) >> 20 + youtubeRecipeRepository.countByKeyword(_) >> 20 youtubeScrapService.countByYoutubeRecipeId(lastYoutubeRecipeId) >> 0 @@ -64,7 +64,7 @@ class YoutubeRecipeServiceTest extends Specification { .build() ] - youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(keyword, lastYoutubeRecipeId, 0, size) >> youtubeRecipes + youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(_, lastYoutubeRecipeId, 0, size) >> youtubeRecipes List youtubeScraps = [ YoutubeScrap.builder() @@ -105,7 +105,7 @@ class YoutubeRecipeServiceTest extends Specification { int size = 2 String sort = "views" - youtubeRecipeRepository.countByKeyword(keyword) >> 20 + youtubeRecipeRepository.countByKeyword(_) >> 20 youtubeViewService.countByYoutubeRecipeId(lastYoutubeRecipeId) >> 0 @@ -134,7 +134,7 @@ class YoutubeRecipeServiceTest extends Specification { .build() ] - youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(keyword, lastYoutubeRecipeId, 0, size) >> youtubeRecipes + youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(_, lastYoutubeRecipeId, 0, size) >> youtubeRecipes List youtubeScraps = [ YoutubeScrap.builder() @@ -175,7 +175,7 @@ class YoutubeRecipeServiceTest extends Specification { int size = 2 String sort = "newest" - youtubeRecipeRepository.countByKeyword(keyword) >> 20 + youtubeRecipeRepository.countByKeyword(_) >> 20 youtubeViewService.countByYoutubeRecipeId(lastYoutubeRecipeId) >> 0 @@ -206,7 +206,7 @@ class YoutubeRecipeServiceTest extends Specification { youtubeRecipeRepository.findById(lastYoutubeRecipeId) >> Optional.empty() - youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(keyword, lastYoutubeRecipeId, null, size) >> youtubeRecipes + youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(_, lastYoutubeRecipeId, null, size) >> youtubeRecipes List youtubeScraps = [ YoutubeScrap.builder() diff --git a/src/test/groovy/com/recipe/app/src/recipe/domain/RecipeIngredientTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/domain/RecipeIngredientTest.groovy index 317d6b3e..36da4dd5 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/domain/RecipeIngredientTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/domain/RecipeIngredientTest.groovy @@ -92,10 +92,10 @@ class RecipeIngredientTest extends Specification { .unit("개") .build() - List ingredientNamesInFridge = ["돼지고기", "김치"] + Set normalizedFridge = ["돼지고기", "김치"] as Set when: - boolean result = ingredient.hasInFridge(ingredientNamesInFridge) + boolean result = ingredient.hasInFridge(normalizedFridge) then: result == expected diff --git a/src/test/groovy/com/recipe/app/src/recipe/domain/RecipeTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/domain/RecipeTest.groovy index 9d19f6aa..49e180a4 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/domain/RecipeTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/domain/RecipeTest.groovy @@ -304,10 +304,10 @@ class RecipeTest extends Specification { .ingredientName("삼겹살") .build() - List ingredientNamesInFridge = ["돼지고기", "오리고기", "김치", "소고기"] + Set normalizedFridge = ["돼지고기", "오리고기", "김치", "소고기"] as Set when: - long result = recipe.calculateIngredientMatchRate(ingredientNamesInFridge) + long result = recipe.calculateIngredientMatchRate(normalizedFridge) then: result == 43 diff --git a/src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy index a1ecfb25..4f101394 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy @@ -1,5 +1,7 @@ package com.recipe.app.src.recipe.infra +import com.recipe.app.src.common.utils.SearchKeywordNormalizer +import com.recipe.app.src.common.utils.SearchKeywordNormalizer.SearchQuery import com.recipe.app.src.recipe.domain.Recipe import com.recipe.app.src.recipe.domain.RecipeIngredient import com.recipe.app.src.recipe.domain.RecipeLevel @@ -13,8 +15,15 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.cloud.openfeign.FeignAutoConfiguration import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.TestPropertySource +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.support.TransactionTemplate import spock.lang.Specification +// InnoDB FULLTEXT 가시성 한계: +// 같은 트랜잭션 안에서 INSERT 한 row 는 MATCH AGAINST 결과로 잡히지 않음 (FT 캐시 → 커밋 시 인덱스로 머지). +// @DataJpaTest 디폴트 트랜잭션은 테스트 끝에 ROLLBACK → MATCH 가 항상 0 건 반환. +// 해법: setup/given 의 INSERT 를 REQUIRES_NEW 로 별도 커밋, cleanup 에서 같은 방식으로 정리. @ActiveProfiles("test") @DataJpaTest @ImportAutoConfiguration(classes = FeignAutoConfiguration.class) @@ -28,10 +37,16 @@ class RecipeCustomRepositoryTest extends Specification { RecipeRepository recipeRepository; @Autowired RecipeScrapRepository recipeScrapRepository; + @Autowired + PlatformTransactionManager transactionManager; private List users; + private TransactionTemplate committedTx; void setup() { + committedTx = new TransactionTemplate(transactionManager) + committedTx.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + users = [ User.builder() .socialId("naver_1") @@ -42,7 +57,15 @@ class RecipeCustomRepositoryTest extends Specification { .nickname("테스터2") .build(), ] - userRepository.saveAll(users); + committedTx.executeWithoutResult { status -> userRepository.saveAll(users) } + } + + void cleanup() { + committedTx.executeWithoutResult { status -> + recipeScrapRepository.deleteAll() + recipeRepository.deleteAll() + userRepository.deleteAll() + } } def "레시피 상세 조회 시 공개인 경우 성공"() { @@ -74,7 +97,7 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("재료") .build() - recipeRepository.saveAll(recipes) + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: Optional recipe = recipeRepository.findRecipeDetail(recipes.get(0).getRecipeId(), users.get(0).getUserId()) @@ -119,7 +142,7 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("재료") .build() - recipeRepository.saveAll(recipes) + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: Optional recipe = recipeRepository.findRecipeDetail(recipes.get(1).getRecipeId(), users.get(0).getUserId()) @@ -165,7 +188,7 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("재료") .build() - recipeRepository.saveAll(recipes) + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: Optional recipe = recipeRepository.findRecipeDetail(recipes.get(1).getRecipeId(), users.get(1).getUserId()) @@ -214,10 +237,10 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("테스트") .build() - recipeRepository.saveAll(recipes); + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: - long response = recipeRepository.countByKeyword("테스트"); + long response = recipeRepository.countByKeyword(SearchKeywordNormalizer.normalize("테스트")); then: response == 3 @@ -263,10 +286,10 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("테스트") .build() - recipeRepository.saveAll(recipes); + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: - List response = recipeRepository.findByKeywordLimitOrderByCreatedAtDesc("테스트", 0L, recipes.createdAt.max().plusMinutes(1), 3); + List response = recipeRepository.findByKeywordLimitOrderByCreatedAtDesc(SearchKeywordNormalizer.normalize("테스트"), 0L, recipes.createdAt.max().plusMinutes(1), 3); then: response.size() == 3 @@ -319,10 +342,10 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("테스트") .build() - recipeRepository.saveAll(recipes); + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: - List response = recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc("테스트", recipes.get(2).recipeId, 2, 3); + List response = recipeRepository.findByKeywordLimitOrderByRecipeScrapCntDesc(SearchKeywordNormalizer.normalize("테스트"), recipes.get(2).recipeId, 2, 3); then: response.size() == 2 @@ -373,10 +396,10 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("테스트") .build() - recipeRepository.saveAll(recipes); + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: - List response = recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc("테스트", recipes.get(2).recipeId, 2, 3); + List response = recipeRepository.findByKeywordLimitOrderByRecipeViewCntDesc(SearchKeywordNormalizer.normalize("테스트"), recipes.get(2).recipeId, 2, 3); then: response.size() == 2 @@ -410,7 +433,7 @@ class RecipeCustomRepositoryTest extends Specification { .isHidden(false) .build(), ] - recipeRepository.saveAll(recipes); + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } List recipeScraps = [ RecipeScrap.builder() @@ -422,7 +445,7 @@ class RecipeCustomRepositoryTest extends Specification { .recipeId(recipes.get(2).recipeId) .build(), ] - recipeScrapRepository.saveAll(recipeScraps); + committedTx.executeWithoutResult { status -> recipeScrapRepository.saveAll(recipeScraps) } when: List response = recipeRepository.findUserScrapRecipesLimit(users.get(0).userId, 0L, recipeScraps.createdAt.max().plusMinutes(1), 3); @@ -459,7 +482,7 @@ class RecipeCustomRepositoryTest extends Specification { .isHidden(false) .build(), ] - recipeRepository.saveAll(recipes); + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: List response = recipeRepository.findLimitByUserId(users.get(0).userId, recipes.get(2).recipeId, 3); @@ -511,7 +534,7 @@ class RecipeCustomRepositoryTest extends Specification { .ingredientName("테스트") .build() - recipeRepository.saveAll(recipes); + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } when: List response = recipeRepository.findRecipesInFridge(["테스트"]); @@ -520,4 +543,61 @@ class RecipeCustomRepositoryTest extends Specification { response.size() == 2 response.recipeId == [recipes.get(0).recipeId, recipes.get(2).recipeId] } + + def "1글자 ExactToken 검색은 단어 경계 정확 매칭만 매치한다 (재료명 기준)"() { + + given: + List recipes = [ + Recipe.builder() + .recipeNm("음식1") + .introduction("설명1") + .level(RecipeLevel.NORMAL) + .userId(users.get(0).userId) + .isHidden(false) + .build(), + Recipe.builder() + .recipeNm("음식2") + .introduction("설명2") + .level(RecipeLevel.NORMAL) + .userId(users.get(0).userId) + .isHidden(false) + .build(), + Recipe.builder() + .recipeNm("음식3") + .introduction("설명3") + .level(RecipeLevel.NORMAL) + .userId(users.get(0).userId) + .isHidden(false) + .build(), + ] + + RecipeIngredient.builder() + .recipe(recipes.get(0)) + .ingredientName("갓") + .build() + RecipeIngredient.builder() + .recipe(recipes.get(1)) + .ingredientName("감자") + .build() + RecipeIngredient.builder() + .recipe(recipes.get(2)) + .ingredientName("감자전") + .build() + + committedTx.executeWithoutResult { status -> recipeRepository.saveAll(recipes) } + + when: "1글자 정확 매칭" + SearchQuery query = SearchKeywordNormalizer.normalize(input) + long count = recipeRepository.countByKeyword(query) + + then: + query instanceof SearchQuery.ExactToken + count == expected + + where: + input || expected + "갓" || 1L // RecipeIngredient '갓' 정확 매치 + "감" || 0L // 어떤 재료도 정확히 '감' 이 아님 ('감자','감자전' 매치 안 됨) + "자" || 0L // 어떤 재료도 정확히 '자' 가 아님 + } } diff --git a/src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy index b95db9ed..3d3f41e8 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy @@ -1,5 +1,6 @@ package com.recipe.app.src.recipe.infra.blog +import com.recipe.app.src.common.utils.SearchKeywordNormalizer import com.recipe.app.src.recipe.domain.blog.BlogRecipe import com.recipe.app.src.recipe.domain.blog.BlogScrap import com.recipe.app.src.user.domain.User @@ -11,10 +12,17 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.cloud.openfeign.FeignAutoConfiguration import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.TestPropertySource +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.support.TransactionTemplate import spock.lang.Specification import java.time.LocalDate +// InnoDB FULLTEXT 가시성 한계: 같은 트랜잭션 안에서 INSERT 한 row 는 +// MATCH AGAINST 결과로 잡히지 않음 (FT 캐시 → 커밋 시 인덱스로 머지). +// @DataJpaTest 디폴트 ROLLBACK 트랜잭션을 우회하려고 INSERT 는 REQUIRES_NEW 로 별도 커밋, +// cleanup 에서 같은 방식으로 정리. @ActiveProfiles("test") @DataJpaTest @ImportAutoConfiguration(classes = FeignAutoConfiguration.class) @@ -28,6 +36,23 @@ class BlogRecipeCustomRepositoryTest extends Specification { BlogScrapRepository blogScrapRepository; @Autowired BlogRecipeRepository blogRecipeRepository; + @Autowired + PlatformTransactionManager transactionManager; + + private TransactionTemplate committedTx; + + void setup() { + committedTx = new TransactionTemplate(transactionManager) + committedTx.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } + + void cleanup() { + committedTx.executeWithoutResult { status -> + blogScrapRepository.deleteAll() + blogRecipeRepository.deleteAll() + userRepository.deleteAll() + } + } def "검색어로 블로그 레시피 갯수 조회"() { @@ -58,10 +83,10 @@ class BlogRecipeCustomRepositoryTest extends Specification { .blogName("테스트") .build() ] - blogRecipeRepository.saveAll(blogRecipes); + committedTx.executeWithoutResult { status -> blogRecipeRepository.saveAll(blogRecipes) } when: - long response = blogRecipeRepository.countByKeyword("테스트"); + long response = blogRecipeRepository.countByKeyword(SearchKeywordNormalizer.normalize("테스트")); then: response == 2 @@ -104,11 +129,11 @@ class BlogRecipeCustomRepositoryTest extends Specification { .blogName("테스트") .build() ] - blogRecipeRepository.saveAll(blogRecipes); + committedTx.executeWithoutResult { status -> blogRecipeRepository.saveAll(blogRecipes) } when: BlogRecipe lastBlogRecipe = blogRecipes.get(1); - List response = blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc("테스트", lastBlogRecipe.getBlogRecipeId(), lastBlogRecipe.getPublishedAt(), 3); + List response = blogRecipeRepository.findByKeywordLimitOrderByPublishedAtDesc(SearchKeywordNormalizer.normalize("테스트"), lastBlogRecipe.getBlogRecipeId(), lastBlogRecipe.getPublishedAt(), 3); then: response.size() == 2 @@ -133,7 +158,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .nickname("테스터3") .build(), ] - userRepository.saveAll(users) + committedTx.executeWithoutResult { status -> userRepository.saveAll(users) } List blogRecipes = [ BlogRecipe.builder() @@ -173,10 +198,10 @@ class BlogRecipeCustomRepositoryTest extends Specification { .scrapCnt(3L) .build() ] - blogRecipeRepository.saveAll(blogRecipes); + committedTx.executeWithoutResult { status -> blogRecipeRepository.saveAll(blogRecipes) } when: - List response = blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc("테스트", blogRecipes.get(3).blogRecipeId, 3, 3); + List response = blogRecipeRepository.findByKeywordLimitOrderByBlogScrapCntDesc(SearchKeywordNormalizer.normalize("테스트"), blogRecipes.get(3).blogRecipeId, 3, 3); then: response.size() == 2 @@ -197,7 +222,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .nickname("테스터2") .build(), ] - userRepository.saveAll(users) + committedTx.executeWithoutResult { status -> userRepository.saveAll(users) } List blogRecipes = [ BlogRecipe.builder() @@ -237,10 +262,10 @@ class BlogRecipeCustomRepositoryTest extends Specification { .viewCnt(2L) .build() ] - blogRecipeRepository.saveAll(blogRecipes); + committedTx.executeWithoutResult { status -> blogRecipeRepository.saveAll(blogRecipes) } when: - List response = blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc("테스트", blogRecipes.get(3).blogRecipeId, 2, 3); + List response = blogRecipeRepository.findByKeywordLimitOrderByBlogViewCntDesc(SearchKeywordNormalizer.normalize("테스트"), blogRecipes.get(3).blogRecipeId, 2, 3); then: response.size() == 2 @@ -255,7 +280,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .socialId("naver_1") .nickname("테스터1") .build(); - userRepository.save(user); + committedTx.executeWithoutResult { status -> userRepository.save(user) } List blogRecipes = [ BlogRecipe.builder() @@ -291,7 +316,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .blogName("테스트") .build() ] - blogRecipeRepository.saveAll(blogRecipes); + committedTx.executeWithoutResult { status -> blogRecipeRepository.saveAll(blogRecipes) } List blogScraps = [ BlogScrap.builder() @@ -303,7 +328,7 @@ class BlogRecipeCustomRepositoryTest extends Specification { .blogRecipeId(blogRecipes.get(3).blogRecipeId) .build(), ] - blogScrapRepository.saveAll(blogScraps); + committedTx.executeWithoutResult { status -> blogScrapRepository.saveAll(blogScraps) } when: List response = blogRecipeRepository.findUserScrapBlogRecipesLimit(user.userId, 0L, blogScraps.get(0).createdAt.plusHours(1), 3) @@ -313,4 +338,5 @@ class BlogRecipeCustomRepositoryTest extends Specification { response.get(0).blogRecipeId == blogRecipes.get(3).blogRecipeId response.get(1).blogRecipeId == blogRecipes.get(0).blogRecipeId } + } diff --git a/src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy index da869ca6..29707ea1 100644 --- a/src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy +++ b/src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy @@ -1,5 +1,6 @@ package com.recipe.app.src.recipe.infra.youtube +import com.recipe.app.src.common.utils.SearchKeywordNormalizer import com.recipe.app.src.recipe.domain.youtube.YoutubeRecipe import com.recipe.app.src.recipe.domain.youtube.YoutubeScrap import com.recipe.app.src.user.domain.User @@ -11,10 +12,17 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.cloud.openfeign.FeignAutoConfiguration import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.TestPropertySource +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.transaction.support.TransactionTemplate import spock.lang.Specification import java.time.LocalDate +// InnoDB FULLTEXT 가시성 한계: 같은 트랜잭션 안에서 INSERT 한 row 는 +// MATCH AGAINST 결과로 잡히지 않음 (FT 캐시 → 커밋 시 인덱스로 머지). +// @DataJpaTest 디폴트 ROLLBACK 트랜잭션을 우회하려고 INSERT 는 REQUIRES_NEW 로 별도 커밋, +// cleanup 에서 같은 방식으로 정리. @ActiveProfiles("test") @DataJpaTest @ImportAutoConfiguration(classes = FeignAutoConfiguration.class) @@ -28,6 +36,23 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { YoutubeRecipeRepository youtubeRecipeRepository; @Autowired YoutubeScrapRepository youtubeScrapRepository; + @Autowired + PlatformTransactionManager transactionManager; + + private TransactionTemplate committedTx; + + void setup() { + committedTx = new TransactionTemplate(transactionManager) + committedTx.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } + + void cleanup() { + committedTx.executeWithoutResult { status -> + youtubeScrapRepository.deleteAll() + youtubeRecipeRepository.deleteAll() + userRepository.deleteAll() + } + } def "검색어로 유튜브 레시피 갯수 조회"() { @@ -66,10 +91,10 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .thumbnailImgUrl("http://test.jpg") .build(), ] - youtubeRecipeRepository.saveAll(youtubeRecipes); + committedTx.executeWithoutResult { status -> youtubeRecipeRepository.saveAll(youtubeRecipes) } when: - long response = youtubeRecipeRepository.countByKeyword("테스트"); + long response = youtubeRecipeRepository.countByKeyword(SearchKeywordNormalizer.normalize("테스트")); then: response == 3 @@ -112,11 +137,11 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .thumbnailImgUrl("http://test.jpg") .build(), ] - youtubeRecipeRepository.saveAll(youtubeRecipes); + committedTx.executeWithoutResult { status -> youtubeRecipeRepository.saveAll(youtubeRecipes) } when: YoutubeRecipe lastYoutubeRecipe = youtubeRecipes.get(1); - List response = youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc("테스트", lastYoutubeRecipe.youtubeRecipeId, lastYoutubeRecipe.postDate, 3); + List response = youtubeRecipeRepository.findByKeywordLimitOrderByPostDateDesc(SearchKeywordNormalizer.normalize("테스트"), lastYoutubeRecipe.youtubeRecipeId, lastYoutubeRecipe.postDate, 3); then: response.size() == 2 @@ -137,7 +162,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .nickname("테스터2") .build(), ] - userRepository.saveAll(users) + committedTx.executeWithoutResult { status -> userRepository.saveAll(users) } List youtubeRecipes = [ YoutubeRecipe.builder() @@ -177,10 +202,10 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .scrapCnt(2L) .build(), ] - youtubeRecipeRepository.saveAll(youtubeRecipes); + committedTx.executeWithoutResult { status -> youtubeRecipeRepository.saveAll(youtubeRecipes) } when: - List response = youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc("테스트", youtubeRecipes.get(3).youtubeRecipeId, 2, 3); + List response = youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeScrapCntDesc(SearchKeywordNormalizer.normalize("테스트"), youtubeRecipes.get(3).youtubeRecipeId, 2, 3); then: response.size() == 2 @@ -201,7 +226,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .nickname("테스터2") .build(), ] - userRepository.saveAll(users) + committedTx.executeWithoutResult { status -> userRepository.saveAll(users) } List youtubeRecipes = [ YoutubeRecipe.builder() @@ -241,10 +266,10 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .viewCnt(2L) .build(), ] - youtubeRecipeRepository.saveAll(youtubeRecipes); + committedTx.executeWithoutResult { status -> youtubeRecipeRepository.saveAll(youtubeRecipes) } when: - List response = youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc("테스트", youtubeRecipes.get(3).youtubeRecipeId, 2, 3) + List response = youtubeRecipeRepository.findByKeywordLimitOrderByYoutubeViewCntDesc(SearchKeywordNormalizer.normalize("테스트"), youtubeRecipes.get(3).youtubeRecipeId, 2, 3) then: response.size() == 2 @@ -259,7 +284,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .socialId("naver_1") .nickname("테스터1") .build(); - userRepository.save(user); + committedTx.executeWithoutResult { status -> userRepository.save(user) } List youtubeRecipes = [ YoutubeRecipe.builder() @@ -295,7 +320,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .thumbnailImgUrl("http://test.jpg") .build(), ] - youtubeRecipeRepository.saveAll(youtubeRecipes); + committedTx.executeWithoutResult { status -> youtubeRecipeRepository.saveAll(youtubeRecipes) } List youtubeScraps = [ YoutubeScrap.builder() @@ -311,7 +336,7 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { .youtubeRecipeId(youtubeRecipes.get(3).youtubeRecipeId) .build(), ] - youtubeScrapRepository.saveAll(youtubeScraps); + committedTx.executeWithoutResult { status -> youtubeScrapRepository.saveAll(youtubeScraps) } when: List response = youtubeRecipeRepository.findUserScrapYoutubeRecipesLimit(user.userId, 0L, youtubeScraps.get(0).createdAt.plusDays(1), 3); @@ -322,4 +347,5 @@ class YoutubeRecipeCustomRepositoryTest extends Specification { response.get(1).youtubeRecipeId == youtubeRecipes.get(2).youtubeRecipeId response.get(2).youtubeRecipeId == youtubeRecipes.get(0).youtubeRecipeId } + } diff --git a/test-db-migration.sql b/test-db-migration.sql new file mode 100644 index 00000000..7557dd5e --- /dev/null +++ b/test-db-migration.sql @@ -0,0 +1,35 @@ +-- ===================================================================== +-- 테스트 DB 한 번 실행 — 검색 관련 스키마 정비 +-- 대상: jdbc:mysql://recipe-240706.../RecipeStorageTest +-- ===================================================================== +-- 적용 후 깨지던 16개 테스트가 통과하게 됩니다. +-- 각 ALTER 는 idempotent 가 아니므로 이미 적용된 부분이 있으면 해당 줄만 주석 처리하고 재실행하세요. +-- 운영 DB 에는 SEARCH_NORI_MIGRATION.md / RECOMMENDED_SEARCH_MIGRATION.md 의 절차를 그대로 따르세요. +-- ===================================================================== + +-- 1. searchTokens 컬럼 (4개 테이블) — 이미 있으면 스킵 +ALTER TABLE Recipe ADD COLUMN searchTokens TEXT; +ALTER TABLE RecipeIngredient ADD COLUMN searchTokens VARCHAR(128); +ALTER TABLE BlogRecipe ADD COLUMN searchTokens TEXT; +ALTER TABLE YoutubeRecipe ADD COLUMN searchTokens TEXT; + +-- 2. FULLTEXT 인덱스 (default parser, ngram 미사용 — Java 측에서 토큰화 후 공백 구분 저장) +ALTER TABLE Recipe ADD FULLTEXT INDEX ft_recipe_search (searchTokens); +ALTER TABLE RecipeIngredient ADD FULLTEXT INDEX ft_recipe_ing_search(searchTokens); +ALTER TABLE BlogRecipe ADD FULLTEXT INDEX ft_blog_search (searchTokens); +ALTER TABLE YoutubeRecipe ADD FULLTEXT INDEX ft_youtube_search (searchTokens); + +-- 3. IngredientSynonym 테이블 (운영 INSERT 전 단계 — 테스트엔 데이터 필요 없음) +CREATE TABLE IngredientSynonym ( + synonymId BIGINT PRIMARY KEY AUTO_INCREMENT, + groupId BIGINT NOT NULL, + name VARCHAR(64) NOT NULL, + UNIQUE KEY uk_synonym_name (name), + KEY idx_synonym_group (groupId) +); + +-- ===================================================================== +-- 검증 +-- ===================================================================== +-- SHOW CREATE TABLE RecipeIngredient; -- ft_recipe_ing_search 보이는지 +-- SELECT COUNT(*) FROM IngredientSynonym; -- 0 정상