Skip to content

Commit 231a184

Browse files
committed
refactor: 프로필 생성 이벤트에 추천용 스냅샷 추가
1 parent c554e89 commit 231a184

8 files changed

Lines changed: 163 additions & 24 deletions

File tree

src/main/java/com/techfork/domain/recommendation/listener/PersonalizedProfileGeneratedEventListener.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ public void handle(PersonalizedProfileGeneratedEvent event) {
2424

2525
try {
2626
User user = userLookupService.getUserOrThrow(userId);
27-
int recommendationCount = recommendationService.generateRecommendationsForUser(user);
27+
int recommendationCount = recommendationService.generateRecommendationsForUser(
28+
user,
29+
event.profileVector(),
30+
event.keyKeywords()
31+
);
2832

2933
log.info("Recommendations generated after personalization profile creation for userId: {} - {} recommendations created",
3034
userId, recommendationCount);

src/main/java/com/techfork/domain/recommendation/service/LlmRecommendationService.java

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,6 @@ public class LlmRecommendationService implements RecommendationService {
6767

6868
@Override
6969
public int generateRecommendationsForUser(User user) {
70-
log.info("사용자 {} 추천 생성 시작", user.getId());
71-
7270
Optional<PersonalizationProfileDocument> personalizationProfileOpt =
7371
personalizationProfileDocumentRepository.findByUserId(user.getId());
7472
if (personalizationProfileOpt.isEmpty() || personalizationProfileOpt.get().getProfileVector() == null) {
@@ -77,11 +75,27 @@ public int generateRecommendationsForUser(User user) {
7775
}
7876

7977
PersonalizationProfileDocument personalizationProfile = personalizationProfileOpt.get();
80-
float[] personalizationProfileVector = personalizationProfile.getProfileVector();
78+
return generateRecommendationsForUser(
79+
user,
80+
personalizationProfile.getProfileVector(),
81+
personalizationProfile.getKeyKeywords()
82+
);
83+
}
84+
85+
@Override
86+
public int generateRecommendationsForUser(User user, float[] personalizationProfileVector, List<String> keyKeywords) {
87+
log.info("사용자 {} 추천 생성 시작", user.getId());
88+
89+
if (personalizationProfileVector == null) {
90+
log.warn("사용자 {}의 개인화 프로필 벡터를 찾을 수 없음. 추천 생성 스킵.", user.getId());
91+
return 0;
92+
}
93+
94+
List<String> safeKeyKeywords = keyKeywords == null ? List.of() : keyKeywords;
8195

8296
try {
8397
// 2. k-NN 검색으로 초기 후보군 가져오기
84-
List<MmrCandidate> candidates = searchCandidates(personalizationProfileVector, user);
98+
List<MmrCandidate> candidates = searchCandidates(personalizationProfileVector, safeKeyKeywords, user);
8599

86100
if (candidates.isEmpty()) {
87101
log.info("사용자 {}의 추천 후보군을 찾을 수 없음", user.getId());
@@ -119,18 +133,16 @@ public int generateRecommendationsForUser(User user) {
119133
}
120134
}
121135

122-
private List<MmrCandidate> searchCandidates(float[] personalizationProfileVector, User user) throws IOException {
136+
private List<MmrCandidate> searchCandidates(
137+
float[] personalizationProfileVector,
138+
List<String> keyKeywords,
139+
User user
140+
) throws IOException {
123141
Set<Long> readPostIds = readPostRepository.findRecentReadPostsByUserIdWithMinDuration(user.getId(), PageRequest.of(0, 1000))
124142
.stream()
125143
.map(readPost -> readPost.getPost().getId())
126144
.collect(Collectors.toSet());
127145

128-
Optional<PersonalizationProfileDocument> personalizationProfileOpt =
129-
personalizationProfileDocumentRepository.findByUserId(user.getId());
130-
List<String> keyKeywords = personalizationProfileOpt
131-
.map(PersonalizationProfileDocument::getKeyKeywords)
132-
.orElse(List.of());
133-
134146
RecommendationProperties.EmbeddingWeights weights = properties.getEmbeddingWeights();
135147
Query filterQuery = vectorQueryBuilder.createExcludeFilter(readPostIds);
136148

src/main/java/com/techfork/domain/recommendation/service/RecommendationService.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.techfork.domain.recommendation.service;
22

33
import com.techfork.useraccount.domain.User;
4+
import java.util.List;
45

56
/**
67
* 추천 전략 인터페이스
@@ -15,4 +16,17 @@ public interface RecommendationService {
1516
* @return 생성된 추천 개수
1617
*/
1718
int generateRecommendationsForUser(User user);
19+
20+
/**
21+
* 사용자별 개인화 추천 게시글 생성
22+
*
23+
* <p>이미 생성된 개인화 프로필 스냅샷을 기반으로 추천을 생성합니다.
24+
* 프로필 저장 직후 이벤트 소비자가 Elasticsearch refresh 이전 검색 가시성에 의존하지 않도록 사용합니다.</p>
25+
*
26+
* @param user 추천 대상 사용자
27+
* @param personalizationProfileVector 개인화 프로필 벡터
28+
* @param keyKeywords 개인화 프로필 핵심 키워드
29+
* @return 생성된 추천 개수
30+
*/
31+
int generateRecommendationsForUser(User user, float[] personalizationProfileVector, List<String> keyKeywords);
1832
}

src/main/java/com/techfork/personalization/application/PersonalizationProfileService.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.techfork.personalization.application.event.PersonalizedProfileGeneratedEvent;
44
import com.techfork.personalization.application.generation.PersonalizedProfileGenerator;
5+
import com.techfork.personalization.infrastructure.PersonalizationProfileDocument;
56
import lombok.RequiredArgsConstructor;
67
import lombok.extern.slf4j.Slf4j;
78
import org.springframework.context.ApplicationEventPublisher;
@@ -30,8 +31,12 @@ public void generatePersonalizationProfile(Long userId) {
3031
@Transactional
3132
public void generatePersonalizationProfileSync(Long userId) {
3233
try {
33-
personalizedProfileGenerator.generate(userId);
34-
eventPublisher.publishEvent(new PersonalizedProfileGeneratedEvent(userId));
34+
PersonalizationProfileDocument profileDocument = personalizedProfileGenerator.generate(userId);
35+
eventPublisher.publishEvent(new PersonalizedProfileGeneratedEvent(
36+
userId,
37+
profileDocument.getProfileVector(),
38+
profileDocument.getKeyKeywords()
39+
));
3540

3641
log.info("Personalization profile generated successfully for userId: {}", userId);
3742

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
package com.techfork.personalization.application.event;
22

3+
import java.util.List;
4+
35
public record PersonalizedProfileGeneratedEvent(
4-
Long userId
6+
Long userId,
7+
float[] profileVector,
8+
List<String> keyKeywords
59
) {
10+
public PersonalizedProfileGeneratedEvent {
11+
profileVector = profileVector == null ? null : profileVector.clone();
12+
keyKeywords = keyKeywords == null ? List.of() : List.copyOf(keyKeywords);
13+
}
14+
15+
@Override
16+
public float[] profileVector() {
17+
return profileVector == null ? null : profileVector.clone();
18+
}
619
}

src/test/java/com/techfork/domain/recommendation/listener/PersonalizedProfileGeneratedEventListenerTest.java

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,14 @@
1313
import org.springframework.transaction.event.TransactionPhase;
1414
import org.springframework.transaction.event.TransactionalEventListener;
1515

16+
import java.util.Arrays;
17+
import java.util.List;
18+
1619
import static org.assertj.core.api.Assertions.assertThat;
1720
import static org.assertj.core.api.Assertions.assertThatCode;
21+
import static org.mockito.ArgumentMatchers.any;
22+
import static org.mockito.ArgumentMatchers.argThat;
23+
import static org.mockito.ArgumentMatchers.eq;
1824
import static org.mockito.BDDMockito.given;
1925
import static org.mockito.Mockito.mock;
2026
import static org.mockito.Mockito.verify;
@@ -37,29 +43,49 @@ class PersonalizedProfileGeneratedEventListenerTest {
3743
void handle_GeneratesRecommendationsWhenProfileGeneratedEventIsReceived() {
3844
Long userId = 1L;
3945
User user = mock(User.class);
46+
float[] profileVector = new float[]{0.1f, 0.2f};
47+
List<String> keyKeywords = List.of("Spring", "JPA");
4048
given(userLookupService.getUserOrThrow(userId)).willReturn(user);
41-
given(recommendationService.generateRecommendationsForUser(user)).willReturn(5);
49+
given(recommendationService.generateRecommendationsForUser(
50+
eq(user),
51+
any(float[].class),
52+
eq(keyKeywords)
53+
)).willReturn(5);
4254

43-
listener.handle(new PersonalizedProfileGeneratedEvent(userId));
55+
listener.handle(new PersonalizedProfileGeneratedEvent(userId, profileVector, keyKeywords));
4456

4557
verify(userLookupService).getUserOrThrow(userId);
46-
verify(recommendationService).generateRecommendationsForUser(user);
58+
verify(recommendationService).generateRecommendationsForUser(
59+
eq(user),
60+
argThat(vector -> Arrays.equals(vector, profileVector)),
61+
eq(keyKeywords)
62+
);
4763
}
4864

4965
@Test
5066
@DisplayName("추천 생성이 실패해도 예외를 전파하지 않는다")
5167
void handle_RecommendationFailureDoesNotPropagateException() {
5268
Long userId = 2L;
5369
User user = mock(User.class);
70+
float[] profileVector = new float[]{0.1f, 0.2f};
71+
List<String> keyKeywords = List.of("Spring", "JPA");
5472
given(userLookupService.getUserOrThrow(userId)).willReturn(user);
55-
given(recommendationService.generateRecommendationsForUser(user))
73+
given(recommendationService.generateRecommendationsForUser(
74+
eq(user),
75+
any(float[].class),
76+
eq(keyKeywords)
77+
))
5678
.willThrow(new RuntimeException("recommendation failure"));
5779

58-
assertThatCode(() -> listener.handle(new PersonalizedProfileGeneratedEvent(userId)))
80+
assertThatCode(() -> listener.handle(new PersonalizedProfileGeneratedEvent(userId, profileVector, keyKeywords)))
5981
.doesNotThrowAnyException();
6082

6183
verify(userLookupService).getUserOrThrow(userId);
62-
verify(recommendationService).generateRecommendationsForUser(user);
84+
verify(recommendationService).generateRecommendationsForUser(
85+
eq(user),
86+
argThat(vector -> Arrays.equals(vector, profileVector)),
87+
eq(keyKeywords)
88+
);
6389
}
6490

6591
@Test
@@ -69,7 +95,7 @@ void handle_UserLookupFailureDoesNotPropagateExceptionAndSkipsRecommendation() {
6995
given(userLookupService.getUserOrThrow(userId))
7096
.willThrow(new RuntimeException("user lookup failure"));
7197

72-
assertThatCode(() -> listener.handle(new PersonalizedProfileGeneratedEvent(userId)))
98+
assertThatCode(() -> listener.handle(new PersonalizedProfileGeneratedEvent(userId, new float[]{0.1f}, List.of("Spring"))))
7399
.doesNotThrowAnyException();
74100

75101
verify(userLookupService).getUserOrThrow(userId);

src/test/java/com/techfork/domain/recommendation/service/LlmRecommendationServiceTest.java

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import static org.mockito.ArgumentMatchers.same;
4444
import static org.mockito.BDDMockito.given;
4545
import static org.mockito.Mockito.mock;
46+
import static org.mockito.Mockito.never;
4647
import static org.mockito.Mockito.times;
4748
import static org.mockito.Mockito.verify;
4849

@@ -172,7 +173,7 @@ void generateRecommendationsForUser_UsesPersonalizationProfileProjectionVectorAn
172173
assertThat(candidatesCaptor.getValue())
173174
.extracting(MmrService.MmrCandidate::getPostId)
174175
.containsExactly(501L, 502L);
175-
verify(personalizationProfileDocumentRepository, times(2)).findByUserId(userId);
176+
verify(personalizationProfileDocumentRepository, times(1)).findByUserId(userId);
176177
verify(vectorQueryBuilder).createKnnSearches(
177178
eq("titleEmbedding"),
178179
eq("summaryEmbedding"),
@@ -188,6 +189,43 @@ void generateRecommendationsForUser_UsesPersonalizationProfileProjectionVectorAn
188189
verify(vectorQueryBuilder).createBm25Query(List.of("Spring", "JPA"), 0.6f, 0.2f, 0.2f);
189190
}
190191

192+
@Test
193+
@DisplayName("프로필 스냅샷 기반 추천 생성은 PersonalizationProfileDocument를 다시 조회하지 않는다")
194+
void generateRecommendationsForUser_WithProfileSnapshot_DoesNotReadPersonalizationProfileProjection() throws IOException {
195+
Long userId = 10L;
196+
User user = createUser(userId);
197+
float[] profileVector = new float[]{0.1f, 0.2f};
198+
List<String> keyKeywords = List.of("Spring", "JPA");
199+
Query filterQuery = Query.of(query -> query.matchAll(matchAll -> matchAll));
200+
Query bm25Query = Query.of(query -> query.matchAll(matchAll -> matchAll));
201+
202+
given(readPostRepository.findRecentReadPostsByUserIdWithMinDuration(userId, PageRequest.of(0, 1000)))
203+
.willReturn(List.of());
204+
given(vectorQueryBuilder.createExcludeFilter(Set.of())).willReturn(filterQuery);
205+
given(vectorQueryBuilder.createKnnSearches(
206+
eq("titleEmbedding"),
207+
eq("summaryEmbedding"),
208+
eq("contentChunks.embedding"),
209+
same(profileVector),
210+
eq(0.6f),
211+
eq(0.2f),
212+
eq(0.2f),
213+
eq(50),
214+
eq(150),
215+
same(filterQuery)
216+
)).willReturn(List.of());
217+
given(vectorQueryBuilder.createBm25Query(keyKeywords, 0.6f, 0.2f, 0.2f))
218+
.willReturn(bm25Query);
219+
given(elasticsearchClient.search(searchRequestBuilder(), eq(PostDocument.class)))
220+
.willReturn(emptySearchResponse(), emptySearchResponse());
221+
222+
int createdCount = llmRecommendationService.generateRecommendationsForUser(user, profileVector, keyKeywords);
223+
224+
assertThat(createdCount).isZero();
225+
verify(personalizationProfileDocumentRepository, never()).findByUserId(any());
226+
verify(vectorQueryBuilder).createBm25Query(keyKeywords, 0.6f, 0.2f, 0.2f);
227+
}
228+
191229
@Test
192230
@DisplayName("추천 후보는 Post aggregate가 아니라 PostDocument projection에서 만들어진다")
193231
void applyRrf_UsesPostDocumentProjectionAsCandidateSource() {
@@ -285,6 +323,19 @@ private SearchResponse<PostDocument> searchResponse(Hit<PostDocument> hit) {
285323
);
286324
}
287325

326+
private SearchResponse<PostDocument> emptySearchResponse() {
327+
return SearchResponse.of(response -> response
328+
.took(1)
329+
.timedOut(false)
330+
.shards(shards -> shards
331+
.total(1)
332+
.successful(1)
333+
.failed(0)
334+
)
335+
.hits(hits -> hits.hits(List.of()))
336+
);
337+
}
338+
288339
private Hit<PostDocument> hit(String id, double score, PostDocument document) {
289340
return Hit.of(hit -> hit
290341
.id(id)

src/test/java/com/techfork/personalization/application/PersonalizationProfileServiceTest.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import com.techfork.personalization.application.event.PersonalizedProfileGeneratedEvent;
44
import com.techfork.personalization.application.generation.PersonalizedProfileGenerator;
5+
import com.techfork.personalization.infrastructure.PersonalizationProfileDocument;
6+
import java.util.List;
57
import org.junit.jupiter.api.DisplayName;
68
import org.junit.jupiter.api.Test;
79
import org.junit.jupiter.api.extension.ExtendWith;
@@ -33,13 +35,25 @@ class PersonalizationProfileServiceTest {
3335
@DisplayName("개인화 프로필 생성 성공 후 프로필 생성 이벤트를 발행한다")
3436
void generatePersonalizationProfileSync_PublishesProfileGeneratedEventAfterProfileGeneration() {
3537
Long userId = 1L;
38+
float[] profileVector = new float[]{0.1f, 0.2f};
39+
PersonalizationProfileDocument profileDocument = PersonalizationProfileDocument.create(
40+
userId,
41+
"Spring과 JPA 기반 백엔드 관심 프로필",
42+
profileVector,
43+
List.of("Backend"),
44+
List.of("Spring", "JPA")
45+
);
46+
given(personalizedProfileGenerator.generate(userId)).willReturn(profileDocument);
3647

3748
personalizationProfileService.generatePersonalizationProfileSync(userId);
3849

3950
ArgumentCaptor<PersonalizedProfileGeneratedEvent> eventCaptor = ArgumentCaptor.forClass(PersonalizedProfileGeneratedEvent.class);
4051
verify(personalizedProfileGenerator).generate(userId);
4152
verify(eventPublisher).publishEvent(eventCaptor.capture());
42-
assertThat(eventCaptor.getValue().userId()).isEqualTo(userId);
53+
PersonalizedProfileGeneratedEvent event = eventCaptor.getValue();
54+
assertThat(event.userId()).isEqualTo(userId);
55+
assertThat(event.profileVector()).containsExactly(0.1f, 0.2f);
56+
assertThat(event.keyKeywords()).containsExactly("Spring", "JPA");
4357
}
4458

4559
@Test

0 commit comments

Comments
 (0)