Skip to content

Commit bb44dac

Browse files
authored
Merge pull request #121 from Recipe-Project/feature/improvements_recipe_search
Feature/improvements recipe search
2 parents ab92cae + 639e6a6 commit bb44dac

46 files changed

Lines changed: 1501 additions & 310 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

RECOMMENDED_SEARCH_MIGRATION.md

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
# 추천 레시피 검색 + 재료 동의어 DB 이전 마이그레이션 가이드
2+
3+
## 배경
4+
5+
기존 추천 검색 (`findRecommendedRecipesByUserFridge`) 의 두 가지 한계:
6+
7+
1. **재료 매칭이 `ingredientName` exact equality** — 케이스/공백 차이 흡수 못함. 한 RecipeIngredient 가 "양파 1/4개" 같이 단어 + 단위 형태일 때 "양파" fridge 와 매치 안 됨.
8+
2. **동의어 매핑이 Java `if/else` 체인** (`Ingredient.getIngredientNameWithSimilar()`) — 운영 중 동의어 추가 시 코드 변경 + 배포 필요. 14개 그룹 33개 단어가 엔티티 메서드 안에 박혀있던 상태.
9+
10+
이번 변경:
11+
- 동의어를 **`IngredientSynonym` DB 테이블** + 부팅 시 캐시 로드로 분리
12+
- 매칭을 `RecipeIngredient.searchTokens` (lowercase+trim 정규화) **FULLTEXT MATCH** 기반으로 전환
13+
- 3-way 동의어 그룹 (새싹채소-어린잎채소-무순, 조개-조갯살-바지락) 의 transitive 매칭으로 통일
14+
15+
---
16+
17+
## 작업 완료 내역 (코드 측)
18+
19+
### 추가된 커밋
20+
21+
```
22+
ed4e08f refactor: 추천 레시피 매칭을 RecipeIngredient.searchTokens FULLTEXT 기반으로 변경
23+
1e19022 refactor: 재료 동의어를 Java if/else 에서 IngredientSynonym DB 테이블로 이전
24+
5b931f3 test: 검색 Repository 통합 테스트의 키워드 인자를 SearchQuery 로 갱신 + 1글자 ExactToken 케이스 추가
25+
```
26+
27+
### 핵심 설계 결정
28+
29+
| 결정 | 선택 | 이유 |
30+
|---|---|---|
31+
| 동의어 저장 | DB 테이블 (`IngredientSynonym`) | 운영 중 코드 배포 없이 추가 가능 |
32+
| 동의어 모델 | `(groupId, name)` 페어 — 같은 groupId = 동의어 그룹 | 단순. 3-way 이상 자연스럽게 표현. 양방향/단방향 고민 불필요 |
33+
| 매칭 transitive | 그룹 안 모든 단어가 서로 동의어 | 기존 if/else 의 비대칭 (어린잎채소→새싹채소만) 보다 자연스러움 |
34+
| 캐시 전략 | `@PostConstruct` 부팅 시 1회 로드 (HashMap 기반) | 동의어 추가 빈도 낮음. TTL/무효화 매커니즘 불필요. DB 추가 시 인스턴스 재시작 필요 |
35+
| 매칭 컬럼 | `RecipeIngredient.searchTokens` (lowercase + trim) | 케이스/공백 흡수. nori 미적용 (도메인 단어 보존) |
36+
| 매칭 식 | `MATCH(searchTokens) AGAINST('... ... ...' IN BOOLEAN MODE)` (OR) | FULLTEXT 인덱스 사용. fridge 모든 동의어를 한 쿼리로 OR 매칭 |
37+
| `hasInFridge` 시그니처 | `List<String>``Set<String> normalizedFridgeNames` | 정규화 1회 + 도메인 메서드 의도 명확화 |
38+
| `calculateIngredientMatchRate` 시그니처 | 동일하게 `Set<String>` | 일관성 |
39+
| 정규화 위치 | DTO (`RecipeDetailResponse`, `RecommendedRecipesResponse`) 진입부 | fridge 리스트 흐름 중 한 번만 정규화 |
40+
41+
### 추가된 / 변경된 파일
42+
43+
**신규**
44+
- `src/main/java/com/recipe/app/src/ingredient/domain/IngredientSynonym.java` — 동의어 엔티티 (groupId + name)
45+
- `src/main/java/com/recipe/app/src/ingredient/infra/IngredientSynonymRepository.java` — JpaRepository
46+
- `src/main/java/com/recipe/app/src/ingredient/application/IngredientSynonymCache.java` — 부팅 시 로드 + `expand(Collection)` API
47+
- `src/test/groovy/com/recipe/app/src/ingredient/application/IngredientSynonymCacheTest.groovy` — 동의어 그룹 / transitive / 미등록 / 빈 입력 케이스
48+
49+
**변경**
50+
- `src/main/java/com/recipe/app/src/ingredient/domain/Ingredient.java``getIngredientNameWithSimilar()` 통째 제거 (54줄)
51+
- `src/main/java/com/recipe/app/src/fridge/application/FridgeService.java``findIngredientNamesInFridge` 가 cache.expand 호출
52+
- `src/main/java/com/recipe/app/src/recipe/domain/Recipe.java``calculateIngredientMatchRate(Set<String>)`. 빈 ingredients 처리
53+
- `src/main/java/com/recipe/app/src/recipe/domain/RecipeIngredient.java``hasInFridge(Set<String>)` searchTokens 토큰 교집합 매칭. 생성자에서 searchTokens 즉시 채움 (단위 테스트 호환)
54+
- `src/main/java/com/recipe/app/src/recipe/application/dto/RecipeDetailResponse.java` — 진입 시 fridge 이름 정규화 Set 변환
55+
- `src/main/java/com/recipe/app/src/recipe/application/dto/RecommendedRecipesResponse.java` — 동일
56+
- `src/main/java/com/recipe/app/src/recipe/infra/RecipeRepositoryImpl.java``findRecipesInFridge` 가 FULLTEXT MATCH OR 매칭
57+
- `src/test/groovy/com/recipe/app/src/recipe/domain/RecipeIngredientTest.groovy` — Set 시그니처
58+
- `src/test/groovy/com/recipe/app/src/recipe/domain/RecipeTest.groovy` — Set 시그니처
59+
- `src/test/groovy/com/recipe/app/src/fridge/application/FridgeServiceTest.groovy` — IngredientSynonymCache mock 추가
60+
- `src/test/groovy/com/recipe/app/src/recipe/infra/RecipeCustomRepositoryTest.groovy` — SearchQuery 시그니처 + ExactToken 케이스 추가
61+
- `src/test/groovy/com/recipe/app/src/recipe/infra/blog/BlogRecipeCustomRepositoryTest.groovy` — 동일
62+
- `src/test/groovy/com/recipe/app/src/recipe/infra/youtube/YoutubeRecipeCustomRepositoryTest.groovy` — 동일
63+
64+
### 자동 검증된 것
65+
- `./gradlew compileJava compileTestGroovy` — 통과
66+
- 단위 테스트
67+
- `IngredientSynonymCacheTest` — 동의어 그룹 매칭 / 3-way transitive / 미등록 / 빈 입력
68+
- `RecipeIngredientTest``hasInFridge(Set)` searchTokens 매칭
69+
- `RecipeTest``calculateIngredientMatchRate(Set)` 일치율 계산
70+
- mock 기반 서비스 테스트
71+
- `FridgeServiceTest` — cache 주입 후 `findIngredientNamesInFridge` 동작
72+
- `RecipeSearchServiceTest` — 추천/상세 케이스 (matchRate 100/33 검증 포함)
73+
74+
### 변경 후 동의어 동작 비교
75+
76+
| 입력 | 기존 (if/else) | 새 (DB groupId) |
77+
|---|---|---|
78+
| 새싹채소 | [어린잎채소, 무순, 새싹채소] | [어린잎채소, 무순, 새싹채소] |
79+
| 어린잎채소 | [새싹채소, 어린잎채소] (무순 X) | [새싹채소, 어린잎채소, **무순**] |
80+
| 무순 | [새싹채소, 무순] (어린잎채소 X) | [새싹채소, **어린잎채소**, 무순] |
81+
| 조갯살 | [조개, 조갯살] (바지락 X) | [조개, **바지락**, 조갯살] |
82+
| 바지락 | [조개, 바지락] (조갯살 X) | [조개, **조갯살**, 바지락] |
83+
| 새우 / 대하 | 양방향 OK | 양방향 OK (변동 없음) |
84+
| 그 외 14 그룹 | 양방향 OK | 양방향 OK (변동 없음) |
85+
86+
→ 3-way 그룹 2개 (새싹채소·조개) 의 비대칭이 transitive 로 통일됨.
87+
88+
---
89+
90+
## 본인이 직접 해야 하는 것
91+
92+
### Step 1 — DB 스키마 + 초기 데이터
93+
94+
```sql
95+
CREATE TABLE IngredientSynonym (
96+
synonymId BIGINT PRIMARY KEY AUTO_INCREMENT,
97+
groupId BIGINT NOT NULL,
98+
name VARCHAR(64) NOT NULL,
99+
UNIQUE KEY uk_synonym_name (name),
100+
KEY idx_synonym_group (groupId)
101+
);
102+
103+
-- 기존 if/else 그대로 이전 (14 그룹 / 29 단어)
104+
INSERT INTO IngredientSynonym (groupId, name) VALUES
105+
(1, '새우'), (1, '대하'),
106+
(2, '계란'), (2, '달걀'),
107+
(3, '소고기'), (3, '쇠고기'),
108+
(4, '후추'), (4, '후춧가루'),
109+
(5, '간마늘'), (5, '다진마늘'),
110+
(6, '새싹채소'), (6, '어린잎채소'), (6, '무순'),
111+
(7, '조개'), (7, '조갯살'), (7, '바지락'),
112+
(8, '케찹'), (8, '케첩'),
113+
(9, '소면'), (9, '국수'),
114+
(10, '김치'), (10, '김칫잎'),
115+
(11, '고춧가루'), (11, '고추가루'),
116+
(12, '올리브유'), (12, '올리브오일'),
117+
(13, '파스타'), (13, '스파게티'),
118+
(14, '포도씨유'), (14, '식용유');
119+
```
120+
121+
### Step 2 — 검증
122+
123+
#### 2-A. 부팅 로그
124+
```
125+
IngredientSynonymCache loaded - groups=14, words=29
126+
```
127+
이 라인이 `INFO` 레벨로 찍히는지 확인.
128+
129+
#### 2-B. 동의어 확장 동작
130+
```sql
131+
SELECT name, groupId FROM IngredientSynonym ORDER BY groupId, name;
132+
```
133+
14 그룹 29 row 가 정확히 들어왔는지.
134+
135+
#### 2-C. 추천 검색 동작
136+
- 냉장고에 "어린잎채소" 만 등록 → 추천 호출 → "무순" 사용한 레시피도 매치되는지 (transitive 동작)
137+
- 냉장고에 "양파" 등록 → 추천 호출 → 결과에 "양파" 들어간 레시피 + matchRate 가 정상 계산되는지
138+
- 냉장고에 "Onion" 등록 (대문자) → 추천 호출 → "onion" / "Onion" / "ONION" 들어간 레시피 모두 매치되는지 (정규화 동작)
139+
140+
#### 2-D. 단위 테스트
141+
```
142+
./gradlew test
143+
```
144+
모두 통과 확인. 만약 `RecipeCustomRepositoryTest` / `BlogRecipeCustomRepositoryTest` / `YoutubeRecipeCustomRepositoryTest` 의 ExactToken 케이스가 깨지면 **테스트 DB 의 FULLTEXT 인덱스 + searchTokens 백필 적용 여부** 확인 (`SEARCH_NORI_MIGRATION.md` 참조).
145+
146+
---
147+
148+
### Step 3 — 운영 환경 적용
149+
150+
```
151+
1. 운영 DB 에 Step 1 의 DDL + INSERT 적용
152+
2. 코드 배포
153+
3. Step 2 의 검증 절차 수행
154+
4. 모니터링: matchRate 분포 / 추천 결과 변화 빈도
155+
```
156+
157+
#### 롤백 시나리오
158+
159+
문제 발견 시:
160+
- **코드 롤백**: 이번 시리즈의 첫 커밋 (`5b931f3`) 직전인 `5168081 feat: 1글자 검색어는 정확 매칭...` 으로 revert
161+
- **DB 롤백 불필요**: `IngredientSynonym` 테이블이 남아있어도 무영향 (이전 코드는 안 읽음)
162+
163+
---
164+
165+
### Step 4 — 운영 중 동의어 추가/수정
166+
167+
새 동의어 그룹 추가:
168+
```sql
169+
-- 다음 사용 가능한 groupId 확인
170+
SELECT MAX(groupId) FROM IngredientSynonym;
171+
172+
-- 새 그룹
173+
INSERT INTO IngredientSynonym (groupId, name) VALUES
174+
(15, '아보카도'), (15, 'avocado');
175+
```
176+
177+
기존 그룹에 단어 추가:
178+
```sql
179+
INSERT INTO IngredientSynonym (groupId, name) VALUES (1, '왕새우');
180+
```
181+
182+
⚠️ **반영 절차**:
183+
- 캐시는 부팅 시 1회 로드. DB 만 수정하면 적용 안 됨
184+
- **인스턴스 재시작** 필요 (다운타임 약 1분)
185+
- 또는 **수동 reload endpoint 추가** (선택, 운영 부담이 커지면 도입)
186+
187+
---
188+
189+
### Step 5 — 후속 검토 (선택)
190+
191+
#### Public 추천 API 의 동의어 확장 ✅ 완료
192+
193+
`findPublicRecommendedRecipesByIngredients` 도 사용자 냉장고 추천과 동일하게 `IngredientSynonymCache.expand` 후 FULLTEXT 매칭하도록 통일. matchRate 도 expanded ingredients 기반으로 계산.
194+
195+
- `RecipeSearchService``IngredientSynonymCache` 의존성 추가
196+
- `findPublicRecommendedRecipesByIngredients`: `expand(input)``findRecipesInFridge(expanded)``RecommendedRecipesResponse.from(..., expanded, ...)`
197+
- 테스트: `RecipeSearchServiceTest` 에 cache mock 주입 + Public 추천 케이스 2개 (expand 인자 전달 검증 / 빈 입력)
198+
199+
#### 수동 cache reload endpoint
200+
운영자가 동의어 추가 후 재시작 없이 적용하려면:
201+
```java
202+
@PostMapping("/admin/ingredient-synonyms/reload")
203+
public void reload() {
204+
ingredientSynonymCache.reload();
205+
}
206+
```
207+
인증/권한 정책 결정 후 도입.
208+
209+
#### 사용자 정의 사전 (운영 데이터로 확장)
210+
운영해보면서 자주 다른 표현으로 등록되는 재료명 패턴이 보이면 동의어 그룹 추가:
211+
212+
```sql
213+
-- 운영 데이터에서 비슷한 재료명 패턴 찾기
214+
SELECT ingredientName, COUNT(*)
215+
FROM RecipeIngredient
216+
GROUP BY ingredientName
217+
ORDER BY COUNT(*) DESC
218+
LIMIT 100;
219+
```
220+
221+
상위 100개에서 변종 (예: "양파 1/2개" vs "양파" vs "어니언") 보이면 후보. 단 자유 입력 ingredientName 자체는 동의어 테이블이 매핑 못 하므로 (마스터 단위 매핑이라), 이 케이스는 클라이언트에서 정규화하거나 별도 자유텍스트 매핑 테이블 검토가 필요할 수 있음.
222+
223+
#### 디버그 코드 정리
224+
세션 중 `JwtFilter.java` 에 추가된 임시 `System.out.println(jwtUtil.createAccessToken(23988L));` 라인은 운영 배포 전 제거 권장 (이번 작업 범위 밖이라 commit 안 함).
225+
226+
---
227+
228+
## 참고
229+
230+
- 동의어 그룹 모델은 transitive 가 항상 true. 비대칭이 필요한 케이스는 현재 모델로 표현 불가
231+
- 캐시는 단순 in-memory `Map<String, Set<String>>`. Caffeine 등 별도 캐시 인프라 사용 안 함 (재시작 시 자동 새로 로드)
232+
- `IngredientSynonym.name` 에 unique 제약 — 같은 단어가 여러 그룹에 못 속함. 도메인상 자연스러운 제약

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ dependencies {
7171

7272
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'
7373

74+
// 한국어 형태소 분석기 (검색 토큰화 용)
75+
implementation 'org.apache.lucene:lucene-analysis-nori:9.11.1'
76+
7477
testImplementation 'org.springframework.boot:spring-boot-starter-test'
7578

7679
testImplementation 'org.spockframework:spock-core:2.4-M1-groovy-4.0'

gradlew

100644100755
File mode changed.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.recipe.app.src.common.config;
2+
3+
import org.hibernate.boot.model.FunctionContributions;
4+
import org.hibernate.boot.model.FunctionContributor;
5+
import org.hibernate.type.StandardBasicTypes;
6+
7+
/**
8+
* Hibernate 에 MySQL FULLTEXT MATCH AGAINST 함수를 등록한다.
9+
* QueryDSL 등에서 function('match_against', col, query) 로 호출 가능.
10+
*/
11+
public class MysqlFulltextFunctionContributor implements FunctionContributor {
12+
13+
@Override
14+
public void contributeFunctions(FunctionContributions functionContributions) {
15+
16+
functionContributions.getFunctionRegistry()
17+
.registerPattern(
18+
"match_against",
19+
"MATCH(?1) AGAINST(?2 IN BOOLEAN MODE)",
20+
functionContributions.getTypeConfiguration()
21+
.getBasicTypeRegistry()
22+
.resolve(StandardBasicTypes.DOUBLE)
23+
);
24+
}
25+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.recipe.app.src.common.utils;
2+
3+
import org.apache.lucene.analysis.TokenStream;
4+
import org.apache.lucene.analysis.ko.KoreanAnalyzer;
5+
import org.apache.lucene.analysis.ko.KoreanPartOfSpeechStopFilter;
6+
import org.apache.lucene.analysis.ko.KoreanTokenizer.DecompoundMode;
7+
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
8+
9+
import java.io.IOException;
10+
import java.io.StringReader;
11+
import java.util.ArrayList;
12+
import java.util.List;
13+
14+
public final class KoreanTokenizer {
15+
16+
private static final KoreanAnalyzer ANALYZER = new KoreanAnalyzer(
17+
null,
18+
DecompoundMode.NONE,
19+
KoreanPartOfSpeechStopFilter.DEFAULT_STOP_TAGS,
20+
false
21+
);
22+
23+
private KoreanTokenizer() {
24+
}
25+
26+
public static String tokenize(String text) {
27+
28+
if (text == null || text.isBlank()) return "";
29+
30+
List<String> tokens = new ArrayList<>();
31+
try (TokenStream ts = ANALYZER.tokenStream(null, new StringReader(text))) {
32+
CharTermAttribute attr = ts.addAttribute(CharTermAttribute.class);
33+
ts.reset();
34+
while (ts.incrementToken()) {
35+
String token = attr.toString();
36+
if (!token.isEmpty()) tokens.add(token);
37+
}
38+
ts.end();
39+
} catch (IOException e) {
40+
throw new IllegalStateException("Failed to tokenize: " + text, e);
41+
}
42+
return String.join(" ", tokens);
43+
}
44+
}

src/main/java/com/recipe/app/src/common/utils/QueryUtils.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.recipe.app.src.common.utils;
22

33
import com.querydsl.core.types.dsl.BooleanExpression;
4+
import com.querydsl.core.types.dsl.Expressions;
5+
import com.querydsl.core.types.dsl.StringPath;
46

57
import java.util.function.BiFunction;
68
import java.util.function.Function;
@@ -14,4 +16,36 @@ public static <T> BooleanExpression ifIdIsNotNullAndGreaterThanZero(BiFunction<L
1416
public static BooleanExpression ifIdIsNotNullAndGreaterThanZero(Function<Long, BooleanExpression> function, Long id) {
1517
return id != null && id > 0 ? function.apply(id) : null;
1618
}
19+
20+
/**
21+
* MySQL FULLTEXT BOOLEAN MODE 매칭 조건. SearchKeywordNormalizer 가 만든 BOOLEAN 모드 쿼리 문자열을 받는다.
22+
*/
23+
public static BooleanExpression matchAgainst(StringPath column, String booleanQuery) {
24+
return Expressions.numberTemplate(Double.class,
25+
"function('match_against', {0}, {1})", column, booleanQuery).gt(0);
26+
}
27+
28+
/**
29+
* 1글자 토큰 정확 매칭. FULLTEXT 인덱스의 ngram_token_size 제약 때문에 1글자는 BOOLEAN 모드로 잡지 못해
30+
* 공백 구분 단어 경계 LIKE 로 대체. 풀스캔이라 비용 있지만 1글자 검색 빈도 자체가 낮다고 가정.
31+
* 매칭 예: searchTokens="갓" 또는 "오 갓 김치" -> "갓" 매치, "갓김치"는 매치 안 됨.
32+
*/
33+
public static BooleanExpression exactTokenMatch(StringPath column, String token) {
34+
return Expressions.booleanTemplate(
35+
"concat(' ', {0}, ' ') like concat('% ', {1}, ' %')",
36+
column, token);
37+
}
38+
39+
/**
40+
* SearchQuery 타입에 맞춰 적절한 매칭 식을 반환. Empty 는 호출 직전 서비스에서 걸러져야 정상.
41+
*/
42+
public static BooleanExpression matchSearchQuery(StringPath column, SearchKeywordNormalizer.SearchQuery query) {
43+
if (query instanceof SearchKeywordNormalizer.SearchQuery.BooleanQuery b) {
44+
return matchAgainst(column, b.query());
45+
}
46+
if (query instanceof SearchKeywordNormalizer.SearchQuery.ExactToken e) {
47+
return exactTokenMatch(column, e.token());
48+
}
49+
return Expressions.asBoolean(false).isTrue();
50+
}
1751
}

0 commit comments

Comments
 (0)