totalViews 필드 추가 및 배포완료 이벤트 개선

변경 사항:
1. EventStats에 totalViews 필드 추가 (모든 채널 노출 수 합계)
2. DistributionCompletedEvent에 expectedViews 필드 추가
3. DistributionCompletedConsumer 개선:
   - ChannelStats.impressions에 expectedViews 저장
   - updateTotalViews() 메서드로 전체 노출 수 집계
4. SampleDataLoader에 채널별 예상 노출 수 설정:
   - 이벤트1: 총 20,000 (우리동네TV 5K, 지니TV 10K, 링고비즈 3K, SNS 2K)
   - 이벤트2: 총 14,000
   - 이벤트3: 총 6,000

설계 다이어그램과 일치:
- 채널별 예상 노출 수 저장
- 총 노출 수 실시간 집계
- 멱등성 및 캐시 무효화 유지

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Hyowon Yang 2025-10-24 16:08:32 +09:00
parent 7b3ca40e22
commit 7735c8472b
11 changed files with 68 additions and 9 deletions

Binary file not shown.

View File

@ -188,6 +188,11 @@ public class SampleDataLoader implements ApplicationRunner {
new BigDecimal("3500000"),
new BigDecimal("2000000")
};
int[][] expectedViews = {
{5000, 10000, 3000, 2000}, // 이벤트1: 우리동네TV, 지니TV, 링고비즈, SNS
{3500, 7000, 2000, 1500}, // 이벤트2
{1500, 3000, 1000, 500} // 이벤트3
};
for (int i = 0; i < eventIds.length; i++) {
String eventId = eventIds[i];
@ -195,19 +200,19 @@ public class SampleDataLoader implements ApplicationRunner {
// 1. 우리동네TV (TV)
publishDistributionEvent(eventId, "우리동네TV", "TV",
distributionBudget.multiply(new BigDecimal("0.3")));
distributionBudget.multiply(new BigDecimal("0.3")), expectedViews[i][0]);
// 2. 지니TV (TV)
publishDistributionEvent(eventId, "지니TV", "TV",
distributionBudget.multiply(new BigDecimal("0.3")));
distributionBudget.multiply(new BigDecimal("0.3")), expectedViews[i][1]);
// 3. 링고비즈 (CALL)
publishDistributionEvent(eventId, "링고비즈", "CALL",
distributionBudget.multiply(new BigDecimal("0.2")));
distributionBudget.multiply(new BigDecimal("0.2")), expectedViews[i][2]);
// 4. SNS (SNS)
publishDistributionEvent(eventId, "SNS", "SNS",
distributionBudget.multiply(new BigDecimal("0.2")));
distributionBudget.multiply(new BigDecimal("0.2")), expectedViews[i][3]);
}
log.info("✅ DistributionCompleted 이벤트 12건 발행 완료 (3 이벤트 × 4 채널)");
@ -217,12 +222,13 @@ public class SampleDataLoader implements ApplicationRunner {
* 개별 DistributionCompleted 이벤트 발행
*/
private void publishDistributionEvent(String eventId, String channelName, String channelType,
BigDecimal distributionCost) throws Exception {
BigDecimal distributionCost, Integer expectedViews) throws Exception {
DistributionCompletedEvent event = DistributionCompletedEvent.builder()
.eventId(eventId)
.channelName(channelName)
.channelType(channelType)
.distributionCost(distributionCost)
.expectedViews(expectedViews)
.build();
publishEvent(DISTRIBUTION_COMPLETED_TOPIC, event);
}

View File

@ -49,6 +49,13 @@ public class EventStats extends BaseTimeEntity {
@Builder.Default
private Integer totalParticipants = 0;
/**
* 노출 (모든 채널의 노출 합계)
*/
@Column(nullable = false)
@Builder.Default
private Integer totalViews = 0;
/**
* 예상 ROI (%)
*/

View File

@ -3,6 +3,7 @@ package com.kt.event.analytics.messaging.consumer;
import com.kt.event.analytics.entity.ChannelStats;
import com.kt.event.analytics.messaging.event.DistributionCompletedEvent;
import com.kt.event.analytics.repository.ChannelStatsRepository;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -11,6 +12,7 @@ import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
@ -25,6 +27,7 @@ import java.util.concurrent.TimeUnit;
public class DistributionCompletedConsumer {
private final ChannelStatsRepository channelStatsRepository;
private final EventStatsRepository eventStatsRepository;
private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate;
@ -64,15 +67,25 @@ public class DistributionCompletedConsumer {
.build());
channelStats.setDistributionCost(event.getDistributionCost());
channelStatsRepository.save(channelStats);
log.info("✅ 채널 통계 업데이트: eventId={}, channel={}", eventId, channelName);
// 3. 캐시 무효화 (다음 조회 최신 배포 통계 반영)
// 예상 노출 저장
if (event.getExpectedViews() != null) {
channelStats.setImpressions(event.getExpectedViews());
}
channelStatsRepository.save(channelStats);
log.info("✅ 채널 통계 업데이트: eventId={}, channel={}, expectedViews={}",
eventId, channelName, event.getExpectedViews());
// 3. EventStats의 totalViews 업데이트 (모든 채널 노출 합계)
updateTotalViews(eventId);
// 4. 캐시 무효화 (다음 조회 최신 배포 통계 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId;
redisTemplate.delete(cacheKey);
log.debug("🗑️ 캐시 무효화: {}", cacheKey);
// 4. 멱등성 처리 완료 기록 (7일 TTL)
// 5. 멱등성 처리 완료 기록 (7일 TTL)
redisTemplate.opsForSet().add(PROCESSED_DISTRIBUTIONS_KEY, distributionKey);
redisTemplate.expire(PROCESSED_DISTRIBUTIONS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS);
log.debug("✅ 멱등성 기록: distributionKey={}", distributionKey);
@ -82,4 +95,32 @@ public class DistributionCompletedConsumer {
throw new RuntimeException("DistributionCompleted 처리 실패", e);
}
}
/**
* 모든 채널의 예상 노출 수를 합산하여 EventStats.totalViews 업데이트
*/
private void updateTotalViews(String eventId) {
try {
// 모든 채널 통계 조회
List<ChannelStats> channelStatsList = channelStatsRepository.findByEventId(eventId);
// 노출 계산
int totalViews = channelStatsList.stream()
.mapToInt(ChannelStats::getImpressions)
.sum();
// EventStats 업데이트
eventStatsRepository.findByEventId(eventId)
.ifPresentOrElse(
eventStats -> {
eventStats.setTotalViews(totalViews);
eventStatsRepository.save(eventStats);
log.info("✅ 총 노출 수 업데이트: eventId={}, totalViews={}", eventId, totalViews);
},
() -> log.warn("⚠️ 이벤트 통계 없음: eventId={}", eventId)
);
} catch (Exception e) {
log.error("❌ totalViews 업데이트 실패: eventId={}", eventId, e);
}
}
}

View File

@ -35,4 +35,9 @@ public class DistributionCompletedEvent {
* 배포 비용
*/
private BigDecimal distributionCost;
/**
* 예상 노출
*/
private Integer expectedViews;
}