Compare commits

14 Commits

Author SHA1 Message Date
Hyowon Yang f80418f5ee 이벤트별 성과분석 날짜 로직 수정 및 설정 개선
- EventCreatedEvent, EventStats에 startDate, endDate 필드 추가
- EventCreatedConsumer에서 이벤트 시작/종료 날짜 저장
- SampleDataLoader에서 실제 날짜로 이벤트 발행
  - evt_2025012301: 2025-01-23 시작 (ACTIVE)
  - evt_2025020101: 2025-02-01 시작 (ACTIVE)
  - evt_2025011501: 2025-01-15~2025-01-31 (COMPLETED)
- AnalyticsService: 이벤트 시작일~종료일(또는 현재) 기간 계산
- UserAnalyticsService: 가장 빠른 이벤트 시작일~현재 기간 계산
- application.yml에서 중복된 context-path 제거
- Consumer Group ID를 analytics-service-consumers-v3로 통일

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 12:47:19 +09:00
Hyowon Yang 108ee10293 Merge branch 'develop' into feature/analytics 2025-10-29 19:31:10 +09:00
Hyowon Yang 20e0d24930 이벤트별 성과분석 대시보드 상세 정보 추가 및 Timeline 날짜 수정
## 주요 변경사항

### 1. Timeline 데이터 날짜 로직 수정
- **파일**: SampleDataLoader.java
- **변경**: 이벤트 ID에서 날짜를 파싱하여 실제 이벤트 시작일 기준으로 Timeline 생성
  - 기존: 모든 이벤트가 2024-09-24부터 시작
  - 수정: evt_2025012301 → 2025-01-23부터 30일치 생성
- **채널 분포**: 가중치 기반 랜덤 배정으로 변경
  - SNS: 45% (최고 비율)
  - 우리동네TV: 25%
  - 지니TV: 20%
  - 링고비즈: 10%

### 2. 이벤트별 API 상세 정보 추가
- **파일**: AnalyticsDashboardResponse.java
- **추가 필드**:
  - investment: InvestmentDetails (투자 비용 상세)
  - revenue: RevenueDetails (수익 상세)
  - costEfficiency: CostEfficiency (비용 효율성)

### 3. 이벤트별 상세 계산 로직 구현
- **파일**: AnalyticsService.java
- **추가 메서드**:
  - buildInvestmentDetails(): 투자 비용 상세 계산
    - 경품비용 50%, 콘텐츠제작비 30%, 운영비 20%, 채널배포비용(실제)
  - buildRevenueDetails(): 수익 상세 계산
    - 직접매출 70%, 예상추가매출 30%, 신규고객 40%, 기존고객 60%
  - buildCostEfficiency(): 비용 효율성 계산
    - 참여자당 비용, 참여자당 수익

### 4. ROI 전용 API 필드 수정
- **파일**: ROICalculator.java
- **수정**: UserRoiAnalyticsService와 동일한 비율 적용
  - investmentDetails에 prizeCost, channelCost 추가
  - revenueDetails에 newCustomerRevenue, existingCustomerRevenue 추가
- **기존 문제**: null 값 반환
- **해결**: 통합분석과 동일한 계산 로직 적용

## API 응답 구조

### GET /api/v1/events/{eventId}/analytics
```json
{
  "investment": {
    "total": 5000000,
    "prizeCost": 1250000,
    "contentCreation": 750000,
    "operation": 500000,
    "distribution": 2500000,
    "channelCost": 2500000
  },
  "revenue": {
    "total": 15000000,
    "directSales": 10500000,
    "expectedSales": 4500000,
    "newCustomerRevenue": 6000000,
    "existingCustomerRevenue": 9000000
  },
  "costEfficiency": {
    "costPerParticipant": 50000,
    "revenuePerParticipant": 150000
  }
}
```

## 테스트 결과
-  Timeline 날짜가 이벤트별로 정확하게 생성됨
-  채널별 참여자 분포가 가중치대로 배정됨
-  이벤트별 API에서 상세 투자/수익 정보 제공
-  ROI API에서 null 값 문제 해결

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 19:28:58 +09:00
wonho 640e94bf17 user-service CORS 및 경로 매핑 수정
- SecurityConfig: CORS 설정 개선 및 context-path 기반 경로 수정
- UserController: RequestMapping 중복 경로 제거
- SwaggerConfig: Production 서버 URL 추가

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 18:25:09 +09:00
Hyowon Yang 98ed508a6f User-level Analytics API 구현 및 Kafka Consumer 설정 개선
주요 변경사항:
- User-level Analytics API 기간 파라미터 제거 (전체 기간 자동 계산)
  * /api/v1/users/{userId}/analytics/dashboard
  * /api/v1/users/{userId}/analytics/channels
  * /api/v1/users/{userId}/analytics/roi
  * /api/v1/users/{userId}/analytics/timeline

- Kafka Consumer 안정성 개선
  * Consumer Group ID를 analytics-service-consumers-v3로 변경
  * Redis 멱등성 키 v2 버전 사용 (processed_events_v2, distribution_completed_v2, processed_participants_v2)
  * ParticipantRegisteredConsumer 멱등성 키를 eventId:participantId 조합으로 변경하여 중복 방지 강화

- 설정 개선
  * UTF-8 인코딩 명시적 설정 추가
  * Kafka auto.offset.reset 설정 명확화

