Skip to content

Commit 152dfae

Browse files
authored
Merge pull request #214 from JECT-Study/in-348-인플루언서-인사이트-조회-최적화
in-348-인플루언서-인사이트-조회-최적화 -> develop
2 parents 4a38cb3 + 66acd1f commit 152dfae

6 files changed

Lines changed: 325 additions & 29 deletions

File tree

src/main/java/com/example/inflace/domain/channel/service/insight/InfluencerInsightCalculator.java

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.example.inflace.domain.channel.dto.response.GetInfluencerInsightResponse;
66
import com.example.inflace.domain.video.domain.Video;
77
import com.example.inflace.domain.video.domain.VideoStats;
8+
import com.example.inflace.domain.video.repository.projection.VideoFormatStatsProjection;
89
import com.example.inflace.global.util.AnalyticsCalculator;
910
import java.time.LocalDateTime;
1011
import java.util.ArrayList;
@@ -25,7 +26,6 @@ public class InfluencerInsightCalculator {
2526
private static final int CONTENT_WINDOW_SIZE = 50;
2627
private static final int GROWTH_WINDOW_SIZE = 25;
2728
private static final int FREQUENCY_INTERVAL_WINDOW_SIZE = 5;
28-
private static final int RECENT_WINDOW_DAYS = 30;
2929
private static final int MIN_ADVERTISEMENT_VIDEO_COUNT = 3;
3030

3131
public InfluencerInsightCalculator(InfluencerInsightScoreCalculator scoreCalculator) {
@@ -37,7 +37,8 @@ public GetInfluencerInsightResponse calculate(
3737
ChannelStats channelStats,
3838
List<String> categories,
3939
List<Video> videos,
40-
Map<Long, VideoStats> videoStatsMap
40+
Map<Long, VideoStats> videoStatsMap,
41+
List<VideoFormatStatsProjection> recentFormatProjections
4142
) {
4243
List<VideoMetric> metrics = buildVideoMetrics(videos, videoStatsMap);
4344
List<Video> latestVideos = getLatestVideos(videos);
@@ -47,7 +48,7 @@ public GetInfluencerInsightResponse calculate(
4748
GetInfluencerInsightResponse.Content content = buildContentMetrics(latestMetrics);
4849
GetInfluencerInsightResponse.Activity activity = buildActivityMetrics(latestVideos);
4950
GetInfluencerInsightResponse.Advertisement advertisement = buildAdvertisementMetrics(latestMetrics, channelStats);
50-
GetInfluencerInsightResponse.FormatAnalysis formatAnalysis = buildFormatAnalysis(metrics);
51+
GetInfluencerInsightResponse.FormatAnalysis formatAnalysis = buildFormatAnalysis(recentFormatProjections);
5152

5253
return new GetInfluencerInsightResponse(
5354
channel.getId(),
@@ -203,29 +204,27 @@ private GetInfluencerInsightResponse.Activity buildActivityMetrics(List<Video> v
203204
);
204205
}
205206

206-
private GetInfluencerInsightResponse.FormatAnalysis buildFormatAnalysis(List<VideoMetric> metrics) {
207-
LocalDateTime recent30d = LocalDateTime.now().minusDays(RECENT_WINDOW_DAYS);
208-
209-
List<VideoMetric> recentLongForm = metrics.stream()
210-
.filter(metric -> metric.publishedAt() != null && !metric.publishedAt().isBefore(recent30d))
211-
.filter(metric -> !metric.isShort())
207+
private GetInfluencerInsightResponse.FormatAnalysis buildFormatAnalysis(
208+
List<VideoFormatStatsProjection> projections
209+
) {
210+
List<VideoFormatStatsProjection> recentLongForm = projections.stream()
211+
.filter(projection -> !projection.isShort())
212212
.toList();
213213

214-
List<VideoMetric> recentShortForm = metrics.stream()
215-
.filter(metric -> metric.publishedAt() != null && !metric.publishedAt().isBefore(recent30d))
216-
.filter(VideoMetric::isShort)
214+
List<VideoFormatStatsProjection> recentShortForm = projections.stream()
215+
.filter(VideoFormatStatsProjection::isShort)
217216
.toList();
218217

219218
return new GetInfluencerInsightResponse.FormatAnalysis(
220219
new GetInfluencerInsightResponse.FormatMetric(
221220
recentLongForm.size(),
222-
calculateRound(averageViewCount(recentLongForm)),
223-
calculateRound(averageEngagementRate(recentLongForm))
221+
calculateRound(averageFormatViewCount(recentLongForm)),
222+
calculateRound(averageFormatEngagementRate(recentLongForm))
224223
),
225224
new GetInfluencerInsightResponse.FormatMetric(
226225
recentShortForm.size(),
227-
calculateRound(averageViewCount(recentShortForm)),
228-
calculateRound(averageEngagementRate(recentShortForm))
226+
calculateRound(averageFormatViewCount(recentShortForm)),
227+
calculateRound(averageFormatEngagementRate(recentShortForm))
229228
)
230229
);
231230
}
@@ -292,7 +291,7 @@ private double calculateFixedWindowPercentOf(
292291
.filter(predicate)
293292
.count();
294293

295-
return matched * 100.0 / CONTENT_WINDOW_SIZE;
294+
return matched * 100.0 / metrics.size();
296295
}
297296

298297
private double averageViewCount(List<VideoMetric> metrics) {
@@ -305,12 +304,26 @@ private double averageViewCount(List<VideoMetric> metrics) {
305304
.orElse(0.0);
306305
}
307306

308-
private double averageEngagementRate(List<VideoMetric> metrics) {
309-
if (metrics.isEmpty()) {
307+
private double averageFormatViewCount(List<VideoFormatStatsProjection> projections) {
308+
if (projections.isEmpty()) {
310309
return 0.0;
311310
}
312-
return metrics.stream()
313-
.mapToDouble(VideoMetric::engagementRate)
311+
return projections.stream()
312+
.mapToLong(VideoFormatStatsProjection::viewCount)
313+
.average()
314+
.orElse(0.0);
315+
}
316+
317+
private double averageFormatEngagementRate(List<VideoFormatStatsProjection> projections) {
318+
if (projections.isEmpty()) {
319+
return 0.0;
320+
}
321+
return projections.stream()
322+
.mapToDouble(projection -> AnalyticsCalculator.engagementRate(
323+
projection.likeCount(),
324+
projection.commentCount(),
325+
projection.viewCount()
326+
))
314327
.average()
315328
.orElse(0.0);
316329
}

src/main/java/com/example/inflace/domain/channel/service/insight/InfluencerInsightQueryService.java

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,18 @@
1010
import com.example.inflace.domain.channel.repository.ChannelStatsRepository;
1111
import com.example.inflace.domain.video.domain.Video;
1212
import com.example.inflace.domain.video.repository.VideoRepository;
13+
import com.example.inflace.domain.video.repository.projection.VideoFormatStatsProjection;
1314
import com.example.inflace.domain.video.service.VideoService;
1415
import com.example.inflace.global.annotation.ReadOnlyTransactional;
1516
import com.example.inflace.global.exception.ApiException;
1617
import com.example.inflace.global.exception.ErrorDefine;
18+
import java.time.LocalDateTime;
19+
import java.time.ZoneOffset;
1720
import java.util.List;
1821
import java.util.Objects;
1922

2023
import lombok.RequiredArgsConstructor;
24+
import org.springframework.data.domain.PageRequest;
2125
import org.springframework.stereotype.Service;
2226
import org.springframework.util.StringUtils;
2327

@@ -26,7 +30,9 @@
2630
public class InfluencerInsightQueryService {
2731

2832
private static final int MIN_VIDEO_COUNT_FOR_INSIGHT = 10;
33+
private static final int LATEST_VIDEO_COUNT_FOR_INSIGHT = 50;
2934
private static final int MAX_RECENT_VIDEO_DESCRIPTION_COUNT = 10;
35+
private static final int RECENT_WINDOW_DAYS = 30;
3036

3137
private final ChannelRepository channelRepository;
3238
private final ChannelStatsRepository channelStatsRepository;
@@ -40,10 +46,18 @@ public InfluencerInsightQueryResult getInsightQueryResult(Long channelId) {
4046
Channel channel = channelRepository.findById(channelId)
4147
.orElseThrow(() -> new ApiException(ErrorDefine.CHANNEL_NOT_FOUND));
4248

43-
List<Video> videos = videoRepository.findByChannelIdOrderByPublishedAtDesc(channelId);
44-
if (videos.size() < MIN_VIDEO_COUNT_FOR_INSIGHT) {
49+
if (videoRepository.countByChannelId(channelId) < MIN_VIDEO_COUNT_FOR_INSIGHT) {
4550
throw new ApiException(ErrorDefine.CHANNEL_INSIGHT_REQUIRES_MIN_VIDEO_COUNT);
4651
}
52+
List<Video> latestVideos = videoRepository.findByChannelIdOrderByPublishedAtDesc(
53+
channelId,
54+
PageRequest.of(0, LATEST_VIDEO_COUNT_FOR_INSIGHT)
55+
);
56+
List<VideoFormatStatsProjection> recentFormatProjections = videoRepository
57+
.findFormatStatsRowsByChannelIdAndPublishedAtGreaterThanEqual(
58+
channelId,
59+
LocalDateTime.now(ZoneOffset.UTC).minusDays(RECENT_WINDOW_DAYS)
60+
);
4761
List<String> categories = channelCategoryRepository.findAllByChannel_Id(channelId).stream()
4862
.map(ChannelCategory::getCategory)
4963
.filter(Objects::nonNull)
@@ -56,13 +70,14 @@ public InfluencerInsightQueryResult getInsightQueryResult(Long channelId) {
5670
channel,
5771
channelStats,
5872
categories,
59-
videos,
60-
videoService.getVideoStatsMap(videos)
73+
latestVideos,
74+
videoService.getVideoStatsMap(latestVideos),
75+
recentFormatProjections
6176
);
6277

6378
return new InfluencerInsightQueryResult(
6479
channel.getDescription(),
65-
getRecentVideoDescriptions(videos),
80+
getRecentVideoDescriptions(latestVideos),
6681
insight
6782
);
6883
}

src/main/java/com/example/inflace/domain/video/repository/VideoRepository.java

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.example.inflace.domain.video.repository;
22

33
import com.example.inflace.domain.video.domain.Video;
4+
import com.example.inflace.domain.video.repository.projection.VideoFormatStatsProjection;
45
import java.time.LocalDateTime;
56
import java.util.List;
67
import java.util.Optional;
@@ -50,12 +51,10 @@ order by coalesce(vs.risingScore, 0) desc, coalesce(va.ctr, 0) desc, v.id desc
5051
""")
5152
List<Video> findAllTopVideos(@Param("channelId") Long channelId, Limit limit);
5253

53-
Long countByChannelIdAndPublishedAtGreaterThanEqual(Long channelId, LocalDateTime publishedAt);
54+
long countByChannelId(Long channelId);
5455

5556
List<Video> findByChannelId(Long channelId);
5657

57-
List<Video> findByChannelIdOrderByPublishedAtDesc(Long channelId);
58-
5958
@Query("""
6059
select v from Video v
6160
join fetch v.channel
@@ -64,4 +63,24 @@ order by coalesce(vs.risingScore, 0) desc, coalesce(va.ctr, 0) desc, v.id desc
6463
order by coalesce(vs.viewCount, 0) desc
6564
""")
6665
List<Video> findTopAdVideos(Limit limit);
66+
67+
List<Video> findByChannelIdOrderByPublishedAtDesc(Long channelId, Pageable pageable);
68+
69+
@Query("""
70+
select new com.example.inflace.domain.video.repository.projection.VideoFormatStatsProjection(
71+
v.isShort,
72+
vs.viewCount,
73+
vs.likeCount,
74+
vs.commentCount
75+
)
76+
from Video v
77+
join VideoStats vs on vs.video = v
78+
where v.channel.id = :channelId
79+
and v.publishedAt >= :publishedAt
80+
""")
81+
List<VideoFormatStatsProjection> findFormatStatsRowsByChannelIdAndPublishedAtGreaterThanEqual(
82+
@Param("channelId") Long channelId,
83+
@Param("publishedAt") LocalDateTime publishedAt
84+
);
85+
6786
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.example.inflace.domain.video.repository.projection;
2+
3+
public record VideoFormatStatsProjection(
4+
boolean isShort,
5+
long viewCount,
6+
long likeCount,
7+
long commentCount
8+
) {
9+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.example.inflace.domain.channel.service.insight;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.example.inflace.domain.channel.domain.Channel;
6+
import com.example.inflace.domain.channel.dto.response.GetInfluencerInsightResponse;
7+
import com.example.inflace.domain.video.domain.Video;
8+
import com.example.inflace.domain.video.domain.VideoStats;
9+
import com.example.inflace.domain.video.repository.projection.VideoFormatStatsProjection;
10+
import java.time.LocalDateTime;
11+
import java.util.ArrayList;
12+
import java.util.HashMap;
13+
import java.util.List;
14+
import java.util.Map;
15+
import org.junit.jupiter.api.Test;
16+
import org.springframework.test.util.ReflectionTestUtils;
17+
18+
class InfluencerInsightCalculatorTest {
19+
20+
@Test
21+
void viralRatesUseActualMetricCountWhenLatestVideosAreLessThanFifty() {
22+
InfluencerInsightCalculator calculator = new InfluencerInsightCalculator(new InfluencerInsightScoreCalculator());
23+
Channel channel = Channel.builder()
24+
.name("test-channel")
25+
.build();
26+
List<Long> viewCounts = List.of(1000L, 500L, 400L, 10L, 10L, 10L, 10L, 10L, 10L, 10L);
27+
List<Video> videos = new ArrayList<>();
28+
Map<Long, VideoStats> statsMap = new HashMap<>();
29+
30+
for (int i = 0; i < viewCounts.size(); i++) {
31+
Video video = video((long) i + 1, LocalDateTime.now().minusDays(i));
32+
videos.add(video);
33+
statsMap.put(video.getId(), VideoStats.builder()
34+
.video(video)
35+
.viewCount(viewCounts.get(i))
36+
.likeCount(0L)
37+
.commentCount(0L)
38+
.build());
39+
}
40+
41+
GetInfluencerInsightResponse response = calculator.calculate(
42+
channel,
43+
null,
44+
List.of(),
45+
videos,
46+
statsMap,
47+
List.of()
48+
);
49+
50+
assertThat(response.content().viral2xRate())
51+
.as("expected viral2xRate to use actual 10-video denominator, not fixed 50 denominator")
52+
.isEqualTo(30.0);
53+
assertThat(response.content().viral5xRate())
54+
.as("expected viral5xRate to use actual 10-video denominator, not fixed 50 denominator")
55+
.isEqualTo(10.0);
56+
}
57+
58+
@Test
59+
void formatAnalysisUsesRecentThirtyDayProjectionRows() {
60+
InfluencerInsightCalculator calculator = new InfluencerInsightCalculator(new InfluencerInsightScoreCalculator());
61+
Video video = video();
62+
VideoStats stats = VideoStats.builder()
63+
.video(video)
64+
.viewCount(100L)
65+
.likeCount(10L)
66+
.commentCount(5L)
67+
.build();
68+
69+
GetInfluencerInsightResponse response = calculator.calculate(
70+
Channel.builder().name("test-channel").build(),
71+
null,
72+
List.of(),
73+
List.of(video),
74+
Map.of(video.getId(), stats),
75+
List.of(
76+
new VideoFormatStatsProjection(false, 100L, 10L, 5L),
77+
new VideoFormatStatsProjection(true, 50L, 5L, 5L)
78+
)
79+
);
80+
81+
assertThat(response.formatAnalysis().longForm().count())
82+
.as("expected long-form count from recent 30-day projection rows")
83+
.isOne();
84+
assertThat(response.formatAnalysis().shortForm().count())
85+
.as("expected short-form count from recent 30-day projection rows")
86+
.isOne();
87+
}
88+
89+
private Video video() {
90+
Video video = Video.builder()
91+
.title("test-video")
92+
.isShort(false)
93+
.build();
94+
ReflectionTestUtils.setField(video, "id", 1L);
95+
return video;
96+
}
97+
98+
private Video video(Long id, LocalDateTime publishedAt) {
99+
Video video = Video.builder()
100+
.title("test-video-" + id)
101+
.publishedAt(publishedAt)
102+
.isShort(false)
103+
.build();
104+
ReflectionTestUtils.setField(video, "id", id);
105+
return video;
106+
}
107+
}

0 commit comments

Comments
 (0)