diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index 5d56e9d..0000000
Binary files a/.DS_Store and /dev/null differ
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index d2eec2e..f0a5018 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -16,28 +16,39 @@
"Bash(git commit:*)",
"Bash(git push)",
"Bash(git pull:*)",
- "Bash(./gradlew ai-service:compileJava:*)",
- "Bash(./gradlew ai-service:build:*)",
- "Bash(.\\gradlew ai-service:compileJava:*)",
- "Bash(./gradlew.bat:*)",
- "Bash(if [ ! -d \"ai-service/.run\" ])",
- "Bash(then mkdir \"ai-service/.run\")",
- "Bash(./gradlew:*)",
- "Bash(python:*)",
- "Bash(then mkdir -p \"ai-service/.run\")",
- "Bash(if [ ! -d \"tools\" ])",
- "Bash(then mkdir tools)",
- "Bash(if [ ! -d \"logs\" ])",
- "Bash(then mkdir logs)",
"Bash(netstat:*)",
"Bash(findstr:*)",
- "Bash(..gradlew.bat test --tests \"com.kt.ai.test.integration.kafka.AIJobConsumerIntegrationTest\" --info)",
- "Bash(.gradlew.bat ai-service:test:*)",
- "Bash(cmd /c \"gradlew.bat ai-service:test --tests com.kt.ai.test.integration.kafka.AIJobConsumerIntegrationTest\")",
- "Bash(timeout 120 cmd:*)",
- "Bash(cmd /c:*)",
- "Bash(Select-String -Pattern \"(test|BUILD|FAILED|SUCCESS)\")",
- "Bash(Select-Object -Last 20)"
+ "Bash(./gradlew analytics-service:compileJava:*)",
+ "Bash(python -m json.tool:*)",
+ "Bash(powershell:*)"
+ "Bash(./gradlew participation-service:compileJava:*)",
+ "Bash(find:*)",
+ "Bash(netstat:*)",
+ "Bash(findstr:*)",
+ "Bash(docker-compose up:*)",
+ "Bash(docker --version:*)",
+ "Bash(timeout 60 bash:*)",
+ "Bash(docker ps:*)",
+ "Bash(docker exec:*)",
+ "Bash(docker-compose down:*)",
+ "Bash(git rm:*)",
+ "Bash(git restore:*)",
+ "Bash(./gradlew participation-service:test:*)",
+ "Bash(timeout 30 bash:*)",
+ "Bash(helm list:*)",
+ "Bash(helm upgrade:*)",
+ "Bash(helm repo add:*)",
+ "Bash(helm repo update:*)",
+ "Bash(kubectl get:*)",
+ "Bash(python3:*)",
+ "Bash(timeout 120 bash -c 'while true; do sleep 5; kubectl get pods -n kt-event-marketing | grep kafka | grep -v Running && continue; echo \"\"\"\"All Kafka pods are Running!\"\"\"\"; break; done')",
+ "Bash(kubectl delete:*)",
+ "Bash(kubectl logs:*)",
+ "Bash(kubectl describe:*)",
+ "Bash(kubectl exec:*)",
+ "mcp__context7__resolve-library-id",
+ "mcp__context7__get-library-docs",
+ "Bash(python -m json.tool:*)"
],
"deny": [],
"ask": []
diff --git a/.gitignore b/.gitignore
index d60fa17..74a08c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,16 @@ Thumbs.db
dist/
build/
*.log
+.gradle/
+logs/
+
+# Gradle
+.gradle/
+!gradle/wrapper/gradle-wrapper.jar
+
+# Logs
+logs/
+*.log
# Gradle
.gradle/
@@ -38,3 +48,16 @@ gradle-app.setting
tmp/
temp/
*.tmp
+
+# Kubernetes Secrets (민감한 정보 포함)
+k8s/**/secret.yaml
+k8s/**/*-secret.yaml
+k8s/**/*-prod.yaml
+k8s/**/*-dev.yaml
+k8s/**/*-local.yaml
+
+# IntelliJ 실행 프로파일 (민감한 환경 변수 포함 가능)
+.run/*.run.xml
+
+# Gradle (로컬 환경 설정)
+gradle.properties
diff --git a/.run/EventServiceApplication.run.xml b/.run/EventServiceApplication.run.xml
new file mode 100644
index 0000000..38d1691
--- /dev/null
+++ b/.run/EventServiceApplication.run.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/ParticipationServiceApplication.run.xml b/.run/ParticipationServiceApplication.run.xml
new file mode 100644
index 0000000..a323100
--- /dev/null
+++ b/.run/ParticipationServiceApplication.run.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ false
+
+
+
diff --git a/.run/analytics-service.run.xml b/.run/analytics-service.run.xml
new file mode 100644
index 0000000..ade144d
--- /dev/null
+++ b/.run/analytics-service.run.xml
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ false
+
+
+
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index 6b665aa..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "liveServer.settings.port": 5501
-}
diff --git a/analytics-service/.run/analytics-service.run.xml b/analytics-service/.run/analytics-service.run.xml
new file mode 100644
index 0000000..44dfb98
--- /dev/null
+++ b/analytics-service/.run/analytics-service.run.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ false
+
+
+
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/AnalyticsServiceApplication.java b/analytics-service/src/main/java/com/kt/event/analytics/AnalyticsServiceApplication.java
new file mode 100644
index 0000000..c109743
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/AnalyticsServiceApplication.java
@@ -0,0 +1,29 @@
+package com.kt.event.analytics;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+import org.springframework.cloud.openfeign.EnableFeignClients;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+import org.springframework.kafka.annotation.EnableKafka;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+/**
+ * Analytics Service 애플리케이션 메인 클래스
+ *
+ * 실시간 효과 측정 및 통합 대시보드를 제공하는 Analytics Service
+ */
+@SpringBootApplication(scanBasePackages = {"com.kt.event.analytics", "com.kt.event.common"})
+@EntityScan(basePackages = {"com.kt.event.analytics.entity", "com.kt.event.common.entity"})
+@EnableJpaRepositories(basePackages = "com.kt.event.analytics.repository")
+@EnableJpaAuditing
+@EnableFeignClients
+@EnableKafka
+@EnableScheduling
+public class AnalyticsServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(AnalyticsServiceApplication.class, args);
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/batch/AnalyticsBatchScheduler.java b/analytics-service/src/main/java/com/kt/event/analytics/batch/AnalyticsBatchScheduler.java
new file mode 100644
index 0000000..82263fd
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/batch/AnalyticsBatchScheduler.java
@@ -0,0 +1,116 @@
+package com.kt.event.analytics.batch;
+
+import com.kt.event.analytics.entity.EventStats;
+import com.kt.event.analytics.repository.EventStatsRepository;
+import com.kt.event.analytics.service.AnalyticsService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * Analytics 배치 스케줄러
+ *
+ * 5분 단위로 Analytics 대시보드 데이터를 갱신하는 배치 작업
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AnalyticsBatchScheduler {
+
+ private final AnalyticsService analyticsService;
+ private final EventStatsRepository eventStatsRepository;
+ private final RedisTemplate redisTemplate;
+
+ /**
+ * 5분 단위 Analytics 데이터 갱신 배치
+ *
+ * - 각 이벤트마다 Redis 캐시 확인
+ * - 캐시 있음 → 건너뛰기 (1시간 유효)
+ * - 캐시 없음 → PostgreSQL + 외부 API → Redis 저장
+ */
+ @Scheduled(fixedRate = 300000) // 5분 = 300,000ms
+ public void refreshAnalyticsDashboard() {
+ log.info("===== Analytics 배치 시작: {} =====", LocalDateTime.now());
+
+ try {
+ // 1. 모든 활성 이벤트 조회
+ List activeEvents = eventStatsRepository.findAll();
+ log.info("활성 이벤트 수: {}", activeEvents.size());
+
+ // 2. 각 이벤트별로 캐시 확인 및 갱신
+ int successCount = 0;
+ int skipCount = 0;
+ int failCount = 0;
+
+ for (EventStats event : activeEvents) {
+ String cacheKey = "analytics:dashboard:" + event.getEventId();
+
+ try {
+ // 2-1. Redis 캐시 확인
+ if (redisTemplate.hasKey(cacheKey)) {
+ log.debug("✅ 캐시 유효, 건너뜀: eventId={}", event.getEventId());
+ skipCount++;
+ continue;
+ }
+
+ // 2-2. 캐시 없음 → 데이터 갱신
+ log.info("캐시 만료, 갱신 시작: eventId={}, title={}",
+ event.getEventId(), event.getEventTitle());
+
+ // refresh=true로 호출하여 캐시 갱신 및 외부 API 호출
+ analyticsService.getDashboardData(event.getEventId(), null, null, true);
+
+ successCount++;
+ log.info("✅ 배치 갱신 완료: eventId={}", event.getEventId());
+
+ } catch (Exception e) {
+ failCount++;
+ log.error("❌ 배치 갱신 실패: eventId={}, error={}",
+ event.getEventId(), e.getMessage(), e);
+ }
+ }
+
+ log.info("===== Analytics 배치 완료: 성공={}, 건너뜀={}, 실패={}, 종료시각={} =====",
+ successCount, skipCount, failCount, LocalDateTime.now());
+
+ } catch (Exception e) {
+ log.error("Analytics 배치 실행 중 오류 발생: {}", e.getMessage(), e);
+ }
+ }
+
+ /**
+ * 초기 데이터 로딩 (애플리케이션 시작 후 30초 뒤 1회 실행)
+ *
+ * - 서버 시작 직후 캐시 워밍업
+ * - 첫 API 요청 시 응답 시간 단축
+ */
+ @Scheduled(initialDelay = 30000, fixedDelay = Long.MAX_VALUE)
+ public void initialDataLoad() {
+ log.info("===== 초기 데이터 로딩 시작: {} =====", LocalDateTime.now());
+
+ try {
+ List allEvents = eventStatsRepository.findAll();
+ log.info("초기 로딩 대상 이벤트 수: {}", allEvents.size());
+
+ for (EventStats event : allEvents) {
+ try {
+ analyticsService.getDashboardData(event.getEventId(), null, null, true);
+ log.debug("초기 데이터 로딩 완료: eventId={}", event.getEventId());
+ } catch (Exception e) {
+ log.warn("초기 데이터 로딩 실패: eventId={}, error={}",
+ event.getEventId(), e.getMessage());
+ }
+ }
+
+ log.info("===== 초기 데이터 로딩 완료: {} =====", LocalDateTime.now());
+
+ } catch (Exception e) {
+ log.error("초기 데이터 로딩 중 오류 발생: {}", e.getMessage(), e);
+ }
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaConsumerConfig.java b/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaConsumerConfig.java
new file mode 100644
index 0000000..8ffefb7
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaConsumerConfig.java
@@ -0,0 +1,50 @@
+package com.kt.event.analytics.config;
+
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
+import org.springframework.kafka.core.ConsumerFactory;
+import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Kafka Consumer 설정
+ */
+@Configuration
+@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = true)
+public class KafkaConsumerConfig {
+
+ @Value("${spring.kafka.bootstrap-servers}")
+ private String bootstrapServers;
+
+ @Value("${spring.kafka.consumer.group-id:analytics-service}")
+ private String groupId;
+
+ @Bean
+ public ConsumerFactory consumerFactory() {
+ Map props = new HashMap<>();
+ props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
+ props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
+ props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+ props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+ props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
+ props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
+ return new DefaultKafkaConsumerFactory<>(props);
+ }
+
+ @Bean
+ public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory() {
+ ConcurrentKafkaListenerContainerFactory factory =
+ new ConcurrentKafkaListenerContainerFactory<>();
+ factory.setConsumerFactory(consumerFactory());
+ // Kafka Consumer 자동 시작 활성화
+ factory.setAutoStartup(true);
+ return factory;
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaTopicConfig.java b/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaTopicConfig.java
new file mode 100644
index 0000000..3c77521
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/config/KafkaTopicConfig.java
@@ -0,0 +1,53 @@
+package com.kt.event.analytics.config;
+
+import org.apache.kafka.clients.admin.NewTopic;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.kafka.config.TopicBuilder;
+
+/**
+ * Kafka 토픽 자동 생성 설정
+ *
+ * ⚠️ MVP 전용: 샘플 데이터용 토픽을 생성합니다.
+ * 실제 운영 토픽(event.created 등)과 구분하기 위해 "sample." 접두사 사용
+ *
+ * 서비스 시작 시 필요한 Kafka 토픽을 자동으로 생성합니다.
+ */
+@Configuration
+@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
+public class KafkaTopicConfig {
+
+ /**
+ * sample.event.created 토픽 (MVP 샘플 데이터용)
+ */
+ @Bean
+ public NewTopic eventCreatedTopic() {
+ return TopicBuilder.name("sample.event.created")
+ .partitions(3)
+ .replicas(1)
+ .build();
+ }
+
+ /**
+ * sample.participant.registered 토픽 (MVP 샘플 데이터용)
+ */
+ @Bean
+ public NewTopic participantRegisteredTopic() {
+ return TopicBuilder.name("sample.participant.registered")
+ .partitions(3)
+ .replicas(1)
+ .build();
+ }
+
+ /**
+ * sample.distribution.completed 토픽 (MVP 샘플 데이터용)
+ */
+ @Bean
+ public NewTopic distributionCompletedTopic() {
+ return TopicBuilder.name("sample.distribution.completed")
+ .partitions(3)
+ .replicas(1)
+ .build();
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/RedisConfig.java b/analytics-service/src/main/java/com/kt/event/analytics/config/RedisConfig.java
new file mode 100644
index 0000000..5c6eebb
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/config/RedisConfig.java
@@ -0,0 +1,35 @@
+package com.kt.event.analytics.config;
+
+import io.lettuce.core.ReadFrom;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
+import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.serializer.StringRedisSerializer;
+
+/**
+ * Redis 캐시 설정
+ */
+@Configuration
+public class RedisConfig {
+
+ @Bean
+ public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) {
+ RedisTemplate template = new RedisTemplate<>();
+ template.setConnectionFactory(connectionFactory);
+ template.setKeySerializer(new StringRedisSerializer());
+ template.setValueSerializer(new StringRedisSerializer());
+ template.setHashKeySerializer(new StringRedisSerializer());
+ template.setHashValueSerializer(new StringRedisSerializer());
+
+ // Read-only 오류 방지: 마스터 노드 우선 사용
+ if (connectionFactory instanceof LettuceConnectionFactory) {
+ LettuceConnectionFactory lettuceFactory = (LettuceConnectionFactory) connectionFactory;
+ lettuceFactory.setValidateConnection(true);
+ }
+
+ return template;
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/Resilience4jConfig.java b/analytics-service/src/main/java/com/kt/event/analytics/config/Resilience4jConfig.java
new file mode 100644
index 0000000..ab4f50e
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/config/Resilience4jConfig.java
@@ -0,0 +1,27 @@
+package com.kt.event.analytics.config;
+
+import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
+import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.Duration;
+
+/**
+ * Resilience4j Circuit Breaker 설정
+ */
+@Configuration
+public class Resilience4jConfig {
+
+ @Bean
+ public CircuitBreakerRegistry circuitBreakerRegistry() {
+ CircuitBreakerConfig config = CircuitBreakerConfig.custom()
+ .failureRateThreshold(50)
+ .waitDurationInOpenState(Duration.ofSeconds(30))
+ .slidingWindowSize(10)
+ .permittedNumberOfCallsInHalfOpenState(3)
+ .build();
+
+ return CircuitBreakerRegistry.of(config);
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/SampleDataLoader.java b/analytics-service/src/main/java/com/kt/event/analytics/config/SampleDataLoader.java
new file mode 100644
index 0000000..72d27f4
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/config/SampleDataLoader.java
@@ -0,0 +1,361 @@
+package com.kt.event.analytics.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.kt.event.analytics.messaging.event.DistributionCompletedEvent;
+import com.kt.event.analytics.messaging.event.EventCreatedEvent;
+import com.kt.event.analytics.messaging.event.ParticipantRegisteredEvent;
+import com.kt.event.analytics.repository.ChannelStatsRepository;
+import com.kt.event.analytics.repository.EventStatsRepository;
+import com.kt.event.analytics.repository.TimelineDataRepository;
+import jakarta.annotation.PreDestroy;
+import jakarta.persistence.EntityManager;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.kafka.core.KafkaTemplate;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+import java.util.UUID;
+
+/**
+ * 샘플 데이터 로더 (Kafka Producer 방식)
+ *
+ * ⚠️ MVP 전용: 다른 마이크로서비스(Event, Participant, Distribution)가
+ * 없는 환경에서 해당 서비스들의 역할을 시뮬레이션합니다.
+ *
+ * ⚠️ 실제 운영: Analytics Service는 순수 Consumer 역할만 수행해야 하며,
+ * 이 클래스는 비활성화되어야 합니다.
+ * → SAMPLE_DATA_ENABLED=false 설정
+ *
+ * - 서비스 시작 시: Kafka 이벤트 발행하여 샘플 데이터 자동 생성
+ * - 서비스 종료 시: PostgreSQL 전체 데이터 삭제
+ *
+ * 활성화 조건: spring.sample-data.enabled=true (기본값: true)
+ */
+@Slf4j
+@Component
+@ConditionalOnProperty(name = "spring.sample-data.enabled", havingValue = "true", matchIfMissing = true)
+@RequiredArgsConstructor
+public class SampleDataLoader implements ApplicationRunner {
+
+ private final KafkaTemplate kafkaTemplate;
+ private final ObjectMapper objectMapper;
+ private final EventStatsRepository eventStatsRepository;
+ private final ChannelStatsRepository channelStatsRepository;
+ private final TimelineDataRepository timelineDataRepository;
+ private final EntityManager entityManager;
+ private final RedisTemplate redisTemplate;
+
+ private final Random random = new Random();
+
+ // Kafka Topic Names (MVP용 샘플 토픽)
+ private static final String EVENT_CREATED_TOPIC = "sample.event.created";
+ private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered";
+ private static final String DISTRIBUTION_COMPLETED_TOPIC = "sample.distribution.completed";
+
+ @Override
+ @Transactional
+ public void run(ApplicationArguments args) {
+ log.info("========================================");
+ log.info("🚀 서비스 시작: Kafka 이벤트 발행하여 샘플 데이터 생성");
+ log.info("========================================");
+
+ // 항상 기존 데이터 삭제 후 새로 생성
+ long existingCount = eventStatsRepository.count();
+ if (existingCount > 0) {
+ log.info("기존 데이터 {} 건 삭제 중...", existingCount);
+ timelineDataRepository.deleteAll();
+ channelStatsRepository.deleteAll();
+ eventStatsRepository.deleteAll();
+
+ // 삭제 커밋 보장
+ entityManager.flush();
+ entityManager.clear();
+
+ log.info("✅ 기존 데이터 삭제 완료");
+ }
+
+ // Redis 멱등성 키 삭제 (새로운 이벤트 처리를 위해)
+ log.info("Redis 멱등성 키 삭제 중...");
+ redisTemplate.delete("processed_events");
+ redisTemplate.delete("distribution_completed");
+ redisTemplate.delete("processed_participants");
+ log.info("✅ Redis 멱등성 키 삭제 완료");
+
+ try {
+ // 1. EventCreated 이벤트 발행 (3개 이벤트)
+ publishEventCreatedEvents();
+ log.info("⏳ EventStats 생성 대기 중... (5초)");
+ Thread.sleep(5000); // EventCreatedConsumer가 EventStats 생성할 시간
+
+ // 2. DistributionCompleted 이벤트 발행 (각 이벤트당 4개 채널)
+ publishDistributionCompletedEvents();
+ log.info("⏳ ChannelStats 생성 대기 중... (3초)");
+ Thread.sleep(3000); // DistributionCompletedConsumer가 ChannelStats 생성할 시간
+
+ // 3. ParticipantRegistered 이벤트 발행 (각 이벤트당 다수 참여자)
+ publishParticipantRegisteredEvents();
+
+ log.info("========================================");
+ log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)");
+ log.info("========================================");
+ log.info("발행된 이벤트:");
+ log.info(" - EventCreated: 3건");
+ log.info(" - DistributionCompleted: 3건 (각 이벤트당 4개 채널 배열)");
+ log.info(" - ParticipantRegistered: 180건 (MVP 테스트용)");
+ log.info("========================================");
+
+ // Consumer 처리 대기 (5초)
+ log.info("⏳ 참여자 수 업데이트 대기 중... (5초)");
+ Thread.sleep(5000);
+
+ // 4. TimelineData 생성 (시간대별 데이터)
+ createTimelineData();
+ log.info("✅ TimelineData 생성 완료");
+
+ } catch (Exception e) {
+ log.error("샘플 데이터 적재 중 오류 발생", e);
+ }
+ }
+
+ /**
+ * 서비스 종료 시 전체 데이터 삭제
+ */
+ @PreDestroy
+ @Transactional
+ public void onShutdown() {
+ log.info("========================================");
+ log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제");
+ log.info("========================================");
+
+ try {
+ long timelineCount = timelineDataRepository.count();
+ long channelCount = channelStatsRepository.count();
+ long eventCount = eventStatsRepository.count();
+
+ log.info("삭제 대상: 이벤트={}, 채널={}, 타임라인={}",
+ eventCount, channelCount, timelineCount);
+
+ timelineDataRepository.deleteAll();
+ channelStatsRepository.deleteAll();
+ eventStatsRepository.deleteAll();
+
+ // 삭제 커밋 보장
+ entityManager.flush();
+ entityManager.clear();
+
+ log.info("✅ 모든 샘플 데이터 삭제 완료!");
+ log.info("========================================");
+
+ } catch (Exception e) {
+ log.error("샘플 데이터 삭제 중 오류 발생", e);
+ }
+ }
+
+ /**
+ * EventCreated 이벤트 발행
+ */
+ private void publishEventCreatedEvents() throws Exception {
+ // 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과)
+ EventCreatedEvent event1 = EventCreatedEvent.builder()
+ .eventId("evt_2025012301")
+ .eventTitle("신년맞이 20% 할인 이벤트")
+ .storeId("store_001")
+ .totalInvestment(new BigDecimal("5000000"))
+ .status("ACTIVE")
+ .build();
+ publishEvent(EVENT_CREATED_TOPIC, event1);
+
+ // 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과)
+ EventCreatedEvent event2 = EventCreatedEvent.builder()
+ .eventId("evt_2025020101")
+ .eventTitle("설날 특가 선물세트 이벤트")
+ .storeId("store_001")
+ .totalInvestment(new BigDecimal("3500000"))
+ .status("ACTIVE")
+ .build();
+ publishEvent(EVENT_CREATED_TOPIC, event2);
+
+ // 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과)
+ EventCreatedEvent event3 = EventCreatedEvent.builder()
+ .eventId("evt_2025011501")
+ .eventTitle("겨울 신메뉴 런칭 이벤트")
+ .storeId("store_001")
+ .totalInvestment(new BigDecimal("2000000"))
+ .status("COMPLETED")
+ .build();
+ publishEvent(EVENT_CREATED_TOPIC, event3);
+
+ log.info("✅ EventCreated 이벤트 3건 발행 완료");
+ }
+
+ /**
+ * DistributionCompleted 이벤트 발행 (설계서 기준 - 이벤트당 1번 발행, 여러 채널 배열)
+ */
+ private void publishDistributionCompletedEvents() throws Exception {
+ String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
+ 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];
+
+ // 4개 채널을 배열로 구성
+ List channels = new ArrayList<>();
+
+ // 1. 우리동네TV (TV)
+ channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
+ .channel("우리동네TV")
+ .channelType("TV")
+ .status("SUCCESS")
+ .expectedViews(expectedViews[i][0])
+ .build());
+
+ // 2. 지니TV (TV)
+ channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
+ .channel("지니TV")
+ .channelType("TV")
+ .status("SUCCESS")
+ .expectedViews(expectedViews[i][1])
+ .build());
+
+ // 3. 링고비즈 (CALL)
+ channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
+ .channel("링고비즈")
+ .channelType("CALL")
+ .status("SUCCESS")
+ .expectedViews(expectedViews[i][2])
+ .build());
+
+ // 4. SNS (SNS)
+ channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
+ .channel("SNS")
+ .channelType("SNS")
+ .status("SUCCESS")
+ .expectedViews(expectedViews[i][3])
+ .build());
+
+ // 이벤트 발행 (채널 배열 포함)
+ DistributionCompletedEvent event = DistributionCompletedEvent.builder()
+ .eventId(eventId)
+ .distributedChannels(channels)
+ .completedAt(java.time.LocalDateTime.now())
+ .build();
+
+ publishEvent(DISTRIBUTION_COMPLETED_TOPIC, event);
+ }
+
+ log.info("✅ DistributionCompleted 이벤트 3건 발행 완료 (3 이벤트 × 4 채널 배열)");
+ }
+
+ /**
+ * ParticipantRegistered 이벤트 발행
+ */
+ private void publishParticipantRegisteredEvents() throws Exception {
+ String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
+ int[] totalParticipants = {100, 50, 30}; // MVP 테스트용 샘플 데이터 (총 180명)
+ String[] channels = {"우리동네TV", "지니TV", "링고비즈", "SNS"};
+
+ int totalPublished = 0;
+
+ for (int i = 0; i < eventIds.length; i++) {
+ String eventId = eventIds[i];
+ int participants = totalParticipants[i];
+
+ // 각 이벤트에 대해 참여자 수만큼 ParticipantRegistered 이벤트 발행
+ for (int j = 0; j < participants; j++) {
+ String participantId = UUID.randomUUID().toString();
+ String channel = channels[j % channels.length]; // 채널 순환 배정
+
+ ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder()
+ .eventId(eventId)
+ .participantId(participantId)
+ .channel(channel)
+ .build();
+
+ publishEvent(PARTICIPANT_REGISTERED_TOPIC, event);
+ totalPublished++;
+ }
+ }
+
+ log.info("✅ ParticipantRegistered 이벤트 {}건 발행 완료", totalPublished);
+ }
+
+ /**
+ * TimelineData 생성 (시간대별 샘플 데이터)
+ *
+ * - 각 이벤트마다 30일 치 daily 데이터 생성
+ * - 참여자 수, 조회수, 참여행동, 전환수, 누적 참여자 수
+ */
+ private void createTimelineData() {
+ log.info("📊 TimelineData 생성 시작...");
+
+ String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
+
+ // 각 이벤트별 기준 참여자 수 (이벤트 성과에 따라 다름)
+ int[] baseParticipants = {20, 12, 5}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음)
+
+ for (int eventIndex = 0; eventIndex < eventIds.length; eventIndex++) {
+ String eventId = eventIds[eventIndex];
+ int baseParticipant = baseParticipants[eventIndex];
+ int cumulativeParticipants = 0;
+
+ // 30일 치 데이터 생성 (2024-09-24부터)
+ java.time.LocalDateTime startDate = java.time.LocalDateTime.of(2024, 9, 24, 0, 0);
+
+ for (int day = 0; day < 30; day++) {
+ java.time.LocalDateTime timestamp = startDate.plusDays(day);
+
+ // 랜덤한 참여자 수 생성 (기준값 ± 50%)
+ int dailyParticipants = baseParticipant + random.nextInt(baseParticipant + 1);
+ cumulativeParticipants += dailyParticipants;
+
+ // 조회수는 참여자의 3~5배
+ int dailyViews = dailyParticipants * (3 + random.nextInt(3));
+
+ // 참여행동은 참여자의 1~2배
+ int dailyEngagement = dailyParticipants * (1 + random.nextInt(2));
+
+ // 전환수는 참여자의 50~80%
+ int dailyConversions = (int) (dailyParticipants * (0.5 + random.nextDouble() * 0.3));
+
+ // TimelineData 생성
+ com.kt.event.analytics.entity.TimelineData timelineData =
+ com.kt.event.analytics.entity.TimelineData.builder()
+ .eventId(eventId)
+ .timestamp(timestamp)
+ .participants(dailyParticipants)
+ .views(dailyViews)
+ .engagement(dailyEngagement)
+ .conversions(dailyConversions)
+ .cumulativeParticipants(cumulativeParticipants)
+ .build();
+
+ timelineDataRepository.save(timelineData);
+ }
+
+ log.info("✅ TimelineData 생성 완료: eventId={}, 30일 데이터", eventId);
+ }
+
+ log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 = 90건");
+ }
+
+ /**
+ * Kafka 이벤트 발행 공통 메서드
+ */
+ private void publishEvent(String topic, Object event) throws Exception {
+ String jsonMessage = objectMapper.writeValueAsString(event);
+ kafkaTemplate.send(topic, jsonMessage);
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/SecurityConfig.java b/analytics-service/src/main/java/com/kt/event/analytics/config/SecurityConfig.java
new file mode 100644
index 0000000..b340f83
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/config/SecurityConfig.java
@@ -0,0 +1,79 @@
+package com.kt.event.analytics.config;
+
+import com.kt.event.common.security.JwtAuthenticationFilter;
+import com.kt.event.common.security.JwtTokenProvider;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+import java.util.Arrays;
+
+/**
+ * Spring Security 설정
+ * JWT 기반 인증 및 API 보안 설정
+ */
+@Configuration
+@EnableWebSecurity
+@RequiredArgsConstructor
+public class SecurityConfig {
+
+ private final JwtTokenProvider jwtTokenProvider;
+
+ @Value("${cors.allowed-origins:http://localhost:*}")
+ private String allowedOrigins;
+
+ @Bean
+ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ return http
+ .csrf(AbstractHttpConfigurer::disable)
+ .cors(cors -> cors.configurationSource(corsConfigurationSource()))
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth
+ // Actuator endpoints
+ .requestMatchers("/actuator/**").permitAll()
+ // Swagger UI endpoints
+ .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll()
+ // Health check
+ .requestMatchers("/health").permitAll()
+ // Analytics API endpoints (테스트 및 개발 용도로 공개)
+ .requestMatchers("/api/**").permitAll()
+ // All other requests require authentication
+ .anyRequest().authenticated()
+ )
+ .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
+ UsernamePasswordAuthenticationFilter.class)
+ .build();
+ }
+
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration configuration = new CorsConfiguration();
+
+ String[] origins = allowedOrigins.split(",");
+ configuration.setAllowedOriginPatterns(Arrays.asList(origins));
+
+ configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
+
+ configuration.setAllowedHeaders(Arrays.asList(
+ "Authorization", "Content-Type", "X-Requested-With", "Accept",
+ "Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"
+ ));
+
+ configuration.setAllowCredentials(true);
+ configuration.setMaxAge(3600L);
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", configuration);
+ return source;
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/config/SwaggerConfig.java b/analytics-service/src/main/java/com/kt/event/analytics/config/SwaggerConfig.java
new file mode 100644
index 0000000..c0660af
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/config/SwaggerConfig.java
@@ -0,0 +1,63 @@
+package com.kt.event.analytics.config;
+
+import io.swagger.v3.oas.models.Components;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Contact;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.security.SecurityRequirement;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+import io.swagger.v3.oas.models.servers.Server;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Swagger/OpenAPI 설정
+ * Analytics Service API 문서화를 위한 설정
+ */
+@Configuration
+public class SwaggerConfig {
+
+ @Bean
+ public OpenAPI openAPI() {
+ return new OpenAPI()
+ .info(apiInfo())
+ .addServersItem(new Server()
+ .url("http://localhost:8086")
+ .description("Local Development"))
+ .addServersItem(new Server()
+ .url("{protocol}://{host}:{port}")
+ .description("Custom Server")
+ .variables(new io.swagger.v3.oas.models.servers.ServerVariables()
+ .addServerVariable("protocol", new io.swagger.v3.oas.models.servers.ServerVariable()
+ ._default("http")
+ .description("Protocol (http or https)")
+ .addEnumItem("http")
+ .addEnumItem("https"))
+ .addServerVariable("host", new io.swagger.v3.oas.models.servers.ServerVariable()
+ ._default("localhost")
+ .description("Server host"))
+ .addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable()
+ ._default("8086")
+ .description("Server port"))))
+ .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication"))
+ .components(new Components()
+ .addSecuritySchemes("Bearer Authentication", createAPIKeyScheme()));
+ }
+
+ private Info apiInfo() {
+ return new Info()
+ .title("Analytics Service API")
+ .description("실시간 효과 측정 및 통합 대시보드를 제공하는 Analytics Service API")
+ .version("1.0.0")
+ .contact(new Contact()
+ .name("Digital Garage Team")
+ .email("support@kt-event-marketing.com"));
+ }
+
+ private SecurityScheme createAPIKeyScheme() {
+ return new SecurityScheme()
+ .type(SecurityScheme.Type.HTTP)
+ .bearerFormat("JWT")
+ .scheme("bearer");
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/AnalyticsDashboardController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/AnalyticsDashboardController.java
new file mode 100644
index 0000000..2dc1d8a
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/AnalyticsDashboardController.java
@@ -0,0 +1,71 @@
+package com.kt.event.analytics.controller;
+
+import com.kt.event.analytics.dto.response.AnalyticsDashboardResponse;
+import com.kt.event.analytics.service.AnalyticsService;
+import com.kt.event.common.dto.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+
+/**
+ * Analytics Dashboard Controller
+ *
+ * 이벤트 성과 대시보드 API
+ */
+@Tag(name = "Analytics", description = "이벤트 성과 분석 및 대시보드 API")
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/events")
+@RequiredArgsConstructor
+public class AnalyticsDashboardController {
+
+ private final AnalyticsService analyticsService;
+
+ /**
+ * 성과 대시보드 조회
+ *
+ * @param eventId 이벤트 ID
+ * @param startDate 조회 시작 날짜
+ * @param endDate 조회 종료 날짜
+ * @param refresh 캐시 갱신 여부
+ * @return 성과 대시보드
+ */
+ @Operation(
+ summary = "성과 대시보드 조회",
+ description = "이벤트의 전체 성과를 통합하여 조회합니다."
+ )
+ @GetMapping("/{eventId}/analytics")
+ public ResponseEntity> getEventAnalytics(
+ @Parameter(description = "이벤트 ID", required = true)
+ @PathVariable String eventId,
+
+ @Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
+ @RequestParam(required = false)
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+ LocalDateTime startDate,
+
+ @Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
+ @RequestParam(required = false)
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+ LocalDateTime endDate,
+
+ @Parameter(description = "캐시 갱신 여부 (true인 경우 외부 API 호출)")
+ @RequestParam(required = false, defaultValue = "false")
+ Boolean refresh
+ ) {
+ log.info("성과 대시보드 조회 API 호출: eventId={}, refresh={}", eventId, refresh);
+
+ AnalyticsDashboardResponse response = analyticsService.getDashboardData(
+ eventId, startDate, endDate, refresh
+ );
+
+ return ResponseEntity.ok(ApiResponse.success(response));
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/ChannelAnalyticsController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/ChannelAnalyticsController.java
new file mode 100644
index 0000000..ea78687
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/ChannelAnalyticsController.java
@@ -0,0 +1,73 @@
+package com.kt.event.analytics.controller;
+
+import com.kt.event.analytics.dto.response.ChannelAnalyticsResponse;
+import com.kt.event.analytics.service.ChannelAnalyticsService;
+import com.kt.event.common.dto.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Channel Analytics Controller
+ *
+ * 채널별 성과 분석 API
+ */
+@Tag(name = "Channels", description = "채널별 성과 분석 API")
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/events")
+@RequiredArgsConstructor
+public class ChannelAnalyticsController {
+
+ private final ChannelAnalyticsService channelAnalyticsService;
+
+ /**
+ * 채널별 성과 분석
+ *
+ * @param eventId 이벤트 ID
+ * @param channels 조회할 채널 목록 (쉼표로 구분)
+ * @param sortBy 정렬 기준
+ * @param order 정렬 순서
+ * @return 채널별 성과 분석
+ */
+ @Operation(
+ summary = "채널별 성과 분석",
+ description = "각 배포 채널별 성과를 상세하게 분석합니다."
+ )
+ @GetMapping("/{eventId}/analytics/channels")
+ public ResponseEntity> getChannelAnalytics(
+ @Parameter(description = "이벤트 ID", required = true)
+ @PathVariable String eventId,
+
+ @Parameter(description = "조회할 채널 목록 (쉼표로 구분, 미지정 시 전체)")
+ @RequestParam(required = false)
+ String channels,
+
+ @Parameter(description = "정렬 기준 (views, participants, engagement_rate, conversion_rate, roi)")
+ @RequestParam(required = false, defaultValue = "roi")
+ String sortBy,
+
+ @Parameter(description = "정렬 순서 (asc, desc)")
+ @RequestParam(required = false, defaultValue = "desc")
+ String order
+ ) {
+ log.info("채널별 성과 분석 API 호출: eventId={}, sortBy={}", eventId, sortBy);
+
+ List channelList = channels != null && !channels.isBlank()
+ ? Arrays.asList(channels.split(","))
+ : null;
+
+ ChannelAnalyticsResponse response = channelAnalyticsService.getChannelAnalytics(
+ eventId, channelList, sortBy, order
+ );
+
+ return ResponseEntity.ok(ApiResponse.success(response));
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/RoiAnalyticsController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/RoiAnalyticsController.java
new file mode 100644
index 0000000..29d6980
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/RoiAnalyticsController.java
@@ -0,0 +1,54 @@
+package com.kt.event.analytics.controller;
+
+import com.kt.event.analytics.dto.response.RoiAnalyticsResponse;
+import com.kt.event.analytics.service.RoiAnalyticsService;
+import com.kt.event.common.dto.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * ROI Analytics Controller
+ *
+ * 투자 대비 수익률 분석 API
+ */
+@Tag(name = "ROI", description = "투자 대비 수익률 분석 API")
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/events")
+@RequiredArgsConstructor
+public class RoiAnalyticsController {
+
+ private final RoiAnalyticsService roiAnalyticsService;
+
+ /**
+ * 투자 대비 수익률 상세
+ *
+ * @param eventId 이벤트 ID
+ * @param includeProjection 예상 수익 포함 여부
+ * @return ROI 상세 분석
+ */
+ @Operation(
+ summary = "투자 대비 수익률 상세",
+ description = "이벤트의 투자 대비 수익률을 상세하게 분석합니다."
+ )
+ @GetMapping("/{eventId}/analytics/roi")
+ public ResponseEntity> getRoiAnalytics(
+ @Parameter(description = "이벤트 ID", required = true)
+ @PathVariable String eventId,
+
+ @Parameter(description = "예상 수익 포함 여부")
+ @RequestParam(required = false, defaultValue = "true")
+ Boolean includeProjection
+ ) {
+ log.info("ROI 상세 분석 API 호출: eventId={}, includeProjection={}", eventId, includeProjection);
+
+ RoiAnalyticsResponse response = roiAnalyticsService.getRoiAnalytics(eventId, includeProjection);
+
+ return ResponseEntity.ok(ApiResponse.success(response));
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/controller/TimelineAnalyticsController.java b/analytics-service/src/main/java/com/kt/event/analytics/controller/TimelineAnalyticsController.java
new file mode 100644
index 0000000..5fc882f
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/controller/TimelineAnalyticsController.java
@@ -0,0 +1,82 @@
+package com.kt.event.analytics.controller;
+
+import com.kt.event.analytics.dto.response.TimelineAnalyticsResponse;
+import com.kt.event.analytics.service.TimelineAnalyticsService;
+import com.kt.event.common.dto.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Timeline Analytics Controller
+ *
+ * 시간대별 분석 API
+ */
+@Tag(name = "Timeline", description = "시간대별 분석 API")
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/events")
+@RequiredArgsConstructor
+public class TimelineAnalyticsController {
+
+ private final TimelineAnalyticsService timelineAnalyticsService;
+
+ /**
+ * 시간대별 참여 추이
+ *
+ * @param eventId 이벤트 ID
+ * @param interval 시간 간격 단위
+ * @param startDate 조회 시작 날짜
+ * @param endDate 조회 종료 날짜
+ * @param metrics 조회할 지표 목록
+ * @return 시간대별 참여 추이
+ */
+ @Operation(
+ summary = "시간대별 참여 추이",
+ description = "이벤트 기간 동안의 시간대별 참여 추이를 분석합니다."
+ )
+ @GetMapping("/{eventId}/analytics/timeline")
+ public ResponseEntity> getTimelineAnalytics(
+ @Parameter(description = "이벤트 ID", required = true)
+ @PathVariable String eventId,
+
+ @Parameter(description = "시간 간격 단위 (hourly, daily, weekly)")
+ @RequestParam(required = false, defaultValue = "daily")
+ String interval,
+
+ @Parameter(description = "조회 시작 날짜 (ISO 8601 format)")
+ @RequestParam(required = false)
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+ LocalDateTime startDate,
+
+ @Parameter(description = "조회 종료 날짜 (ISO 8601 format)")
+ @RequestParam(required = false)
+ @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
+ LocalDateTime endDate,
+
+ @Parameter(description = "조회할 지표 목록 (쉼표로 구분)")
+ @RequestParam(required = false)
+ String metrics
+ ) {
+ log.info("시간대별 참여 추이 API 호출: eventId={}, interval={}", eventId, interval);
+
+ List metricList = metrics != null && !metrics.isBlank()
+ ? Arrays.asList(metrics.split(","))
+ : null;
+
+ TimelineAnalyticsResponse response = timelineAnalyticsService.getTimelineAnalytics(
+ eventId, interval, startDate, endDate, metricList
+ );
+
+ return ResponseEntity.ok(ApiResponse.success(response));
+ }
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsDashboardResponse.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsDashboardResponse.java
new file mode 100644
index 0000000..9fb9b3e
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsDashboardResponse.java
@@ -0,0 +1,59 @@
+package com.kt.event.analytics.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 이벤트 성과 대시보드 응답
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AnalyticsDashboardResponse {
+
+ /**
+ * 이벤트 ID
+ */
+ private String eventId;
+
+ /**
+ * 이벤트 제목
+ */
+ private String eventTitle;
+
+ /**
+ * 조회 기간 정보
+ */
+ private PeriodInfo period;
+
+ /**
+ * 성과 요약
+ */
+ private AnalyticsSummary summary;
+
+ /**
+ * 채널별 성과 요약
+ */
+ private List channelPerformance;
+
+ /**
+ * ROI 요약
+ */
+ private RoiSummary roi;
+
+ /**
+ * 마지막 업데이트 시간
+ */
+ private LocalDateTime lastUpdatedAt;
+
+ /**
+ * 데이터 출처 (real-time, cached, fallback)
+ */
+ private String dataSource;
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsSummary.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsSummary.java
new file mode 100644
index 0000000..e4fb561
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/AnalyticsSummary.java
@@ -0,0 +1,51 @@
+package com.kt.event.analytics.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 성과 요약
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AnalyticsSummary {
+
+ /**
+ * 총 참여자 수
+ */
+ private Integer totalParticipants;
+
+ /**
+ * 총 조회수
+ */
+ private Integer totalViews;
+
+ /**
+ * 총 도달 수
+ */
+ private Integer totalReach;
+
+ /**
+ * 참여율 (%)
+ */
+ private Double engagementRate;
+
+ /**
+ * 전환율 (%)
+ */
+ private Double conversionRate;
+
+ /**
+ * 평균 참여 시간 (초)
+ */
+ private Integer averageEngagementTime;
+
+ /**
+ * SNS 반응 통계
+ */
+ private SocialInteractionStats socialInteractions;
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelAnalytics.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelAnalytics.java
new file mode 100644
index 0000000..51dccaa
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelAnalytics.java
@@ -0,0 +1,46 @@
+package com.kt.event.analytics.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 채널별 상세 분석
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ChannelAnalytics {
+
+ /**
+ * 채널명
+ */
+ private String channelName;
+
+ /**
+ * 채널 유형
+ */
+ private String channelType;
+
+ /**
+ * 채널 지표
+ */
+ private ChannelMetrics metrics;
+
+ /**
+ * 성과 지표
+ */
+ private ChannelPerformance performance;
+
+ /**
+ * 비용 정보
+ */
+ private ChannelCosts costs;
+
+ /**
+ * 외부 API 연동 상태 (success, fallback, failed)
+ */
+ private String externalApiStatus;
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelAnalyticsResponse.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelAnalyticsResponse.java
new file mode 100644
index 0000000..2bd8f0c
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelAnalyticsResponse.java
@@ -0,0 +1,39 @@
+package com.kt.event.analytics.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 채널별 성과 분석 응답
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ChannelAnalyticsResponse {
+
+ /**
+ * 이벤트 ID
+ */
+ private String eventId;
+
+ /**
+ * 채널별 상세 분석
+ */
+ private List channels;
+
+ /**
+ * 채널 간 비교 분석
+ */
+ private ChannelComparison comparison;
+
+ /**
+ * 마지막 업데이트 시간
+ */
+ private LocalDateTime lastUpdatedAt;
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelComparison.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelComparison.java
new file mode 100644
index 0000000..24d2584
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelComparison.java
@@ -0,0 +1,28 @@
+package com.kt.event.analytics.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Map;
+
+/**
+ * 채널 간 비교 분석
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ChannelComparison {
+
+ /**
+ * 최고 성과 채널
+ */
+ private Map bestPerforming;
+
+ /**
+ * 전체 채널 평균 지표
+ */
+ private Map averageMetrics;
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelCosts.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelCosts.java
new file mode 100644
index 0000000..d74e647
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelCosts.java
@@ -0,0 +1,43 @@
+package com.kt.event.analytics.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+
+/**
+ * 채널별 비용
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ChannelCosts {
+
+ /**
+ * 배포 비용 (원)
+ */
+ private BigDecimal distributionCost;
+
+ /**
+ * 조회당 비용 (CPV, 원)
+ */
+ private Double costPerView;
+
+ /**
+ * 클릭당 비용 (CPC, 원)
+ */
+ private Double costPerClick;
+
+ /**
+ * 고객 획득 비용 (CPA, 원)
+ */
+ private Double costPerAcquisition;
+
+ /**
+ * ROI (%)
+ */
+ private Double roi;
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelMetrics.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelMetrics.java
new file mode 100644
index 0000000..0029a71
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelMetrics.java
@@ -0,0 +1,51 @@
+package com.kt.event.analytics.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 채널 지표
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ChannelMetrics {
+
+ /**
+ * 노출 수
+ */
+ private Integer impressions;
+
+ /**
+ * 조회수
+ */
+ private Integer views;
+
+ /**
+ * 클릭 수
+ */
+ private Integer clicks;
+
+ /**
+ * 참여자 수
+ */
+ private Integer participants;
+
+ /**
+ * 전환 수
+ */
+ private Integer conversions;
+
+ /**
+ * SNS 반응 통계
+ */
+ private SocialInteractionStats socialInteractions;
+
+ /**
+ * 링고비즈 통화 통계
+ */
+ private VoiceCallStats voiceCallStats;
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelPerformance.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelPerformance.java
new file mode 100644
index 0000000..0e4db39
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelPerformance.java
@@ -0,0 +1,41 @@
+package com.kt.event.analytics.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 채널 성과 지표
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ChannelPerformance {
+
+ /**
+ * 클릭률 (CTR, %)
+ */
+ private Double clickThroughRate;
+
+ /**
+ * 참여율 (%)
+ */
+ private Double engagementRate;
+
+ /**
+ * 전환율 (%)
+ */
+ private Double conversionRate;
+
+ /**
+ * 평균 참여 시간 (초)
+ */
+ private Integer averageEngagementTime;
+
+ /**
+ * 이탈율 (%)
+ */
+ private Double bounceRate;
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelSummary.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelSummary.java
new file mode 100644
index 0000000..49e99da
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/ChannelSummary.java
@@ -0,0 +1,46 @@
+package com.kt.event.analytics.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 채널별 성과 요약
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ChannelSummary {
+
+ /**
+ * 채널명
+ */
+ private String channelName;
+
+ /**
+ * 조회수
+ */
+ private Integer views;
+
+ /**
+ * 참여자 수
+ */
+ private Integer participants;
+
+ /**
+ * 참여율 (%)
+ */
+ private Double engagementRate;
+
+ /**
+ * 전환율 (%)
+ */
+ private Double conversionRate;
+
+ /**
+ * ROI (%)
+ */
+ private Double roi;
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/CostEfficiency.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/CostEfficiency.java
new file mode 100644
index 0000000..7c3919b
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/CostEfficiency.java
@@ -0,0 +1,36 @@
+package com.kt.event.analytics.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 비용 효율성
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CostEfficiency {
+
+ /**
+ * 참여자당 비용 (원)
+ */
+ private Double costPerParticipant;
+
+ /**
+ * 전환당 비용 (원)
+ */
+ private Double costPerConversion;
+
+ /**
+ * 조회당 비용 (원)
+ */
+ private Double costPerView;
+
+ /**
+ * 참여자당 수익 (원)
+ */
+ private Double revenuePerParticipant;
+}
diff --git a/analytics-service/src/main/java/com/kt/event/analytics/dto/response/InvestmentDetails.java b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/InvestmentDetails.java
new file mode 100644
index 0000000..abff813
--- /dev/null
+++ b/analytics-service/src/main/java/com/kt/event/analytics/dto/response/InvestmentDetails.java
@@ -0,0 +1,45 @@
+package com.kt.event.analytics.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 투자 비용 상세
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class InvestmentDetails {
+
+ /**
+ * 콘텐츠 제작비 (원)
+ */
+ private BigDecimal contentCreation;
+
+ /**
+ * 배포 비용 (원)
+ */
+ private BigDecimal distribution;
+
+ /**
+ * 운영 비용 (원)
+ */
+ private BigDecimal operation;
+
+ /**
+ * 총 투자 비용 (원)
+ */
+ private BigDecimal total;
+
+ /**
+ * 채널별 비용 상세
+ */
+ private List