- 테스트 도구 추가
  * tools/reset-analytics-data.ps1: 테스트 데이터 초기화 스크립트
  * DebugController: 개발 환경 디버깅용 엔드포인트

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 18:07:20 +09:00
wonho e8d0a1d4b4 백엔드 서비스 설정 및 CORS 정책 업데이트
- CORS 설정에 https 프로토콜 지원 추가
- User-Service CORS를 모든 Origin 허용으로 변경
- ConfigMap CORS_ALLOWED_ORIGINS 확장
- User-Service DB migration 스크립트 추가
- Application 설정 파일 업데이트

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 17:59:01 +09:00
wonho 857fa5501c GitHub Actions workflow push 이벤트 비활성화
- push 트리거를 주석 처리하여 자동 실행 방지
- Pull Request 생성 시에만 자동 실행
- 수동 실행(workflow_dispatch)은 계속 가능

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 17:59:01 +09:00
kkkd-max ab39c76585 Merge pull request #26 from ktds-dg0501/feature/partici
url추가
2025-10-29 17:54:02 +09:00
jhbkjh 1e38d52967 url추가 2025-10-29 17:53:32 +09:00
이선민 6205a98ca0 Merge pull request #25 from ktds-dg0501/feature/distribution
api path 수정_2
2025-10-29 17:03:47 +09:00
sunmingLee ebd7ae12b6 api path 추가수정 2025-10-29 17:02:25 +09:00
sunmingLee 2cd1ba76f5 api path 수정 2025-10-29 16:44:07 +09:00
hyeda2020 a41e431daf Disable test execution in CI workflow
Comment out the test execution step in the CI workflow.
2025-10-29 16:11:28 +09:00
wonho 3da9303091 백엔드 서비스 설정 및 배포 구성 개선
- CORS 설정 업데이트 (모든 서비스)
- Swagger UI 경로 및 설정 수정
- Kubernetes 배포 설정 개선 (Ingress, Deployment)
- distribution-service SecurityConfig 및 Controller 개선
- IntelliJ 실행 프로파일 업데이트
- 컨테이너 이미지 빌드 문서화 (deployment/container/build-image.md)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:55:30 +09:00
56 changed files with 1230 additions and 609 deletions
@@ -41,21 +41,21 @@ spec:
memory: "1024Mi"
startupProbe:
httpGet:
path: /actuator/health
path: /api/v1/distribution/actuator/health
port: 8085
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 30
readinessProbe:
httpGet:
path: /actuator/health/readiness
path: /api/v1/distribution/actuator/health/readiness
port: 8085
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /actuator/health/liveness
path: /api/v1/distribution/actuator/health/liveness
port: 8085
initialDelaySeconds: 30
periodSeconds: 10
+10 -10
View File
@@ -1,14 +1,14 @@
name: Backend CI/CD Pipeline
on:
push:
branches:
- develop
- main
paths:
- '*-service/**'
- '.github/workflows/backend-cicd.yaml'
- '.github/kustomize/**'
# push:
# branches:
# - develop
# - main
# paths:
# - '*-service/**'
# - '.github/workflows/backend-cicd.yaml'
# - '.github/kustomize/**'
pull_request:
branches:
- develop
@@ -107,8 +107,8 @@ jobs:
- name: Build with Gradle
run: ./gradlew ${{ matrix.service }}:build -x test
- name: Run tests
run: ./gradlew ${{ matrix.service }}:test
# - name: Run tests
# run: ./gradlew ${{ matrix.service }}:test
- name: Build JAR
run: ./gradlew ${{ matrix.service }}:bootJar
+1 -1
View File
@@ -24,7 +24,7 @@
<!-- Kafka Configuration (원격 서버) -->
<entry key="KAFKA_ENABLED" value="true" />
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers" />
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers-v3" />
<!-- Sample Data Configuration (MVP Only) -->
<!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) -->
@@ -24,7 +24,7 @@
<!-- Kafka Configuration (원격 서버) -->
<entry key="KAFKA_ENABLED" value="true" />
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers" />
<entry key="KAFKA_CONSUMER_GROUP_ID" value="analytics-service-consumers-v3" />
<!-- Sample Data Configuration (MVP Only) -->
<!-- ⚠️ Kafka Producer로 이벤트 발행 (Consumer가 처리) -->
@@ -39,7 +39,7 @@
<entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400" />
<!-- CORS Configuration -->
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*" />
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*,http://*.nip.io:*" />
<!-- Logging Configuration -->
<entry key="LOG_FILE" value="logs/analytics-service.log" />
@@ -63,7 +63,7 @@ public class AnalyticsBatchScheduler {
event.getEventId(), event.getEventTitle());
// refresh=true로 호출하여 캐시 갱신 및 외부 API 호출
analyticsService.getDashboardData(event.getEventId(), null, null, true);
analyticsService.getDashboardData(event.getEventId(), true);
successCount++;
log.info("✅ 배치 갱신 완료: eventId={}", event.getEventId());
@@ -99,7 +99,7 @@ public class AnalyticsBatchScheduler {
for (EventStats event : allEvents) {
try {
analyticsService.getDashboardData(event.getEventId(), null, null, true);
analyticsService.getDashboardData(event.getEventId(), true);
log.debug("초기 데이터 로딩 완료: eventId={}", event.getEventId());
} catch (Exception e) {
log.warn("초기 데이터 로딩 실패: eventId={}, error={}",
@@ -17,13 +17,13 @@ import java.util.Map;
* Kafka Consumer 설정
*/
@Configuration
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = true)
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
public class KafkaConsumerConfig {
@Value("${spring.kafka.bootstrap-servers}")
private String bootstrapServers;
@Value("${spring.kafka.consumer.group-id:analytics-service}")
@Value("${spring.kafka.consumer.group-id:analytics-service-consumers-v3}")
private String groupId;
@Bean
@@ -0,0 +1,46 @@
package com.kt.event.analytics.config;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
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.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import java.util.HashMap;
import java.util.Map;
/**
* Kafka Producer 설정
*
* ⚠️ MVP 전용: SampleDataLoader가 Kafka 이벤트를 발행하기 위해 필요
* ⚠️ 실제 운영: Analytics Service는 순수 Consumer 역할만 수행하므로 Producer 불필요
*
* String 직렬화 방식 사용 (SampleDataLoader가 JSON 문자열을 직접 발행)
*/
@Configuration
@ConditionalOnProperty(name = "spring.kafka.enabled", havingValue = "true", matchIfMissing = false)
public class KafkaProducerConfig {
@Value("${spring.kafka.bootstrap-servers}")
private String bootstrapServers;
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> configProps = new HashMap<>();
configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.ACKS_CONFIG, "all");
configProps.put(ProducerConfig.RETRIES_CONFIG, 3);
return new DefaultKafkaProducerFactory<>(configProps);
}
@Bean
public KafkaTemplate<String, String> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}
@@ -11,19 +11,23 @@ import jakarta.annotation.PreDestroy;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.admin.AdminClient;
import org.apache.kafka.clients.admin.DeleteConsumerGroupOffsetsResult;
import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsResult;
import org.apache.kafka.common.TopicPartition;
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.beans.factory.annotation.Value;
import org.springframework.kafka.core.KafkaAdmin;
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;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* 샘플 데이터 로더 (Kafka Producer 방식)
@@ -47,6 +51,7 @@ import java.util.UUID;
public class SampleDataLoader implements ApplicationRunner {
private final KafkaTemplate<String, String> kafkaTemplate;
private final KafkaAdmin kafkaAdmin;
private final ObjectMapper objectMapper;
private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
@@ -56,6 +61,9 @@ public class SampleDataLoader implements ApplicationRunner {
private final Random random = new Random();
@Value("${spring.kafka.consumer.group-id}")
private String consumerGroupId;
// Kafka Topic Names (MVP용 샘플 토픽)
private static final String EVENT_CREATED_TOPIC = "sample.event.created";
private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered";
@@ -85,9 +93,9 @@ public class SampleDataLoader implements ApplicationRunner {
// Redis 멱등성 키 삭제 (새로운 이벤트 처리를 위해)
log.info("Redis 멱등성 키 삭제 중...");
redisTemplate.delete("processed_events");
redisTemplate.delete("distribution_completed");
redisTemplate.delete("processed_participants");
redisTemplate.delete("processed_events_v2");
redisTemplate.delete("distribution_completed_v2");
redisTemplate.delete("processed_participants_v2");
log.info("✅ Redis 멱등성 키 삭제 완료");
try {
@@ -103,6 +111,8 @@ public class SampleDataLoader implements ApplicationRunner {
// 3. ParticipantRegistered 이벤트 발행 (각 이벤트당 다수 참여자)
publishParticipantRegisteredEvents();
log.info("⏳ 참여자 등록 이벤트 처리 대기 중... (20초)");
Thread.sleep(20000); // ParticipantRegisteredConsumer가 180개 이벤트 처리할 시간 (비관적 락 고려)
log.info("========================================");
log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)");
@@ -127,16 +137,17 @@ public class SampleDataLoader implements ApplicationRunner {
}
/**
* 서비스 종료 시 전체 데이터 삭제
* 서비스 종료 시 전체 데이터 삭제 및 Consumer Offset 리셋
*/
@PreDestroy
@Transactional
public void onShutdown() {
log.info("========================================");
log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제");
log.info("🛑 서비스 종료: PostgreSQL 전체 데이터 삭제 + Kafka Consumer Offset 리셋");
log.info("========================================");
try {
// 1. PostgreSQL 데이터 삭제
long timelineCount = timelineDataRepository.count();
long channelCount = channelStatsRepository.count();
long eventCount = eventStatsRepository.count();
@@ -153,6 +164,10 @@ public class SampleDataLoader implements ApplicationRunner {
entityManager.clear();
log.info("✅ 모든 샘플 데이터 삭제 완료!");
// 2. Kafka Consumer Offset 리셋 (다음 시작 시 처음부터 읽도록)
resetConsumerOffsets();
log.info("========================================");
} catch (Exception e) {
@@ -160,37 +175,85 @@ public class SampleDataLoader implements ApplicationRunner {
}
}
/**
* Kafka Consumer Group Offset 리셋
*
* 서비스 종료 시 Consumer offset을 삭제하여 다음 시작 시
* auto.offset.reset=earliest 설정에 따라 처음부터 읽도록 함
*/
private void resetConsumerOffsets() {
try (AdminClient adminClient = AdminClient.create(kafkaAdmin.getConfigurationProperties())) {
log.info("🔄 Kafka Consumer Offset 리셋 시작: group={}", consumerGroupId);
// 모든 토픽의 offset 삭제
Set<TopicPartition> partitions = new HashSet<>();
// 토픽별 파티션 추가 (설계서상 각 토픽은 3개 파티션)
for (int i = 0; i < 3; i++) {
partitions.add(new TopicPartition(EVENT_CREATED_TOPIC, i));
partitions.add(new TopicPartition(PARTICIPANT_REGISTERED_TOPIC, i));
partitions.add(new TopicPartition(DISTRIBUTION_COMPLETED_TOPIC, i));
}
// Consumer Group Offset 삭제
DeleteConsumerGroupOffsetsResult result = adminClient.deleteConsumerGroupOffsets(
consumerGroupId,
partitions
);
// 완료 대기 (최대 10초)
result.all().get(10, TimeUnit.SECONDS);
log.info("✅ Kafka Consumer Offset 리셋 완료!");
log.info(" → 다음 시작 시 처음부터(earliest) 메시지를 읽습니다.");
} catch (Exception e) {
// Offset 리셋 실패는 치명적이지 않으므로 경고만 출력
log.warn("⚠️ Kafka Consumer Offset 리셋 실패 (무시 가능): {}", e.getMessage());
log.warn(" → 수동으로 Consumer Group ID를 변경하거나, Kafka 도구로 offset을 삭제하세요.");
}
}
/**
* EventCreated 이벤트 발행
*/
private void publishEventCreatedEvents() throws Exception {
// 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과)
// 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과 - ROI 200%)
EventCreatedEvent event1 = EventCreatedEvent.builder()
.eventId("evt_2025012301")
.eventTitle("신년맞이 20% 할인 이벤트")
.storeId("store_001")
.totalInvestment(new BigDecimal("5000000"))
.expectedRevenue(new BigDecimal("15000000")) // 투자 대비 3배 수익
.status("ACTIVE")
.startDate(java.time.LocalDateTime.of(2025, 1, 23, 0, 0)) // 2025-01-23 시작
.endDate(null) // 진행중
.build();
publishEvent(EVENT_CREATED_TOPIC, event1);
// 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과)
// 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과 - ROI 100%)
EventCreatedEvent event2 = EventCreatedEvent.builder()
.eventId("evt_2025020101")
.eventTitle("설날 특가 선물세트 이벤트")
.storeId("store_001")
.totalInvestment(new BigDecimal("3500000"))
.expectedRevenue(new BigDecimal("7000000")) // 투자 대비 2배 수익
.status("ACTIVE")
.startDate(java.time.LocalDateTime.of(2025, 2, 1, 0, 0)) // 2025-02-01 시작
.endDate(null) // 진행중
.build();
publishEvent(EVENT_CREATED_TOPIC, event2);
// 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과)
// 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과 - ROI 50%)
EventCreatedEvent event3 = EventCreatedEvent.builder()
.eventId("evt_2025011501")
.eventTitle("겨울 신메뉴 런칭 이벤트")
.storeId("store_001")
.totalInvestment(new BigDecimal("2000000"))
.expectedRevenue(new BigDecimal("3000000")) // 투자 대비 1.5배 수익
.status("COMPLETED")
.startDate(java.time.LocalDateTime.of(2025, 1, 15, 0, 0)) // 2025-01-15 시작
.endDate(java.time.LocalDateTime.of(2025, 1, 31, 23, 59)) // 2025-01-31 종료
.build();
publishEvent(EVENT_CREATED_TOPIC, event3);
@@ -208,42 +271,63 @@ public class SampleDataLoader implements ApplicationRunner {
{1500, 3000, 1000, 500} // 이벤트3
};
// 각 이벤트의 총 투자 금액
BigDecimal[] totalInvestments = {
new BigDecimal("5000000"), // 이벤트1: 500만원
new BigDecimal("3500000"), // 이벤트2: 350만원
new BigDecimal("2000000") // 이벤트3: 200만원
};
// 채널 배포는 총 투자의 50%만 사용 (나머지는 경품/콘텐츠/운영비용)
double channelBudgetRatio = 0.50;
// 채널별 비용 비율 (채널 예산 내에서: 우리동네TV 30%, 지니TV 30%, 링고비즈 25%, SNS 15%)
double[] costRatios = {0.30, 0.30, 0.25, 0.15};
for (int i = 0; i < eventIds.length; i++) {
String eventId = eventIds[i];
BigDecimal totalInvestment = totalInvestments[i];
// 채널 배포 예산: 총 투자의 50%
BigDecimal channelBudget = totalInvestment.multiply(BigDecimal.valueOf(channelBudgetRatio));
// 4개 채널을 배열로 구성
List<DistributionCompletedEvent.ChannelDistribution> channels = new ArrayList<>();
// 1. 우리동네TV (TV)
// 1. 우리동네TV (TV) - 채널 예산의 30%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("우리동네TV")
.channelType("TV")
.status("SUCCESS")
.expectedViews(expectedViews[i][0])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[0])))
.build());
// 2. 지니TV (TV)
// 2. 지니TV (TV) - 채널 예산의 30%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("지니TV")
.channelType("TV")
.status("SUCCESS")
.expectedViews(expectedViews[i][1])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[1])))
.build());
// 3. 링고비즈 (CALL)
// 3. 링고비즈 (CALL) - 채널 예산의 25%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("링고비즈")
.channelType("CALL")
.status("SUCCESS")
.expectedViews(expectedViews[i][2])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[2])))
.build());
// 4. SNS (SNS)
// 4. SNS (SNS) - 채널 예산의 15%
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
.channel("SNS")
.channelType("SNS")
.status("SUCCESS")
.expectedViews(expectedViews[i][3])
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[3])))
.build());
// 이벤트 발행 (채널 배열 포함)
@@ -261,22 +345,53 @@ public class SampleDataLoader implements ApplicationRunner {
/**
* ParticipantRegistered 이벤트 발행
*
* 현실적인 참여 패턴 반영:
* - 총 120명의 고유 참여자 풀 생성
* - 일부 참여자는 여러 이벤트에 중복 참여
* - 이벤트1: 100명 (user001~user100)
* - 이벤트2: 50명 (user051~user100) → 50명이 이벤트1과 중복
* - 이벤트3: 30명 (user071~user100) → 30명이 이전 이벤트들과 중복
*/
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[][] participantRanges = {
{1, 100}, // 이벤트1: user001~user100 (100명)
{51, 100}, // 이벤트2: user051~user100 (50명, 이벤트1과 50명 중복)
{71, 100} // 이벤트3: user071~user100 (30명, 모두 중복)
};
int totalPublished = 0;
for (int i = 0; i < eventIds.length; i++) {
String eventId = eventIds[i];
int participants = totalParticipants[i];
int startUser = participantRanges[i][0];
int endUser = participantRanges[i][1];
int eventParticipants = endUser - startUser + 1;
// 각 이벤트에 대해 참여자 수만큼 ParticipantRegistered 이벤트 발행
for (int j = 0; j < participants; j++) {
String participantId = UUID.randomUUID().toString();
String channel = channels[j % channels.length]; // 채널 순환 배정
log.info("이벤트 {} 참여자 발행 시작: user{:03d}~user{:03d} ({}명)",
eventId, startUser, endUser, eventParticipants);
// 각 참여자에 대해 ParticipantRegistered 이벤트 발행
for (int userId = startUser; userId <= endUser; userId++) {
String participantId = String.format("user%03d", userId); // user001, user002, ...
// 채널별 가중치 기반 랜덤 배정
// SNS: 45%, 우리동네TV: 25%, 지니TV: 20%, 링고비즈: 10%
int randomValue = random.nextInt(100);
String channel;
if (randomValue < 45) {
channel = "SNS"; // 0~44: 45%
} else if (randomValue < 70) {
channel = "우리동네TV"; // 45~69: 25%
} else if (randomValue < 90) {
channel = "지니TV"; // 70~89: 20%
} else {
channel = "링고비즈"; // 90~99: 10%
}
ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder()
.eventId(eventId)
@@ -288,19 +403,38 @@ public class SampleDataLoader implements ApplicationRunner {
totalPublished++;
// 동시성 충돌 방지: 10개마다 100ms 대기
if ((j + 1) % 10 == 0) {
if (totalPublished % 10 == 0) {
Thread.sleep(100);
}
}
log.info("✅ 이벤트 {} 참여자 발행 완료: {}명", eventId, eventParticipants);
}
log.info("========================================");
log.info("✅ ParticipantRegistered 이벤트 {}건 발행 완료", totalPublished);
log.info("📊 참여 패턴:");
log.info(" - 총 고유 참여자: 100명 (user001~user100)");
log.info(" - 이벤트1 참여: 100명");
log.info(" - 이벤트2 참여: 50명 (이벤트1과 50명 중복)");
log.info(" - 이벤트3 참여: 30명 (이벤트1,2와 모두 중복)");
log.info(" - 3개 이벤트 모두 참여: 30명");
log.info(" - 2개 이벤트 참여: 20명");
log.info(" - 1개 이벤트만 참여: 50명");
log.info("📺 채널별 참여 비율 (가중치):");
log.info(" - SNS: 45% (가장 높음)");
log.info(" - 우리동네TV: 25%");
log.info(" - 지니TV: 20%");
log.info(" - 링고비즈: 10%");
log.info("========================================");
}
/**
* TimelineData 생성 (시간대별 샘플 데이터)
*
* - 각 이벤트마다 30일 치 daily 데이터 생성
* - 각 이벤트마다 30일 × 24시간 = 720시간 치 hourly 데이터 생성
* - interval=hourly: 시간별 표시 (최근 7일 적합)
* - interval=daily: 일별 자동 집계 (30일 전체)
* - 참여자 수, 조회수, 참여행동, 전환수, 누적 참여자 수
*/
private void createTimelineData() {
@@ -308,52 +442,63 @@ public class SampleDataLoader implements ApplicationRunner {
String[] eventIds = {"evt_2025012301", "evt_2025020101", "evt_2025011501"};
// 각 이벤트별 기준 참여자 수 (이벤트 성과에 따라 다름)
int[] baseParticipants = {20, 12, 5}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음)
// 각 이벤트별 시간당 기준 참여자 수 (이벤트 성과에 따라 다름)
int[] baseParticipantsPerHour = {4, 2, 1}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음)
for (int eventIndex = 0; eventIndex < eventIds.length; eventIndex++) {
String eventId = eventIds[eventIndex];
int baseParticipant = baseParticipants[eventIndex];
int baseParticipant = baseParticipantsPerHour[eventIndex];
int cumulativeParticipants = 0;
// 30일 치 데이터 생성 (2024-09-24부터)
java.time.LocalDateTime startDate = java.time.LocalDateTime.of(2024, 9, 24, 0, 0);
// 이벤트 ID에서 날짜 파싱 (evt_2025012301 → 2025-01-23)
String dateStr = eventId.substring(4); // "2025012301"
int year = Integer.parseInt(dateStr.substring(0, 4)); // 2025
int month = Integer.parseInt(dateStr.substring(4, 6)); // 01
int day = Integer.parseInt(dateStr.substring(6, 8)); // 23
for (int day = 0; day < 30; day++) {
java.time.LocalDateTime timestamp = startDate.plusDays(day);
// 이벤트 시작일부터 30일 치 hourly 데이터 생성
java.time.LocalDateTime startDate = java.time.LocalDateTime.of(year, month, day, 0, 0);
// 랜덤한 참여자 수 생성 (기준값 ± 50%)
int dailyParticipants = baseParticipant + random.nextInt(baseParticipant + 1);
cumulativeParticipants += dailyParticipants;
for (int dayOffset = 0; dayOffset < 30; dayOffset++) {
for (int hour = 0; hour < 24; hour++) {
java.time.LocalDateTime timestamp = startDate.plusDays(dayOffset).plusHours(hour);
// 조회수는 참여자의 3~5배
int dailyViews = dailyParticipants * (3 + random.nextInt(3));
// 시간대별 참여자 수 변화 (낮 시간대 12~20시에 더 많음)
int hourMultiplier = (hour >= 12 && hour <= 20) ? 2 : 1;
int hourlyParticipants = (baseParticipant * hourMultiplier) + random.nextInt(baseParticipant + 1);
// 참여행동은 참여자의 1~2배
int dailyEngagement = dailyParticipants * (1 + random.nextInt(2));
cumulativeParticipants += hourlyParticipants;
// 전환수는 참여자의 50~80%
int dailyConversions = (int) (dailyParticipants * (0.5 + random.nextDouble() * 0.3));
// 조회수는 참여자의 3~5배
int hourlyViews = hourlyParticipants * (3 + random.nextInt(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();
// 참여행동은 참여자의 1~2배
int hourlyEngagement = hourlyParticipants * (1 + random.nextInt(2));
timelineDataRepository.save(timelineData);
// 전환수는 참여자의 50~80%
int hourlyConversions = (int) (hourlyParticipants * (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(hourlyParticipants)
.views(hourlyViews)
.engagement(hourlyEngagement)
.conversions(hourlyConversions)
.cumulativeParticipants(cumulativeParticipants)
.build();
timelineDataRepository.save(timelineData);
}
}
log.info("✅ TimelineData 생성 완료: eventId={}, 30일 데이터", eventId);
log.info("✅ TimelineData 생성 완료: eventId={}, 시작일={}-{:02d}-{:02d}, 30일 × 24시간 = 720건",
eventId, year, month, day);
}
log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 = 90건");
log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 × 24시간 = 2,160건");
}
/**
@@ -31,31 +31,19 @@ public class AnalyticsDashboardController {
/**
* 성과 대시보드 조회
*
* @param eventId 이벤트 ID
* @param startDate 조회 시작 날짜
* @param endDate 조회 종료 날짜
* @param refresh 캐시 갱신 여부
* @return 성과 대시보드
* @param eventId 이벤트 ID
* @param refresh 캐시 갱신 여부
* @return 성과 대시보드 (이벤트 시작일 ~ 현재까지)
*/
@Operation(
summary = "성과 대시보드 조회",
description = "이벤트의 전체 성과를 통합하여 조회합니다."
description = "이벤트의 전체 성과를 통합하여 조회합니다. (이벤트 시작일 ~ 현재까지)"
)
@GetMapping("/{eventId}/analytics")
public ResponseEntity<ApiResponse<AnalyticsDashboardResponse>> 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
@@ -63,7 +51,7 @@ public class AnalyticsDashboardController {
log.info("성과 대시보드 조회 API 호출: eventId={}, refresh={}", eventId, refresh);
AnalyticsDashboardResponse response = analyticsService.getDashboardData(
eventId, startDate, endDate, refresh
eventId, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
@@ -0,0 +1,75 @@
package com.kt.event.analytics.controller;
import com.kt.event.analytics.config.SampleDataLoader;
import com.kt.event.common.dto.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 디버그 컨트롤러
*
* ⚠️ 개발/테스트 전용
*/
@Tag(name = "Debug", description = "디버그 API (개발/테스트 전용)")
@Slf4j
@RestController
@RequestMapping("/api/debug")
@RequiredArgsConstructor
public class DebugController {
private final SampleDataLoader sampleDataLoader;
/**
* 샘플 데이터 수동 생성
*/
@Operation(
summary = "샘플 데이터 수동 생성",
description = "SampleDataLoader를 수동으로 실행하여 샘플 데이터를 생성합니다."
)
@PostMapping("/reload-sample-data")
public ResponseEntity<ApiResponse<String>> reloadSampleData() {
try {
log.info("🔧 수동으로 샘플 데이터 생성 요청");
// SampleDataLoader 실행
sampleDataLoader.run(new ApplicationArguments() {
@Override
public String[] getSourceArgs() {
return new String[0];
}
@Override
public java.util.Set<String> getOptionNames() {
return java.util.Collections.emptySet();
}
@Override
public boolean containsOption(String name) {
return false;
}
@Override
public java.util.List<String> getOptionValues(String name) {
return null;
}
@Override
public java.util.List<String> getNonOptionArgs() {
return java.util.Collections.emptyList();
}
});
return ResponseEntity.ok(ApiResponse.success("샘플 데이터 생성 완료"));
} catch (Exception e) {
log.error("❌ 샘플 데이터 생성 실패", e);
return ResponseEntity.ok(ApiResponse.success("샘플 데이터 생성 실패: " + e.getMessage()));
}
}
}
@@ -33,16 +33,14 @@ public class TimelineAnalyticsController {
/**
* 시간대별 참여 추이
*
* @param eventId 이벤트 ID
* @param interval 시간 간격 단위
* @param startDate 조회 시작 날짜
* @param endDate 조회 종료 날짜
* @param metrics 조회할 지표 목록
* @return 시간대별 참여 추이
* @param eventId 이벤트 ID
* @param interval 시간 간격 단위
* @param metrics 조회할 지표 목록
* @return 시간대별 참여 추이 (이벤트 시작일 ~ 현재까지)
*/
@Operation(
summary = "시간대별 참여 추이",
description = "이벤트 기간 동안의 시간대별 참여 추이를 분석합니다."
description = "이벤트 기간 동안의 시간대별 참여 추이를 분석합니다. (이벤트 시작일 ~ 현재까지)"
)
@GetMapping("/{eventId}/analytics/timeline")
public ResponseEntity<ApiResponse<TimelineAnalyticsResponse>> getTimelineAnalytics(
@@ -53,16 +51,6 @@ public class TimelineAnalyticsController {
@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
@@ -74,7 +62,7 @@ public class TimelineAnalyticsController {
: null;
TimelineAnalyticsResponse response = timelineAnalyticsService.getTimelineAnalytics(
eventId, interval, startDate, endDate, metricList
eventId, interval, metricList
);
return ResponseEntity.ok(ApiResponse.success(response));
@@ -31,31 +31,19 @@ public class UserAnalyticsDashboardController {
/**
* 사용자 전체 성과 대시보드 조회
*
* @param userId 사용자 ID
* @param startDate 조회 시작 날짜
* @param endDate 조회 종료 날짜
* @param refresh 캐시 갱신 여부
* @return 전체 통합 성과 대시보드
* @param userId 사용자 ID
* @param refresh 캐시 갱신 여부
* @return 전체 통합 성과 대시보드 (userId 기반 전체 이벤트 조회)
*/
@Operation(
summary = "사용자 전체 성과 대시보드 조회",
description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다."
description = "사용자의 모든 이벤트 성과를 통합하여 조회합니다. (userId 기반 전체 이벤트 조회)"
)
@GetMapping("/{userId}/analytics")
public ResponseEntity<ApiResponse<UserAnalyticsDashboardResponse>> getUserAnalytics(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId,
@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, defaultValue = "false")
Boolean refresh
@@ -63,7 +51,7 @@ public class UserAnalyticsDashboardController {
log.info("사용자 전체 성과 대시보드 조회 API 호출: userId={}, refresh={}", userId, refresh);
UserAnalyticsDashboardResponse response = userAnalyticsService.getUserDashboardData(
userId, startDate, endDate, refresh
userId, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
@@ -30,17 +30,13 @@ public class UserChannelAnalyticsController {
@Operation(
summary = "사용자 전체 채널별 성과 분석",
description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다."
description = "사용자의 모든 이벤트 채널 성과를 통합하여 분석합니다. (전체 채널 무조건 표시)"
)
@GetMapping("/{userId}/analytics/channels")
public ResponseEntity<ApiResponse<UserChannelAnalyticsResponse>> getUserChannelAnalytics(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId,
@Parameter(description = "조회할 채널 목록 (쉼표로 구분)")
@RequestParam(required = false)
String channels,
@Parameter(description = "정렬 기준")
@RequestParam(required = false, defaultValue = "participants")
String sortBy,
@@ -49,28 +45,14 @@ public class UserChannelAnalyticsController {
@RequestParam(required = false, defaultValue = "desc")
String order,
@Parameter(description = "조회 시작 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "캐시 갱신 여부")
@RequestParam(required = false, defaultValue = "false")
Boolean refresh
) {
log.info("사용자 채널 분석 API 호출: userId={}, sortBy={}", userId, sortBy);
List<String> channelList = channels != null && !channels.isBlank()
? Arrays.asList(channels.split(","))
: null;
UserChannelAnalyticsResponse response = userChannelAnalyticsService.getUserChannelAnalytics(
userId, channelList, sortBy, order, startDate, endDate, refresh
userId, sortBy, order, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
@@ -28,7 +28,7 @@ public class UserRoiAnalyticsController {
@Operation(
summary = "사용자 전체 ROI 상세 분석",
description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다."
description = "사용자의 모든 이벤트 ROI를 통합하여 분석합니다. (userId 기반 전체 이벤트 조회)"
)
@GetMapping("/{userId}/analytics/roi")
public ResponseEntity<ApiResponse<UserRoiAnalyticsResponse>> getUserRoiAnalytics(
@@ -39,16 +39,6 @@ public class UserRoiAnalyticsController {
@RequestParam(required = false, defaultValue = "true")
Boolean includeProjection,
@Parameter(description = "조회 시작 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "캐시 갱신 여부")
@RequestParam(required = false, defaultValue = "false")
Boolean refresh
@@ -56,7 +46,7 @@ public class UserRoiAnalyticsController {
log.info("사용자 ROI 분석 API 호출: userId={}, includeProjection={}", userId, includeProjection);
UserRoiAnalyticsResponse response = userRoiAnalyticsService.getUserRoiAnalytics(
userId, includeProjection, startDate, endDate, refresh
userId, includeProjection, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
@@ -30,7 +30,7 @@ public class UserTimelineAnalyticsController {
@Operation(
summary = "사용자 전체 시간대별 참여 추이",
description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다."
description = "사용자의 모든 이벤트 시간대별 데이터를 통합하여 분석합니다. (userId 기반 전체 이벤트 조회)"
)
@GetMapping("/{userId}/analytics/timeline")
public ResponseEntity<ApiResponse<UserTimelineAnalyticsResponse>> getUserTimelineAnalytics(
@@ -41,16 +41,6 @@ public class UserTimelineAnalyticsController {
@RequestParam(required = false, defaultValue = "daily")
String interval,
@Parameter(description = "조회 시작 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime startDate,
@Parameter(description = "조회 종료 날짜")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime endDate,
@Parameter(description = "조회할 지표 목록 (쉼표로 구분)")
@RequestParam(required = false)
String metrics,
@@ -66,7 +56,7 @@ public class UserTimelineAnalyticsController {
: null;
UserTimelineAnalyticsResponse response = userTimelineAnalyticsService.getUserTimelineAnalytics(
userId, interval, startDate, endDate, metricList, refresh
userId, interval, metricList, refresh
);
return ResponseEntity.ok(ApiResponse.success(response));
@@ -47,6 +47,21 @@ public class AnalyticsDashboardResponse {
*/
private RoiSummary roi;
/**
* 투자 비용 상세
*/
private InvestmentDetails investment;
/**
* 수익 상세
*/
private RevenueDetails revenue;
/**
* 비용 효율성 분석
*/
private CostEfficiency costEfficiency;
/**
* 마지막 업데이트 시간
*/
@@ -33,6 +33,16 @@ public class InvestmentDetails {
*/
private BigDecimal operation;
/**
* 경품 비용 (원)
*/
private BigDecimal prizeCost;
/**
* 채널 비용 (원) - distribution과 동일한 값
*/
private BigDecimal channelCost;
/**
* 총 투자 비용 (원)
*/
@@ -26,6 +26,16 @@ public class RevenueDetails {
*/
private BigDecimal expectedSales;
/**
* 신규 고객 매출 (원)
*/
private BigDecimal newCustomerRevenue;
/**
* 기존 고객 매출 (원)
*/
private BigDecimal existingCustomerRevenue;
/**
* 브랜드 가치 향상 추정액 (원)
*/
@@ -125,4 +125,11 @@ public class ChannelStats extends BaseTimeEntity {
@Column(name = "average_duration")
@Builder.Default
private Integer averageDuration = 0;
/**
* 참여자 수 증가
*/
public void incrementParticipants() {
this.participants++;
}
}
@@ -97,6 +97,18 @@ public class EventStats extends BaseTimeEntity {
@Column(length = 20)
private String status;
/**
* 이벤트 시작일
*/
@Column(name = "start_date")
private java.time.LocalDateTime startDate;
/**
* 이벤트 종료일 (null이면 진행중)
*/
@Column(name = "end_date")
private java.time.LocalDateTime endDate;
/**
* 참여자 수 증가
*/
@@ -32,7 +32,7 @@ public class DistributionCompletedConsumer {
private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate;
private static final String PROCESSED_DISTRIBUTIONS_KEY = "distribution_completed";
private static final String PROCESSED_DISTRIBUTIONS_KEY = "distribution_completed_v2";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7;
@@ -109,10 +109,15 @@ public class DistributionCompletedConsumer {
channelStats.setImpressions(channel.getExpectedViews());
}
// 배포 비용 저장
if (channel.getDistributionCost() != null) {
channelStats.setDistributionCost(channel.getDistributionCost());
}
channelStatsRepository.save(channelStats);
log.debug("✅ 채널 통계 저장: eventId={}, channel={}, expectedViews={}",
eventId, channelName, channel.getExpectedViews());
log.debug("✅ 채널 통계 저장: eventId={}, channel={}, expectedViews={}, distributionCost={}",
eventId, channelName, channel.getExpectedViews(), channel.getDistributionCost());
} catch (Exception e) {
log.error("❌ 채널 통계 처리 실패: eventId={}, channel={}", eventId, channel.getChannel(), e);
@@ -12,6 +12,7 @@ import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.concurrent.TimeUnit;
/**
@@ -29,7 +30,7 @@ public class EventCreatedConsumer {
private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate;
private static final String PROCESSED_EVENTS_KEY = "processed_events";
private static final String PROCESSED_EVENTS_KEY = "processed_events_v2";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7;
@@ -61,11 +62,15 @@ public class EventCreatedConsumer {
.userId(event.getStoreId()) // MVP: 1 user = 1 store, storeId를 userId로 매핑
.totalParticipants(0)
.totalInvestment(event.getTotalInvestment())
.expectedRevenue(event.getExpectedRevenue() != null ? event.getExpectedRevenue() : BigDecimal.ZERO)
.status(event.getStatus())
.startDate(event.getStartDate())
.endDate(event.getEndDate())
.build();
eventStatsRepository.save(eventStats);
log.info("✅ 이벤트 통계 초기화 완료: eventId={}", eventId);
log.info("✅ 이벤트 통계 초기화 완료: eventId={}, userId={}, startDate={}, endDate={}",
eventId, eventStats.getUserId(), event.getStartDate(), event.getEndDate());
// 3. 캐시 무효화 (다음 조회 시 최신 데이터 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId;
@@ -1,7 +1,9 @@
package com.kt.event.analytics.messaging.consumer;
import com.kt.event.analytics.entity.ChannelStats;
import com.kt.event.analytics.entity.EventStats;
import com.kt.event.analytics.messaging.event.ParticipantRegisteredEvent;
import com.kt.event.analytics.repository.ChannelStatsRepository;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
@@ -26,10 +28,11 @@ import java.util.concurrent.TimeUnit;
public class ParticipantRegisteredConsumer {
private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate;
private static final String PROCESSED_PARTICIPANTS_KEY = "processed_participants";
private static final String PROCESSED_PARTICIPANTS_KEY = "processed_participants_v2";
private static final String CACHE_KEY_PREFIX = "analytics:dashboard:";
private static final long IDEMPOTENCY_TTL_DAYS = 7;
@@ -47,11 +50,13 @@ public class ParticipantRegisteredConsumer {
ParticipantRegisteredEvent event = objectMapper.readValue(message, ParticipantRegisteredEvent.class);
String participantId = event.getParticipantId();
String eventId = event.getEventId();
String channel = event.getChannel();
// ✅ 1. 멱등성 체크 (중복 처리 방지)
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, participantId);
// ✅ 1. 멱등성 체크 (중복 처리 방지) - eventId:participantId 조합으로 체크
String idempotencyKey = eventId + ":" + participantId;
Boolean isProcessed = redisTemplate.opsForSet().isMember(PROCESSED_PARTICIPANTS_KEY, idempotencyKey);
if (Boolean.TRUE.equals(isProcessed)) {
log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): participantId={}", participantId);
log.warn("⚠️ 중복 이벤트 스킵 (이미 처리됨): eventId={}, participantId={}", eventId, participantId);
return;
}
@@ -67,15 +72,29 @@ public class ParticipantRegisteredConsumer {
() -> log.warn("⚠️ 이벤트 통계 없음: eventId={}", eventId)
);
// 3. 캐시 무효화 (다음 조회 시 최신 참여자 수 반영)
// 3. 채널별 참여자 수 업데이트 - 비관적 락 적용
if (channel != null && !channel.isEmpty()) {
channelStatsRepository.findByEventIdAndChannelNameWithLock(eventId, channel)
.ifPresentOrElse(
channelStats -> {
channelStats.incrementParticipants();
channelStatsRepository.save(channelStats);
log.info("✅ 채널별 참여자 수 업데이트: eventId={}, channel={}, participants={}",
eventId, channel, channelStats.getParticipants());
},
() -> log.warn("⚠️ 채널 통계 없음: eventId={}, channel={}", eventId, channel)
);
}
// 4. 캐시 무효화 (다음 조회 시 최신 참여자 수 반영)
String cacheKey = CACHE_KEY_PREFIX + eventId;
redisTemplate.delete(cacheKey);
log.debug("🗑️ 캐시 무효화: {}", cacheKey);
// 4. 멱등성 처리 완료 기록 (7일 TTL)
redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, participantId);
// 5. 멱등성 처리 완료 기록 (7일 TTL)
redisTemplate.opsForSet().add(PROCESSED_PARTICIPANTS_KEY, idempotencyKey);
redisTemplate.expire(PROCESSED_PARTICIPANTS_KEY, IDEMPOTENCY_TTL_DAYS, TimeUnit.DAYS);
log.debug("✅ 멱등성 기록: participantId={}", participantId);
log.debug("✅ 멱등성 기록: eventId={}, participantId={}", eventId, participantId);
} catch (Exception e) {
log.error("❌ ParticipantRegistered 이벤트 처리 실패: {}", e.getMessage(), e);
@@ -62,5 +62,10 @@ public class DistributionCompletedEvent {
* 예상 노출 수
*/
private Integer expectedViews;
/**
* 배포 비용 (원)
*/
private java.math.BigDecimal distributionCost;
}
}
@@ -6,6 +6,7 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 이벤트 생성 이벤트
@@ -36,8 +37,23 @@ public class EventCreatedEvent {
*/
private BigDecimal totalInvestment;
/**
* 예상 수익
*/
private BigDecimal expectedRevenue;
/**
* 이벤트 상태
*/
private String status;
/**
* 이벤트 시작일
*/
private LocalDateTime startDate;
/**
* 이벤트 종료일 (null이면 진행중)
*/
private LocalDateTime endDate;
}
@@ -1,7 +1,11 @@
package com.kt.event.analytics.repository;
import com.kt.event.analytics.entity.ChannelStats;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@@ -30,6 +34,18 @@ public interface ChannelStatsRepository extends JpaRepository<ChannelStats, Long
*/
Optional<ChannelStats> findByEventIdAndChannelName(String eventId, String channelName);
/**
* 이벤트 ID와 채널명으로 통계 조회 (비관적 락)
*
* @param eventId 이벤트 ID
* @param channelName 채널명
* @return 채널 통계
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM ChannelStats c WHERE c.eventId = :eventId AND c.channelName = :channelName")
Optional<ChannelStats> findByEventIdAndChannelNameWithLock(@Param("eventId") String eventId,
@Param("channelName") String channelName);
/**
* 여러 이벤트 ID로 모든 채널 통계 조회
*
@@ -47,12 +47,10 @@ public class AnalyticsService {
* 대시보드 데이터 조회
*
* @param eventId 이벤트 ID
* @param startDate 조회 시작 날짜 (선택)
* @param endDate 조회 종료 날짜 (선택)
* @param refresh 캐시 갱신 여부
* @return 대시보드 응답
* @param refresh 캐시 갱신 여부
* @return 대시보드 응답 (이벤트 시작일 ~ 현재까지)
*/
public AnalyticsDashboardResponse getDashboardData(String eventId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
public AnalyticsDashboardResponse getDashboardData(String eventId, boolean refresh) {
log.info("대시보드 데이터 조회 시작: eventId={}, refresh={}", eventId, refresh);
String cacheKey = CACHE_KEY_PREFIX + eventId;
@@ -91,7 +89,7 @@ public class AnalyticsService {
}
// 3. 대시보드 데이터 구성
AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList, startDate, endDate);
AnalyticsDashboardResponse response = buildDashboardData(eventStats, channelStatsList);
// 4. Redis 캐싱 (1시간 TTL)
try {
@@ -110,10 +108,9 @@ public class AnalyticsService {
/**
* 대시보드 데이터 구성
*/
private AnalyticsDashboardResponse buildDashboardData(EventStats eventStats, List<ChannelStats> channelStatsList,
LocalDateTime startDate, LocalDateTime endDate) {
// 기간 정보
PeriodInfo period = buildPeriodInfo(startDate, endDate);
private AnalyticsDashboardResponse buildDashboardData(EventStats eventStats, List<ChannelStats> channelStatsList) {
// 기간 정보 (이벤트 시작일 ~ 현재)
PeriodInfo period = buildPeriodInfo(eventStats);
// 성과 요약
AnalyticsSummary summary = buildAnalyticsSummary(eventStats, channelStatsList);
@@ -124,6 +121,15 @@ public class AnalyticsService {
// ROI 요약
RoiSummary roiSummary = roiCalculator.calculateRoiSummary(eventStats);
// 투자 비용 상세
InvestmentDetails investment = buildInvestmentDetails(eventStats, channelStatsList);
// 수익 상세
RevenueDetails revenue = buildRevenueDetails(eventStats);
// 비용 효율성
CostEfficiency costEfficiency = buildCostEfficiency(eventStats);
return AnalyticsDashboardResponse.builder()
.eventId(eventStats.getEventId())
.eventTitle(eventStats.getEventTitle())
@@ -131,17 +137,21 @@ public class AnalyticsService {
.summary(summary)
.channelPerformance(channelPerformance)
.roi(roiSummary)
.investment(investment)
.revenue(revenue)
.costEfficiency(costEfficiency)
.lastUpdatedAt(LocalDateTime.now())
.dataSource("cached")
.build();
}
/**
* 기간 정보 구성
* 기간 정보 구성 (이벤트 시작일 ~ 종료일 또는 현재)
*/
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
private PeriodInfo buildPeriodInfo(EventStats eventStats) {
LocalDateTime start = eventStats.getStartDate();
LocalDateTime end = eventStats.getEndDate() != null ?
eventStats.getEndDate() : LocalDateTime.now();
long durationDays = ChronoUnit.DAYS.between(start, end);
@@ -215,4 +225,88 @@ public class AnalyticsService {
return summaries;
}
/**
* 투자 비용 상세 구성
*
* UserRoiAnalyticsService와 동일한 로직:
* - 실제 채널 배포 비용 집계
* - 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
*/
private InvestmentDetails buildInvestmentDetails(EventStats eventStats, List<ChannelStats> channelStatsList) {
java.math.BigDecimal totalInvestment = eventStats.getTotalInvestment();
// ChannelStats에서 실제 배포 비용 집계
java.math.BigDecimal actualDistribution = channelStatsList.stream()
.map(ChannelStats::getDistributionCost)
.reduce(java.math.BigDecimal.ZERO, java.math.BigDecimal::add);
// 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용)
java.math.BigDecimal remaining = totalInvestment.subtract(actualDistribution);
// 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
java.math.BigDecimal prizeCost = remaining.multiply(java.math.BigDecimal.valueOf(0.50));
java.math.BigDecimal contentCreation = remaining.multiply(java.math.BigDecimal.valueOf(0.30));
java.math.BigDecimal operation = remaining.multiply(java.math.BigDecimal.valueOf(0.20));
return InvestmentDetails.builder()
.total(totalInvestment)
.contentCreation(contentCreation)
.operation(operation)
.distribution(actualDistribution)
.prizeCost(prizeCost)
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
.build();
}
/**
* 수익 상세 구성
*
* UserRoiAnalyticsService와 동일한 로직:
* - 직접 매출 70%, 예상 추가 매출 30%
* - 신규 고객 40%, 기존 고객 60%
*/
private RevenueDetails buildRevenueDetails(EventStats eventStats) {
java.math.BigDecimal totalRevenue = eventStats.getExpectedRevenue();
// 매출 분배: 직접 매출 70%, 예상 추가 매출 30%
java.math.BigDecimal directSales = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.70));
java.math.BigDecimal expectedSales = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.30));
// 신규 고객 40%, 기존 고객 60%
java.math.BigDecimal newCustomerRevenue = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.40));
java.math.BigDecimal existingCustomerRevenue = totalRevenue.multiply(java.math.BigDecimal.valueOf(0.60));
return RevenueDetails.builder()
.total(totalRevenue)
.directSales(directSales)
.expectedSales(expectedSales)
.newCustomerRevenue(newCustomerRevenue)
.existingCustomerRevenue(existingCustomerRevenue)
.brandValue(java.math.BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가
.build();
}
/**
* 비용 효율성 구성
*
* UserRoiAnalyticsService와 동일한 로직:
* - 참여자당 비용 = 총투자 ÷ 총참여자수
* - 참여자당 수익 = 총수익 ÷ 총참여자수
*/
private CostEfficiency buildCostEfficiency(EventStats eventStats) {
int totalParticipants = eventStats.getTotalParticipants();
java.math.BigDecimal totalInvestment = eventStats.getTotalInvestment();
java.math.BigDecimal totalRevenue = eventStats.getExpectedRevenue();
double costPerParticipant = totalParticipants > 0 ?
totalInvestment.doubleValue() / totalParticipants : 0.0;
double revenuePerParticipant = totalParticipants > 0 ?
totalRevenue.doubleValue() / totalParticipants : 0.0;
return CostEfficiency.builder()
.costPerParticipant(costPerParticipant)
.revenuePerParticipant(revenuePerParticipant)
.build();
}
}
@@ -60,43 +60,62 @@ public class ROICalculator {
/**
* 투자 비용 계산
*
* UserRoiAnalyticsService와 동일한 로직:
* - ChannelStats에서 실제 배포 비용 집계
* - 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
*/
private InvestmentDetails calculateInvestment(EventStats eventStats, List<ChannelStats> channelStats) {
BigDecimal distributionCost = channelStats.stream()
BigDecimal totalInvestment = eventStats.getTotalInvestment();
// ChannelStats에서 실제 배포 비용 집계
BigDecimal actualDistribution = channelStats.stream()
.map(ChannelStats::getDistributionCost)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal contentCreation = eventStats.getTotalInvestment()
.multiply(BigDecimal.valueOf(0.4)); // 전체 투자의 40%를 콘텐츠 제작비로 가정
// 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용)
BigDecimal remaining = totalInvestment.subtract(actualDistribution);
BigDecimal operation = eventStats.getTotalInvestment()
.multiply(BigDecimal.valueOf(0.1)); // 10%를 운영비로 가정
// 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
BigDecimal prizeCost = remaining.multiply(BigDecimal.valueOf(0.50));
BigDecimal contentCreation = remaining.multiply(BigDecimal.valueOf(0.30));
BigDecimal operation = remaining.multiply(BigDecimal.valueOf(0.20));
return InvestmentDetails.builder()
.total(totalInvestment)
.contentCreation(contentCreation)
.distribution(distributionCost)
.operation(operation)
.total(eventStats.getTotalInvestment())
.distribution(actualDistribution)
.prizeCost(prizeCost)
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
.build();
}
/**
* 수익 계산
*
* UserRoiAnalyticsService와 동일한 로직:
* - 직접 매출 70%, 예상 추가 매출 30%
* - 신규 고객 40%, 기존 고객 60%
*/
private RevenueDetails calculateRevenue(EventStats eventStats) {
BigDecimal directSales = eventStats.getExpectedRevenue()
.multiply(BigDecimal.valueOf(0.66)); // 예상 수익의 66%를 직접 매출로 가정
BigDecimal totalRevenue = eventStats.getExpectedRevenue();
BigDecimal expectedSales = eventStats.getExpectedRevenue()
.multiply(BigDecimal.valueOf(0.34)); // 34%를 예상 추가 매출로 가정
// 매출 분배: 직접 매출 70%, 예상 추가 매출 30%
BigDecimal directSales = totalRevenue.multiply(BigDecimal.valueOf(0.70));
BigDecimal expectedSales = totalRevenue.multiply(BigDecimal.valueOf(0.30));
BigDecimal brandValue = BigDecimal.ZERO; // 브랜드 가치는 별도 계산 필요
// 신규 고객 40%, 기존 고객 60%
BigDecimal newCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.40));
BigDecimal existingCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.60));
return RevenueDetails.builder()
.total(totalRevenue)
.directSales(directSales)
.expectedSales(expectedSales)
.brandValue(brandValue)
.total(eventStats.getExpectedRevenue())
.newCustomerRevenue(newCustomerRevenue)
.existingCustomerRevenue(existingCustomerRevenue)
.brandValue(BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가
.build();
}
@@ -26,20 +26,13 @@ public class TimelineAnalyticsService {
private final TimelineDataRepository timelineDataRepository;
/**
* 시간대별 참여 추이 조회
* 시간대별 참여 추이 조회 (이벤트 전체 기간)
*/
public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval,
LocalDateTime startDate, LocalDateTime endDate,
List<String> metrics) {
public TimelineAnalyticsResponse getTimelineAnalytics(String eventId, String interval, List<String> metrics) {
log.info("시간대별 참여 추이 조회: eventId={}, interval={}", eventId, interval);
// 시간대별 데이터 조회
List<TimelineData> timelineDataList;
if (startDate != null && endDate != null) {
timelineDataList = timelineDataRepository.findByEventIdAndTimestampBetween(eventId, startDate, endDate);
} else {
timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId);
}
// 시간대별 데이터 조회 (이벤트 전체 기간)
List<TimelineData> timelineDataList = timelineDataRepository.findByEventIdOrderByTimestampAsc(eventId);
// 시간대별 데이터 포인트 구성
List<TimelineDataPoint> dataPoints = buildTimelineDataPoints(timelineDataList);
@@ -44,13 +44,11 @@ public class UserAnalyticsService {
/**
* 사용자 전체 대시보드 데이터 조회
*
* @param userId 사용자 ID
* @param startDate 조회 시작 날짜 (선택)
* @param endDate 조회 종료 날짜 (선택)
* @param refresh 캐시 갱신 여부
* @return 사용자 통합 대시보드 응답
* @param userId 사용자 ID
* @param refresh 캐시 갱신 여부
* @return 사용자 통합 대시보드 응답 (userId 기반 전체 이벤트 조회)
*/
public UserAnalyticsDashboardResponse getUserDashboardData(String userId, LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
public UserAnalyticsDashboardResponse getUserDashboardData(String userId, boolean refresh) {
log.info("사용자 전체 대시보드 데이터 조회 시작: userId={}, refresh={}", userId, refresh);
String cacheKey = CACHE_KEY_PREFIX + userId;
@@ -75,7 +73,7 @@ public class UserAnalyticsService {
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) {
log.warn("사용자에 이벤트가 없음: userId={}", userId);
return buildEmptyResponse(userId, startDate, endDate);
return buildEmptyResponse(userId);
}
log.debug("사용자 이벤트 조회 완료: userId={}, 이벤트 수={}", userId, allEvents.size());
@@ -87,7 +85,7 @@ public class UserAnalyticsService {
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
// 3. 통합 대시보드 데이터 구성
UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats, startDate, endDate);
UserAnalyticsDashboardResponse response = buildUserDashboardData(userId, allEvents, allChannelStats);
// 4. Redis 캐싱 (30분 TTL)
try {
@@ -104,10 +102,15 @@ public class UserAnalyticsService {
/**
* 빈 응답 생성 (이벤트가 없는 경우)
*/
private UserAnalyticsDashboardResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
private UserAnalyticsDashboardResponse buildEmptyResponse(String userId) {
LocalDateTime now = LocalDateTime.now();
return UserAnalyticsDashboardResponse.builder()
.userId(userId)
.period(buildPeriodInfo(startDate, endDate))
.period(PeriodInfo.builder()
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0)
.activeEvents(0)
.overallSummary(buildEmptyAnalyticsSummary())
@@ -123,10 +126,9 @@ public class UserAnalyticsService {
* 사용자 통합 대시보드 데이터 구성
*/
private UserAnalyticsDashboardResponse buildUserDashboardData(String userId, List<EventStats> allEvents,
List<ChannelStats> allChannelStats,
LocalDateTime startDate, LocalDateTime endDate) {
// 기간 정보
PeriodInfo period = buildPeriodInfo(startDate, endDate);
List<ChannelStats> allChannelStats) {
// 기간 정보 (전체 이벤트의 최소/최대 날짜 기반)
PeriodInfo period = buildPeriodFromEvents(allEvents);
// 전체 이벤트 수 및 활성 이벤트 수
int totalEvents = allEvents.size();
@@ -299,16 +301,22 @@ public class UserAnalyticsService {
/**
* 기간 정보 구성
*
* 전체 이벤트 중 가장 빠른 시작일 ~ 현재까지의 기간 계산
*/
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
long durationDays = ChronoUnit.DAYS.between(start, end);
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
LocalDateTime start = events.stream()
.map(EventStats::getStartDate)
.filter(Objects::nonNull)
.min(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
LocalDateTime end = LocalDateTime.now();
return PeriodInfo.builder()
.startDate(start)
.endDate(end)
.durationDays((int) durationDays)
.durationDays((int) ChronoUnit.DAYS.between(start, end))
.build();
}
@@ -42,10 +42,9 @@ public class UserChannelAnalyticsService {
private static final long CACHE_TTL = 1800; // 30분
/**
* 사용자 전체 채널 분석 데이터 조회
* 사용자 전체 채널 분석 데이터 조회 (전체 채널 무조건 표시)
*/
public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, List<String> channels, String sortBy, String order,
LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
public UserChannelAnalyticsResponse getUserChannelAnalytics(String userId, String sortBy, String order, boolean refresh) {
log.info("사용자 채널 분석 조회 시작: userId={}, refresh={}", userId, refresh);
String cacheKey = CACHE_KEY_PREFIX + userId;
@@ -66,14 +65,14 @@ public class UserChannelAnalyticsService {
// 2. 데이터 조회
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) {
return buildEmptyResponse(userId, startDate, endDate);
return buildEmptyResponse(userId);
}
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
// 3. 응답 구성
UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, channels, sortBy, order, startDate, endDate);
// 3. 응답 구성 (전체 채널)
UserChannelAnalyticsResponse response = buildChannelAnalyticsResponse(userId, allEvents, allChannelStats, sortBy, order);
// 4. 캐싱
try {
@@ -87,10 +86,15 @@ public class UserChannelAnalyticsService {
return response;
}
private UserChannelAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
private UserChannelAnalyticsResponse buildEmptyResponse(String userId) {
LocalDateTime now = LocalDateTime.now();
return UserChannelAnalyticsResponse.builder()
.userId(userId)
.period(buildPeriodInfo(startDate, endDate))
.period(PeriodInfo.builder()
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0)
.channels(new ArrayList<>())
.comparison(ChannelComparison.builder().build())
@@ -100,15 +104,10 @@ public class UserChannelAnalyticsService {
}
private UserChannelAnalyticsResponse buildChannelAnalyticsResponse(String userId, List<EventStats> allEvents,
List<ChannelStats> allChannelStats, List<String> channels,
String sortBy, String order, LocalDateTime startDate, LocalDateTime endDate) {
// 채널 필터링
List<ChannelStats> filteredChannels = channels != null && !channels.isEmpty()
? allChannelStats.stream().filter(c -> channels.contains(c.getChannelName())).collect(Collectors.toList())
: allChannelStats;
// 채널별 집계
List<ChannelAnalytics> channelAnalyticsList = aggregateChannelAnalytics(filteredChannels);
List<ChannelStats> allChannelStats,
String sortBy, String order) {
// 채널별 집계 (전체 채널)
List<ChannelAnalytics> channelAnalyticsList = aggregateChannelAnalytics(allChannelStats);
// 정렬
channelAnalyticsList = sortChannels(channelAnalyticsList, sortBy, order);
@@ -118,7 +117,7 @@ public class UserChannelAnalyticsService {
return UserChannelAnalyticsResponse.builder()
.userId(userId)
.period(buildPeriodInfo(startDate, endDate))
.period(buildPeriodFromEvents(allEvents))
.totalEvents(allEvents.size())
.channels(channelAnalyticsList)
.comparison(comparison)
@@ -246,15 +245,24 @@ public class UserChannelAnalyticsService {
.build();
}
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
long durationDays = ChronoUnit.DAYS.between(start, end);
/**
* 전체 이벤트의 생성/수정 시간 기반으로 period 계산
*/
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
LocalDateTime start = events.stream()
.map(EventStats::getCreatedAt)
.min(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
LocalDateTime end = events.stream()
.map(EventStats::getUpdatedAt)
.max(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
return PeriodInfo.builder()
.startDate(start)
.endDate(end)
.durationDays((int) durationDays)
.durationDays((int) ChronoUnit.DAYS.between(start, end))
.build();
}
}
@@ -1,7 +1,9 @@
package com.kt.event.analytics.service;
import com.kt.event.analytics.dto.response.*;
import com.kt.event.analytics.entity.ChannelStats;
import com.kt.event.analytics.entity.EventStats;
import com.kt.event.analytics.repository.ChannelStatsRepository;
import com.kt.event.analytics.repository.EventStatsRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -31,14 +33,14 @@ import java.util.stream.Collectors;
public class UserRoiAnalyticsService {
private final EventStatsRepository eventStatsRepository;
private final ChannelStatsRepository channelStatsRepository;
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
private static final String CACHE_KEY_PREFIX = "analytics:user:roi:";
private static final long CACHE_TTL = 1800;
public UserRoiAnalyticsResponse getUserRoiAnalytics(String userId, boolean includeProjection,
LocalDateTime startDate, LocalDateTime endDate, boolean refresh) {
public UserRoiAnalyticsResponse getUserRoiAnalytics(String userId, boolean includeProjection, boolean refresh) {
log.info("사용자 ROI 분석 조회 시작: userId={}, refresh={}", userId, refresh);
String cacheKey = CACHE_KEY_PREFIX + userId;
@@ -56,10 +58,10 @@ public class UserRoiAnalyticsService {
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) {
return buildEmptyResponse(userId, startDate, endDate);
return buildEmptyResponse(userId);
}
UserRoiAnalyticsResponse response = buildRoiResponse(userId, allEvents, includeProjection, startDate, endDate);
UserRoiAnalyticsResponse response = buildRoiResponse(userId, allEvents, includeProjection);
try {
String jsonData = objectMapper.writeValueAsString(response);
@@ -71,13 +73,32 @@ public class UserRoiAnalyticsService {
return response;
}
private UserRoiAnalyticsResponse buildEmptyResponse(String userId, LocalDateTime startDate, LocalDateTime endDate) {
private UserRoiAnalyticsResponse buildEmptyResponse(String userId) {
LocalDateTime now = LocalDateTime.now();
return UserRoiAnalyticsResponse.builder()
.userId(userId)
.period(buildPeriodInfo(startDate, endDate))
.period(PeriodInfo.builder()
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0)
.overallInvestment(InvestmentDetails.builder().total(BigDecimal.ZERO).build())
.overallRevenue(RevenueDetails.builder().total(BigDecimal.ZERO).build())
.overallInvestment(InvestmentDetails.builder()
.total(BigDecimal.ZERO)
.contentCreation(BigDecimal.ZERO)
.operation(BigDecimal.ZERO)
.distribution(BigDecimal.ZERO)
.prizeCost(BigDecimal.ZERO)
.channelCost(BigDecimal.ZERO)
.build())
.overallRevenue(RevenueDetails.builder()
.total(BigDecimal.ZERO)
.directSales(BigDecimal.ZERO)
.expectedSales(BigDecimal.ZERO)
.newCustomerRevenue(BigDecimal.ZERO)
.existingCustomerRevenue(BigDecimal.ZERO)
.brandValue(BigDecimal.ZERO)
.build())
.overallRoi(RoiCalculation.builder()
.netProfit(BigDecimal.ZERO)
.roiPercentage(0.0)
@@ -88,8 +109,7 @@ public class UserRoiAnalyticsService {
.build();
}
private UserRoiAnalyticsResponse buildRoiResponse(String userId, List<EventStats> allEvents, boolean includeProjection,
LocalDateTime startDate, LocalDateTime endDate) {
private UserRoiAnalyticsResponse buildRoiResponse(String userId, List<EventStats> allEvents, boolean includeProjection) {
BigDecimal totalInvestment = allEvents.stream().map(EventStats::getTotalInvestment).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal totalRevenue = allEvents.stream().map(EventStats::getExpectedRevenue).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal totalProfit = totalRevenue.subtract(totalInvestment);
@@ -98,17 +118,44 @@ public class UserRoiAnalyticsService {
? totalProfit.divide(totalInvestment, 4, RoundingMode.HALF_UP).multiply(BigDecimal.valueOf(100)).doubleValue()
: 0.0;
// ChannelStats에서 실제 배포 비용 집계
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
List<ChannelStats> allChannelStats = channelStatsRepository.findByEventIdIn(eventIds);
BigDecimal actualDistribution = allChannelStats.stream()
.map(ChannelStats::getDistributionCost)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 나머지 비용 계산 (총 투자 - 실제 채널 배포 비용)
BigDecimal remaining = totalInvestment.subtract(actualDistribution);
// 나머지 비용 분배: 경품 50%, 콘텐츠 제작 30%, 운영 20%
BigDecimal prizeCost = remaining.multiply(BigDecimal.valueOf(0.50));
BigDecimal contentCreation = remaining.multiply(BigDecimal.valueOf(0.30));
BigDecimal operation = remaining.multiply(BigDecimal.valueOf(0.20));
InvestmentDetails investment = InvestmentDetails.builder()
.total(totalInvestment)
.contentCreation(totalInvestment.multiply(BigDecimal.valueOf(0.6)))
.operation(totalInvestment.multiply(BigDecimal.valueOf(0.2)))
.distribution(totalInvestment.multiply(BigDecimal.valueOf(0.2)))
.contentCreation(contentCreation)
.operation(operation)
.distribution(actualDistribution)
.prizeCost(prizeCost)
.channelCost(actualDistribution) // 채널비용은 배포비용과 동일
.build();
// 매출 분배: 직접 매출 70%, 예상 추가 매출 30% / 신규 고객 40%, 기존 고객 60%
BigDecimal directSales = totalRevenue.multiply(BigDecimal.valueOf(0.70));
BigDecimal expectedSales = totalRevenue.multiply(BigDecimal.valueOf(0.30));
BigDecimal newCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.40));
BigDecimal existingCustomerRevenue = totalRevenue.multiply(BigDecimal.valueOf(0.60));
RevenueDetails revenue = RevenueDetails.builder()
.total(totalRevenue)
.directSales(totalRevenue.multiply(BigDecimal.valueOf(0.7)))
.expectedSales(totalRevenue.multiply(BigDecimal.valueOf(0.3)))
.directSales(directSales)
.expectedSales(expectedSales)
.newCustomerRevenue(newCustomerRevenue)
.existingCustomerRevenue(existingCustomerRevenue)
.brandValue(BigDecimal.ZERO) // 브랜드 가치는 별도 계산 필요 시 추가
.build();
RoiCalculation roiCalc = RoiCalculation.builder()
@@ -149,9 +196,12 @@ public class UserRoiAnalyticsService {
.sorted(Comparator.comparingDouble(UserRoiAnalyticsResponse.EventRoiSummary::getRoi).reversed())
.collect(Collectors.toList());
// 전체 이벤트의 최소/최대 날짜로 period 계산
PeriodInfo period = buildPeriodFromEvents(allEvents);
return UserRoiAnalyticsResponse.builder()
.userId(userId)
.period(buildPeriodInfo(startDate, endDate))
.period(period)
.totalEvents(allEvents.size())
.overallInvestment(investment)
.overallRevenue(revenue)
@@ -164,9 +214,20 @@ public class UserRoiAnalyticsService {
.build();
}
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
/**
* 전체 이벤트의 생성/수정 시간 기반으로 period 계산
*/
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
LocalDateTime start = events.stream()
.map(EventStats::getCreatedAt)
.min(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
LocalDateTime end = events.stream()
.map(EventStats::getUpdatedAt)
.max(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
return PeriodInfo.builder()
.startDate(start)
.endDate(end)
@@ -37,7 +37,6 @@ public class UserTimelineAnalyticsService {
private static final long CACHE_TTL = 1800;
public UserTimelineAnalyticsResponse getUserTimelineAnalytics(String userId, String interval,
LocalDateTime startDate, LocalDateTime endDate,
List<String> metrics, boolean refresh) {
log.info("사용자 타임라인 분석 조회 시작: userId={}, interval={}, refresh={}", userId, interval, refresh);
@@ -56,15 +55,13 @@ public class UserTimelineAnalyticsService {
List<EventStats> allEvents = eventStatsRepository.findAllByUserId(userId);
if (allEvents.isEmpty()) {
return buildEmptyResponse(userId, interval, startDate, endDate);
return buildEmptyResponse(userId, interval);
}
List<String> eventIds = allEvents.stream().map(EventStats::getEventId).collect(Collectors.toList());
List<TimelineData> allTimelineData = startDate != null && endDate != null
? timelineDataRepository.findByEventIdInAndTimestampBetween(eventIds, startDate, endDate)
: timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
List<TimelineData> allTimelineData = timelineDataRepository.findByEventIdInOrderByTimestampAsc(eventIds);
UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval, startDate, endDate);
UserTimelineAnalyticsResponse response = buildTimelineResponse(userId, allEvents, allTimelineData, interval);
try {
String jsonData = objectMapper.writeValueAsString(response);
@@ -76,10 +73,15 @@ public class UserTimelineAnalyticsService {
return response;
}
private UserTimelineAnalyticsResponse buildEmptyResponse(String userId, String interval, LocalDateTime startDate, LocalDateTime endDate) {
private UserTimelineAnalyticsResponse buildEmptyResponse(String userId, String interval) {
LocalDateTime now = LocalDateTime.now();
return UserTimelineAnalyticsResponse.builder()
.userId(userId)
.period(buildPeriodInfo(startDate, endDate))
.period(PeriodInfo.builder()
.startDate(now)
.endDate(now)
.durationDays(0)
.build())
.totalEvents(0)
.interval(interval != null ? interval : "daily")
.dataPoints(new ArrayList<>())
@@ -91,8 +93,7 @@ public class UserTimelineAnalyticsService {
}
private UserTimelineAnalyticsResponse buildTimelineResponse(String userId, List<EventStats> allEvents,
List<TimelineData> allTimelineData, String interval,
LocalDateTime startDate, LocalDateTime endDate) {
List<TimelineData> allTimelineData, String interval) {
Map<LocalDateTime, TimelineDataPoint> aggregatedData = new LinkedHashMap<>();
for (TimelineData data : allTimelineData) {
@@ -119,7 +120,7 @@ public class UserTimelineAnalyticsService {
return UserTimelineAnalyticsResponse.builder()
.userId(userId)
.period(buildPeriodInfo(startDate, endDate))
.period(buildPeriodFromEvents(allEvents))
.totalEvents(allEvents.size())
.interval(interval != null ? interval : "daily")
.dataPoints(dataPoints)
@@ -179,9 +180,20 @@ public class UserTimelineAnalyticsService {
.build() : PeakTimeInfo.builder().build();
}
private PeriodInfo buildPeriodInfo(LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime start = startDate != null ? startDate : LocalDateTime.now().minusDays(30);
LocalDateTime end = endDate != null ? endDate : LocalDateTime.now();
/**
* 전체 이벤트의 생성/수정 시간 기반으로 period 계산
*/
private PeriodInfo buildPeriodFromEvents(List<EventStats> events) {
LocalDateTime start = events.stream()
.map(EventStats::getCreatedAt)
.min(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
LocalDateTime end = events.stream()
.map(EventStats::getUpdatedAt)
.max(LocalDateTime::compareTo)
.orElse(LocalDateTime.now());
return PeriodInfo.builder()
.startDate(start)
.endDate(end)
@@ -47,11 +47,13 @@ spring:
enabled: ${KAFKA_ENABLED:true}
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095}
consumer:
group-id: ${KAFKA_CONSUMER_GROUP_ID:analytics-service}
group-id: ${KAFKA_CONSUMER_GROUP_ID:analytics-service-consumers-v3}
auto-offset-reset: earliest
enable-auto-commit: true
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
properties:
auto.offset.reset: earliest
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
@@ -74,7 +76,10 @@ spring:
server:
port: ${SERVER_PORT:8086}
servlet:
context-path: /api/v1/analytics
encoding:
charset: UTF-8
enabled: true
force: true
# JWT
jwt:
@@ -84,7 +89,11 @@ jwt:
# CORS Configuration
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*}
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io}
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
max-age: ${CORS_MAX_AGE:3600}
# Actuator
management:
@@ -40,7 +40,11 @@ replicate:
# CORS Configuration
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*}
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io}
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
max-age: ${CORS_MAX_AGE:3600}
# Actuator
management:
+149 -278
View File
@@ -1,68 +1,57 @@
# 백엔드 컨테이너 이미지 작성 결과
# 백엔드 컨테이너 이미지 빌드 결과
## 작업 개요
- **작업일시**: 2025-10-29
- **작성자**: DevOps Engineer (송근정 "데브옵스 마스터")
- **대상 서비스**: 6개 백엔드 마이크로서비스
## 개요
KT 이벤트 마케팅 서비스의 백엔드 마이크로서비스들에 대한 컨테이너 이미지를 생성하였습니다.
## 1. 서비스 확인
## 작업 일시
- 날짜: 2025-10-29
- 빌드 환경: Windows (MINGW64_NT-10.0-19045)
### settings.gradle 분석
```gradle
## 서비스 목록 확인
settings.gradle에서 확인한 서비스 목록:
```
rootProject.name = 'kt-event-marketing'
// Common module
include 'common'
// Microservices
include 'user-service'
include 'event-service'
include 'ai-service'
include 'content-service'
include 'distribution-service'
include 'participation-service'
include 'analytics-service'
```
### 빌드 가능한 서비스 (6개)
Main Application 클래스가 존재하는 서비스:
1. **user-service** - `UserServiceApplication.java`
2. **event-service** - `EventServiceApplication.java`
3. **ai-service** - `AiServiceApplication.java`
4. **content-service** - `ContentApplication.java`
5. **participation-service** - `ParticipationServiceApplication.java`
6. **analytics-service** - `AnalyticsServiceApplication.java`
**빌드 대상 서비스 (6개):**
- user-service (Java/Spring Boot)
- event-service (Java/Spring Boot)
- ai-service (Java/Spring Boot)
- distribution-service (Java/Spring Boot)
- participation-service (Java/Spring Boot)
- analytics-service (Java/Spring Boot)
### 제외된 서비스
- **distribution-service**: 소스 코드 미구현 상태 (src/main/java 디렉토리 없음)
**제외 대상:**
- common: 공통 라이브러리 모듈 (독립 실행 서비스 아님)
- content-service: Python 기반 서비스 (별도 빌드 필요)
## 2. bootJar 설정
## bootJar 설정 확인
서비스의 `build.gradle`에 bootJar 설정 추가/수정:
모든 Java 서비스의 build.gradle에 bootJar 설정이 올바르게 구성되어 있음을 확인:
### 설정 추가된 서비스 (5개)
```gradle
bootJar {
archiveFileName = '{service-name}.jar'
}
```
| 서비스명 | JAR 파일명 | 경로 |
|---------|-----------|------|
| user-service | user-service.jar | user-service/build/libs/user-service.jar |
| event-service | event-service.jar | event-service/build/libs/event-service.jar |
| ai-service | ai-service.jar | ai-service/build/libs/ai-service.jar |
| distribution-service | distribution-service.jar | distribution-service/build/libs/distribution-service.jar |
| participation-service | participation-service.jar | participation-service/build/libs/participation-service.jar |
| analytics-service | analytics-service.jar | analytics-service/build/libs/analytics-service.jar |
- user-service/build.gradle
- ai-service/build.gradle
- distribution-service/build.gradle (향후 구현 대비)
- participation-service/build.gradle
- analytics-service/build.gradle
## Dockerfile 생성
### 기존 설정 확인된 서비스 (2개)
- event-service/build.gradle ✅
- content-service/build.gradle ✅
**파일 위치:** `deployment/container/Dockerfile-backend`
## 3. Dockerfile 생성
### 파일 경로
`deployment/container/Dockerfile-backend`
### Dockerfile 내용
**Dockerfile 구성:**
```dockerfile
# Build stage
FROM openjdk:23-oraclelinux8 AS builder
@@ -91,58 +80,34 @@ ENTRYPOINT [ "sh", "-c" ]
CMD ["java ${JAVA_OPTS} -jar app.jar"]
```
### Dockerfile 특징
- **Multi-stage build**: 빌드와 실행 스테이지 분리
- **Non-root user**: 보안을 위한 k8s 사용자 실행
- **플랫폼**: linux/amd64 (K8s 클러스터 호환)
- **Java 버전**: OpenJDK 23
**주요 특징:**
- Multi-stage 빌드: 빌드 이미지와 런타임 이미지 분리
- Base Image: openjdk:23-slim (경량화)
- 보안: 비root 사용자(k8s)로 실행
- 플랫폼: linux/amd64
## 4. JAR 파일 빌드
## Gradle 빌드 실행
### 빌드 명령어
**실행 명령:**
```bash
./gradlew user-service:bootJar ai-service:bootJar event-service:bootJar \
content-service:bootJar participation-service:bootJar analytics-service:bootJar
./gradlew clean build -x test
```
### 빌드 결과
```
BUILD SUCCESSFUL in 27s
33 actionable tasks: 15 executed, 18 up-to-date
```
**빌드 결과:**
- 상태: ✅ BUILD SUCCESSFUL
- 소요 시간: 33초
- 실행된 태스크: 56개
### 생성된 JAR 파일
```bash
$ ls -lh */build/libs/*.jar
## 컨테이너 이미지 빌드
-rw-r--r-- 1 KTDS 197121 94M 10월 29 09:49 ai-service/build/libs/ai-service.jar
-rw-r--r-- 1 KTDS 197121 95M 10월 29 09:48 analytics-service/build/libs/analytics-service.jar
-rw-r--r-- 1 KTDS 197121 78M 10월 29 09:49 content-service/build/libs/content-service.jar
-rw-r--r-- 1 KTDS 197121 94M 10월 29 09:49 event-service/build/libs/event-service.jar
-rw-r--r-- 1 KTDS 197121 85M 10월 29 09:49 participation-service/build/libs/participation-service.jar
-rw-r--r-- 1 KTDS 197121 96M 10월 29 09:49 user-service/build/libs/user-service.jar
```
### 병렬 빌드 전략
서브 에이전트를 활용하여 6개 서비스를 동시에 빌드하여 시간 단축
## 5. Docker 이미지 빌드
### 1. user-service
### 사전 준비사항
⚠️ **Docker Desktop이 실행 중이어야 합니다**
Docker Desktop 시작 확인:
```bash
# Docker 상태 확인
docker version
docker ps
# Docker Desktop이 정상 실행되면 위 명령들이 정상 동작합니다
```
### 빌드 명령어
#### 5.1 user-service
**빌드 명령:**
```bash
DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="user-service/build/libs" \
@@ -151,22 +116,17 @@ docker build \
-t user-service:latest .
```
#### 5.2 ai-service
**결과:**
- 상태: ✅ SUCCESS
- 이미지 ID: fb07547604be
- 이미지 크기: 1.09GB
- Image SHA: sha256:fb07547604bee7e8ff69e56e8423299b7dec277e80d865ee5013ddd876a0b4c6
### 2. event-service
**빌드 명령:**
```bash
DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="ai-service/build/libs" \
--build-arg ARTIFACTORY_FILE="ai-service.jar" \
-f ${DOCKER_FILE} \
-t ai-service:latest .
```
#### 5.3 event-service
```bash
DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="event-service/build/libs" \
@@ -175,22 +135,56 @@ docker build \
-t event-service:latest .
```
#### 5.4 content-service
**결과:**
- 상태: ✅ SUCCESS
- 이미지 ID: 191a9882a628
- 이미지 크기: 1.08GB
- 빌드 시간: ~20초
### 3. ai-service
**빌드 명령:**
```bash
DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="content-service/build/libs" \
--build-arg ARTIFACTORY_FILE="content-service.jar" \
--build-arg BUILD_LIB_DIR="ai-service/build/libs" \
--build-arg ARTIFACTORY_FILE="ai-service.jar" \
-f ${DOCKER_FILE} \
-t content-service:latest .
-t ai-service:latest .
```
#### 5.5 participation-service
**결과:**
- 상태: ✅ SUCCESS
- 이미지 ID: 498feb888dc5
- 이미지 크기: 1.08GB
- Image SHA: sha256:498feb888dc58a98715841c4e50f191bc8434eccd12baefa79e82b0e44a5bc40
### 4. distribution-service
**빌드 명령:**
```bash
DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="distribution-service/build/libs" \
--build-arg ARTIFACTORY_FILE="distribution-service.jar" \
-f ${DOCKER_FILE} \
-t distribution-service:latest .
```
**결과:**
- 상태: ✅ SUCCESS
- 이미지 ID: e0ad31c51b63
- 이미지 크기: 1.08GB
- Image SHA: sha256:e0ad31c51b63b44d67f017cca8a729ae9cbb5e9e9503feddb308c09f19b70fba
- 빌드 시간: ~60초
### 5. participation-service
**빌드 명령:**
```bash
DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="participation-service/build/libs" \
@@ -199,10 +193,18 @@ docker build \
-t participation-service:latest .
```
#### 5.6 analytics-service
**결과:**
- 상태: ✅ SUCCESS
- 이미지 ID: 9bd60358659b
- 이미지 크기: 1.04GB
- Image SHA: sha256:9bd60358659b528190edcab699152b5126dc906070e05d355310303ac292f02b
- 빌드 시간: ~37초
### 6. analytics-service
**빌드 명령:**
```bash
DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="analytics-service/build/libs" \
@@ -211,186 +213,55 @@ docker build \
-t analytics-service:latest .
```
### 빌드 스크립트 (일괄 실행)
**결과:**
- 상태: ✅ SUCCESS
- 이미지 ID: 33b53299ec16
- 이미지 크기: 1.08GB
- Image SHA: sha256:33b53299ec16e0021a9adca4fb32535708021073df03c30b8a0ea335348547de
## 생성된 이미지 확인
**확인 명령:**
```bash
#!/bin/bash
# build-all-images.sh
DOCKER_FILE=deployment/container/Dockerfile-backend
services=(
"user-service"
"ai-service"
"event-service"
"content-service"
"participation-service"
"analytics-service"
)
for service in "${services[@]}"; do
echo "Building ${service}..."
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="${service}/build/libs" \
--build-arg ARTIFACTORY_FILE="${service}.jar" \
-f ${DOCKER_FILE} \
-t ${service}:latest .
if [ $? -eq 0 ]; then
echo "${service} build successful"
else
echo "${service} build failed"
exit 1
fi
done
echo "🎉 All images built successfully!"
docker images | grep -E "(user-service|event-service|ai-service|distribution-service|participation-service|analytics-service)" | grep latest
```
## 6. 이미지 확인
### 생성된 이미지 확인 명령어
```bash
# 모든 서비스 이미지 확인
docker images | grep -E "(user-service|ai-service|event-service|content-service|participation-service|analytics-service)"
# 개별 서비스 확인
docker images user-service:latest
docker images ai-service:latest
docker images event-service:latest
docker images content-service:latest
docker images participation-service:latest
docker images analytics-service:latest
**확인 결과:**
```
event-service latest 191a9882a628 39 seconds ago 1.08GB
ai-service latest 498feb888dc5 46 seconds ago 1.08GB
analytics-service latest 33b53299ec16 46 seconds ago 1.08GB
user-service latest fb07547604be 47 seconds ago 1.09GB
participation-service latest 9bd60358659b 48 seconds ago 1.04GB
distribution-service latest e0ad31c51b63 48 seconds ago 1.08GB
```
### 빌드 결과
```
REPOSITORY TAG IMAGE ID CREATED SIZE
user-service latest 91c511ef86bd About a minute ago 1.09GB
ai-service latest 9477022fa493 About a minute ago 1.08GB
event-service latest add81de69536 About a minute ago 1.08GB
content-service latest aa9cc16ad041 About a minute ago 1.01GB
participation-service latest 9b044a3854dd About a minute ago 1.04GB
analytics-service latest ac569de42545 About a minute ago 1.08GB
```
## 빌드 결과 요약
**빌드 일시**: 2025-10-29 09:50 KST
**빌드 소요 시간**: 약 13초 (병렬 빌드)
**총 이미지 크기**: 6.48GB
| 서비스명 | 이미지 태그 | 이미지 ID | 크기 | 상태 |
|---------|-----------|----------|------|------|
| user-service | user-service:latest | fb07547604be | 1.09GB | ✅ |
| event-service | event-service:latest | 191a9882a628 | 1.08GB | ✅ |
| ai-service | ai-service:latest | 498feb888dc5 | 1.08GB | ✅ |
| distribution-service | distribution-service:latest | e0ad31c51b63 | 1.08GB | ✅ |
| participation-service | participation-service:latest | 9bd60358659b | 1.04GB | ✅ |
| analytics-service | analytics-service:latest | 33b53299ec16 | 1.08GB | ✅ |
## 7. 이미지 테스트
**총 6개 서비스 이미지 빌드 성공**
### 로컬 실행 테스트 (예시: user-service)
```bash
# 컨테이너 실행
docker run -d \
--name user-service-test \
-p 8080:8080 \
-e SPRING_PROFILES_ACTIVE=dev \
user-service:latest
## 다음 단계
# 로그 확인
docker logs -f user-service-test
생성된 이미지를 사용하여 다음 작업을 진행할 수 있습니다:
# 헬스체크
curl http://localhost:8080/actuator/health
1. **로컬 테스트:** Docker Compose 또는 개별 컨테이너 실행
2. **ACR 푸시:** Azure Container Registry에 이미지 업로드
3. **AKS 배포:** Kubernetes 클러스터에 배포
4. **CI/CD 통합:** GitHub Actions 또는 Jenkins 파이프라인 연동
# 정리
docker stop user-service-test
docker rm user-service-test
```
## 참고사항
## 8. 다음 단계
### 8.1 컨테이너 레지스트리 푸시
```bash
# Docker Hub 예시
docker tag user-service:latest <your-registry>/user-service:latest
docker push <your-registry>/user-service:latest
# Azure Container Registry 예시
docker tag user-service:latest <acr-name>.azurecr.io/user-service:latest
docker push <acr-name>.azurecr.io/user-service:latest
```
### 8.2 Kubernetes 배포
- Kubernetes Deployment 매니페스트 작성
- Service 리소스 정의
- ConfigMap/Secret 설정
- Ingress 구성
### 8.3 CI/CD 파이프라인 구성
- GitHub Actions 또는 Jenkins 파이프라인 작성
- 자동 빌드 및 배포 설정
- 이미지 태깅 전략 수립 (semantic versioning)
## 9. 트러블슈팅
### Issue 1: Docker Desktop 미실행
**증상**:
```
ERROR: error during connect: open //./pipe/dockerDesktopLinuxEngine:
The system cannot find the file specified.
```
**해결**:
1. Docker Desktop 애플리케이션 시작
2. 시스템 트레이의 Docker 아이콘이 안정화될 때까지 대기
3. `docker ps` 명령으로 정상 동작 확인
### Issue 2: JAR 파일 없음
**증상**:
```
COPY failed: file not found in build context
```
**해결**:
```bash
# JAR 파일 재빌드
./gradlew {service-name}:clean {service-name}:bootJar
# 생성 확인
ls -l {service-name}/build/libs/{service-name}.jar
```
### Issue 3: 플랫폼 불일치
**증상**: K8s 클러스터에서 실행 안됨
**해결**: `--platform linux/amd64` 옵션 사용 (이미 적용됨)
## 10. 요약
### ✅ 완료된 작업
1. ✅ 6개 서비스의 bootJar 설정 완료
2. ✅ Dockerfile-backend 생성 완료
3. ✅ 6개 서비스 JAR 파일 빌드 완료 (총 542MB)
4. ✅ 6개 서비스 Docker 이미지 빌드 완료 (총 6.48GB)
### 📊 최종 서비스 현황
| 서비스 | JAR 빌드 | Docker 이미지 | 이미지 크기 | Image ID | 상태 |
|--------|---------|--------------|-----------|----------|------|
| user-service | ✅ 96MB | ✅ | 1.09GB | 91c511ef86bd | ✅ Ready |
| ai-service | ✅ 94MB | ✅ | 1.08GB | 9477022fa493 | ✅ Ready |
| event-service | ✅ 94MB | ✅ | 1.08GB | add81de69536 | ✅ Ready |
| content-service | ✅ 78MB | ✅ | 1.01GB | aa9cc16ad041 | ✅ Ready |
| participation-service | ✅ 85MB | ✅ | 1.04GB | 9b044a3854dd | ✅ Ready |
| analytics-service | ✅ 95MB | ✅ | 1.08GB | ac569de42545 | ✅ Ready |
| distribution-service | ❌ | ❌ | - | - | 소스 미구현 |
### 🎯 빌드 성능 메트릭
- **JAR 빌드 시간**: 27초
- **Docker 이미지 빌드**: 병렬 실행으로 약 13초
- **총 소요 시간**: 약 40초
- **빌드 성공률**: 100% (6/6 서비스)
### 🚀 다음 단계 권장사항
1. **컨테이너 레지스트리 푸시** (예: Azure ACR, Docker Hub)
2. **Kubernetes 배포 매니페스트 작성**
3. **CI/CD 파이프라인 구성** (GitHub Actions 또는 Jenkins)
4. **모니터링 및 로깅 설정**
---
**작성일**: 2025-10-29 09:50 KST
**작성자**: DevOps Engineer (송근정 "데브옵스 마스터")
**빌드 완료**: ✅ 모든 서비스 이미지 빌드 성공
- 모든 이미지는 linux/amd64 플랫폼용으로 빌드됨
- 보안을 위해 비root 사용자(k8s)로 실행 구성
- Multi-stage 빌드로 이미지 크기 최적화
- Java 23 (OpenJDK) 기반 런타임 사용
- content-service(Python)는 별도의 Dockerfile로 빌드 필요
+1 -1
View File
@@ -20,7 +20,7 @@ data:
EXCLUDE_REDIS: ""
# CORS Configuration
CORS_ALLOWED_ORIGINS: "http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io"
CORS_ALLOWED_ORIGINS: "http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io,http://kt-event-marketing-api.20.214.196.128.nip.io,http://*.20.214.196.128.nip.io,https://kt-event-marketing.20.214.196.128.nip.io,https://kt-event-marketing-api.20.214.196.128.nip.io,https://*.20.214.196.128.nip.io"
CORS_ALLOWED_METHODS: "GET,POST,PUT,DELETE,OPTIONS,PATCH"
CORS_ALLOWED_HEADERS: "*"
CORS_ALLOW_CREDENTIALS: "true"
+1 -1
View File
@@ -99,7 +99,7 @@ spec:
number: 80
# Distribution Service
- path: /distribution
- path: /api/v1/distribution
pathType: Prefix
backend:
service:
@@ -42,21 +42,21 @@ spec:
memory: "1024Mi"
startupProbe:
httpGet:
path: /distribution/actuator/health
path: /api/v1/distribution/actuator/health
port: 8085
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 30
readinessProbe:
httpGet:
path: /distribution/actuator/health/readiness
path: /api/v1/distribution/actuator/health/readiness
port: 8085
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /distribution/actuator/health/liveness
path: /api/v1/distribution/actuator/health/liveness
port: 8085
initialDelaySeconds: 30
periodSeconds: 10
@@ -39,7 +39,7 @@ public class OpenApiConfig {
.email("support@kt-event-marketing.com")))
.servers(List.of(
new Server()
.url("http://localhost:8085")
.url("http://localhost:8085/api/v1/distribution")
.description("Local Development Server"),
new Server()
.url("https://dev-api.kt-event-marketing.com/distribution/v1")
@@ -48,7 +48,7 @@ public class OpenApiConfig {
.url("https://api.kt-event-marketing.com/distribution/v1")
.description("Production Server"),
new Server()
.url("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1")
.url("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1/distribution")
.description("VM Development Server")
));
}
@@ -18,15 +18,15 @@ import org.springframework.web.bind.annotation.*;
/**
* Distribution Controller
* POST api/v1/distribution/distribute - 다중 채널 배포 실행
* GET api/v1/distribution/{eventId}/status - 배포 상태 조회
* POST /distribute - 다중 채널 배포 실행
* GET /{eventId}/status - 배포 상태 조회
*
* @author System Architect
* @since 2025-10-23
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/distribution")
@RequestMapping
@RequiredArgsConstructor
@Tag(name = "Distribution", description = "다중 채널 배포 관리 API")
public class DistributionController {
@@ -68,7 +68,7 @@ kafka:
server:
port: ${SERVER_PORT:8085}
servlet:
context-path: /distribution
context-path: /api/v1/distribution
# Resilience4j Configuration
resilience4j:
@@ -123,6 +123,15 @@ channel:
url: ${KAKAO_API_URL:http://localhost:9006/api/kakao}
timeout: 10000
# Naver Blog Configuration (Playwright 기반)
naver:
blog:
username: ${NAVER_BLOG_USERNAME:}
password: ${NAVER_BLOG_PASSWORD:}
blog-id: ${NAVER_BLOG_ID:}
headless: ${NAVER_BLOG_HEADLESS:true}
session-path: ${NAVER_BLOG_SESSION_PATH:playwright-sessions}
# Springdoc OpenAPI (Swagger)
springdoc:
api-docs:
@@ -136,6 +145,14 @@ springdoc:
display-request-duration: true
show-actuator: true
# CORS Configuration
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io}
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
max-age: ${CORS_MAX_AGE:3600}
# Logging
logging:
file:
+3
View File
@@ -31,6 +31,9 @@
<!-- JWT Configuration -->
<entry key="JWT_SECRET" value="kt-event-marketing-secret-key-for-development-only-please-change-in-production" />
<!-- CORS Configuration -->
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*,http://*.nip.io:*" />
<!-- Logging Configuration -->
<entry key="LOG_LEVEL" value="DEBUG" />
<entry key="SQL_LOG_LEVEL" value="DEBUG" />
@@ -167,3 +167,11 @@ app:
jwt:
secret: ${JWT_SECRET:default-jwt-secret-key-for-development-minimum-32-bytes-required}
expiration: 86400000 # 24시간 (밀리초 단위)
# CORS Configuration
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io}
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
max-age: ${CORS_MAX_AGE:3600}
@@ -12,6 +12,7 @@
<entry key="JWT_EXPIRATION" value="86400000" />
<entry key="JWT_SECRET" value="kt-event-marketing-secret-key-for-development-only-change-in-production" />
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*,http://*.nip.io:*" />
<entry key="LOG_FILE" value="logs/participation-service.log" />
<entry key="LOG_LEVEL" value="INFO" />
<entry key="REDIS_HOST" value="20.214.210.71" />
@@ -1,11 +1,17 @@
package com.kt.event.participation.infrastructure.config;
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.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* Security Configuration for Participation Service
@@ -18,10 +24,14 @@ import org.springframework.security.web.SecurityFilterChain;
@EnableWebSecurity
public class SecurityConfig {
@Value("${cors.allowed-origins:http://localhost:*,https://kt-event-marketing-api.20.214.196.128.nip.io/api/v1}")
private String allowedOrigins;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Actuator endpoints
@@ -31,4 +41,26 @@ public class SecurityConfig {
return http.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;
}
}
@@ -54,6 +54,14 @@ jwt:
secret: ${JWT_SECRET:dev-jwt-secret-key-for-development-only}
expiration: ${JWT_EXPIRATION:86400000}
# CORS 설정
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io}
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
max-age: ${CORS_MAX_AGE:3600}
# 서버 설정
server:
port: ${SERVER_PORT:8084}
@@ -90,4 +98,14 @@ management:
livenessState:
enabled: true
readinessState:
enabled: true
enabled: true
# OpenAPI Documentation
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
show-actuator: false
+33
View File
@@ -0,0 +1,33 @@
# Analytics Redis 초기화 스크립트
Write-Host "Analytics Redis 초기화 시작..." -ForegroundColor Cyan
# Redis 컨테이너 찾기
$redisContainer = docker ps --filter "ancestor=redis" --format "{{.Names}}" | Select-Object -First 1
if ($redisContainer) {
Write-Host "Redis 컨테이너 발견: $redisContainer" -ForegroundColor Green
# 멱등성 키 삭제
Write-Host "멱등성 키 삭제 중..." -ForegroundColor Yellow
docker exec $redisContainer redis-cli DEL processed_participants
docker exec $redisContainer redis-cli DEL processed_events
docker exec $redisContainer redis-cli DEL distribution_completed
# 캐시 삭제
Write-Host "Analytics 캐시 삭제 중..." -ForegroundColor Yellow
docker exec $redisContainer redis-cli --scan --pattern "analytics:*" | ForEach-Object {
docker exec $redisContainer redis-cli DEL $_
}
Write-Host "완료! 서버를 재시작해주세요." -ForegroundColor Green
} else {
Write-Host "Redis 컨테이너를 찾을 수 없습니다." -ForegroundColor Red
Write-Host "로컬 Redis를 시도합니다..." -ForegroundColor Yellow
redis-cli DEL processed_participants
redis-cli DEL processed_events
redis-cli DEL distribution_completed
Write-Host "완료! 서버를 재시작해주세요." -ForegroundColor Green
}
+1 -1
View File
@@ -42,7 +42,7 @@
<entry key="JWT_ACCESS_TOKEN_VALIDITY" value="604800000" />
<!-- CORS Configuration -->
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*" />
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:*,http://*.nip.io:*" />
<!-- Logging Configuration -->
<entry key="LOG_LEVEL_APP" value="DEBUG" />
+4
View File
@@ -12,6 +12,10 @@ dependencies {
// OpenFeign for external API calls (사업자번호 검증)
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
// Flyway for database migration
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-database-postgresql'
// H2 Database for development
runtimeOnly 'com.h2database:h2'
@@ -38,6 +38,18 @@ public class SecurityConfig {
@Value("${cors.allowed-origins:http://localhost:*}")
private String allowedOrigins;
@Value("${cors.allowed-methods:GET,POST,PUT,DELETE,OPTIONS,PATCH}")
private String allowedMethods;
@Value("${cors.allowed-headers:*}")
private String allowedHeaders;
@Value("${cors.allow-credentials:true}")
private boolean allowCredentials;
@Value("${cors.max-age:3600}")
private long maxAge;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
@@ -45,8 +57,8 @@ public class SecurityConfig {
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Public endpoints
.requestMatchers("/api/v1/users/register", "/api/v1/users/login").permitAll()
// Public endpoints (context-path가 /api/v1/users이므로 상대 경로 사용)
.requestMatchers("/register", "/login").permitAll()
// Actuator endpoints
.requestMatchers("/actuator/**").permitAll()
// Swagger UI endpoints
@@ -65,24 +77,23 @@ public class SecurityConfig {
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 환경변수에서 허용할 Origin 패턴 설정
String[] origins = allowedOrigins.split(",");
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
// application.yml에서 설정한 Origin 목록 사용
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
// 허용할 HTTP 메소드
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
configuration.setAllowedMethods(Arrays.asList(allowedMethods.split(",")));
// 허용할 헤더
configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Content-Type", "X-Requested-With", "Accept",
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"
));
configuration.setAllowedHeaders(Arrays.asList(allowedHeaders.split(",")));
// 자격 증명 허용
configuration.setAllowCredentials(true);
configuration.setAllowCredentials(allowCredentials);
// Pre-flight 요청 캐시 시간
configuration.setMaxAge(3600L);
configuration.setMaxAge(maxAge);
// Exposed Headers 추가
configuration.setExposedHeaders(Arrays.asList("Authorization", "Content-Type", "X-Total-Count"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
@@ -26,10 +26,13 @@ public class SwaggerConfig {
return new OpenAPI()
.info(apiInfo())
.addServersItem(new Server()
.url("http://localhost:8081")
.url("http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1/users")
.description("Production Server (AKS Ingress)"))
.addServersItem(new Server()
.url("http://localhost:8081/api/v1/users")
.description("Local Development"))
.addServersItem(new Server()
.url("{protocol}://{host}:{port}")
.url("{protocol}://{host}:{port}/api/v1/users")
.description("Custom Server")
.variables(new io.swagger.v3.oas.models.servers.ServerVariables()
.addServerVariable("protocol", new io.swagger.v3.oas.models.servers.ServerVariable()
@@ -33,7 +33,7 @@ import java.util.UUID;
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/users")
@RequestMapping("") // context-path가 /api/v1/users이므로 빈 문자열 사용
@RequiredArgsConstructor
@Tag(name = "User", description = "사용자 인증 및 프로필 관리 API")
public class UserController {
@@ -31,7 +31,13 @@ spring:
use_sql_comments: true
dialect: ${JPA_DIALECT:org.hibernate.dialect.PostgreSQLDialect}
hibernate:
ddl-auto: ${DDL_AUTO:update}
ddl-auto: ${DDL_AUTO:validate}
# Flyway Configuration
flyway:
enabled: ${FLYWAY_ENABLED:true}
baseline-on-migrate: ${FLYWAY_BASELINE:true}
locations: classpath:db/migration
# Auto-configuration exclusions for development without external services
autoconfigure:
@@ -76,7 +82,11 @@ jwt:
# CORS Configuration
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*}
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084,http://kt-event-marketing.20.214.196.128.nip.io,http://kt-event-marketing-api.20.214.196.128.nip.io,http://*.kt-event-marketing-api.20.214.196.128.nip.io,http://*.20.214.196.128.nip.io}
allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,DELETE,OPTIONS,PATCH}
allowed-headers: ${CORS_ALLOWED_HEADERS:*}
allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
max-age: ${CORS_MAX_AGE:3600}
# Actuator
management:
@@ -0,0 +1,45 @@
-- Migration script to change user_id from BIGINT to UUID
-- WARNING: This will delete all existing data in users and stores tables
-- Make sure to backup your data before running this script!
-- Step 1: Drop dependent tables/constraints
DROP TABLE IF EXISTS stores CASCADE;
DROP TABLE IF EXISTS users CASCADE;
-- Step 2: Create users table with UUID
CREATE TABLE users (
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) NOT NULL,
phone_number VARCHAR(20) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'OWNER',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
last_login_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Step 3: Create indexes on users table
CREATE UNIQUE INDEX idx_user_phone ON users(phone_number);
CREATE UNIQUE INDEX idx_user_email ON users(email);
-- Step 4: Create stores table with UUID foreign key
CREATE TABLE stores (
store_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
industry VARCHAR(50),
address VARCHAR(255) NOT NULL,
business_hours VARCHAR(255),
user_id UUID NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_stores_user FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
-- Step 5: Create index on stores table
CREATE INDEX idx_stores_user ON stores(user_id);
-- Enable UUID extension if not already enabled
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
@@ -0,0 +1,45 @@
-- Migration script V002: Change user_id and store_id from BIGINT to UUID
-- WARNING: This will delete all existing data in users and stores tables
-- Make sure to backup your data before running this script!
-- Step 1: Drop dependent tables/constraints
DROP TABLE IF EXISTS stores CASCADE;
DROP TABLE IF EXISTS users CASCADE;
-- Step 2: Create users table with UUID
CREATE TABLE users (
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) NOT NULL,
phone_number VARCHAR(20) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'OWNER',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
last_login_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Step 3: Create indexes on users table
CREATE UNIQUE INDEX idx_user_phone ON users(phone_number);
CREATE UNIQUE INDEX idx_user_email ON users(email);
-- Step 4: Create stores table with UUID foreign key
CREATE TABLE stores (
store_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
industry VARCHAR(50),
address VARCHAR(255) NOT NULL,
business_hours VARCHAR(255),
user_id UUID NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_stores_user FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
-- Step 5: Create index on stores table
CREATE INDEX idx_stores_user ON stores(user_id);
-- Enable UUID extension if not already enabled
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";