Merged
Conversation
createBlogRecipes 단계에서 이미 적재된 게시글은 filter 로 빠지지만, 검색 결과 50개 전체(transient 객체 포함)가 saveThumbnails 로 그대로 전달되어 saveAll 시 id 가 null 인 객체들이 새 row 로 INSERT 되어 중복 누적되던 문제. 신규로 INSERT 된 entity 만 썸네일 크롤링 대상으로 넘기도록 수정. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- YoutubeRecipeService.findYoutubeRecipesByKeyword 가 IOException 을 외부로 던지지 않고 내부에서 캐치/로깅 후 DB 폴백으로 흐르도록 수정 (블로그 검색 흐름과 일관) - BlogRecipeThumbnailCrawlingService 의 디버그용 System.out.println 제거 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
countByKeyword 가 매칭되는 모든 row 를 group by + having 으로 fetch 한 뒤 list size 를 반환하던 구조를 EXISTS 서브쿼리 + countDistinct 단일 쿼리로 변경. 키워드 매칭 카운트만 필요한 상황에서 불필요한 row 로딩이 사라져 응답 속도/메모리 사용에 이득. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- GeneralExceptionHandler 에 IOException 핸들러 추가 (500 응답으로 일관 처리) - YoutubeRecipeController.getYoutubeRecipes 의 throws IOException 제거 (서비스 계층에서 캐치/로깅 후 DB 폴백으로 흐르는 흐름과 정합) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- lucene-analysis-nori 9.11.1 의존성 추가
- KoreanTokenizer: nori KoreanAnalyzer (DecompoundMode.NONE) 로 입력 텍스트를
공백 구분 토큰 문자열로 변환. 합성어("양파","감자전","김치찌개") 가 분리되지
않도록 NONE 모드 사용 (정확성 우선, 누락 허용 정책)
- SearchKeywordNormalizer: 사용자 입력 -> trim -> 특수문자 제거 ->
nori 토큰화 -> "+토큰" AND BOOLEAN MODE 쿼리 문자열. 1글자 입력은 null 반환
(FULLTEXT 인덱스 token size 제약 대응)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- MysqlFulltextFunctionContributor: Hibernate 6 FunctionContributor SPI 로 match_against(col, query) 함수 등록 -> SQL: MATCH(col) AGAINST(query IN BOOLEAN MODE) - META-INF/services/org.hibernate.boot.model.FunctionContributor 로 자동 로드 - QueryUtils.matchAgainst(StringPath, String): Querydsl BooleanExpression 헬퍼. 점수 0 초과(매칭 성공) 조건으로 WHERE 절에서 사용 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Recipe / RecipeIngredient / BlogRecipe / YoutubeRecipe 엔티티에 searchTokens 컬럼 추가하고 @PrePersist / @PreUpdate 로 저장/수정 시 KoreanTokenizer 가 자동으로 토큰화한 결과를 채운다. - Recipe: recipeNm + " " + introduction 토큰화 - RecipeIngredient: ingredientName 토큰화 (재료 검색 대응) - BlogRecipe / YoutubeRecipe: title + " " + description 토큰화 이 컬럼을 FULLTEXT 인덱스 대상으로 삼아 LIKE 풀스캔 -> 인덱스 매칭으로 전환 하기 위한 사전 작업이며, 컬럼/인덱스 DDL 은 운영 DB 에서 별도 적용 필요. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
기존 LIKE %keyword% 풀스캔 검색이 갖던 두 문제를 해결한다. - 인덱스 미사용 -> 데이터 누적 시 응답속도 저하 - "소면" 검색이 "소고기"에 매칭되는 부분 일치 false positive 서비스 계층에서 SearchKeywordNormalizer 로 키워드를 BOOLEAN MODE 쿼리로 변환한 뒤 Repository 에 전달. 1글자 등 토큰화 불가 입력은 빈 결과 즉시 반환 하여 외부 API 호출(naver/youtube) 도 함께 회피. Repository 는 QueryUtils.matchAgainst 헬퍼를 통해 FULLTEXT 매칭으로 변경. Recipe 검색은 recipe.searchTokens 매칭 OR RecipeIngredient.searchTokens EXISTS 서브쿼리 패턴으로 단순화하여 count/list 쿼리의 매칭 룰을 통일했다. 정렬 옵션(scraps/views/newest)은 기존 그대로 유지. 점수 기반 정렬은 도입하지 않음 (정확성 우선, 누락 허용 정책). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
운영 DB 의 기존 row 들에 searchTokens 를 채우기 위한 일회성 마이그레이션 러너. - 4개 도메인(Recipe / RecipeIngredient / BlogRecipe / YoutubeRecipe) 모두 처리 - 500건 단위 배치 + 트랜잭션 분리로 메모리/롤백 부담 최소화 - recipe.migration.search-tokens.enabled=true property 로 gating (운영에서는 1회 실행 후 property 제거) - 기존 blogUrlHash 백필 패턴과 동일 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
서비스가 키워드를 BOOLEAN MODE 쿼리("+감자")로 변환한 뒤 Repository 에 전달
하도록 바뀌었으므로, 기존 mock stub 의 첫 인자(원시 키워드)는 더 이상 매칭되지
않는다. 키워드 변환은 서비스의 구현 디테일이므로 stub 첫 인자를 _ as String
와일드카드로 변경.
추가로 RecipeSearchServiceTest 에 1글자 입력은 외부 의존(repo/api) 호출 없이
빈 결과를 즉시 반환하는 정책을 검증하는 케이스 추가.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
기존 fallback 메서드 시그니처가 원본 메서드와 불일치(반환 타입/파라미터 개수)
하여 Resilience4j 가 fallback 으로 인식하지 못하고 원본 예외가 그대로 전파되던
버그 수정. 외부 API 실패 시 fallback 은 조용히 종료하고 service 의 후속 DB
조회로 자연스럽게 fallback 되도록 정렬.
- BlogRecipeClientSearchService.fallback
: List<BlogRecipe>(keyword, size, e) -> void(keyword, Throwable)
- YoutubeRecipeClientSearchService.fallback
: 동일하게 void 시그니처로 정렬
- YoutubeRecipeClientSearchService.searchYoutube
: throws IOException 제거, 내부에서 IllegalStateException 으로 wrap.
circuit breaker 가 unchecked 도 동일하게 처리하므로 호출자 try/catch 불필요
- YoutubeRecipeService.findYoutubeRecipesByKeyword
: 더 이상 컴파일 강제도 없고 런타임에도 도달 불가능한 try/catch(IOException) 제거
- GeneralExceptionHandler
: 어떤 컨트롤러도 IOException 을 던지지 않게 되어 사용되지 않는 핸들러 제거
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nori KoreanAnalyzer 의 기본 stopword 정책이 한국어 부사/어미/조사를 제거하는데, 요리 재료 도메인에서 "갓", "다시다", "우묵" 같은 단일 명사 재료명을 nori 가 부사/어미로 잘못 분류해 stopword 로 제거 -> searchTokens 가 빈 문자열로 채워져 재료 검색에서 누락되던 문제. 재료명은 짧은 단일 명사가 대부분이라 형태소 분석 효용이 낮으므로 lowercase + trim 정규화로 충분. RecipeIngredient.@PrePersist/@PreUpdate 와 백필 러너 모두 동일 처리하도록 수정. - RecipeIngredient.refreshSearchTokens: KoreanTokenizer.tokenize -> lowercase + trim - SearchTokensBackfillRunner: 테이블별 토크나이저 분기 (RecipeIngredient 만 단순 정규화, 나머지는 nori 그대로) 운영 DB 는 백필 러너 1회 재실행 필요 (recipe.migration.search-tokens.enabled=true). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
기존엔 1글자 입력을 빈 결과로 차단했으나, "갓","닭","감" 같은 단일 명사 재료/음식
검색이 의미 있어 정확 매칭(단어 경계) 으로 허용한다. FULLTEXT BOOLEAN 모드는
innodb_ft_min_token_size 제약 때문에 1글자 토큰을 잡지 못하므로 LIKE 기반 단어 경계
매칭으로 분기.
- SearchKeywordNormalizer: toBooleanModeQuery -> normalize 로 교체. 결과 타입을
sealed SearchQuery (Empty / ExactToken / BooleanQuery) 로 명시. 1글자는 ExactToken,
2글자 이상은 nori 토큰화 후 BooleanQuery
- QueryUtils.exactTokenMatch: concat(' ', col, ' ') like '% token %' 로 단어 경계
정확 매칭. 1글자 검색에만 사용 (풀스캔 비용 감수)
- QueryUtils.matchSearchQuery: SearchQuery 타입에 맞춰 matchAgainst 또는
exactTokenMatch 로 분기하는 통합 헬퍼
- 3개 RepositoryImpl + 3개 CustomRepository: keyword(String) -> query(SearchQuery)
로 시그니처 변경. 내부에서 matchSearchQuery 헬퍼 호출
- 3개 Service: SearchKeywordNormalizer.normalize 호출 후 Empty 면 빈 결과 즉시 반환,
외 SearchQuery 그대로 Repository 전달
- 테스트: SearchKeywordNormalizerTest 새 API 로 갱신, 서비스 mock stub 의 _ as String
-> _ (SearchQuery 매처). RecipeSearchServiceTest 의 1글자 차단 케이스는 빈/공백
입력 차단 케이스로 대체
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… 케이스 추가
기존 검색 통합 테스트가 String "테스트" 를 직접 넘겨 SearchQuery 시그니처와 안 맞던 부분을
SearchKeywordNormalizer.normalize("테스트") 호출로 일괄 갱신.
추가로 1글자 ExactToken 정확 매칭이 단어 경계만 잡고 부분 일치는 잡지 않는지 검증하는
케이스를 Recipe / Blog / Youtube 각각 추가.
- RecipeCustomRepositoryTest: "갓" 재료 정확 매치 / "감" "자" 비매치
- BlogRecipeCustomRepositoryTest: title="갓" 매치 / title="감자" 비매치
- YoutubeRecipeCustomRepositoryTest: 동일 패턴
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
운영 중 동의어 추가/수정을 코드 배포 없이 가능하게 하기 위해 Ingredient 엔티티의 getIngredientNameWithSimilar() if/else 체인을 별도 테이블로 분리. - IngredientSynonym(synonymId, groupId, name): 같은 groupId 공유 = 동의어 그룹. 3-way 이상도 자연스럽게 transitive 매칭 (예: 새싹채소-어린잎채소-무순 셋이 모두 서로 동의어로 인식) - IngredientSynonymRepository: JpaRepository 인터페이스 - IngredientSynonymCache: 부팅 시 1회 로드(@PostConstruct) + groupId 기준 재구성한 Map<String, Set<String>> 으로 expand(Collection) 제공. 운영 중 DB 추가 시 재시작 또는 reload() 필요 - Ingredient.getIngredientNameWithSimilar(): 메서드 통째 제거 - FridgeService.findIngredientNamesInFridge: cache.expand(rawNames) 호출하여 동의어 확장 - 테스트: IngredientSynonymCacheTest 신규 (그룹 매칭 / 3-way transitive / 미등록 단어 / 빈 입력) + FridgeServiceTest 의 cache mock 추가 운영 적용 시 IngredientSynonym 테이블 생성 + 초기 데이터 INSERT 필요 (별도 안내 문서 참조). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
기존 ingredientName.in(ingredientNames) 의 exact equality 매칭은 케이스/공백 차이를 못 잡고, 한 RecipeIngredient 안에 여러 단어가 있는 경우(예: "양파 1/4개")의 부분 매칭도 못 했다. searchTokens(=lowercase trim 정규화 컬럼) 의 FULLTEXT 인덱스를 활용하여 토큰 단위 매칭으로 전환. - RecipeRepositoryImpl.findRecipesInFridge: ingredientName.in -> MATCH(searchTokens) AGAINST(... IN BOOLEAN MODE) (OR 매칭, "+" 미사용) - RecipeIngredient.hasInFridge: List<String> -> Set<String> 받고 searchTokens 의 공백 분리 토큰들을 fridge set 과 교집합 검사 - RecipeIngredient 생성자: searchTokens 를 즉시 채움 (기존 @PrePersist 만으로는 비영속 엔티티 단위 테스트가 깨짐) - Recipe.calculateIngredientMatchRate: List<String> -> Set<String>. 빈 ingredients 면 0 반환 - RecipeDetailResponse / RecommendedRecipesResponse.from: 진입 시 fridge 이름을 lowercase trim 정규화한 Set 으로 변환해서 도메인 메서드에 전달 - RecipeIngredientTest / RecipeTest: 새 시그니처 (Set) 로 갱신 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
기존 사용자 냉장고 추천(findRecommendedRecipesByUserFridge) 은 FridgeService 내부에서 cache.expand 를 거쳐 동의어 확장된 ingredient 로 FULLTEXT 매칭하지만, 비로그인 public 추천(findPublicRecommendedRecipesByIngredients) 은 입력 ingredientNames 를 그대로 repo 에 넘겨서 동의어가 무시됐음. 일관성을 위해 두 경로 모두 expand 후 매칭하도록 통일. - RecipeSearchService 에 IngredientSynonymCache 의존성 추가 - findPublicRecommendedRecipesByIngredients: expand(input) -> findRecipesInFridge(expanded). matchRate 계산도 expanded ingredients 기반 - RecipeSearchServiceTest: cache mock 주입 + Public 추천 케이스 2개 (expand 결과가 검색 인자로 전달되는지 / 빈 입력 시 빈 결과) API 시그니처는 그대로. 같은 입력에 대해 동의어 그룹의 다른 단어가 들어간 레시피도 매치되어 추천 결과가 풍부해짐. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@DataJpaTest 의 디폴트 트랜잭션은 끝에서 ROLLBACK 되는데, InnoDB FULLTEXT 인덱스는 같은 트랜잭션 안에서 INSERT 한 row 를 MATCH AGAINST 결과로 잡지 못함 (FT 캐시 → 커밋 시점에 인덱스로 머지). 그래서 saveAll 직후 MATCH 검색 검증이 항상 0건 반환. 일반 SELECT 는 read-your-writes 라 영향 없음 (LIKE 기반 ExactToken 검색은 통과). 해법: PlatformTransactionManager 주입 + REQUIRES_NEW 전파의 TransactionTemplate 으로 setup/given 의 INSERT 를 별도 트랜잭션에서 커밋. cleanup 에서 같은 방식으로 정리해서 다음 테스트에 영향 없게. - RecipeCustomRepositoryTest / BlogRecipeCustomRepositoryTest / YoutubeRecipeCustomRepositoryTest: committedTx 멤버 + setup/cleanup + saveAll 호출을 committedTx.executeWithoutResult 로 래핑 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ed4e08f / 1e19022 / 5b931f3 / 8ff5af0 시리즈로 완료된 추천 검색 매칭 + 동의어 DB 이전 작업의 배경, 설계 결정, 운영 적용 절차를 한 문서로 정리. - RECOMMENDED_SEARCH_MIGRATION.md: 시리즈 커밋 요약, 핵심 설계 결정 표, 변경 파일 목록, 운영 DB DDL/INSERT, 검증 절차, 롤백 시나리오, 후속 검토 항목. Step 5 의 'public 추천 API 동의어 확장' 은 8ff5af0 으로 완료 표시 - test-db-migration.sql: 운영 DB 와 별개로 테스트 DB(RecipeStorageTest) 에 searchTokens 컬럼 / FULLTEXT 인덱스 / IngredientSynonym 테이블을 한 번에 적용할 idempotent 하지 않지만 1회용으로 명확한 DDL Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
검증하려던 시나리오: title='갓' 인 row 가 1글자 입력 '갓' 검색에 매치된다. 실제 동작: BlogRecipe / YoutubeRecipe 의 searchTokens 는 nori 토큰화 결과인데 KoreanPartOfSpeechStopFilter.DEFAULT_STOP_TAGS 가 '갓' 을 stopword 로 거름 (또는 outputUnknownUnigrams=false 라 unknown 1글자 unigram 으로 필터링). 결과적으로 title='갓' / description='재료 갓 설명' 이어도 searchTokens='재료 설명' 이 되어 LIKE 단어 경계 매칭이 0건. 테스트 가정 자체가 nori 동작과 어긋남. 대안인 'nori 가 살리는 다른 1글자 명사' 로 데이터를 바꿔도, description 까지 같이 토큰화하는 BlogRecipe/YoutubeRecipe 특성상 단어 경계의 의미가 모호해져 검증 의도를 명확히 잡기 어려움. 1글자 ExactToken 정책 자체의 검증은 RecipeCustomRepositoryTest 에 남아있는 동명의 테스트(simple normalize 컬럼인 RecipeIngredient.searchTokens 매칭) 가 커버. - BlogRecipeCustomRepositoryTest / YoutubeRecipeCustomRepositoryTest 의 '1글자 ExactToken 검색은 단어 경계 정확 매칭만 매치한다' 메서드 삭제 - 사용 지점이 사라진 SearchQuery import 정리 nori 가 '갓' 같은 단어를 stopword 처리하는 정책 자체는 도메인 검토 사항. 필요하면 별도 PR 에서 outputUnknownUnigrams=true 또는 stopword 커스텀. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue Number
Summary
Describe