Skip to content

Commit 9ee1a12

Browse files
authored
Merge pull request #182 from SWU-Elixir/feat/50-custom-recipe-recommend
refactor: 추천 레시피 조회 성능 최적화 및 동적 캐시 키 설계
2 parents 48f83ce + d011ddc commit 9ee1a12

File tree

4 files changed

+96
-47
lines changed

4 files changed

+96
-47
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,38 @@
11
package BE_Elixir.Elixir.domain.recommendation.repository;
22

33
import BE_Elixir.Elixir.domain.recipe.entity.Recipe;
4+
import BE_Elixir.Elixir.global.enums.CategorySlowAging;
5+
import BE_Elixir.Elixir.global.enums.CategoryType;
6+
import org.springframework.data.domain.Pageable;
47
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Query;
9+
import org.springframework.data.repository.query.Param;
10+
11+
import java.util.List;
512

613
public interface RecommendationRepository extends JpaRepository<Recipe, Long> {
14+
15+
@Query("""
16+
SELECT r FROM Recipe r
17+
WHERE (:recipeStyles IS NULL OR r.categoryType IN :recipeStyles)
18+
AND (:reasons IS NULL OR r.categorySlowAging IN :reasons)
19+
""")
20+
List<Recipe> findFilteredRecipes(
21+
@Param("recipeStyles") List<String> recipeStyles,
22+
@Param("reasons") List<String> reasons
23+
);
24+
25+
// categoryType(레시피 스타일), categorySlowAging(이유) 기준 필터링
26+
@Query("SELECT r FROM Recipe r " +
27+
"WHERE (:categoryTypes IS NULL OR r.categoryType IN :categoryTypes) " +
28+
"AND (:categorySlowAgings IS NULL OR r.categorySlowAging IN :categorySlowAgings)")
29+
List<Recipe> findByCategoryFilters(
30+
@Param("categoryTypes") List<CategoryType> categoryTypes,
31+
@Param("categorySlowAgings") List<CategorySlowAging> categorySlowAgings
32+
);
33+
34+
// 랜덤 3개 레시피 (native query)
35+
@Query(value = "SELECT * FROM recipe ORDER BY RAND() LIMIT 3", nativeQuery = true)
36+
List<Recipe> findRandom3();
737
}
38+

src/main/java/BE_Elixir/Elixir/domain/recommendation/service/RecommendationService.java

