Skip to content

Commit 3cc52a1

Browse files
authored
[refactor/#421] Recommendation application 경계 정리 (#454)
* refactor: 기존 Recommendation 서비스에서 다른 도메인의 repository를 직접 참조하던 걸 해결 * refactor: Llm 추천 서비스에서 타 도메인 리포지토리 직접 참조를 application seam으로 해결 * refactor: 추천 스케쥴러에서 타 도메인의 리포지토리 직접 조회를 application seam으로 개선 * refactor: 내부적으로 getReferenceById를 사용하도록 하여 작동 변화를 롤백 * improve: 값 객체 내부의 가변 객체들을 방어 복사로 개선
1 parent c99432c commit 3cc52a1

19 files changed

Lines changed: 421 additions & 99 deletions

File tree

src/main/java/com/techfork/activity/readpost/application/query/lookup/ReadPostLookupService.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import org.springframework.transaction.annotation.Transactional;
99

1010
import java.util.List;
11+
import java.util.Set;
12+
import java.util.stream.Collectors;
1113

1214
@Service
1315
@RequiredArgsConstructor
@@ -16,6 +18,13 @@ public class ReadPostLookupService {
1618

1719
private final ReadPostRepository readPostRepository;
1820

21+
public Set<Long> getRecentReadPostIds(Long userId, int limit) {
22+
return readPostRepository.findRecentReadPostsByUserIdWithMinDuration(userId, PageRequest.of(0, limit))
23+
.stream()
24+
.map(readPost -> readPost.getPost().getId())
25+
.collect(Collectors.toSet());
26+
}
27+
1928
public List<ReadPostLookupItem> getRecentReadPostActivities(Long userId, int limit) {
2029
return readPostRepository.findRecentReadPostsByUserIdWithMinDuration(userId, PageRequest.of(0, limit))
2130
.stream()

src/main/java/com/techfork/domain/recommendation/scheduler/RecommendationScheduler.java

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

33
import com.techfork.domain.recommendation.config.RecommendationProperties;
44
import com.techfork.domain.recommendation.service.RecommendationService;
5+
import com.techfork.useraccount.application.query.lookup.UserLookupService;
56
import com.techfork.useraccount.domain.User;
6-
import com.techfork.useraccount.infrastructure.UserRepository;
77
import lombok.RequiredArgsConstructor;
88
import lombok.extern.slf4j.Slf4j;
99
import org.springframework.scheduling.annotation.Scheduled;
@@ -17,7 +17,7 @@
1717
@RequiredArgsConstructor
1818
public class RecommendationScheduler {
1919

20-
private final UserRepository userRepository;
20+
private final UserLookupService userLookupService;
2121
private final RecommendationService recommendationService;
2222
private final RecommendationProperties properties;
2323

@@ -32,7 +32,7 @@ public void generateDailyRecommendations() {
3232
log.info("활성 사용자 대상으로 게시글 추천 시작");
3333

3434
LocalDateTime since = LocalDateTime.now().minusHours(properties.getActiveUserHours());
35-
List<User> activeUsers = userRepository.findActiveUsersSince(since);
35+
List<User> activeUsers = userLookupService.getActiveUsersSince(since);
3636

3737
log.info("{} 명의 활성 사용자를 찾았습니다. ({} 시간 이내)", activeUsers.size(), properties.getActiveUserHours());
3838

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

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,37 +5,35 @@
55
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
66
import co.elastic.clients.elasticsearch.core.SearchResponse;
77
import co.elastic.clients.elasticsearch.core.search.Hit;
8-
import com.techfork.activity.readpost.infrastructure.ReadPostRepository;
9-
import com.techfork.global.elasticsearch.query.VectorQueryBuilder;
10-
import com.techfork.post.domain.projection.PostDocument;
11-
import com.techfork.post.domain.Post;
12-
import com.techfork.post.infrastructure.PostRepository;
8+
import com.techfork.activity.readpost.application.query.lookup.ReadPostLookupService;
139
import com.techfork.domain.recommendation.config.RecommendationProperties;
14-
import com.techfork.domain.recommendation.entity.RecommendedPost;
1510
import com.techfork.domain.recommendation.entity.RecommendationHistory;
16-
import com.techfork.domain.recommendation.repository.RecommendedPostRepository;
11+
import com.techfork.domain.recommendation.entity.RecommendedPost;
1712
import com.techfork.domain.recommendation.repository.RecommendationHistoryRepository;
13+
import com.techfork.domain.recommendation.repository.RecommendedPostRepository;
1814
import com.techfork.domain.recommendation.service.MmrService.MmrCandidate;
1915
import com.techfork.domain.recommendation.service.MmrService.MmrResult;
20-
import com.techfork.personalization.infrastructure.PersonalizationProfileDocument;
21-
import com.techfork.useraccount.domain.User;
22-
import com.techfork.personalization.infrastructure.PersonalizationProfileDocumentRepository;
16+
import com.techfork.global.elasticsearch.query.VectorQueryBuilder;
2317
import com.techfork.global.util.RrfScorer;
2418
import com.techfork.global.util.TimeDecayStrategy;
2519
import com.techfork.global.util.VectorUtil;
20+
import com.techfork.personalization.application.query.lookup.PersonalizationProfileLookupItem;
21+
import com.techfork.personalization.application.query.lookup.PersonalizationProfileLookupService;
22+
import com.techfork.post.application.query.lookup.PostLookupService;
23+
import com.techfork.post.domain.Post;
24+
import com.techfork.post.domain.projection.PostDocument;
25+
import com.techfork.useraccount.domain.User;
2626
import lombok.RequiredArgsConstructor;
2727
import lombok.extern.slf4j.Slf4j;
2828
import org.springframework.beans.factory.annotation.Qualifier;
2929
import org.springframework.context.annotation.Primary;
30-
import org.springframework.data.domain.PageRequest;
3130
import org.springframework.stereotype.Service;
3231
import org.springframework.transaction.annotation.Transactional;
3332

3433
import java.io.IOException;
3534
import java.util.*;
3635
import java.util.concurrent.CompletableFuture;
3736
import java.util.concurrent.Executor;
38-
import java.util.stream.Collectors;
3937

4038
/**
4139
* MMR 알고리즘 기반 추천 전략 구현
@@ -48,11 +46,11 @@
4846
public class LlmRecommendationService implements RecommendationService {
4947

5048
private final ElasticsearchClient elasticsearchClient;
51-
private final PersonalizationProfileDocumentRepository personalizationProfileDocumentRepository;
49+
private final PersonalizationProfileLookupService personalizationProfileLookupService;
5250
private final RecommendedPostRepository recommendedPostRepository;
5351
private final RecommendationHistoryRepository recommendationHistoryRepository;
54-
private final ReadPostRepository readPostRepository;
55-
private final PostRepository postRepository;
52+
private final ReadPostLookupService readPostLookupService;
53+
private final PostLookupService postLookupService;
5654
private final MmrService mmrService;
5755
private final TimeDecayStrategy timeDecayStrategy;
5856
private final RecommendationProperties properties;
@@ -64,21 +62,22 @@ public class LlmRecommendationService implements RecommendationService {
6462
private static final String TITLE_EMBEDDING_FIELD = "titleEmbedding";
6563
private static final String SUMMARY_EMBEDDING_FIELD = "summaryEmbedding";
6664
private static final String CONTENT_CHUNKS_EMBEDDING_FIELD = "contentChunks.embedding";
65+
private static final int RECENT_READ_POST_LIMIT = 1000;
6766

6867
@Override
6968
public int generateRecommendationsForUser(User user) {
70-
Optional<PersonalizationProfileDocument> personalizationProfileOpt =
71-
personalizationProfileDocumentRepository.findByUserId(user.getId());
72-
if (personalizationProfileOpt.isEmpty() || personalizationProfileOpt.get().getProfileVector() == null) {
69+
Optional<PersonalizationProfileLookupItem> personalizationProfileOpt =
70+
personalizationProfileLookupService.findByUserId(user.getId());
71+
if (personalizationProfileOpt.isEmpty() || personalizationProfileOpt.get().profileVector() == null) {
7372
log.warn("사용자 {}의 개인화 프로필 또는 벡터를 찾을 수 없음. 추천 생성 스킵.", user.getId());
7473
return 0;
7574
}
7675

77-
PersonalizationProfileDocument personalizationProfile = personalizationProfileOpt.get();
76+
PersonalizationProfileLookupItem personalizationProfile = personalizationProfileOpt.get();
7877
return generateRecommendationsForUser(
7978
user,
80-
personalizationProfile.getProfileVector(),
81-
personalizationProfile.getKeyKeywords()
79+
personalizationProfile.profileVector(),
80+
personalizationProfile.keyKeywords()
8281
);
8382
}
8483

@@ -118,7 +117,7 @@ public int generateRecommendationsForUser(User user, float[] personalizationProf
118117
// 5. 새 추천 저장
119118
List<RecommendedPost> recommendations = new ArrayList<>();
120119
for (MmrResult result : mmrResults) {
121-
Post post = postRepository.getReferenceById(result.getPostId());
120+
Post post = postLookupService.getPostReference(result.getPostId());
122121
recommendations.add(RecommendedPost.create(
123122
user, post, result.getSimilarityScore(), result.getMmrScore(), result.getRank()
124123
));
@@ -138,10 +137,7 @@ private List<MmrCandidate> searchCandidates(
138137
List<String> keyKeywords,
139138
User user
140139
) throws IOException {
141-
Set<Long> readPostIds = readPostRepository.findRecentReadPostsByUserIdWithMinDuration(user.getId(), PageRequest.of(0, 1000))
142-
.stream()
143-
.map(readPost -> readPost.getPost().getId())
144-
.collect(Collectors.toSet());
140+
Set<Long> readPostIds = readPostLookupService.getRecentReadPostIds(user.getId(), RECENT_READ_POST_LIMIT);
145141

146142
RecommendationProperties.EmbeddingWeights weights = properties.getEmbeddingWeights();
147143
Query filterQuery = vectorQueryBuilder.createExcludeFilter(readPostIds);

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

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

3+
import com.techfork.useraccount.application.query.lookup.UserLookupService;
34
import com.techfork.useraccount.domain.User;
4-
import com.techfork.useraccount.infrastructure.UserRepository;
55
import lombok.RequiredArgsConstructor;
66
import lombok.extern.slf4j.Slf4j;
77
import org.springframework.stereotype.Service;
@@ -14,10 +14,10 @@
1414
public class RecommendationCommandService {
1515

1616
private final RecommendationService recommendationService;
17-
private final UserRepository userRepository;
17+
private final UserLookupService userLookupService;
1818

1919
public void regenerateRecommendations(Long userId) {
20-
User user = userRepository.getReferenceById(userId);
20+
User user = userLookupService.getUserReference(userId);
2121
int generatedCount = recommendationService.generateRecommendationsForUser(user);
2222
log.info("사용자 {} 추천 즉시 재생성 완료: {} 개", userId, generatedCount);
2323
}

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

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

3-
import com.techfork.activity.bookmark.infrastructure.BookmarkRepository;
3+
import com.techfork.activity.bookmark.application.query.lookup.BookmarkLookupService;
44
import com.techfork.domain.recommendation.converter.RecommendationConverter;
55
import com.techfork.domain.recommendation.dto.RecommendationListResponse;
66
import com.techfork.domain.recommendation.dto.RecommendedPostDto;
77
import com.techfork.domain.recommendation.entity.RecommendedPost;
88
import com.techfork.domain.recommendation.repository.RecommendedPostRepository;
9+
import com.techfork.useraccount.application.query.lookup.UserLookupService;
910
import com.techfork.useraccount.domain.User;
10-
import com.techfork.useraccount.infrastructure.UserRepository;
1111
import lombok.RequiredArgsConstructor;
1212
import lombok.extern.slf4j.Slf4j;
1313
import org.springframework.stereotype.Service;
1414
import org.springframework.transaction.annotation.Transactional;
1515

1616
import java.util.List;
17+
import java.util.Set;
1718

1819
@Slf4j
1920
@Service
@@ -22,12 +23,12 @@
2223
public class RecommendationQueryService {
2324

2425
private final RecommendedPostRepository recommendedPostRepository;
25-
private final UserRepository userRepository;
26+
private final UserLookupService userLookupService;
2627
private final RecommendationConverter recommendationConverter;
27-
private final BookmarkRepository bookmarkRepository;
28+
private final BookmarkLookupService bookmarkLookupService;
2829

2930
public RecommendationListResponse getRecommendations(Long userId) {
30-
User user = userRepository.getReferenceById(userId);
31+
User user = userLookupService.getUserReference(userId);
3132
List<RecommendedPost> recommendedPosts = recommendedPostRepository.findByUserOrderByRankAsc(user);
3233
log.info("사용자 {} 추천 목록 조회: {} 개", userId, recommendedPosts.size());
3334

@@ -45,7 +46,7 @@ private RecommendationListResponse attachBookmarkStatus(RecommendationListRespon
4546
List<Long> postIds = response.recommendations().stream()
4647
.map(RecommendedPostDto::postId)
4748
.toList();
48-
List<Long> bookmarkedPostIds = bookmarkRepository.findBookmarkedPostIds(userId, postIds);
49+
Set<Long> bookmarkedPostIds = bookmarkLookupService.getBookmarkedPostIds(userId, postIds);
4950

5051
List<RecommendedPostDto> updatedRecommendations = response.recommendations().stream()
5152
.map(dto -> dto.withBookmarkStatus(bookmarkedPostIds.contains(dto.postId())))
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.techfork.personalization.application.query.lookup;
2+
3+
import java.util.List;
4+
5+
public record PersonalizationProfileLookupItem(
6+
float[] profileVector,
7+
List<String> keyKeywords
8+
) {
9+
public PersonalizationProfileLookupItem {
10+
profileVector = profileVector == null ? null : profileVector.clone();
11+
keyKeywords = keyKeywords == null ? List.of() : List.copyOf(keyKeywords);
12+
}
13+
14+
@Override
15+
public float[] profileVector() {
16+
return profileVector == null ? null : profileVector.clone();
17+
}
18+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.techfork.personalization.application.query.lookup;
2+
3+
import com.techfork.personalization.infrastructure.PersonalizationProfileDocumentRepository;
4+
import java.util.Optional;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.stereotype.Service;
7+
import org.springframework.transaction.annotation.Transactional;
8+
9+
@Service
10+
@RequiredArgsConstructor
11+
@Transactional(readOnly = true)
12+
public class PersonalizationProfileLookupService {
13+
14+
private final PersonalizationProfileDocumentRepository personalizationProfileDocumentRepository;
15+
16+
public Optional<PersonalizationProfileLookupItem> findByUserId(Long userId) {
17+
return personalizationProfileDocumentRepository.findByUserId(userId)
18+
.map(profile -> new PersonalizationProfileLookupItem(
19+
profile.getProfileVector(),
20+
profile.getKeyKeywords()
21+
));
22+
}
23+
}

src/main/java/com/techfork/post/application/query/lookup/PostLookupService.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ public class PostLookupService {
2323

2424
private final PostRepository postRepository;
2525

26+
public Post getPostReference(Long postId) {
27+
return postRepository.getReferenceById(postId);
28+
}
29+
2630
public Post getPostOrThrow(Long postId) {
2731
return postRepository.findById(postId)
2832
.orElseThrow(() -> new GeneralException(PostErrorCode.POST_NOT_FOUND));

src/main/java/com/techfork/useraccount/application/query/lookup/UserLookupService.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,21 @@ public class UserLookupService {
1717

1818
private final UserRepository userRepository;
1919

20+
public User getUserReference(Long userId) {
21+
return userRepository.getReferenceById(userId);
22+
}
23+
2024
public User getUserOrThrow(Long userId) {
2125
return userRepository.findById(userId)
2226
.orElseThrow(() -> new GeneralException(UserErrorCode.USER_NOT_FOUND));
2327
}
2428

29+
public List<User> getActiveUsersSince(LocalDateTime since) {
30+
return userRepository.findActiveUsersSince(since);
31+
}
32+
2533
public List<Long> getActiveUserIdsSince(LocalDateTime since) {
26-
return userRepository.findActiveUsersSince(since)
34+
return getActiveUsersSince(since)
2735
.stream()
2836
.map(User::getId)
2937
.toList();

src/test/java/com/techfork/activity/readpost/application/query/lookup/ReadPostLookupServiceTest.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import org.springframework.data.domain.PageRequest;
1515

1616
import java.util.List;
17+
import java.util.Set;
1718

1819
import static org.assertj.core.api.Assertions.assertThat;
1920
import static org.mockito.BDDMockito.given;
@@ -29,6 +30,36 @@ class ReadPostLookupServiceTest {
2930
@InjectMocks
3031
private ReadPostLookupService readPostLookupService;
3132

33+
@Nested
34+
@DisplayName("최근 읽은 게시글 ID 조회")
35+
class GetRecentReadPostIds {
36+
37+
@Test
38+
@DisplayName("조회된 읽기 기록의 게시글 ID를 중복 없이 반환한다")
39+
void recentReadPostsExist_ReturnsDistinctPostIds() {
40+
Long userId = 1L;
41+
int limit = 1000;
42+
Post firstPost = mock(Post.class);
43+
Post secondPost = mock(Post.class);
44+
ReadPost firstReadPost = mock(ReadPost.class);
45+
ReadPost duplicateReadPost = mock(ReadPost.class);
46+
ReadPost secondReadPost = mock(ReadPost.class);
47+
given(firstReadPost.getPost()).willReturn(firstPost);
48+
given(duplicateReadPost.getPost()).willReturn(firstPost);
49+
given(secondReadPost.getPost()).willReturn(secondPost);
50+
given(firstPost.getId()).willReturn(10L);
51+
given(secondPost.getId()).willReturn(20L);
52+
given(readPostRepository.findRecentReadPostsByUserIdWithMinDuration(userId, PageRequest.of(0, limit)))
53+
.willReturn(List.of(firstReadPost, duplicateReadPost, secondReadPost));
54+
55+
Set<Long> result = readPostLookupService.getRecentReadPostIds(userId, limit);
56+
57+
assertThat(result).containsExactlyInAnyOrder(10L, 20L);
58+
verify(readPostRepository)
59+
.findRecentReadPostsByUserIdWithMinDuration(userId, PageRequest.of(0, limit));
60+
}
61+
}
62+
3263
@Nested
3364
@DisplayName("최근 읽은 게시글 활동 신호 조회")
3465
class GetRecentReadPostActivities {

0 commit comments

Comments
 (0)