From 09cdfd0db14bc50947852b8334418110bd2fc3ac Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Sun, 19 Jul 2026 13:02:57 +0900 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=20Recommenda?= =?UTF-8?q?tion=20=EC=84=9C=EB=B9=84=EC=8A=A4=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9D=98=20rep?= =?UTF-8?q?ository=EB=A5=BC=20=EC=A7=81=EC=A0=91=20=EC=B0=B8=EC=A1=B0?= =?UTF-8?q?=ED=95=98=EB=8D=98=20=EA=B1=B8=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/RecommendationCommandService.java | 6 ++-- .../service/RecommendationQueryService.java | 13 ++++---- .../RecommendationCommandServiceTest.java | 12 +++---- .../RecommendationQueryServiceTest.java | 31 ++++++++++--------- 4 files changed, 32 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/techfork/domain/recommendation/service/RecommendationCommandService.java b/src/main/java/com/techfork/domain/recommendation/service/RecommendationCommandService.java index 978533b3..417aedda 100644 --- a/src/main/java/com/techfork/domain/recommendation/service/RecommendationCommandService.java +++ b/src/main/java/com/techfork/domain/recommendation/service/RecommendationCommandService.java @@ -1,7 +1,7 @@ package com.techfork.domain.recommendation.service; +import com.techfork.useraccount.application.query.lookup.UserLookupService; import com.techfork.useraccount.domain.User; -import com.techfork.useraccount.infrastructure.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -14,10 +14,10 @@ public class RecommendationCommandService { private final RecommendationService recommendationService; - private final UserRepository userRepository; + private final UserLookupService userLookupService; public void regenerateRecommendations(Long userId) { - User user = userRepository.getReferenceById(userId); + User user = userLookupService.getUserOrThrow(userId); int generatedCount = recommendationService.generateRecommendationsForUser(user); log.info("사용자 {} 추천 즉시 재생성 완료: {} 개", userId, generatedCount); } diff --git a/src/main/java/com/techfork/domain/recommendation/service/RecommendationQueryService.java b/src/main/java/com/techfork/domain/recommendation/service/RecommendationQueryService.java index c061600e..7129261d 100644 --- a/src/main/java/com/techfork/domain/recommendation/service/RecommendationQueryService.java +++ b/src/main/java/com/techfork/domain/recommendation/service/RecommendationQueryService.java @@ -1,19 +1,20 @@ package com.techfork.domain.recommendation.service; -import com.techfork.activity.bookmark.infrastructure.BookmarkRepository; +import com.techfork.activity.bookmark.application.query.lookup.BookmarkLookupService; import com.techfork.domain.recommendation.converter.RecommendationConverter; import com.techfork.domain.recommendation.dto.RecommendationListResponse; import com.techfork.domain.recommendation.dto.RecommendedPostDto; import com.techfork.domain.recommendation.entity.RecommendedPost; import com.techfork.domain.recommendation.repository.RecommendedPostRepository; +import com.techfork.useraccount.application.query.lookup.UserLookupService; import com.techfork.useraccount.domain.User; -import com.techfork.useraccount.infrastructure.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Set; @Slf4j @Service @@ -22,12 +23,12 @@ public class RecommendationQueryService { private final RecommendedPostRepository recommendedPostRepository; - private final UserRepository userRepository; + private final UserLookupService userLookupService; private final RecommendationConverter recommendationConverter; - private final BookmarkRepository bookmarkRepository; + private final BookmarkLookupService bookmarkLookupService; public RecommendationListResponse getRecommendations(Long userId) { - User user = userRepository.getReferenceById(userId); + User user = userLookupService.getUserOrThrow(userId); List recommendedPosts = recommendedPostRepository.findByUserOrderByRankAsc(user); log.info("사용자 {} 추천 목록 조회: {} 개", userId, recommendedPosts.size()); @@ -45,7 +46,7 @@ private RecommendationListResponse attachBookmarkStatus(RecommendationListRespon List postIds = response.recommendations().stream() .map(RecommendedPostDto::postId) .toList(); - List bookmarkedPostIds = bookmarkRepository.findBookmarkedPostIds(userId, postIds); + Set bookmarkedPostIds = bookmarkLookupService.getBookmarkedPostIds(userId, postIds); List updatedRecommendations = response.recommendations().stream() .map(dto -> dto.withBookmarkStatus(bookmarkedPostIds.contains(dto.postId()))) diff --git a/src/test/java/com/techfork/domain/recommendation/service/RecommendationCommandServiceTest.java b/src/test/java/com/techfork/domain/recommendation/service/RecommendationCommandServiceTest.java index cb096638..35b141b7 100644 --- a/src/test/java/com/techfork/domain/recommendation/service/RecommendationCommandServiceTest.java +++ b/src/test/java/com/techfork/domain/recommendation/service/RecommendationCommandServiceTest.java @@ -1,7 +1,7 @@ package com.techfork.domain.recommendation.service; +import com.techfork.useraccount.application.query.lookup.UserLookupService; import com.techfork.useraccount.domain.User; -import com.techfork.useraccount.infrastructure.UserRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -22,7 +22,7 @@ class RecommendationCommandServiceTest { private RecommendationService recommendationService; @Mock - private UserRepository userRepository; + private UserLookupService userLookupService; @InjectMocks private RecommendationCommandService recommendationCommandService; @@ -32,16 +32,16 @@ class RecommendationCommandServiceTest { class RegenerateRecommendations { @Test - @DisplayName("userId로 사용자 참조를 조회해 수동 추천 재생성을 요청한다") - void userIdProvided_GeneratesRecommendationsForUserReference() { + @DisplayName("lookup seam으로 사용자를 조회해 수동 추천 재생성을 요청한다") + void userIdProvided_GeneratesRecommendationsForLookedUpUser() { Long userId = 1L; User user = mock(User.class); - given(userRepository.getReferenceById(userId)).willReturn(user); + given(userLookupService.getUserOrThrow(userId)).willReturn(user); given(recommendationService.generateRecommendationsForUser(user)).willReturn(5); recommendationCommandService.regenerateRecommendations(userId); - verify(userRepository).getReferenceById(userId); + verify(userLookupService).getUserOrThrow(userId); verify(recommendationService).generateRecommendationsForUser(user); } } diff --git a/src/test/java/com/techfork/domain/recommendation/service/RecommendationQueryServiceTest.java b/src/test/java/com/techfork/domain/recommendation/service/RecommendationQueryServiceTest.java index 8fb61548..a4319a56 100644 --- a/src/test/java/com/techfork/domain/recommendation/service/RecommendationQueryServiceTest.java +++ b/src/test/java/com/techfork/domain/recommendation/service/RecommendationQueryServiceTest.java @@ -1,6 +1,6 @@ package com.techfork.domain.recommendation.service; -import com.techfork.activity.bookmark.infrastructure.BookmarkRepository; +import com.techfork.activity.bookmark.application.query.lookup.BookmarkLookupService; import com.techfork.post.domain.Post; import com.techfork.domain.recommendation.converter.RecommendationConverter; import com.techfork.domain.recommendation.dto.RecommendationListResponse; @@ -8,8 +8,8 @@ import com.techfork.domain.recommendation.entity.RecommendedPost; import com.techfork.domain.recommendation.repository.RecommendedPostRepository; import com.techfork.domain.source.entity.TechBlog; +import com.techfork.useraccount.application.query.lookup.UserLookupService; import com.techfork.useraccount.domain.User; -import com.techfork.useraccount.infrastructure.UserRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -21,6 +21,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Set; import static com.techfork.post.fixture.PostFixture.createPost; import static com.techfork.post.fixture.PostFixture.DEFAULT_PUBLISHED_AT; @@ -41,13 +42,13 @@ class RecommendationQueryServiceTest { private RecommendedPostRepository recommendedPostRepository; @Mock - private UserRepository userRepository; + private UserLookupService userLookupService; @Mock private RecommendationConverter recommendationConverter; @Mock - private BookmarkRepository bookmarkRepository; + private BookmarkLookupService bookmarkLookupService; @InjectMocks private RecommendationQueryService recommendationQueryService; @@ -98,10 +99,10 @@ void recommendationsExist_ReturnsRecommendations() { .totalCount(3) .build(); - given(userRepository.getReferenceById(userId)).willReturn(testUser); + given(userLookupService.getUserOrThrow(userId)).willReturn(testUser); given(recommendedPostRepository.findByUserOrderByRankAsc(testUser)).willReturn(recommendedPosts); given(recommendationConverter.toRecommendationListResponse(recommendedPosts)).willReturn(initialResponse); - given(bookmarkRepository.findBookmarkedPostIds(userId, postIds)).willReturn(List.of()); + given(bookmarkLookupService.getBookmarkedPostIds(userId, postIds)).willReturn(Set.of()); // when RecommendationListResponse response = recommendationQueryService.getRecommendations(userId); @@ -112,10 +113,10 @@ void recommendationsExist_ReturnsRecommendations() { assertThat(response.recommendations().get(0).title()).isEqualTo("게시글 1"); assertThat(response.recommendations().get(0).isBookmarked()).isFalse(); - verify(userRepository).getReferenceById(userId); + verify(userLookupService).getUserOrThrow(userId); verify(recommendedPostRepository).findByUserOrderByRankAsc(testUser); verify(recommendationConverter).toRecommendationListResponse(recommendedPosts); - verify(bookmarkRepository).findBookmarkedPostIds(userId, postIds); + verify(bookmarkLookupService).getBookmarkedPostIds(userId, postIds); } @Test @@ -138,12 +139,12 @@ void bookmarkedPostsExist_ReturnsBookmarkedRecommendations() { .build(); // 101L, 103L 게시글은 북마크됨 - List bookmarkedPostIds = List.of(101L, 103L); + Set bookmarkedPostIds = Set.of(101L, 103L); - given(userRepository.getReferenceById(userId)).willReturn(testUser); + given(userLookupService.getUserOrThrow(userId)).willReturn(testUser); given(recommendedPostRepository.findByUserOrderByRankAsc(testUser)).willReturn(recommendedPosts); given(recommendationConverter.toRecommendationListResponse(recommendedPosts)).willReturn(initialResponse); - given(bookmarkRepository.findBookmarkedPostIds(userId, postIds)).willReturn(bookmarkedPostIds); + given(bookmarkLookupService.getBookmarkedPostIds(userId, postIds)).willReturn(bookmarkedPostIds); // when RecommendationListResponse response = recommendationQueryService.getRecommendations(userId); @@ -156,7 +157,7 @@ void bookmarkedPostsExist_ReturnsBookmarkedRecommendations() { assertThat(response.recommendations().get(1).isBookmarked()).isFalse(); assertThat(response.recommendations().get(2).postId()).isEqualTo(103L); assertThat(response.recommendations().get(2).isBookmarked()).isTrue(); - verify(bookmarkRepository).findBookmarkedPostIds(userId, postIds); + verify(bookmarkLookupService).getBookmarkedPostIds(userId, postIds); } @Test @@ -171,7 +172,7 @@ void noRecommendations_ReturnsEmptyList() { .totalCount(0) .build(); - given(userRepository.getReferenceById(userId)).willReturn(testUser); + given(userLookupService.getUserOrThrow(userId)).willReturn(testUser); given(recommendedPostRepository.findByUserOrderByRankAsc(testUser)).willReturn(emptyList); given(recommendationConverter.toRecommendationListResponse(emptyList)).willReturn(emptyResponse); @@ -182,10 +183,10 @@ void noRecommendations_ReturnsEmptyList() { assertThat(response.recommendations()).isEmpty(); assertThat(response.totalCount()).isZero(); - verify(userRepository).getReferenceById(userId); + verify(userLookupService).getUserOrThrow(userId); verify(recommendedPostRepository).findByUserOrderByRankAsc(testUser); verify(recommendationConverter).toRecommendationListResponse(emptyList); - verify(bookmarkRepository, never()).findBookmarkedPostIds(any(), any()); + verify(bookmarkLookupService, never()).getBookmarkedPostIds(any(), any()); } } From 4ecc4ed3a0c83a80d095990475ca00e35506c1d7 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Sun, 19 Jul 2026 13:09:48 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20Llm=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EC=97=90=EC=84=9C=20=ED=83=80=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A6=AC=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=A7=81=EC=A0=91=20=EC=B0=B8=EC=A1=B0?= =?UTF-8?q?=EB=A5=BC=20application=20seam=EC=9C=BC=EB=A1=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../query/lookup/ReadPostLookupService.java | 9 +++ .../service/LlmRecommendationService.java | 48 ++++++------- .../PersonalizationProfileLookupItem.java | 9 +++ .../PersonalizationProfileLookupService.java | 23 +++++++ .../query/lookup/PostLookupService.java | 4 ++ .../lookup/ReadPostLookupServiceTest.java | 31 +++++++++ .../service/LlmRecommendationServiceTest.java | 64 ++++++++++------- .../RecommendationEvaluationService.java | 14 ++-- ...rsonalizationProfileLookupServiceTest.java | 68 +++++++++++++++++++ .../query/lookup/PostLookupServiceTest.java | 19 ++++++ 10 files changed, 231 insertions(+), 58 deletions(-) create mode 100644 src/main/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupItem.java create mode 100644 src/main/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupService.java create mode 100644 src/test/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupServiceTest.java diff --git a/src/main/java/com/techfork/activity/readpost/application/query/lookup/ReadPostLookupService.java b/src/main/java/com/techfork/activity/readpost/application/query/lookup/ReadPostLookupService.java index e7b7ffd6..f0f02797 100644 --- a/src/main/java/com/techfork/activity/readpost/application/query/lookup/ReadPostLookupService.java +++ b/src/main/java/com/techfork/activity/readpost/application/query/lookup/ReadPostLookupService.java @@ -8,6 +8,8 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -16,6 +18,13 @@ public class ReadPostLookupService { private final ReadPostRepository readPostRepository; + public Set getRecentReadPostIds(Long userId, int limit) { + return readPostRepository.findRecentReadPostsByUserIdWithMinDuration(userId, PageRequest.of(0, limit)) + .stream() + .map(readPost -> readPost.getPost().getId()) + .collect(Collectors.toSet()); + } + public List getRecentReadPostActivities(Long userId, int limit) { return readPostRepository.findRecentReadPostsByUserIdWithMinDuration(userId, PageRequest.of(0, limit)) .stream() diff --git a/src/main/java/com/techfork/domain/recommendation/service/LlmRecommendationService.java b/src/main/java/com/techfork/domain/recommendation/service/LlmRecommendationService.java index 1eadbf10..25a2dc6c 100644 --- a/src/main/java/com/techfork/domain/recommendation/service/LlmRecommendationService.java +++ b/src/main/java/com/techfork/domain/recommendation/service/LlmRecommendationService.java @@ -5,29 +5,28 @@ import co.elastic.clients.elasticsearch._types.query_dsl.Query; import co.elastic.clients.elasticsearch.core.SearchResponse; import co.elastic.clients.elasticsearch.core.search.Hit; -import com.techfork.activity.readpost.infrastructure.ReadPostRepository; -import com.techfork.global.elasticsearch.query.VectorQueryBuilder; -import com.techfork.post.domain.projection.PostDocument; -import com.techfork.post.domain.Post; -import com.techfork.post.infrastructure.PostRepository; +import com.techfork.activity.readpost.application.query.lookup.ReadPostLookupService; import com.techfork.domain.recommendation.config.RecommendationProperties; -import com.techfork.domain.recommendation.entity.RecommendedPost; import com.techfork.domain.recommendation.entity.RecommendationHistory; -import com.techfork.domain.recommendation.repository.RecommendedPostRepository; +import com.techfork.domain.recommendation.entity.RecommendedPost; import com.techfork.domain.recommendation.repository.RecommendationHistoryRepository; +import com.techfork.domain.recommendation.repository.RecommendedPostRepository; import com.techfork.domain.recommendation.service.MmrService.MmrCandidate; import com.techfork.domain.recommendation.service.MmrService.MmrResult; -import com.techfork.personalization.infrastructure.PersonalizationProfileDocument; -import com.techfork.useraccount.domain.User; -import com.techfork.personalization.infrastructure.PersonalizationProfileDocumentRepository; +import com.techfork.global.elasticsearch.query.VectorQueryBuilder; import com.techfork.global.util.RrfScorer; import com.techfork.global.util.TimeDecayStrategy; import com.techfork.global.util.VectorUtil; +import com.techfork.personalization.application.query.lookup.PersonalizationProfileLookupItem; +import com.techfork.personalization.application.query.lookup.PersonalizationProfileLookupService; +import com.techfork.post.application.query.lookup.PostLookupService; +import com.techfork.post.domain.Post; +import com.techfork.post.domain.projection.PostDocument; +import com.techfork.useraccount.domain.User; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Primary; -import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,7 +34,6 @@ import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; -import java.util.stream.Collectors; /** * MMR 알고리즘 기반 추천 전략 구현 @@ -48,11 +46,11 @@ public class LlmRecommendationService implements RecommendationService { private final ElasticsearchClient elasticsearchClient; - private final PersonalizationProfileDocumentRepository personalizationProfileDocumentRepository; + private final PersonalizationProfileLookupService personalizationProfileLookupService; private final RecommendedPostRepository recommendedPostRepository; private final RecommendationHistoryRepository recommendationHistoryRepository; - private final ReadPostRepository readPostRepository; - private final PostRepository postRepository; + private final ReadPostLookupService readPostLookupService; + private final PostLookupService postLookupService; private final MmrService mmrService; private final TimeDecayStrategy timeDecayStrategy; private final RecommendationProperties properties; @@ -64,21 +62,22 @@ public class LlmRecommendationService implements RecommendationService { private static final String TITLE_EMBEDDING_FIELD = "titleEmbedding"; private static final String SUMMARY_EMBEDDING_FIELD = "summaryEmbedding"; private static final String CONTENT_CHUNKS_EMBEDDING_FIELD = "contentChunks.embedding"; + private static final int RECENT_READ_POST_LIMIT = 1000; @Override public int generateRecommendationsForUser(User user) { - Optional personalizationProfileOpt = - personalizationProfileDocumentRepository.findByUserId(user.getId()); - if (personalizationProfileOpt.isEmpty() || personalizationProfileOpt.get().getProfileVector() == null) { + Optional personalizationProfileOpt = + personalizationProfileLookupService.findByUserId(user.getId()); + if (personalizationProfileOpt.isEmpty() || personalizationProfileOpt.get().profileVector() == null) { log.warn("사용자 {}의 개인화 프로필 또는 벡터를 찾을 수 없음. 추천 생성 스킵.", user.getId()); return 0; } - PersonalizationProfileDocument personalizationProfile = personalizationProfileOpt.get(); + PersonalizationProfileLookupItem personalizationProfile = personalizationProfileOpt.get(); return generateRecommendationsForUser( user, - personalizationProfile.getProfileVector(), - personalizationProfile.getKeyKeywords() + personalizationProfile.profileVector(), + personalizationProfile.keyKeywords() ); } @@ -118,7 +117,7 @@ public int generateRecommendationsForUser(User user, float[] personalizationProf // 5. 새 추천 저장 List recommendations = new ArrayList<>(); for (MmrResult result : mmrResults) { - Post post = postRepository.getReferenceById(result.getPostId()); + Post post = postLookupService.getPostReference(result.getPostId()); recommendations.add(RecommendedPost.create( user, post, result.getSimilarityScore(), result.getMmrScore(), result.getRank() )); @@ -138,10 +137,7 @@ private List searchCandidates( List keyKeywords, User user ) throws IOException { - Set readPostIds = readPostRepository.findRecentReadPostsByUserIdWithMinDuration(user.getId(), PageRequest.of(0, 1000)) - .stream() - .map(readPost -> readPost.getPost().getId()) - .collect(Collectors.toSet()); + Set readPostIds = readPostLookupService.getRecentReadPostIds(user.getId(), RECENT_READ_POST_LIMIT); RecommendationProperties.EmbeddingWeights weights = properties.getEmbeddingWeights(); Query filterQuery = vectorQueryBuilder.createExcludeFilter(readPostIds); diff --git a/src/main/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupItem.java b/src/main/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupItem.java new file mode 100644 index 00000000..a240da25 --- /dev/null +++ b/src/main/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupItem.java @@ -0,0 +1,9 @@ +package com.techfork.personalization.application.query.lookup; + +import java.util.List; + +public record PersonalizationProfileLookupItem( + float[] profileVector, + List keyKeywords +) { +} diff --git a/src/main/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupService.java b/src/main/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupService.java new file mode 100644 index 00000000..b37f2efd --- /dev/null +++ b/src/main/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupService.java @@ -0,0 +1,23 @@ +package com.techfork.personalization.application.query.lookup; + +import com.techfork.personalization.infrastructure.PersonalizationProfileDocumentRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PersonalizationProfileLookupService { + + private final PersonalizationProfileDocumentRepository personalizationProfileDocumentRepository; + + public Optional findByUserId(Long userId) { + return personalizationProfileDocumentRepository.findByUserId(userId) + .map(profile -> new PersonalizationProfileLookupItem( + profile.getProfileVector(), + profile.getKeyKeywords() + )); + } +} diff --git a/src/main/java/com/techfork/post/application/query/lookup/PostLookupService.java b/src/main/java/com/techfork/post/application/query/lookup/PostLookupService.java index 362889de..15e93997 100644 --- a/src/main/java/com/techfork/post/application/query/lookup/PostLookupService.java +++ b/src/main/java/com/techfork/post/application/query/lookup/PostLookupService.java @@ -23,6 +23,10 @@ public class PostLookupService { private final PostRepository postRepository; + public Post getPostReference(Long postId) { + return postRepository.getReferenceById(postId); + } + public Post getPostOrThrow(Long postId) { return postRepository.findById(postId) .orElseThrow(() -> new GeneralException(PostErrorCode.POST_NOT_FOUND)); diff --git a/src/test/java/com/techfork/activity/readpost/application/query/lookup/ReadPostLookupServiceTest.java b/src/test/java/com/techfork/activity/readpost/application/query/lookup/ReadPostLookupServiceTest.java index 40e18ff6..8818c5c4 100644 --- a/src/test/java/com/techfork/activity/readpost/application/query/lookup/ReadPostLookupServiceTest.java +++ b/src/test/java/com/techfork/activity/readpost/application/query/lookup/ReadPostLookupServiceTest.java @@ -14,6 +14,7 @@ import org.springframework.data.domain.PageRequest; import java.util.List; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; @@ -29,6 +30,36 @@ class ReadPostLookupServiceTest { @InjectMocks private ReadPostLookupService readPostLookupService; + @Nested + @DisplayName("최근 읽은 게시글 ID 조회") + class GetRecentReadPostIds { + + @Test + @DisplayName("조회된 읽기 기록의 게시글 ID를 중복 없이 반환한다") + void recentReadPostsExist_ReturnsDistinctPostIds() { + Long userId = 1L; + int limit = 1000; + Post firstPost = mock(Post.class); + Post secondPost = mock(Post.class); + ReadPost firstReadPost = mock(ReadPost.class); + ReadPost duplicateReadPost = mock(ReadPost.class); + ReadPost secondReadPost = mock(ReadPost.class); + given(firstReadPost.getPost()).willReturn(firstPost); + given(duplicateReadPost.getPost()).willReturn(firstPost); + given(secondReadPost.getPost()).willReturn(secondPost); + given(firstPost.getId()).willReturn(10L); + given(secondPost.getId()).willReturn(20L); + given(readPostRepository.findRecentReadPostsByUserIdWithMinDuration(userId, PageRequest.of(0, limit))) + .willReturn(List.of(firstReadPost, duplicateReadPost, secondReadPost)); + + Set result = readPostLookupService.getRecentReadPostIds(userId, limit); + + assertThat(result).containsExactlyInAnyOrder(10L, 20L); + verify(readPostRepository) + .findRecentReadPostsByUserIdWithMinDuration(userId, PageRequest.of(0, limit)); + } + } + @Nested @DisplayName("최근 읽은 게시글 활동 신호 조회") class GetRecentReadPostActivities { diff --git a/src/test/java/com/techfork/domain/recommendation/service/LlmRecommendationServiceTest.java b/src/test/java/com/techfork/domain/recommendation/service/LlmRecommendationServiceTest.java index 375d8e4a..d8aa94fa 100644 --- a/src/test/java/com/techfork/domain/recommendation/service/LlmRecommendationServiceTest.java +++ b/src/test/java/com/techfork/domain/recommendation/service/LlmRecommendationServiceTest.java @@ -6,10 +6,7 @@ import co.elastic.clients.elasticsearch.core.SearchResponse; import co.elastic.clients.elasticsearch.core.search.Hit; import co.elastic.clients.util.ObjectBuilder; -import com.techfork.activity.readpost.infrastructure.ReadPostRepository; -import com.techfork.personalization.fixture.PersonalizationProfileDocumentFixture; -import com.techfork.personalization.infrastructure.PersonalizationProfileDocument; -import com.techfork.personalization.infrastructure.PersonalizationProfileDocumentRepository; +import com.techfork.activity.readpost.application.query.lookup.ReadPostLookupService; import com.techfork.global.elasticsearch.query.VectorQueryBuilder; import com.techfork.post.domain.projection.PostDocument; import com.techfork.post.fixture.PostDocumentFixture; @@ -17,8 +14,10 @@ import com.techfork.domain.recommendation.repository.RecommendedPostRepository; import com.techfork.domain.recommendation.repository.RecommendationHistoryRepository; import com.techfork.post.domain.Post; -import com.techfork.post.infrastructure.PostRepository; import com.techfork.global.util.TimeDecayStrategy; +import com.techfork.personalization.application.query.lookup.PersonalizationProfileLookupItem; +import com.techfork.personalization.application.query.lookup.PersonalizationProfileLookupService; +import com.techfork.post.application.query.lookup.PostLookupService; import com.techfork.useraccount.domain.User; import com.techfork.useraccount.domain.enums.SocialType; import com.techfork.useraccount.fixture.UserFixture; @@ -37,11 +36,11 @@ import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.PageRequest; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.within; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; @@ -59,7 +58,7 @@ class LlmRecommendationServiceTest { private ElasticsearchClient elasticsearchClient; @Mock - private PersonalizationProfileDocumentRepository personalizationProfileDocumentRepository; + private PersonalizationProfileLookupService personalizationProfileLookupService; @Mock private RecommendedPostRepository recommendedPostRepository; @@ -68,10 +67,10 @@ class LlmRecommendationServiceTest { private RecommendationHistoryRepository recommendationHistoryRepository; @Mock - private ReadPostRepository readPostRepository; + private ReadPostLookupService readPostLookupService; @Mock - private PostRepository postRepository; + private PostLookupService postLookupService; @Mock private MmrService mmrService; @@ -90,11 +89,11 @@ void setUp() { properties.setMmrCandidateSize(5); llmRecommendationService = new LlmRecommendationService( elasticsearchClient, - personalizationProfileDocumentRepository, + personalizationProfileLookupService, recommendedPostRepository, recommendationHistoryRepository, - readPostRepository, - postRepository, + readPostLookupService, + postLookupService, mmrService, timeDecayStrategy, properties, @@ -108,16 +107,13 @@ void setUp() { class GenerateRecommendationsForUser { @Test - @DisplayName("추천 생성은 PersonalizationProfileDocument projection의 벡터와 핵심 키워드로 후보를 검색한다") + @DisplayName("추천 생성은 personalization lookup의 벡터와 핵심 키워드로 후보를 검색한다") void storedProfileExists_UsesProfileVectorAndKeywords() throws IOException { Long userId = 9L; User user = createUser(userId); float[] profileVector = new float[]{0.1f, 0.2f}; - PersonalizationProfileDocument personalizationProfile = PersonalizationProfileDocumentFixture.personalizationProfileDocument( - userId, - "Spring과 JPA 기반 백엔드 성능 개선에 관심이 높은 사용자", + PersonalizationProfileLookupItem personalizationProfile = new PersonalizationProfileLookupItem( profileVector, - List.of("Backend"), List.of("Spring", "JPA") ); Query filterQuery = Query.of(query -> query.matchAll(matchAll -> matchAll)); @@ -136,11 +132,11 @@ void storedProfileExists_UsesProfileVectorAndKeywords() throws IOException { ); Post recommendedPost = mock(Post.class); - given(personalizationProfileDocumentRepository.findByUserId(userId)) + given(personalizationProfileLookupService.findByUserId(userId)) .willReturn(Optional.of(personalizationProfile)); - given(readPostRepository.findRecentReadPostsByUserIdWithMinDuration(userId, PageRequest.of(0, 1000))) - .willReturn(List.of()); - given(vectorQueryBuilder.createExcludeFilter(Set.of())).willReturn(filterQuery); + Set readPostIds = Set.of(301L); + given(readPostLookupService.getRecentReadPostIds(userId, 1000)).willReturn(readPostIds); + given(vectorQueryBuilder.createExcludeFilter(readPostIds)).willReturn(filterQuery); given(vectorQueryBuilder.createKnnSearches( eq("titleEmbedding"), eq("summaryEmbedding"), @@ -172,7 +168,7 @@ void storedProfileExists_UsesProfileVectorAndKeywords() throws IOException { .rank(1) .build())); given(recommendedPostRepository.findByUserOrderByRankAsc(user)).willReturn(List.of()); - given(postRepository.getReferenceById(501L)).willReturn(recommendedPost); + given(postLookupService.getPostReference(501L)).willReturn(recommendedPost); given(recommendedPostRepository.saveAll(anyList())).willAnswer(invocation -> invocation.getArgument(0)); int createdCount = llmRecommendationService.generateRecommendationsForUser(user); @@ -184,7 +180,10 @@ void storedProfileExists_UsesProfileVectorAndKeywords() throws IOException { assertThat(candidatesCaptor.getValue()) .extracting(MmrService.MmrCandidate::getPostId) .containsExactly(501L, 502L); - verify(personalizationProfileDocumentRepository, times(1)).findByUserId(userId); + verify(personalizationProfileLookupService, times(1)).findByUserId(userId); + verify(readPostLookupService).getRecentReadPostIds(userId, 1000); + verify(vectorQueryBuilder).createExcludeFilter(readPostIds); + verify(postLookupService).getPostReference(501L); verify(vectorQueryBuilder).createKnnSearches( eq("titleEmbedding"), eq("summaryEmbedding"), @@ -200,6 +199,20 @@ void storedProfileExists_UsesProfileVectorAndKeywords() throws IOException { verify(vectorQueryBuilder).createBm25Query(List.of("Spring", "JPA"), 0.6f, 0.2f, 0.2f); } + @Test + @DisplayName("저장된 개인화 프로필이 없으면 추천 생성을 건너뛴다") + void storedProfileMissing_SkipsRecommendationGeneration() { + Long userId = 11L; + User user = createUser(userId); + given(personalizationProfileLookupService.findByUserId(userId)).willReturn(Optional.empty()); + + int createdCount = llmRecommendationService.generateRecommendationsForUser(user); + + assertThat(createdCount).isZero(); + verify(personalizationProfileLookupService).findByUserId(userId); + verify(readPostLookupService, never()).getRecentReadPostIds(any(), anyInt()); + } + @Test @DisplayName("프로필 스냅샷 기반 추천 생성은 PersonalizationProfileDocument를 다시 조회하지 않는다") void profileSnapshotProvided_DoesNotReadStoredProfile() throws IOException { @@ -210,8 +223,7 @@ void profileSnapshotProvided_DoesNotReadStoredProfile() throws IOException { Query filterQuery = Query.of(query -> query.matchAll(matchAll -> matchAll)); Query bm25Query = Query.of(query -> query.matchAll(matchAll -> matchAll)); - given(readPostRepository.findRecentReadPostsByUserIdWithMinDuration(userId, PageRequest.of(0, 1000))) - .willReturn(List.of()); + given(readPostLookupService.getRecentReadPostIds(userId, 1000)).willReturn(Set.of()); given(vectorQueryBuilder.createExcludeFilter(Set.of())).willReturn(filterQuery); given(vectorQueryBuilder.createKnnSearches( eq("titleEmbedding"), @@ -236,7 +248,7 @@ void profileSnapshotProvided_DoesNotReadStoredProfile() throws IOException { int createdCount = llmRecommendationService.generateRecommendationsForUser(user, profileVector, keyKeywords); assertThat(createdCount).isZero(); - verify(personalizationProfileDocumentRepository, never()).findByUserId(any()); + verify(personalizationProfileLookupService, never()).findByUserId(any()); verify(vectorQueryBuilder).createBm25Query(keyKeywords, 0.6f, 0.2f, 0.2f); } } diff --git a/src/test/java/com/techfork/evaluation/recommendation/RecommendationEvaluationService.java b/src/test/java/com/techfork/evaluation/recommendation/RecommendationEvaluationService.java index b226bccb..fd0c6fa3 100644 --- a/src/test/java/com/techfork/evaluation/recommendation/RecommendationEvaluationService.java +++ b/src/test/java/com/techfork/evaluation/recommendation/RecommendationEvaluationService.java @@ -5,9 +5,8 @@ import co.elastic.clients.elasticsearch.core.search.Hit; import co.elastic.clients.elasticsearch._types.query_dsl.Query; import co.elastic.clients.elasticsearch._types.KnnSearch; -import com.techfork.activity.readpost.infrastructure.ReadPostRepository; +import com.techfork.activity.readpost.application.query.lookup.ReadPostLookupService; import com.techfork.post.domain.projection.PostDocument; -import com.techfork.post.infrastructure.PostRepository; import com.techfork.domain.recommendation.config.RecommendationProperties; import com.techfork.domain.recommendation.entity.RecommendedPost; import com.techfork.domain.recommendation.repository.RecommendationHistoryRepository; @@ -18,6 +17,8 @@ import com.techfork.domain.recommendation.service.MmrService.MmrResult; import com.techfork.global.util.RrfScorer; import com.techfork.personalization.infrastructure.PersonalizationProfileDocument; +import com.techfork.personalization.application.query.lookup.PersonalizationProfileLookupService; +import com.techfork.post.application.query.lookup.PostLookupService; import com.techfork.useraccount.domain.User; import com.techfork.personalization.infrastructure.PersonalizationProfileDocumentRepository; import com.techfork.global.elasticsearch.query.VectorQueryBuilder; @@ -51,17 +52,18 @@ public class RecommendationEvaluationService extends LlmRecommendationService { public RecommendationEvaluationService( ElasticsearchClient elasticsearchClient, PersonalizationProfileDocumentRepository personalizationProfileDocumentRepository, + PersonalizationProfileLookupService personalizationProfileLookupService, RecommendedPostRepository recommendedPostRepository, RecommendationHistoryRepository recommendationHistoryRepository, - ReadPostRepository readPostRepository, - PostRepository postRepository, + ReadPostLookupService readPostLookupService, + PostLookupService postLookupService, MmrService mmrService, TimeDecayStrategy timeDecayStrategy, RecommendationProperties properties, VectorQueryBuilder vectorQueryBuilder ) { - super(elasticsearchClient, personalizationProfileDocumentRepository, recommendedPostRepository, - recommendationHistoryRepository, readPostRepository, postRepository, + super(elasticsearchClient, personalizationProfileLookupService, recommendedPostRepository, + recommendationHistoryRepository, readPostLookupService, postLookupService, mmrService, timeDecayStrategy, properties, vectorQueryBuilder, Executors.newSingleThreadExecutor()); this.elasticsearchClient = elasticsearchClient; diff --git a/src/test/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupServiceTest.java b/src/test/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupServiceTest.java new file mode 100644 index 00000000..8cf50153 --- /dev/null +++ b/src/test/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupServiceTest.java @@ -0,0 +1,68 @@ +package com.techfork.personalization.application.query.lookup; + +import com.techfork.personalization.infrastructure.PersonalizationProfileDocument; +import com.techfork.personalization.infrastructure.PersonalizationProfileDocumentRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PersonalizationProfileLookupServiceTest { + + @Mock + private PersonalizationProfileDocumentRepository personalizationProfileDocumentRepository; + + @InjectMocks + private PersonalizationProfileLookupService personalizationProfileLookupService; + + @Nested + @DisplayName("사용자 개인화 프로필 조회") + class FindByUserId { + + @Test + @DisplayName("프로필이 존재하면 추천에 필요한 벡터와 핵심 키워드를 반환한다") + void profileExists_ReturnsRecommendationProfileData() { + Long userId = 1L; + float[] profileVector = new float[]{0.1f, 0.2f}; + List keyKeywords = List.of("Spring", "JPA"); + PersonalizationProfileDocument profileDocument = mock(PersonalizationProfileDocument.class); + given(profileDocument.getProfileVector()).willReturn(profileVector); + given(profileDocument.getKeyKeywords()).willReturn(keyKeywords); + given(personalizationProfileDocumentRepository.findByUserId(userId)) + .willReturn(Optional.of(profileDocument)); + + Optional result = + personalizationProfileLookupService.findByUserId(userId); + + assertThat(result).hasValueSatisfying(profile -> { + assertThat(profile.profileVector()).isSameAs(profileVector); + assertThat(profile.keyKeywords()).isEqualTo(keyKeywords); + }); + verify(personalizationProfileDocumentRepository).findByUserId(userId); + } + + @Test + @DisplayName("프로필이 없으면 빈 결과를 반환한다") + void profileMissing_ReturnsEmpty() { + Long userId = 999L; + given(personalizationProfileDocumentRepository.findByUserId(userId)).willReturn(Optional.empty()); + + Optional result = + personalizationProfileLookupService.findByUserId(userId); + + assertThat(result).isEmpty(); + verify(personalizationProfileDocumentRepository).findByUserId(userId); + } + } +} diff --git a/src/test/java/com/techfork/post/application/query/lookup/PostLookupServiceTest.java b/src/test/java/com/techfork/post/application/query/lookup/PostLookupServiceTest.java index 37240036..737d158d 100644 --- a/src/test/java/com/techfork/post/application/query/lookup/PostLookupServiceTest.java +++ b/src/test/java/com/techfork/post/application/query/lookup/PostLookupServiceTest.java @@ -17,6 +17,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class PostLookupServiceTest { @@ -27,6 +28,24 @@ class PostLookupServiceTest { @InjectMocks private PostLookupService postLookupService; + @Nested + @DisplayName("게시글 참조 조회") + class GetPostReference { + + @Test + @DisplayName("repository의 지연 참조를 그대로 반환한다") + void postIdProvided_ReturnsPostReference() { + Long postId = 100L; + Post postReference = mock(Post.class); + given(postRepository.getReferenceById(postId)).willReturn(postReference); + + Post result = postLookupService.getPostReference(postId); + + assertThat(result).isSameAs(postReference); + verify(postRepository).getReferenceById(postId); + } + } + @Nested @DisplayName("게시글 조회") class GetPostOrThrow { From 5749850f4c1bdb39a75c315e034f93aec2515703 Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Sun, 19 Jul 2026 13:19:44 +0900 Subject: [PATCH 3/5] =?UTF-8?q?refactor:=20=EC=B6=94=EC=B2=9C=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A5=B4=EB=9F=AC=EC=97=90=EC=84=9C=20=ED=83=80=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9D=98=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=A7=81=EC=A0=91=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20application=20seam=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduler/RecommendationScheduler.java | 6 +++--- .../query/lookup/UserLookupService.java | 6 +++++- .../RecommendationSchedulerTest.java | 11 ++++++----- .../query/lookup/UserLookupServiceTest.java | 19 +++++++++++++++++++ 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/techfork/domain/recommendation/scheduler/RecommendationScheduler.java b/src/main/java/com/techfork/domain/recommendation/scheduler/RecommendationScheduler.java index d3d300b0..c5c54c63 100644 --- a/src/main/java/com/techfork/domain/recommendation/scheduler/RecommendationScheduler.java +++ b/src/main/java/com/techfork/domain/recommendation/scheduler/RecommendationScheduler.java @@ -2,8 +2,8 @@ import com.techfork.domain.recommendation.config.RecommendationProperties; import com.techfork.domain.recommendation.service.RecommendationService; +import com.techfork.useraccount.application.query.lookup.UserLookupService; import com.techfork.useraccount.domain.User; -import com.techfork.useraccount.infrastructure.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -17,7 +17,7 @@ @RequiredArgsConstructor public class RecommendationScheduler { - private final UserRepository userRepository; + private final UserLookupService userLookupService; private final RecommendationService recommendationService; private final RecommendationProperties properties; @@ -32,7 +32,7 @@ public void generateDailyRecommendations() { log.info("활성 사용자 대상으로 게시글 추천 시작"); LocalDateTime since = LocalDateTime.now().minusHours(properties.getActiveUserHours()); - List activeUsers = userRepository.findActiveUsersSince(since); + List activeUsers = userLookupService.getActiveUsersSince(since); log.info("{} 명의 활성 사용자를 찾았습니다. ({} 시간 이내)", activeUsers.size(), properties.getActiveUserHours()); diff --git a/src/main/java/com/techfork/useraccount/application/query/lookup/UserLookupService.java b/src/main/java/com/techfork/useraccount/application/query/lookup/UserLookupService.java index 91bfdfbd..48717f9d 100644 --- a/src/main/java/com/techfork/useraccount/application/query/lookup/UserLookupService.java +++ b/src/main/java/com/techfork/useraccount/application/query/lookup/UserLookupService.java @@ -22,8 +22,12 @@ public User getUserOrThrow(Long userId) { .orElseThrow(() -> new GeneralException(UserErrorCode.USER_NOT_FOUND)); } + public List getActiveUsersSince(LocalDateTime since) { + return userRepository.findActiveUsersSince(since); + } + public List getActiveUserIdsSince(LocalDateTime since) { - return userRepository.findActiveUsersSince(since) + return getActiveUsersSince(since) .stream() .map(User::getId) .toList(); diff --git a/src/test/java/com/techfork/domain/recommendation/scheduler/RecommendationSchedulerTest.java b/src/test/java/com/techfork/domain/recommendation/scheduler/RecommendationSchedulerTest.java index 71302565..1070ead6 100644 --- a/src/test/java/com/techfork/domain/recommendation/scheduler/RecommendationSchedulerTest.java +++ b/src/test/java/com/techfork/domain/recommendation/scheduler/RecommendationSchedulerTest.java @@ -2,8 +2,8 @@ import com.techfork.domain.recommendation.config.RecommendationProperties; import com.techfork.domain.recommendation.service.RecommendationService; +import com.techfork.useraccount.application.query.lookup.UserLookupService; import com.techfork.useraccount.domain.User; -import com.techfork.useraccount.infrastructure.UserRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -29,7 +29,7 @@ class RecommendationSchedulerTest { @Mock - private UserRepository userRepository; + private UserLookupService userLookupService; @Mock private RecommendationService recommendationService; @@ -51,7 +51,7 @@ void activeUsersExist_GeneratesRecommendationsForUsers() { User user1 = mockUser(1L); User user2 = mockUser(2L); given(properties.getActiveUserHours()).willReturn(activeUserHours); - given(userRepository.findActiveUsersSince(any(LocalDateTime.class))).willReturn(List.of(user1, user2)); + given(userLookupService.getActiveUsersSince(any(LocalDateTime.class))).willReturn(List.of(user1, user2)); given(recommendationService.generateRecommendationsForUser(user1)).willReturn(2); given(recommendationService.generateRecommendationsForUser(user2)).willReturn(3); @@ -60,7 +60,7 @@ void activeUsersExist_GeneratesRecommendationsForUsers() { LocalDateTime upperBound = LocalDateTime.now().minusHours(activeUserHours); ArgumentCaptor sinceCaptor = ArgumentCaptor.forClass(LocalDateTime.class); - verify(userRepository).findActiveUsersSince(sinceCaptor.capture()); + verify(userLookupService).getActiveUsersSince(sinceCaptor.capture()); assertThat(sinceCaptor.getValue()).isBetween(lowerBound, upperBound); verify(recommendationService).generateRecommendationsForUser(user1); verify(recommendationService).generateRecommendationsForUser(user2); @@ -72,7 +72,8 @@ void oneUserFails_ContinuesWithNextUser() { User failingUser = mockUser(1L); User successfulUser = mockUser(2L); given(properties.getActiveUserHours()).willReturn(24); - given(userRepository.findActiveUsersSince(any(LocalDateTime.class))).willReturn(List.of(failingUser, successfulUser)); + given(userLookupService.getActiveUsersSince(any(LocalDateTime.class))) + .willReturn(List.of(failingUser, successfulUser)); given(recommendationService.generateRecommendationsForUser(failingUser)) .willThrow(new RuntimeException("recommendation failure")); given(recommendationService.generateRecommendationsForUser(successfulUser)).willReturn(3); diff --git a/src/test/java/com/techfork/useraccount/application/query/lookup/UserLookupServiceTest.java b/src/test/java/com/techfork/useraccount/application/query/lookup/UserLookupServiceTest.java index dfc1f04e..36f30453 100644 --- a/src/test/java/com/techfork/useraccount/application/query/lookup/UserLookupServiceTest.java +++ b/src/test/java/com/techfork/useraccount/application/query/lookup/UserLookupServiceTest.java @@ -59,6 +59,25 @@ void userNotFound_ThrowsUserNotFound() { } } + @Nested + @DisplayName("최근 활동 사용자 조회") + class GetActiveUsersSince { + + @Test + @DisplayName("최근 활동 사용자 목록을 반환한다") + void sinceDate_ReturnsActiveUsers() { + LocalDateTime since = LocalDateTime.now().minusHours(24); + User firstUser = mock(User.class); + User secondUser = mock(User.class); + given(userRepository.findActiveUsersSince(since)).willReturn(List.of(firstUser, secondUser)); + + List result = userLookupService.getActiveUsersSince(since); + + assertThat(result).containsExactly(firstUser, secondUser); + verify(userRepository).findActiveUsersSince(since); + } + } + @Nested @DisplayName("최근 활동 사용자 ID 조회") class GetActiveUserIdsSince { From be965011248823f3c1dfeee6908da440cfa341cb Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Sun, 19 Jul 2026 13:33:44 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20=EB=82=B4=EB=B6=80=EC=A0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20getReferenceById=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=9E=91=EB=8F=99=20=EB=B3=80=ED=99=94=EB=A5=BC=20=EB=A1=A4?= =?UTF-8?q?=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/RecommendationCommandService.java | 2 +- .../service/RecommendationQueryService.java | 2 +- .../query/lookup/UserLookupService.java | 4 ++++ .../RecommendationCommandServiceTest.java | 4 ++-- .../RecommendationQueryServiceTest.java | 10 +++++----- .../query/lookup/UserLookupServiceTest.java | 18 ++++++++++++++++++ 6 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/techfork/domain/recommendation/service/RecommendationCommandService.java b/src/main/java/com/techfork/domain/recommendation/service/RecommendationCommandService.java index 417aedda..d8fe8556 100644 --- a/src/main/java/com/techfork/domain/recommendation/service/RecommendationCommandService.java +++ b/src/main/java/com/techfork/domain/recommendation/service/RecommendationCommandService.java @@ -17,7 +17,7 @@ public class RecommendationCommandService { private final UserLookupService userLookupService; public void regenerateRecommendations(Long userId) { - User user = userLookupService.getUserOrThrow(userId); + User user = userLookupService.getUserReference(userId); int generatedCount = recommendationService.generateRecommendationsForUser(user); log.info("사용자 {} 추천 즉시 재생성 완료: {} 개", userId, generatedCount); } diff --git a/src/main/java/com/techfork/domain/recommendation/service/RecommendationQueryService.java b/src/main/java/com/techfork/domain/recommendation/service/RecommendationQueryService.java index 7129261d..a8b9c478 100644 --- a/src/main/java/com/techfork/domain/recommendation/service/RecommendationQueryService.java +++ b/src/main/java/com/techfork/domain/recommendation/service/RecommendationQueryService.java @@ -28,7 +28,7 @@ public class RecommendationQueryService { private final BookmarkLookupService bookmarkLookupService; public RecommendationListResponse getRecommendations(Long userId) { - User user = userLookupService.getUserOrThrow(userId); + User user = userLookupService.getUserReference(userId); List recommendedPosts = recommendedPostRepository.findByUserOrderByRankAsc(user); log.info("사용자 {} 추천 목록 조회: {} 개", userId, recommendedPosts.size()); diff --git a/src/main/java/com/techfork/useraccount/application/query/lookup/UserLookupService.java b/src/main/java/com/techfork/useraccount/application/query/lookup/UserLookupService.java index 48717f9d..67ce79f2 100644 --- a/src/main/java/com/techfork/useraccount/application/query/lookup/UserLookupService.java +++ b/src/main/java/com/techfork/useraccount/application/query/lookup/UserLookupService.java @@ -17,6 +17,10 @@ public class UserLookupService { private final UserRepository userRepository; + public User getUserReference(Long userId) { + return userRepository.getReferenceById(userId); + } + public User getUserOrThrow(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new GeneralException(UserErrorCode.USER_NOT_FOUND)); diff --git a/src/test/java/com/techfork/domain/recommendation/service/RecommendationCommandServiceTest.java b/src/test/java/com/techfork/domain/recommendation/service/RecommendationCommandServiceTest.java index 35b141b7..6d2338d9 100644 --- a/src/test/java/com/techfork/domain/recommendation/service/RecommendationCommandServiceTest.java +++ b/src/test/java/com/techfork/domain/recommendation/service/RecommendationCommandServiceTest.java @@ -36,12 +36,12 @@ class RegenerateRecommendations { void userIdProvided_GeneratesRecommendationsForLookedUpUser() { Long userId = 1L; User user = mock(User.class); - given(userLookupService.getUserOrThrow(userId)).willReturn(user); + given(userLookupService.getUserReference(userId)).willReturn(user); given(recommendationService.generateRecommendationsForUser(user)).willReturn(5); recommendationCommandService.regenerateRecommendations(userId); - verify(userLookupService).getUserOrThrow(userId); + verify(userLookupService).getUserReference(userId); verify(recommendationService).generateRecommendationsForUser(user); } } diff --git a/src/test/java/com/techfork/domain/recommendation/service/RecommendationQueryServiceTest.java b/src/test/java/com/techfork/domain/recommendation/service/RecommendationQueryServiceTest.java index a4319a56..5a3b1a3e 100644 --- a/src/test/java/com/techfork/domain/recommendation/service/RecommendationQueryServiceTest.java +++ b/src/test/java/com/techfork/domain/recommendation/service/RecommendationQueryServiceTest.java @@ -99,7 +99,7 @@ void recommendationsExist_ReturnsRecommendations() { .totalCount(3) .build(); - given(userLookupService.getUserOrThrow(userId)).willReturn(testUser); + given(userLookupService.getUserReference(userId)).willReturn(testUser); given(recommendedPostRepository.findByUserOrderByRankAsc(testUser)).willReturn(recommendedPosts); given(recommendationConverter.toRecommendationListResponse(recommendedPosts)).willReturn(initialResponse); given(bookmarkLookupService.getBookmarkedPostIds(userId, postIds)).willReturn(Set.of()); @@ -113,7 +113,7 @@ void recommendationsExist_ReturnsRecommendations() { assertThat(response.recommendations().get(0).title()).isEqualTo("게시글 1"); assertThat(response.recommendations().get(0).isBookmarked()).isFalse(); - verify(userLookupService).getUserOrThrow(userId); + verify(userLookupService).getUserReference(userId); verify(recommendedPostRepository).findByUserOrderByRankAsc(testUser); verify(recommendationConverter).toRecommendationListResponse(recommendedPosts); verify(bookmarkLookupService).getBookmarkedPostIds(userId, postIds); @@ -141,7 +141,7 @@ void bookmarkedPostsExist_ReturnsBookmarkedRecommendations() { // 101L, 103L 게시글은 북마크됨 Set bookmarkedPostIds = Set.of(101L, 103L); - given(userLookupService.getUserOrThrow(userId)).willReturn(testUser); + given(userLookupService.getUserReference(userId)).willReturn(testUser); given(recommendedPostRepository.findByUserOrderByRankAsc(testUser)).willReturn(recommendedPosts); given(recommendationConverter.toRecommendationListResponse(recommendedPosts)).willReturn(initialResponse); given(bookmarkLookupService.getBookmarkedPostIds(userId, postIds)).willReturn(bookmarkedPostIds); @@ -172,7 +172,7 @@ void noRecommendations_ReturnsEmptyList() { .totalCount(0) .build(); - given(userLookupService.getUserOrThrow(userId)).willReturn(testUser); + given(userLookupService.getUserReference(userId)).willReturn(testUser); given(recommendedPostRepository.findByUserOrderByRankAsc(testUser)).willReturn(emptyList); given(recommendationConverter.toRecommendationListResponse(emptyList)).willReturn(emptyResponse); @@ -183,7 +183,7 @@ void noRecommendations_ReturnsEmptyList() { assertThat(response.recommendations()).isEmpty(); assertThat(response.totalCount()).isZero(); - verify(userLookupService).getUserOrThrow(userId); + verify(userLookupService).getUserReference(userId); verify(recommendedPostRepository).findByUserOrderByRankAsc(testUser); verify(recommendationConverter).toRecommendationListResponse(emptyList); verify(bookmarkLookupService, never()).getBookmarkedPostIds(any(), any()); diff --git a/src/test/java/com/techfork/useraccount/application/query/lookup/UserLookupServiceTest.java b/src/test/java/com/techfork/useraccount/application/query/lookup/UserLookupServiceTest.java index 36f30453..f88c005a 100644 --- a/src/test/java/com/techfork/useraccount/application/query/lookup/UserLookupServiceTest.java +++ b/src/test/java/com/techfork/useraccount/application/query/lookup/UserLookupServiceTest.java @@ -31,6 +31,24 @@ class UserLookupServiceTest { @InjectMocks private UserLookupService userLookupService; + @Nested + @DisplayName("사용자 참조 조회") + class GetUserReference { + + @Test + @DisplayName("사용자를 즉시 조회하지 않고 지연 참조로 반환한다") + void userIdProvided_ReturnsUserReference() { + Long userId = 1L; + User userReference = mock(User.class); + given(userRepository.getReferenceById(userId)).willReturn(userReference); + + User result = userLookupService.getUserReference(userId); + + assertThat(result).isSameAs(userReference); + verify(userRepository).getReferenceById(userId); + } + } + @Nested @DisplayName("사용자 조회") class GetUserOrThrow { From 4d09c1ce82e6549e4103ec21f2fa272ec797b37f Mon Sep 17 00:00:00 2001 From: Dimo-2562 Date: Sun, 19 Jul 2026 13:39:01 +0900 Subject: [PATCH 5/5] =?UTF-8?q?improve:=20=EA=B0=92=20=EA=B0=9D=EC=B2=B4?= =?UTF-8?q?=20=EB=82=B4=EB=B6=80=EC=9D=98=20=EA=B0=80=EB=B3=80=20=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=EB=93=A4=EC=9D=84=20=EB=B0=A9=EC=96=B4=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PersonalizationProfileLookupItem.java | 9 ++ .../service/LlmRecommendationServiceTest.java | 5 +- .../PersonalizationProfileLookupItemTest.java | 89 +++++++++++++++++++ ...rsonalizationProfileLookupServiceTest.java | 4 +- 4 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupItemTest.java diff --git a/src/main/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupItem.java b/src/main/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupItem.java index a240da25..15cde698 100644 --- a/src/main/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupItem.java +++ b/src/main/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupItem.java @@ -6,4 +6,13 @@ public record PersonalizationProfileLookupItem( float[] profileVector, List keyKeywords ) { + public PersonalizationProfileLookupItem { + profileVector = profileVector == null ? null : profileVector.clone(); + keyKeywords = keyKeywords == null ? List.of() : List.copyOf(keyKeywords); + } + + @Override + public float[] profileVector() { + return profileVector == null ? null : profileVector.clone(); + } } diff --git a/src/test/java/com/techfork/domain/recommendation/service/LlmRecommendationServiceTest.java b/src/test/java/com/techfork/domain/recommendation/service/LlmRecommendationServiceTest.java index d8aa94fa..8dc9918a 100644 --- a/src/test/java/com/techfork/domain/recommendation/service/LlmRecommendationServiceTest.java +++ b/src/test/java/com/techfork/domain/recommendation/service/LlmRecommendationServiceTest.java @@ -39,6 +39,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.within; +import static org.mockito.AdditionalMatchers.aryEq; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyList; @@ -141,7 +142,7 @@ void storedProfileExists_UsesProfileVectorAndKeywords() throws IOException { eq("titleEmbedding"), eq("summaryEmbedding"), eq("contentChunks.embedding"), - same(profileVector), + aryEq(profileVector), eq(0.6f), eq(0.2f), eq(0.2f), @@ -188,7 +189,7 @@ void storedProfileExists_UsesProfileVectorAndKeywords() throws IOException { eq("titleEmbedding"), eq("summaryEmbedding"), eq("contentChunks.embedding"), - same(profileVector), + aryEq(profileVector), eq(0.6f), eq(0.2f), eq(0.2f), diff --git a/src/test/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupItemTest.java b/src/test/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupItemTest.java new file mode 100644 index 00000000..789db598 --- /dev/null +++ b/src/test/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupItemTest.java @@ -0,0 +1,89 @@ +package com.techfork.personalization.application.query.lookup; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class PersonalizationProfileLookupItemTest { + + @Nested + @DisplayName("생성자") + class Constructor { + + @Test + @DisplayName("프로필 벡터는 생성 시점 값으로 복사된다") + void profileVectorProvided_CopiesProfileVector() { + float[] profileVector = new float[]{0.1f, 0.2f}; + + PersonalizationProfileLookupItem item = new PersonalizationProfileLookupItem( + profileVector, + List.of("Spring") + ); + profileVector[0] = 9.9f; + + assertThat(item.profileVector()).containsExactly(0.1f, 0.2f); + } + + @Test + @DisplayName("핵심 키워드는 생성 시점 값으로 복사되고 불변 목록으로 유지된다") + void keyKeywordsProvided_CopiesAsImmutableList() { + List keyKeywords = new ArrayList<>(List.of("Spring", "JPA")); + + PersonalizationProfileLookupItem item = new PersonalizationProfileLookupItem( + new float[]{0.1f}, + keyKeywords + ); + keyKeywords.add("Kafka"); + + assertThat(item.keyKeywords()).containsExactly("Spring", "JPA"); + assertThatThrownBy(() -> item.keyKeywords().add("Kafka")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + @DisplayName("핵심 키워드가 null이면 빈 목록으로 정규화한다") + void nullKeyKeywords_DefaultsToEmptyList() { + PersonalizationProfileLookupItem item = new PersonalizationProfileLookupItem( + new float[]{0.1f}, + null + ); + + assertThat(item.keyKeywords()).isEmpty(); + } + + @Test + @DisplayName("프로필 벡터가 null이면 null로 유지한다") + void nullProfileVector_RemainsNull() { + PersonalizationProfileLookupItem item = new PersonalizationProfileLookupItem( + null, + List.of("Spring") + ); + + assertThat(item.profileVector()).isNull(); + } + } + + @Nested + @DisplayName("profileVector") + class ProfileVector { + + @Test + @DisplayName("프로필 벡터 조회 결과를 변경해도 lookup item 내부 상태는 변경되지 않는다") + void profileVectorAccessor_ReturnsCopy() { + PersonalizationProfileLookupItem item = new PersonalizationProfileLookupItem( + new float[]{0.1f, 0.2f}, + List.of("Spring") + ); + + float[] returnedVector = item.profileVector(); + returnedVector[0] = 9.9f; + + assertThat(item.profileVector()).containsExactly(0.1f, 0.2f); + } + } +} diff --git a/src/test/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupServiceTest.java b/src/test/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupServiceTest.java index 8cf50153..4e0c6a83 100644 --- a/src/test/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupServiceTest.java +++ b/src/test/java/com/techfork/personalization/application/query/lookup/PersonalizationProfileLookupServiceTest.java @@ -46,7 +46,9 @@ void profileExists_ReturnsRecommendationProfileData() { personalizationProfileLookupService.findByUserId(userId); assertThat(result).hasValueSatisfying(profile -> { - assertThat(profile.profileVector()).isSameAs(profileVector); + assertThat(profile.profileVector()) + .isNotSameAs(profileVector) + .containsExactly(profileVector); assertThat(profile.keyKeywords()).isEqualTo(keyKeywords); }); verify(personalizationProfileDocumentRepository).findByUserId(userId);