Lines changed: 49 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@
55
import BE_Elixir.Elixir.domain.ingredient.repository.IngredientRepository;
66
import BE_Elixir.Elixir.domain.member.entity.Member;
77
import BE_Elixir.Elixir.domain.recipe.entity.Recipe;
8-
import BE_Elixir.Elixir.domain.recipe.entity.RecipeIngredient;
98
import BE_Elixir.Elixir.domain.recipe.repository.RecipeEventRepository;
109
import BE_Elixir.Elixir.domain.recipe.repository.RecipeRepository;
1110
import BE_Elixir.Elixir.domain.recommendation.dto.RecommendationResponseDTO;
12-
import BE_Elixir.Elixir.global.enums.CategoryType;
11+
import BE_Elixir.Elixir.domain.recommendation.repository.RecommendationRepository;
1312
import BE_Elixir.Elixir.global.redis.RedisRecipeService;
1413
import org.springframework.transaction.annotation.Transactional;
1514
import lombok.RequiredArgsConstructor;
@@ -28,61 +27,81 @@ public class RecommendationService {
2827
private final RedisRecipeService redisRecipeService;
2928
private final RecipeEventRepository recipeEventRepository;
3029
private final IngredientRepository ingredientRepository;
30+
private final RecommendationRepository recommendationRepository;
3131

3232

3333
// 사용자 맞춤형 레시피 추천
3434
@Transactional(readOnly = true)
3535
public List<RecommendationResponseDTO> getRecommendationsForUser(Member member) {
36+
// 동적 캐시 키 생성
37+
String cacheKey = createCacheKey(member);
38+
3639
// 캐시 확인
37-
List<RecommendationResponseDTO> cached = redisRecipeService.getCachedRecommendations(member.getId());
40+
List<RecommendationResponseDTO> cached = redisRecipeService.getCachedRecommendations(cacheKey);
3841
if (cached != null) return cached;
3942

40-
List<Recipe> allRecipes = recipeRepository.findAll();
43+
// 필터링 조건 추출
44+
List<String> mealStyles = member.getMealStyles();
45+
List<String> recipeStyles = member.getRecipeStyles();
46+
List<String> reasons = member.getReasons();
47+
List<String> allergies = member.getAllergies();
48+
49+
// Repository에서 필터된 레시피 조회 (필요에 따라 쿼리 수정 필요)
50+
List<Recipe> filteredRecipes = recommendationRepository.findFilteredRecipes(recipeStyles, reasons);
4151

42-
// 필터링
43-
List<Recipe> filtered = allRecipes.stream()
44-
.filter(recipe -> !hasAllergyConflict(member, recipe))
45-
.filter(recipe -> matchesMealStyle(member, recipe))
46-
.filter(recipe -> matchesRecipeStyle(member, recipe))
47-
.filter(recipe -> matchesReason(member, recipe))
52+
// 알러지 필터링 + 식사 스타일, 이유 조건 필터링 추가 로직 (필요시)
53+
filteredRecipes = filteredRecipes.stream()
54+
.filter(recipe -> !hasAllergyConflict(allergies, recipe))
55+
.filter(recipe -> mealStyles.isEmpty() || mealStyles.contains(recipe.getCategoryType().name()))
4856
.limit(3)
4957
.collect(Collectors.toList());
5058

51-
// 필터링된 레시피가 없을 경우 → 랜덤
52-
if (filtered.isEmpty()) {
53-
Collections.shuffle(allRecipes); // 리스트를 무작위로
54-
filtered = allRecipes.stream()
55-
.limit(3)
56-
.collect(Collectors.toList());
59+
// 필터링된 결과 없으면 랜덤 3개
60+
if (filteredRecipes.isEmpty()) {
61+
filteredRecipes = recommendationRepository.findRandom3();
5762
}
5863

59-
// 스크랩 여부 확인 및 DTO 변환
60-
List<RecommendationResponseDTO> recommendations = filtered.stream()
64+
// DTO 변환 및 스크랩 여부 체크
65+
List<RecommendationResponseDTO> recommendations = filteredRecipes.stream()
6166
.map(recipe -> {
62-
boolean scrappedByCurrentUser = recipeEventRepository.existsByRecipeIdAndMemberIdAndScrapFlagTrue(recipe.getId(), member.getId());
63-
return new RecommendationResponseDTO(recipe, scrappedByCurrentUser);
67+
boolean scrappedByUser = recipeEventRepository.existsByRecipeIdAndMemberIdAndScrapFlagTrue(recipe.getId(), member.getId());
68+
return new RecommendationResponseDTO(recipe, scrappedByUser);
6469
})
6570
.collect(Collectors.toList());
6671

67-
// 캐싱 (1시간)
68-
redisRecipeService.cacheRecommendations(member.getId(), recommendations, Duration.ofHours(1));
72+
// 캐싱
73+
redisRecipeService.cacheRecommendations(cacheKey, recommendations, Duration.ofMinutes(15));
74+
6975
return recommendations;
7076
}
7177

72-
// 알러지가 포함된 레시피는 제외
73-
private boolean hasAllergyConflict(Member member, Recipe recipe) {
74-
List<String> userAllergies = member.getAllergies(); // 사용자의 알러지 리스트
75-
List<String> recipeAllergies = recipe.getAllergyList(); // 레시피에 포함된 알러지 리스트
78+
private String createCacheKey(Member member) {
79+
String mealKey = String.join(",", member.getMealStyles());
80+
String recipeKey = String.join(",", member.getRecipeStyles());
81+
String reasonKey = String.join(",", member.getReasons());
82+
String allergyKey = String.join(",", member.getAllergies());
83+
84+
return "recommendations:"
85+
+ member.getId()
86+
+ ":mealStyles=" + mealKey
87+
+ ":recipeStyles=" + recipeKey
88+
+ ":reasons=" + reasonKey
89+
+ ":allergies=" + allergyKey;
90+
}
91+
7692

93+
// 알러지가 포함된 레시피는 제외
94+
private boolean hasAllergyConflict(List<String> userAllergies, Recipe recipe) {
7795
for (String allergy : userAllergies) {
78-
if (recipeAllergies.contains(allergy)) {
79-
return true; // 겹치는 알러지가 있으면 true
96+
if (recipe.getAllergyList().contains(allergy)) {
97+
return true;
8098
}
8199
}
82-
return false; // 겹치는 알러지가 없으면 false
100+
return false;
83101
}
84102

85103

104+
86105
// 식사 스타일(육류기반, 채식기반, 혼합식)에 따라 레시피 필터링
87106
public boolean matchesMealStyle(Member member, Recipe recipe) {
88107
// 레시피의 식재료 카테고리 리스트 추출
@@ -178,7 +197,7 @@ private boolean matchesReason(Member member, Recipe recipe) {
178197
@Transactional(readOnly = true)
179198
public List<String> getRecommendedKeywords(Member member) {
180199
// 캐시된 추천 레시피 확인
181-
List<RecommendationResponseDTO> cached = redisRecipeService.getCachedRecommendations(member.getId());
200+
List<RecommendationResponseDTO> cached = redisRecipeService.getCachedRecommendations(String.valueOf(member.getId()));
182201
if (cached == null || cached.isEmpty()) return List.of();
183202

184203
Set<String> keywords = new LinkedHashSet<>(); // 중복 제거 + 순서 유지

src/main/java/BE_Elixir/Elixir/global/redis/RedisRecipeService.java

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,25 +32,23 @@ public List<String> getTopKeywords(int count) {
3232
.toList();
3333
}
3434

35-
// 추천 레시피 캐시 저장
36-
public void cacheRecommendations(Long userId, List<RecommendationResponseDTO> recommendations, Duration ttl) {
35+
// 추천 레시피 캐시 저장 메서드
36+
public void cacheRecommendations(String cacheKey, List<RecommendationResponseDTO> recommendations, Duration ttl) {
3737
if (recommendations == null || recommendations.isEmpty()) {
38-
// 빈 리스트면 캐시 저장하지 않음 (혹은 삭제)
39-
redisTemplate.delete(RECOMMEND_KEY_PREFIX + userId);
38+
redisTemplate.delete(cacheKey);
4039
return;
4140
}
4241
try {
4342
String json = objectMapper.writeValueAsString(recommendations);
44-
redisTemplate.opsForValue().set(RECOMMEND_KEY_PREFIX + userId, json, ttl);
43+
redisTemplate.opsForValue().set(cacheKey, json, ttl);
4544
} catch (Exception e) {
4645
e.printStackTrace();
4746
}
4847
}
4948

50-
51-
// 추천 레시피 캐시 조회
52-
public List<RecommendationResponseDTO> getCachedRecommendations(Long userId) {
53-
String json = redisTemplate.opsForValue().get(RECOMMEND_KEY_PREFIX + userId);
49+
// 추천 레시피 캐시 조회 메서드
50+
public List<RecommendationResponseDTO> getCachedRecommendations(String cacheKey) {
51+
String json = redisTemplate.opsForValue().get(cacheKey);
5452
if (json == null) return null;
5553

5654
try {
@@ -60,4 +58,5 @@ public List<RecommendationResponseDTO> getCachedRecommendations(Long userId) {
6058
return Collections.emptyList();
6159
}
6260
}
61+
6362
}

src/test/java/BE_Elixir/Elixir/domain/recommendation/service/RecommendationServiceTest.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,22 +111,22 @@ void returnsCachedRecommendations() {
111111
List<RecommendationResponseDTO> cached = List.of(
112112
new RecommendationResponseDTO(new Recipe(), false)
113113
);
114-
given(redisRecipeService.getCachedRecommendations(member.getId())).willReturn(cached);
114+
given(redisRecipeService.getCachedRecommendations(String.valueOf(member.getId()))).willReturn(cached);
115115

116116
// when
117117
List<RecommendationResponseDTO> result = recommendationService.getRecommendationsForUser(member);
118118

119119
// then
120120
assertThat(result).isEqualTo(cached);
121-
then(redisRecipeService).should(times(1)).getCachedRecommendations(member.getId());
121+
then(redisRecipeService).should(times(1)).getCachedRecommendations(String.valueOf(member.getId()));
122122
then(recipeRepository).should(never()).findAll();
123123
}
124124

125125
@Test
126126
@DisplayName("성공: 캐시 없고, 필터링 후 추천 결과 반환 및 캐싱")
127127
void returnsFilteredRecommendationsAndCaches() {
128128
// given
129-
given(redisRecipeService.getCachedRecommendations(member.getId())).willReturn(null);
129+
given(redisRecipeService.getCachedRecommendations(String.valueOf(member.getId()))).willReturn(null);
130130

131131
Recipe recipe1 = Mockito.mock(Recipe.class);
132132
given(recipe1.getId()).willReturn(10L);
@@ -147,14 +147,14 @@ void returnsFilteredRecommendationsAndCaches() {
147147
assertThat(result).hasSize(1);
148148
assertThat(result.get(0).getScrappedByCurrentUser()).isTrue();
149149

150-
then(redisRecipeService).should().cacheRecommendations(eq(member.getId()), anyList(), eq(Duration.ofHours(1)));
150+
then(redisRecipeService).should().cacheRecommendations(eq(String.valueOf(member.getId())), anyList(), eq(Duration.ofHours(1)));
151151
}
152152

153153
@Test
154154
@DisplayName("성공: 필터링된 결과 없으면 전체 중 랜덤 3개 추천")
155155
void returnsRandomWhenNoFiltered() {
156156
// given
157-
given(redisRecipeService.getCachedRecommendations(member.getId())).willReturn(null);
157+
given(redisRecipeService.getCachedRecommendations(String.valueOf(member.getId()))).willReturn(null);
158158

159159
Recipe recipe1 = new Recipe();
160160
recipe1.setCategoryType(CategoryType.한식);
@@ -184,14 +184,14 @@ void returnsRandomWhenNoFiltered() {
184184

185185
// then
186186
assertThat(result).hasSizeLessThanOrEqualTo(3);
187-
then(redisRecipeService).should().cacheRecommendations(eq(member.getId()), anyList(), eq(Duration.ofHours(1)));
187+
then(redisRecipeService).should().cacheRecommendations(eq(String.valueOf(member.getId())), anyList(), eq(Duration.ofHours(1)));
188188
}
189189

190190
@Test
191191
@DisplayName("예외: 캐시 조회 후 결과가 null 이거나 비어있으면 빈 리스트 반환")
192192
void getRecommendedKeywords_ReturnsEmptyWhenCacheEmpty() {
193193
// given
194-
given(redisRecipeService.getCachedRecommendations(member.getId())).willReturn(null);
194+
given(redisRecipeService.getCachedRecommendations(String.valueOf(member.getId()))).willReturn(null);
195195

196196
// when
197197
List<String> keywords = recommendationService.getRecommendedKeywords(member);
@@ -214,7 +214,7 @@ void returnsKeywordsFromCachedRecommendations() {
214214
given(dto.getTitle()).willReturn("감자조림은 맛있다");
215215
given(dto.getIngredientTagIds()).willReturn(List.of(1L, 2L));
216216

217-
given(redisRecipeService.getCachedRecommendations(member.getId()))
217+
given(redisRecipeService.getCachedRecommendations(String.valueOf(member.getId())))
218218
.willReturn(List.of(dto));
219219

220220
Ingredient ing1 = new Ingredient();

0 commit comments

Comments
 (0)