From eeae9b159bf8ebb8ad64a823c1f40dba687a28ce Mon Sep 17 00:00:00 2001 From: joona95 Date: Sun, 5 Jul 2026 01:18:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A0=88=EC=8B=9C=ED=94=BC=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=8B=9C=20=EA=B2=80=EC=83=89=EC=96=B4=20=EC=8C=93?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...Controller.java => KeywordController.java} | 16 +++-- .../application/RecipeSearchService.java | 9 ++- .../application/keyword/KeywordService.java | 57 ++++++++++++++++++ .../keyword/SearchKeywordService.java | 59 +++++++------------ .../src/recipe/domain/keyword/Keyword.java | 24 ++++++++ .../recipe/domain/keyword/SearchKeyword.java | 35 ++++++++++- .../infra/keyword/KeywordRepository.java | 9 +++ .../keyword/SearchKeywordRepository.java | 2 +- .../RecipeSearchServiceTest.groovy | 4 +- .../keyword/KeywordServiceTest.groovy | 55 +++++++++++++++++ 10 files changed, 217 insertions(+), 53 deletions(-) rename src/main/java/com/recipe/app/src/recipe/api/{SearchKeywordController.java => KeywordController.java} (60%) create mode 100644 src/main/java/com/recipe/app/src/recipe/application/keyword/KeywordService.java create mode 100644 src/main/java/com/recipe/app/src/recipe/domain/keyword/Keyword.java create mode 100644 src/main/java/com/recipe/app/src/recipe/infra/keyword/KeywordRepository.java create mode 100644 src/test/groovy/com/recipe/app/src/recipe/application/keyword/KeywordServiceTest.groovy diff --git a/src/main/java/com/recipe/app/src/recipe/api/SearchKeywordController.java b/src/main/java/com/recipe/app/src/recipe/api/KeywordController.java similarity index 60% rename from src/main/java/com/recipe/app/src/recipe/api/SearchKeywordController.java rename to src/main/java/com/recipe/app/src/recipe/api/KeywordController.java index 6dc665f4..3b0d532e 100644 --- a/src/main/java/com/recipe/app/src/recipe/api/SearchKeywordController.java +++ b/src/main/java/com/recipe/app/src/recipe/api/KeywordController.java @@ -1,6 +1,6 @@ package com.recipe.app.src.recipe.api; -import com.recipe.app.src.recipe.application.keyword.SearchKeywordService; +import com.recipe.app.src.recipe.application.keyword.KeywordService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.GetMapping; @@ -12,20 +12,18 @@ @Tag(name = "검색어 Controller") @RestController @RequestMapping("/recipes") -public class SearchKeywordController { +public class KeywordController { - private final SearchKeywordService searchKeywordService; + private final KeywordService keywordService; - public SearchKeywordController(SearchKeywordService searchKeywordService) { - this.searchKeywordService = searchKeywordService; + public KeywordController(KeywordService keywordService) { + this.keywordService = keywordService; } @Operation(summary = "검색어 추천 목록 조회 API") @GetMapping("/best-keywords") public List getRecipeBestKeywords() { - return searchKeywordService.retrieveRecipesBestKeyword(); + return keywordService.retrieveRecipesBestKeyword(); } - - -} \ No newline at end of file +} 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 7566b0ea..84dc9ebc 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 @@ -7,6 +7,7 @@ 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.keyword.SearchKeywordService; import com.recipe.app.src.recipe.domain.Recipe; import com.recipe.app.src.recipe.domain.RecipeScrap; import com.recipe.app.src.recipe.domain.Recipes; @@ -29,9 +30,11 @@ public class RecipeSearchService { private final RecipeScrapService recipeScrapService; private final RecipeViewService recipeViewService; private final IngredientSynonymCache ingredientSynonymCache; + private final SearchKeywordService searchKeywordService; public RecipeSearchService(RecipeRepository recipeRepository, FridgeService fridgeService, UserService userService, BadWordFiltering badWordFiltering, - RecipeScrapService recipeScrapService, RecipeViewService recipeViewService, IngredientSynonymCache ingredientSynonymCache) { + RecipeScrapService recipeScrapService, RecipeViewService recipeViewService, IngredientSynonymCache ingredientSynonymCache, + SearchKeywordService searchKeywordService) { this.recipeRepository = recipeRepository; this.fridgeService = fridgeService; this.userService = userService; @@ -39,6 +42,7 @@ public RecipeSearchService(RecipeRepository recipeRepository, FridgeService frid this.recipeScrapService = recipeScrapService; this.recipeViewService = recipeViewService; this.ingredientSynonymCache = ingredientSynonymCache; + this.searchKeywordService = searchKeywordService; } @Transactional(readOnly = true) @@ -62,6 +66,9 @@ public RecipesResponse findRecipesByKeywordOrderBy(User user, String keyword, lo recipes = findByKeywordOrderByCreatedAt(query, lastRecipeId, size); } + // 검색 로그 적재 (비동기·fire-and-forget). 욕설/빈 검색어는 위에서 이미 걸러진 상태. + searchKeywordService.record(keyword, user != null ? user.getUserId() : null); + return getRecipes(user, totalCnt, new Recipes(recipes)); } diff --git a/src/main/java/com/recipe/app/src/recipe/application/keyword/KeywordService.java b/src/main/java/com/recipe/app/src/recipe/application/keyword/KeywordService.java new file mode 100644 index 00000000..a19d8462 --- /dev/null +++ b/src/main/java/com/recipe/app/src/recipe/application/keyword/KeywordService.java @@ -0,0 +1,57 @@ +package com.recipe.app.src.recipe.application.keyword; + +import com.recipe.app.src.recipe.domain.keyword.Keyword; +import com.recipe.app.src.recipe.infra.keyword.KeywordRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.stream.Collectors; + +@Service +public class KeywordService { + + private final KeywordRepository keywordRepository; + + public KeywordService(KeywordRepository keywordRepository) { + this.keywordRepository = keywordRepository; + } + + private static final int BEST_KEYWORD_COUNT = 10; + + @Transactional(readOnly = true) + public List retrieveRecipesBestKeyword() { + List recipeKeywords = keywordRepository.findAll().stream() + .map(Keyword::getKeyword) + .distinct() + .collect(Collectors.toList()); + + // 검색어가 없으면 빈 목록 (random.nextInt(0) 예외 방지) + if (recipeKeywords.isEmpty()) { + return List.of(); + } + + // 고유 검색어 수가 10개 미만이어도 끝나도록 목표치를 보유 수로 제한 (무한 루프 방지) + int target = Math.min(BEST_KEYWORD_COUNT, recipeKeywords.size()); + + Random random = new Random(); + List keywords = new ArrayList<>(); + int cnt = 0; + while (keywords.size() < target) { + long seed = LocalDateTime.now().withMinute(cnt % 60).withSecond(0).withNano(0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + random.setSeed(seed); + + String keyword = recipeKeywords.get(random.nextInt(recipeKeywords.size())); + if (!keywords.contains(keyword)) { + keywords.add(keyword); + } + cnt++; + } + + return keywords; + } +} diff --git a/src/main/java/com/recipe/app/src/recipe/application/keyword/SearchKeywordService.java b/src/main/java/com/recipe/app/src/recipe/application/keyword/SearchKeywordService.java index 2f2e055b..806e41df 100644 --- a/src/main/java/com/recipe/app/src/recipe/application/keyword/SearchKeywordService.java +++ b/src/main/java/com/recipe/app/src/recipe/application/keyword/SearchKeywordService.java @@ -2,56 +2,37 @@ import com.recipe.app.src.recipe.domain.keyword.SearchKeyword; import com.recipe.app.src.recipe.infra.keyword.SearchKeywordRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.ArrayList; -import java.util.List; -import java.util.Random; -import java.util.stream.Collectors; - +/** + * 사용자 검색어를 로그 테이블(SearchKeyword)에 쌓는다. + * 검색 응답을 막지 않도록 비동기(@Async)로 기록하며, 기록 실패가 검색을 깨뜨리지 않게 예외를 삼킨다. + */ @Service public class SearchKeywordService { + private static final Logger log = LoggerFactory.getLogger(SearchKeywordService.class); + private final SearchKeywordRepository searchKeywordRepository; public SearchKeywordService(SearchKeywordRepository searchKeywordRepository) { this.searchKeywordRepository = searchKeywordRepository; } - private static final int BEST_KEYWORD_COUNT = 10; - - @Transactional(readOnly = true) - public List retrieveRecipesBestKeyword() { - List recipeKeywords = searchKeywordRepository.findAll().stream() - .map(SearchKeyword::getKeyword) - .distinct() - .collect(Collectors.toList()); - - // 검색어가 없으면 빈 목록 (random.nextInt(0) 예외 방지) - if (recipeKeywords.isEmpty()) { - return List.of(); + @Async + @Transactional + public void record(String keyword, Long userId) { + try { + searchKeywordRepository.save(SearchKeyword.builder() + .keyword(keyword) + .userId(userId) + .build()); + } catch (Exception e) { + log.warn("검색어 기록 실패. keyword={}, userId={}", keyword, userId, e); } - - // 고유 검색어 수가 10개 미만이어도 끝나도록 목표치를 보유 수로 제한 (무한 루프 방지) - int target = Math.min(BEST_KEYWORD_COUNT, recipeKeywords.size()); - - Random random = new Random(); - List keywords = new ArrayList<>(); - int cnt = 0; - while (keywords.size() < target) { - long seed = LocalDateTime.now().withMinute(cnt % 60).withSecond(0).withNano(0).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); - random.setSeed(seed); - - String keyword = recipeKeywords.get(random.nextInt(recipeKeywords.size())); - if (!keywords.contains(keyword)) { - keywords.add(keyword); - } - cnt++; - } - - return keywords; } -} \ No newline at end of file +} diff --git a/src/main/java/com/recipe/app/src/recipe/domain/keyword/Keyword.java b/src/main/java/com/recipe/app/src/recipe/domain/keyword/Keyword.java new file mode 100644 index 00000000..6233fe86 --- /dev/null +++ b/src/main/java/com/recipe/app/src/recipe/domain/keyword/Keyword.java @@ -0,0 +1,24 @@ +package com.recipe.app.src.recipe.domain.keyword; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 인기 검색어(추천 검색어) 후보 풀. Keywords 테이블에 매핑된다. + * 실제 사용자 검색 로그는 {@link SearchKeyword} 에 별도로 쌓인다. + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "Keywords") +public class Keyword { + + @Id + @Column(name = "keyword", nullable = false, updatable = false) + private String keyword; +} diff --git a/src/main/java/com/recipe/app/src/recipe/domain/keyword/SearchKeyword.java b/src/main/java/com/recipe/app/src/recipe/domain/keyword/SearchKeyword.java index 673add19..91e4bc3f 100644 --- a/src/main/java/com/recipe/app/src/recipe/domain/keyword/SearchKeyword.java +++ b/src/main/java/com/recipe/app/src/recipe/domain/keyword/SearchKeyword.java @@ -2,19 +2,50 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +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.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import java.time.LocalDateTime; + +/** + * 사용자 검색 로그. 검색이 일어날 때마다 1건씩 쌓인다 (원본 append-log). + * 인기 검색어 승격/집계는 이 로그를 나중에 가공해서 처리한다. (지금은 쌓기만) + */ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity -@Table(name = "Keywords") +@EntityListeners(AuditingEntityListener.class) +@Table(name = "SearchKeyword") public class SearchKeyword { @Id - @Column(name = "keyword", nullable = false, updatable = false) + @Column(name = "searchKeywordId", nullable = false, updatable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long searchKeywordId; + + @Column(name = "keyword", nullable = false) private String keyword; + + @Column(name = "userId") + private Long userId; + + @CreatedDate + @Column(name = "createdAt", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Builder + public SearchKeyword(Long searchKeywordId, String keyword, Long userId) { + this.searchKeywordId = searchKeywordId; + this.keyword = keyword; + this.userId = userId; + } } diff --git a/src/main/java/com/recipe/app/src/recipe/infra/keyword/KeywordRepository.java b/src/main/java/com/recipe/app/src/recipe/infra/keyword/KeywordRepository.java new file mode 100644 index 00000000..c89e1996 --- /dev/null +++ b/src/main/java/com/recipe/app/src/recipe/infra/keyword/KeywordRepository.java @@ -0,0 +1,9 @@ +package com.recipe.app.src.recipe.infra.keyword; + +import com.recipe.app.src.recipe.domain.keyword.Keyword; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface KeywordRepository extends JpaRepository { +} diff --git a/src/main/java/com/recipe/app/src/recipe/infra/keyword/SearchKeywordRepository.java b/src/main/java/com/recipe/app/src/recipe/infra/keyword/SearchKeywordRepository.java index 70a13c85..d06b52ac 100644 --- a/src/main/java/com/recipe/app/src/recipe/infra/keyword/SearchKeywordRepository.java +++ b/src/main/java/com/recipe/app/src/recipe/infra/keyword/SearchKeywordRepository.java @@ -5,5 +5,5 @@ import org.springframework.stereotype.Repository; @Repository -public interface SearchKeywordRepository extends JpaRepository { +public interface SearchKeywordRepository extends JpaRepository { } 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 13bd6552..e709c36f 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 @@ -6,6 +6,7 @@ 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.keyword.SearchKeywordService import com.recipe.app.src.recipe.domain.* import com.recipe.app.src.recipe.exception.NotFoundRecipeException import com.recipe.app.src.recipe.infra.RecipeRepository @@ -24,8 +25,9 @@ class RecipeSearchServiceTest extends Specification { private RecipeScrapService recipeScrapService = Mock() private RecipeViewService recipeViewService = Mock() private IngredientSynonymCache ingredientSynonymCache = Mock() + private SearchKeywordService searchKeywordService = Mock() private RecipeSearchService recipeSearchService = new RecipeSearchService(recipeRepository, fridgeService, userService, badWordService, - recipeScrapService, recipeViewService, ingredientSynonymCache) + recipeScrapService, recipeViewService, ingredientSynonymCache, searchKeywordService) def "레시피 키워드 검색 - 스크랩 수 정렬"() { diff --git a/src/test/groovy/com/recipe/app/src/recipe/application/keyword/KeywordServiceTest.groovy b/src/test/groovy/com/recipe/app/src/recipe/application/keyword/KeywordServiceTest.groovy new file mode 100644 index 00000000..6513e711 --- /dev/null +++ b/src/test/groovy/com/recipe/app/src/recipe/application/keyword/KeywordServiceTest.groovy @@ -0,0 +1,55 @@ +package com.recipe.app.src.recipe.application.keyword + +import com.recipe.app.src.recipe.domain.keyword.Keyword +import com.recipe.app.src.recipe.infra.keyword.KeywordRepository +import spock.lang.Specification +import spock.lang.Timeout + +import java.util.concurrent.TimeUnit + +class KeywordServiceTest extends Specification { + + KeywordRepository keywordRepository = Mock() + KeywordService keywordService = new KeywordService(keywordRepository) + + def kw(String k) { + Stub(Keyword) { getKeyword() >> k } + } + + def "검색어가 없으면 빈 목록을 반환한다 (예외 없이)"() { + + given: + keywordRepository.findAll() >> [] + + expect: + keywordService.retrieveRecipesBestKeyword() == [] + } + + @Timeout(value = 5, unit = TimeUnit.SECONDS) + def "고유 검색어가 10개 미만이면 무한루프 없이 보유 개수만 반환한다"() { + + given: + keywordRepository.findAll() >> [kw("감자"), kw("양파"), kw("당근")] + + when: + List result = keywordService.retrieveRecipesBestKeyword() + + then: + result.size() == 3 + result.toSet() == ["감자", "양파", "당근"].toSet() + } + + @Timeout(value = 5, unit = TimeUnit.SECONDS) + def "고유 검색어가 10개 이상이면 10개를 반환한다"() { + + given: + keywordRepository.findAll() >> (1..15).collect { kw("키워드" + it) } + + when: + List result = keywordService.retrieveRecipesBestKeyword() + + then: + result.size() == 10 + result.unique().size() == 10 + } +}