mirror of
https://github.com/ktds-dg0501/kt-event-marketing.git
synced 2025-12-06 19:26:23 +00:00
Compare commits
No commits in common. "9dfbb5866b6abd944ecc98ed754a76918e587eb1" and "d14a7349bc89552b6ceb1b9fad4fbb0aa5e0c371" have entirely different histories.
9dfbb5866b
...
d14a7349bc
23
.github/README.md
vendored
23
.github/README.md
vendored
@ -1,15 +1,12 @@
|
|||||||
# KT 이벤트 파트너
|
# KT Event Marketing - CI/CD Infrastructure
|
||||||
|
|
||||||
KT 이벤트 파트너는 소상공인이 AI를 활용하여 효과적인 이벤트를 쉽게 기획하고 관리할 수 있도록 지원하는 플랫폼입니다.
|
이 디렉토리는 KT Event Marketing 백엔드 서비스의 CI/CD 인프라를 포함합니다.
|
||||||
매장 정보와 AI 추천을 기반으로 이벤트를 생성하고, SNS 콘텐츠를 자동 생성하며, 다양한 채널로 배포하고, 실시간으로 성과를 분석할 수 있습니다.
|
|
||||||
|
|
||||||
이 디렉토리는 KT 이벤트 파트너 서비스의 CI/CD 인프라를 포함합니다.
|
|
||||||
|
|
||||||
## 디렉토리 구조
|
## 디렉토리 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
.github/
|
.github/
|
||||||
├── README.md
|
├── README.md # 이 파일
|
||||||
├── workflows/
|
├── workflows/
|
||||||
│ └── backend-cicd.yaml # GitHub Actions 워크플로우
|
│ └── backend-cicd.yaml # GitHub Actions 워크플로우
|
||||||
├── kustomize/ # Kubernetes 매니페스트 관리
|
├── kustomize/ # Kubernetes 매니페스트 관리
|
||||||
@ -129,13 +126,13 @@ GitHub Actions 워크플로우 정의 파일입니다.
|
|||||||
|
|
||||||
## 서비스 목록
|
## 서비스 목록
|
||||||
|
|
||||||
1. **user-service** (8081) - 사용자 및 매장 관리
|
1. **user-service** (8081) - 사용자 관리
|
||||||
2. **event-service** (8080) - 이벤트 관리
|
2. **event-service** (8082) - 이벤트 관리
|
||||||
3. **ai-service** (8083) - AI 기반 트렌드 분석 및 이벤트 추천
|
3. **ai-service** (8083) - AI 기반 콘텐츠 생성
|
||||||
4. **content-service** (8084) - SNS 콘텐츠(이미지) 생성
|
4. **content-service** (8084) - 콘텐츠 관리
|
||||||
5. **distribution-service** (8085) - 다중 채 배포
|
5. **distribution-service** (8085) - 경품 배포
|
||||||
6. **participation-service** (8084) - 이벤트 참여자 관리
|
6. **participation-service** (8086) - 이벤트 참여
|
||||||
7. **analytics-service** (8086) - 성과 분석 및 통계
|
7. **analytics-service** (8087) - 분석 및 통계
|
||||||
|
|
||||||
## 모니터링
|
## 모니터링
|
||||||
|
|
||||||
|
|||||||
22
.github/kustomize/base/ingress.yaml
vendored
22
.github/kustomize/base/ingress.yaml
vendored
@ -55,7 +55,7 @@ spec:
|
|||||||
number: 80
|
number: 80
|
||||||
|
|
||||||
# AI Service
|
# AI Service
|
||||||
- path: /api/v1/ai
|
- path: /api/v1/ai-service
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
@ -106,29 +106,11 @@ spec:
|
|||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
|
|
||||||
# Analytics Service - Swagger UI 및 기타 경로
|
|
||||||
- path: /api/v1/analytics
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: analytics-service
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
|
|
||||||
# Distribution Service
|
# Distribution Service
|
||||||
- path: /api/v1/distribution
|
- path: /distribution
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: distribution-service
|
name: distribution-service
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
|
|
||||||
# Event Service - Swagger UI 및 기타 경로 (맨 마지막에 배치 - catch-all)
|
|
||||||
- path: /api/v1
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: event-service
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
|
|||||||
@ -41,21 +41,21 @@ spec:
|
|||||||
memory: "1024Mi"
|
memory: "1024Mi"
|
||||||
startupProbe:
|
startupProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/v1/participations/actuator/health
|
path: /actuator/health/liveness
|
||||||
port: 8084
|
port: 8084
|
||||||
initialDelaySeconds: 60
|
initialDelaySeconds: 60
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
failureThreshold: 30
|
failureThreshold: 30
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/v1/participations/actuator/health/liveness
|
path: /actuator/health/liveness
|
||||||
port: 8084
|
port: 8084
|
||||||
initialDelaySeconds: 0
|
initialDelaySeconds: 0
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
failureThreshold: 3
|
failureThreshold: 3
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/v1/participations/actuator/health/readiness
|
path: /actuator/health/readiness
|
||||||
port: 8084
|
port: 8084
|
||||||
initialDelaySeconds: 0
|
initialDelaySeconds: 0
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
|
|||||||
@ -41,21 +41,21 @@ spec:
|
|||||||
memory: "1024Mi"
|
memory: "1024Mi"
|
||||||
startupProbe:
|
startupProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/v1/users/actuator/health
|
path: /actuator/health
|
||||||
port: 8081
|
port: 8081
|
||||||
initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
failureThreshold: 30
|
failureThreshold: 30
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/v1/users/actuator/health/readiness
|
path: /actuator/health/readiness
|
||||||
port: 8081
|
port: 8081
|
||||||
initialDelaySeconds: 10
|
initialDelaySeconds: 10
|
||||||
periodSeconds: 5
|
periodSeconds: 5
|
||||||
failureThreshold: 3
|
failureThreshold: 3
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/v1/users/actuator/health/liveness
|
path: /actuator/health/liveness
|
||||||
port: 8081
|
port: 8081
|
||||||
initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
|
|||||||
12
.github/workflows/backend-cicd.yaml
vendored
12
.github/workflows/backend-cicd.yaml
vendored
@ -9,12 +9,12 @@ on:
|
|||||||
# - '*-service/**'
|
# - '*-service/**'
|
||||||
# - '.github/workflows/backend-cicd.yaml'
|
# - '.github/workflows/backend-cicd.yaml'
|
||||||
# - '.github/kustomize/**'
|
# - '.github/kustomize/**'
|
||||||
# pull_request:
|
pull_request:
|
||||||
# branches:
|
branches:
|
||||||
# - develop
|
- develop
|
||||||
# - main
|
- main
|
||||||
# paths:
|
paths:
|
||||||
# - '*-service/**'
|
- '*-service/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<configuration default="false" name="AiServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
|
<configuration default="false" name="AiServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
|
||||||
<option name="ACTIVE_PROFILES" />
|
<option name="ACTIVE_PROFILES" />
|
||||||
<module name="kt-event-marketing.ai-service.main" />
|
<module name="kt-event-marketing.ai-service.main" />
|
||||||
<option name="SPRING_BOOT_MAIN_CLASS" value="com.kt.ai.AiServiceApplication" />
|
<option name="SPRING_BOOT_MAIN_CLASS" value="com.kt.ai.AiApplication" />
|
||||||
<extension name="coverage">
|
<extension name="coverage">
|
||||||
<pattern>
|
<pattern>
|
||||||
<option name="PATTERN" value="com.kt.ai.*" />
|
<option name="PATTERN" value="com.kt.ai.*" />
|
||||||
@ -10,25 +10,19 @@
|
|||||||
</pattern>
|
</pattern>
|
||||||
</extension>
|
</extension>
|
||||||
<envs>
|
<envs>
|
||||||
<env name="SERVER_PORT" value="8083" />
|
<env name="SERVER_PORT" value="8081" />
|
||||||
|
<env name="DB_HOST" value="4.230.112.141" />
|
||||||
|
<env name="DB_PORT" value="5432" />
|
||||||
|
<env name="DB_NAME" value="aidb" />
|
||||||
|
<env name="DB_USERNAME" value="eventuser" />
|
||||||
|
<env name="DB_PASSWORD" value="Hi5Jessica!" />
|
||||||
<env name="REDIS_HOST" value="20.214.210.71" />
|
<env name="REDIS_HOST" value="20.214.210.71" />
|
||||||
<env name="REDIS_PORT" value="6379" />
|
<env name="REDIS_PORT" value="6379" />
|
||||||
<env name="REDIS_PASSWORD" value="Hi5Jessica!" />
|
<env name="REDIS_PASSWORD" value="Hi5Jessica!" />
|
||||||
<env name="REDIS_DATABASE" value="3" />
|
|
||||||
<env name="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
|
<env name="KAFKA_BOOTSTRAP_SERVERS" value="20.249.182.13:9095,4.217.131.59:9095" />
|
||||||
<env name="KAFKA_CONSUMER_GROUP" value="ai-service-consumers" />
|
<env name="KAFKA_CONSUMER_GROUP" value="ai" />
|
||||||
<env name="KAFKA_TOPICS_AI_JOB" value="ai-event-generation-job" />
|
<env name="JPA_DDL_AUTO" value="update" />
|
||||||
<env name="KAFKA_TOPICS_AI_JOB_DLQ" value="ai-event-generation-job-dlq" />
|
<env name="JPA_SHOW_SQL" value="false" />
|
||||||
<env name="AI_PROVIDER" value="CLAUDE" />
|
|
||||||
<env name="AI_CLAUDE_API_URL" value="https://api.anthropic.com/v1/messages" />
|
|
||||||
<env name="AI_CLAUDE_API_KEY" value="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" />
|
|
||||||
<env name="AI_CLAUDE_ANTHROPIC_VERSION" value="2023-06-01" />
|
|
||||||
<env name="AI_CLAUDE_MODEL" value="claude-sonnet-4-5-20250929" />
|
|
||||||
<env name="AI_CLAUDE_MAX_TOKENS" value="4096" />
|
|
||||||
<env name="AI_CLAUDE_TEMPERATURE" value="0.7" />
|
|
||||||
<env name="LOG_LEVEL_ROOT" value="INFO" />
|
|
||||||
<env name="LOG_LEVEL_AI" value="DEBUG" />
|
|
||||||
<env name="LOG_LEVEL_KAFKA" value="INFO" />
|
|
||||||
</envs>
|
</envs>
|
||||||
<method v="2">
|
<method v="2">
|
||||||
<option name="Make" enabled="true" />
|
<option name="Make" enabled="true" />
|
||||||
|
|||||||
@ -23,11 +23,6 @@
|
|||||||
<env name="KAFKA_CONSUMER_GROUP" value="distribution-service" />
|
<env name="KAFKA_CONSUMER_GROUP" value="distribution-service" />
|
||||||
<env name="JPA_DDL_AUTO" value="update" />
|
<env name="JPA_DDL_AUTO" value="update" />
|
||||||
<env name="JPA_SHOW_SQL" value="false" />
|
<env name="JPA_SHOW_SQL" value="false" />
|
||||||
<env name="NAVER_BLOG_USERNAME" value="" />
|
|
||||||
<env name="NAVER_BLOG_PASSWORD" value="" />
|
|
||||||
<env name="NAVER_BLOG_BLOG_ID" value="" />
|
|
||||||
<env name="NAVER_BLOG_HEADLESS" value="false" />
|
|
||||||
<env name="NAVER_BLOG_SESSION_PATH" value="playwright-sessions" />
|
|
||||||
</envs>
|
</envs>
|
||||||
<method v="2">
|
<method v="2">
|
||||||
<option name="Make" enabled="true" />
|
<option name="Make" enabled="true" />
|
||||||
|
|||||||
804
README.md
804
README.md
@ -1,804 +0,0 @@
|
|||||||
# KT AI 기반 소상공인 이벤트 자동 생성 서비스 (Backend)
|
|
||||||
|
|
||||||
> **AI 기반 이벤트 자동 생성 및 관리 서비스의 백엔드 시스템**
|
|
||||||
>
|
|
||||||
> 마이크로서비스 아키텍처 기반으로 설계된 확장 가능한 이벤트 관리 플랫폼
|
|
||||||
|
|
||||||
[]()
|
|
||||||
[](https://openjdk.org/projects/jdk/21/)
|
|
||||||
[](https://spring.io/projects/spring-boot)
|
|
||||||
[](https://azure.microsoft.com/en-us/products/kubernetes-service)
|
|
||||||
|
|
||||||
## 🚀 빠른 시작 (Quick Start)
|
|
||||||
|
|
||||||
### 사전 요구사항
|
|
||||||
```bash
|
|
||||||
Java 21, Gradle 8.x, Docker & Docker Compose
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3단계 실행
|
|
||||||
```bash
|
|
||||||
# 1. 백킹 서비스 시작 (PostgreSQL, Redis, Kafka)
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# 2. 프로젝트 빌드
|
|
||||||
./gradlew clean build -x test
|
|
||||||
|
|
||||||
# 3. 서비스 실행 (IntelliJ 실행 프로파일 또는 명령줄)
|
|
||||||
# IntelliJ: .run/*.run.xml 프로파일 사용
|
|
||||||
# 명령줄: java -jar {service-name}/build/libs/{service-name}.jar
|
|
||||||
```
|
|
||||||
|
|
||||||
### 동작 확인
|
|
||||||
```bash
|
|
||||||
# Health Check
|
|
||||||
curl http://localhost:8080/actuator/health # Event Service
|
|
||||||
curl http://localhost:8081/actuator/health # User Service
|
|
||||||
|
|
||||||
# Swagger UI
|
|
||||||
http://localhost:8080/swagger-ui.html
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 목차
|
|
||||||
- [1. 소개](#1-소개)
|
|
||||||
- [2. 프로젝트 구조](#2-프로젝트-구조)
|
|
||||||
- [3. 시스템 아키텍처](#3-시스템-아키텍처)
|
|
||||||
- [4. 로컬 개발 환경 설정](#4-로컬-개발-환경-설정)
|
|
||||||
- [5. Docker 컨테이너 빌드 및 실행](#5-docker-컨테이너-빌드-및-실행)
|
|
||||||
- [6. Kubernetes 배포](#6-kubernetes-배포)
|
|
||||||
- [7. CI/CD](#7-cicd)
|
|
||||||
- [8. 테스트](#8-테스트)
|
|
||||||
- [9. 모니터링 및 로깅](#9-모니터링-및-로깅)
|
|
||||||
- [10. 트러블슈팅](#10-트러블슈팅)
|
|
||||||
- [11. 개발 참고 자료](#11-개발-참고-자료)
|
|
||||||
- [12. 팀](#12-팀)
|
|
||||||
- [13. 라이선스](#13-라이선스)
|
|
||||||
- [14. 문의](#14-문의)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 소개
|
|
||||||
KT AI 기반 소상공인 이벤트 자동 생성 서비스는 고객 유치와 매출 증대를 위한 이벤트를 하고 싶지만, 기획·제작·운영 역량과 시간이 부족한 소상공인 및 중소기업의 업무를 지원하는 AI 기반 자동화 서비스입니다.
|
|
||||||
|
|
||||||
본 저장소는 **백엔드 마이크로서비스**를 포함하며, Spring Boot 3.3.0 기반의 7개 독립 서비스로 구성됩니다.
|
|
||||||
|
|
||||||
### 1.1 관련 저장소
|
|
||||||
|
|
||||||
| 구분 | 설명 | 저장소 URL |
|
|
||||||
|------|------|-----------|
|
|
||||||
| **사내 통합** | 설계 문서 및 통합 관리 | [https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing) |
|
|
||||||
| **백엔드** | Spring Boot 마이크로서비스 (현재 저장소) | [https://github.com/ktds-dg0501/kt-event-marketing](https://github.com/ktds-dg0501/kt-event-marketing) |
|
|
||||||
| **프론트엔드** | React 기반 웹 애플리케이션 | [https://github.com/ktds-dg0501/kt-event-marketing-fe](https://github.com/ktds-dg0501/kt-event-marketing-fe) |
|
|
||||||
| **K8s Manifest** | Kubernetes 배포 매니페스트 | {Manifest Repo} |
|
|
||||||
|
|
||||||
### 1.2 핵심 기능
|
|
||||||
- **AI 이벤트 기획**: 업종과 목적에 맞는 이벤트 자동 기획
|
|
||||||
- **이벤트 콘텐츠 생성**: AI 기반 이미지 및 텍스트 콘텐츠 자동 생성
|
|
||||||
- **참여자 관리**: 이벤트 참여자 등록 및 당첨자 추첨 자동화
|
|
||||||
- **실시간 분석**: 이벤트 진행 상황 및 성과 실시간 모니터링
|
|
||||||
- **다채널 배포**: 다양한 채널로 이벤트 자동 배포
|
|
||||||
|
|
||||||
### 1.3 MVP 산출물
|
|
||||||
- **발표자료**: {발표자료 링크}
|
|
||||||
- **설계결과**:
|
|
||||||
- [유저스토리](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/userstory.md)
|
|
||||||
- [UI/UX 설계서](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/uiux/uiux.md)
|
|
||||||
- [아키텍처 패턴](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/pattern/architecture-pattern.md)
|
|
||||||
- [High-Level 아키텍처](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/high-level-architecture.md)
|
|
||||||
- [API 설계서](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/backend/api/)
|
|
||||||
- [논리 아키텍처](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/backend/logical/)
|
|
||||||
- [시퀀스 설계](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/backend/sequence/)
|
|
||||||
- [클래스 설계](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/backend/class/)
|
|
||||||
- [데이터베이스 설계](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/backend/database/)
|
|
||||||
- [프론트엔드 설계](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/frontend/)
|
|
||||||
- **Git Repository**:
|
|
||||||
- **사내 통합 저장소**: https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing
|
|
||||||
- **백엔드 (GitHub)**: https://github.com/ktds-dg0501/kt-event-marketing.git
|
|
||||||
- **프론트엔드 (GitHub)**: https://github.com/ktds-dg0501/kt-event-marketing-fe.git
|
|
||||||
- **K8s Manifest**: {Manifest Repo}
|
|
||||||
- **시연 동영상**: {시연 동영상 링크}
|
|
||||||
|
|
||||||
## 2. 프로젝트 구조
|
|
||||||
|
|
||||||
### 2.1 디렉토리 구조
|
|
||||||
```
|
|
||||||
kt-event-marketing/
|
|
||||||
├── user-service/ # 사용자 인증 및 회원 관리
|
|
||||||
├── event-service/ # 이벤트 생성, 관리, 스케줄링
|
|
||||||
├── content-service/ # AI 콘텐츠 생성 (Python)
|
|
||||||
├── ai-service/ # AI 이벤트 추천 및 분석
|
|
||||||
├── participation-service/ # 참여자 관리 및 당첨자 추첨
|
|
||||||
├── analytics-service/ # 통계 분석
|
|
||||||
├── distribution-service/ # 다채널 배포
|
|
||||||
├── .run/ # IntelliJ 실행 프로파일
|
|
||||||
├── design/ # 설계 문서
|
|
||||||
│ ├── userstory.md
|
|
||||||
│ ├── uiux/
|
|
||||||
│ ├── backend/
|
|
||||||
│ │ ├── api/ # API 설계서
|
|
||||||
│ │ ├── logical/ # 논리 아키텍처
|
|
||||||
│ │ ├── sequence/ # 시퀀스 다이어그램
|
|
||||||
│ │ ├── class/ # 클래스 다이어그램
|
|
||||||
│ │ └── database/ # 데이터베이스 설계
|
|
||||||
│ ├── frontend/
|
|
||||||
│ ├── pattern/
|
|
||||||
│ └── high-level-architecture.md
|
|
||||||
├── deployment/
|
|
||||||
│ ├── container/ # Docker 관련 파일
|
|
||||||
│ ├── k8s/ # Kubernetes 매니페스트
|
|
||||||
│ └── cicd/ # CI/CD 파이프라인
|
|
||||||
├── docker-compose.yml # 백킹 서비스 실행
|
|
||||||
└── README.md # 본 문서
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 시스템 아키텍처
|
|
||||||
|
|
||||||
### 3.1 전체 구조
|
|
||||||
마이크로서비스 아키텍처 기반의 백엔드 서비스와 React 기반 프론트엔드로 구성된 웹 애플리케이션
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐
|
|
||||||
│ 프론트엔드 │
|
|
||||||
│ (React) │
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
┌────┴─────────────────────────────┐
|
|
||||||
│ API Gateway (Ingress) │
|
|
||||||
└────┬─────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌────┴────────────────────────────────────────────┐
|
|
||||||
│ 백엔드 서비스 │
|
|
||||||
├──────────┬──────────┬──────────┬──────────────┤
|
|
||||||
│ User │ Event │ Content │ Participation│
|
|
||||||
│ Service │ Service │ Service │ Service │
|
|
||||||
├──────────┼──────────┼──────────┼──────────────┤
|
|
||||||
│ AI │Analytics │Distribution│ │
|
|
||||||
│ Service │ Service │ Service │ │
|
|
||||||
└──────────┴──────────┴──────────┴──────────────┘
|
|
||||||
│ │ │
|
|
||||||
┌────┴───────────┴───────────┴────┐
|
|
||||||
│ 백킹 서비스 │
|
|
||||||
├──────────┬──────────┬───────────┤
|
|
||||||
│PostgreSQL│ Redis │ Kafka │
|
|
||||||
└──────────┴──────────┴───────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 마이크로서비스 구성
|
|
||||||
|
|
||||||
| 서비스 | 포트 | 설명 | API Path |
|
|
||||||
|--------|------|------|----------|
|
|
||||||
| **user-service** | 8081 | 사용자 인증 및 회원 관리 | /api/v1/users |
|
|
||||||
| **event-service** | 8080 | 이벤트 생성 및 관리, 스케줄링 | /api/v1/events, /api/v1/jobs |
|
|
||||||
| **content-service** | 8085 | AI 기반 콘텐츠 생성 (이미지, 텍스트) | /api/v1/content |
|
|
||||||
| **ai-service** | 8083 | AI 이벤트 추천 및 분석 | /api/v1/ai-service |
|
|
||||||
| **participation-service** | 8084 | 이벤트 참여자 관리 및 당첨자 추첨 | /api/v1/participations, /api/v1/winners |
|
|
||||||
| **analytics-service** | 8086 | 이벤트 및 사용자 통계 분석 | /api/v1/events/.../analytics, /api/v1/users/.../analytics |
|
|
||||||
| **distribution-service** | 8087 | 다채널 이벤트 배포 | /api/v1/distribution |
|
|
||||||
|
|
||||||
### 4.3 기술 스택
|
|
||||||
- **백엔드**:
|
|
||||||
- Java 21
|
|
||||||
- Spring Boot 3.3.0
|
|
||||||
- Spring Data JPA
|
|
||||||
- Spring Security
|
|
||||||
- Spring Kafka
|
|
||||||
- MapStruct
|
|
||||||
- OpenAPI (Swagger)
|
|
||||||
- **프론트엔드**: {React 버전 및 주요 라이브러리}
|
|
||||||
- **인프라**:
|
|
||||||
- Azure Kubernetes Service (AKS)
|
|
||||||
- Azure Container Registry (ACR)
|
|
||||||
- Ingress Controller
|
|
||||||
- **CI/CD**: GitHub Actions, Jenkins
|
|
||||||
- **백킹 서비스**:
|
|
||||||
- **Database**: PostgreSQL (서비스별 독립 DB)
|
|
||||||
- **Cache**: Redis (서비스별 독립 DB)
|
|
||||||
- **Message Queue**: Apache Kafka
|
|
||||||
|
|
||||||
## 4. 로컬 개발 환경 설정
|
|
||||||
|
|
||||||
### 4.1 사전 요구사항
|
|
||||||
- Java 21
|
|
||||||
- Gradle 8.x
|
|
||||||
- Docker & Docker Compose
|
|
||||||
- PostgreSQL, Redis, Kafka (Docker로 설치 가능)
|
|
||||||
|
|
||||||
### 4.2 백킹 서비스 설치
|
|
||||||
|
|
||||||
#### 4.2.1 Docker Compose로 백킹 서비스 설치
|
|
||||||
프로젝트 루트에 있는 `docker-compose.yml` 사용:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 모든 백킹 서비스 시작 (PostgreSQL, Redis, Kafka)
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# 상태 확인
|
|
||||||
docker-compose ps
|
|
||||||
|
|
||||||
# 로그 확인
|
|
||||||
docker-compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.2.2 개별 설치 (선택사항)
|
|
||||||
|
|
||||||
**PostgreSQL 설치**
|
|
||||||
```bash
|
|
||||||
# 각 서비스별 데이터베이스 생성
|
|
||||||
docker run -d \
|
|
||||||
--name postgres-user \
|
|
||||||
-e POSTGRES_USER=admin \
|
|
||||||
-e POSTGRES_PASSWORD=Passw0rd \
|
|
||||||
-e POSTGRES_DB=user_db \
|
|
||||||
-p 5432:5432 \
|
|
||||||
postgres:15
|
|
||||||
|
|
||||||
docker run -d \
|
|
||||||
--name postgres-event \
|
|
||||||
-e POSTGRES_USER=admin \
|
|
||||||
-e POSTGRES_PASSWORD=Passw0rd \
|
|
||||||
-e POSTGRES_DB=event_db \
|
|
||||||
-p 5433:5432 \
|
|
||||||
postgres:15
|
|
||||||
|
|
||||||
docker run -d \
|
|
||||||
--name postgres-participation \
|
|
||||||
-e POSTGRES_USER=admin \
|
|
||||||
-e POSTGRES_PASSWORD=Passw0rd \
|
|
||||||
-e POSTGRES_DB=participation_db \
|
|
||||||
-p 5434:5432 \
|
|
||||||
postgres:15
|
|
||||||
|
|
||||||
docker run -d \
|
|
||||||
--name postgres-analytics \
|
|
||||||
-e POSTGRES_USER=admin \
|
|
||||||
-e POSTGRES_PASSWORD=Passw0rd \
|
|
||||||
-e POSTGRES_DB=analytics_db \
|
|
||||||
-p 5435:5432 \
|
|
||||||
postgres:15
|
|
||||||
```
|
|
||||||
|
|
||||||
**Redis 설치**
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name redis \
|
|
||||||
-p 6379:6379 \
|
|
||||||
redis:7-alpine
|
|
||||||
```
|
|
||||||
|
|
||||||
**Kafka 설치**
|
|
||||||
```bash
|
|
||||||
# Zookeeper
|
|
||||||
docker run -d \
|
|
||||||
--name zookeeper \
|
|
||||||
-p 2181:2181 \
|
|
||||||
-e ZOOKEEPER_CLIENT_PORT=2181 \
|
|
||||||
confluentinc/cp-zookeeper:latest
|
|
||||||
|
|
||||||
# Kafka
|
|
||||||
docker run -d \
|
|
||||||
--name kafka \
|
|
||||||
-p 9092:9092 \
|
|
||||||
-e KAFKA_ZOOKEEPER_CONNECT=localhost:2181 \
|
|
||||||
-e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092 \
|
|
||||||
-e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \
|
|
||||||
confluentinc/cp-kafka:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 애플리케이션 빌드 및 실행
|
|
||||||
|
|
||||||
#### 4.3.1 전체 프로젝트 빌드
|
|
||||||
```bash
|
|
||||||
# 프로젝트 루트에서 실행
|
|
||||||
./gradlew clean build -x test
|
|
||||||
|
|
||||||
# 테스트 포함 빌드
|
|
||||||
./gradlew clean build
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.3.2 개별 서비스 빌드 및 실행
|
|
||||||
|
|
||||||
**서비스별 빌드**
|
|
||||||
```bash
|
|
||||||
# User Service
|
|
||||||
./gradlew :user-service:clean :user-service:build -x test
|
|
||||||
|
|
||||||
# Event Service
|
|
||||||
./gradlew :event-service:clean :event-service:build -x test
|
|
||||||
|
|
||||||
# Content Service (Python)
|
|
||||||
cd content-service
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# AI Service
|
|
||||||
./gradlew :ai-service:clean :ai-service:build -x test
|
|
||||||
|
|
||||||
# Participation Service
|
|
||||||
./gradlew :participation-service:clean :participation-service:build -x test
|
|
||||||
|
|
||||||
# Analytics Service
|
|
||||||
./gradlew :analytics-service:clean :analytics-service:build -x test
|
|
||||||
|
|
||||||
# Distribution Service
|
|
||||||
./gradlew :distribution-service:clean :distribution-service:build -x test
|
|
||||||
```
|
|
||||||
|
|
||||||
**서비스 실행**
|
|
||||||
|
|
||||||
IntelliJ IDEA를 사용하는 경우:
|
|
||||||
1. `.run` 디렉토리에 있는 실행 프로파일 사용
|
|
||||||
2. 각 서비스별 실행 프로파일 선택 후 실행
|
|
||||||
|
|
||||||
명령줄에서 실행:
|
|
||||||
```bash
|
|
||||||
# User Service
|
|
||||||
java -jar user-service/build/libs/user-service.jar
|
|
||||||
|
|
||||||
# Event Service
|
|
||||||
java -jar event-service/build/libs/event-service.jar
|
|
||||||
|
|
||||||
# Content Service (Python)
|
|
||||||
cd content-service
|
|
||||||
python app.py
|
|
||||||
|
|
||||||
# AI Service
|
|
||||||
java -jar ai-service/build/libs/ai-service.jar
|
|
||||||
|
|
||||||
# Participation Service
|
|
||||||
java -jar participation-service/build/libs/participation-service.jar
|
|
||||||
|
|
||||||
# Analytics Service
|
|
||||||
java -jar analytics-service/build/libs/analytics-service.jar
|
|
||||||
|
|
||||||
# Distribution Service
|
|
||||||
java -jar distribution-service/build/libs/distribution-service.jar
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.3.3 서비스 동작 확인
|
|
||||||
```bash
|
|
||||||
# User Service Health Check
|
|
||||||
curl http://localhost:8081/actuator/health
|
|
||||||
|
|
||||||
# Event Service Health Check
|
|
||||||
curl http://localhost:8080/actuator/health
|
|
||||||
|
|
||||||
# Content Service Health Check
|
|
||||||
curl http://localhost:8085/health
|
|
||||||
|
|
||||||
# AI Service Health Check
|
|
||||||
curl http://localhost:8083/actuator/health
|
|
||||||
|
|
||||||
# Participation Service Health Check
|
|
||||||
curl http://localhost:8084/actuator/health
|
|
||||||
|
|
||||||
# Analytics Service Health Check
|
|
||||||
curl http://localhost:8086/actuator/health
|
|
||||||
|
|
||||||
# Distribution Service Health Check
|
|
||||||
curl http://localhost:8087/actuator/health
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.3.4 API 문서 확인
|
|
||||||
각 서비스의 Swagger UI 접근:
|
|
||||||
- User Service: http://localhost:8081/swagger-ui.html
|
|
||||||
- Event Service: http://localhost:8080/swagger-ui.html
|
|
||||||
- AI Service: http://localhost:8083/swagger-ui.html
|
|
||||||
- Participation Service: http://localhost:8084/swagger-ui.html
|
|
||||||
- Analytics Service: http://localhost:8086/swagger-ui.html
|
|
||||||
- Distribution Service: http://localhost:8087/swagger-ui.html
|
|
||||||
|
|
||||||
## 5. Docker 컨테이너 빌드 및 실행
|
|
||||||
|
|
||||||
### 5.1 컨테이너 이미지 빌드
|
|
||||||
|
|
||||||
#### 5.1.1 Java 서비스 이미지 빌드
|
|
||||||
```bash
|
|
||||||
# User Service
|
|
||||||
docker build \
|
|
||||||
--build-arg SERVICE_NAME=user-service \
|
|
||||||
-f user-service/Dockerfile \
|
|
||||||
-t acrdigitalgarage01.azurecr.io/kt-event/user-service:latest .
|
|
||||||
|
|
||||||
# Event Service
|
|
||||||
docker build \
|
|
||||||
--build-arg SERVICE_NAME=event-service \
|
|
||||||
-f event-service/Dockerfile \
|
|
||||||
-t acrdigitalgarage01.azurecr.io/kt-event/event-service:latest .
|
|
||||||
|
|
||||||
# AI Service
|
|
||||||
docker build \
|
|
||||||
--build-arg SERVICE_NAME=ai-service \
|
|
||||||
-f ai-service/Dockerfile \
|
|
||||||
-t acrdigitalgarage01.azurecr.io/kt-event/ai-service:latest .
|
|
||||||
|
|
||||||
# Participation Service
|
|
||||||
docker build \
|
|
||||||
--build-arg SERVICE_NAME=participation-service \
|
|
||||||
-f participation-service/Dockerfile \
|
|
||||||
-t acrdigitalgarage01.azurecr.io/kt-event/participation-service:latest .
|
|
||||||
|
|
||||||
# Analytics Service
|
|
||||||
docker build \
|
|
||||||
--build-arg SERVICE_NAME=analytics-service \
|
|
||||||
-f analytics-service/Dockerfile \
|
|
||||||
-t acrdigitalgarage01.azurecr.io/kt-event/analytics-service:latest .
|
|
||||||
|
|
||||||
# Distribution Service
|
|
||||||
docker build \
|
|
||||||
--build-arg SERVICE_NAME=distribution-service \
|
|
||||||
-f distribution-service/Dockerfile \
|
|
||||||
-t acrdigitalgarage01.azurecr.io/kt-event/distribution-service:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5.1.2 Python 서비스 이미지 빌드
|
|
||||||
```bash
|
|
||||||
# Content Service
|
|
||||||
docker build \
|
|
||||||
-f content-service/Dockerfile \
|
|
||||||
-t acrdigitalgarage01.azurecr.io/kt-event/content-service:latest \
|
|
||||||
content-service/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 컨테이너 실행
|
|
||||||
|
|
||||||
#### 5.2.1 Docker Compose로 전체 실행
|
|
||||||
```bash
|
|
||||||
# deployment/container 디렉토리로 이동
|
|
||||||
cd deployment/container
|
|
||||||
|
|
||||||
# 전체 서비스 시작
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# 상태 확인
|
|
||||||
docker-compose ps
|
|
||||||
|
|
||||||
# 로그 확인
|
|
||||||
docker-compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5.2.2 개별 컨테이너 실행
|
|
||||||
```bash
|
|
||||||
# User Service
|
|
||||||
docker run -d \
|
|
||||||
--name user-service \
|
|
||||||
-p 8081:8081 \
|
|
||||||
-e DB_HOST=host.docker.internal \
|
|
||||||
-e DB_PORT=5432 \
|
|
||||||
-e DB_NAME=user_db \
|
|
||||||
-e REDIS_HOST=host.docker.internal \
|
|
||||||
-e KAFKA_BOOTSTRAP_SERVERS=host.docker.internal:9092 \
|
|
||||||
acrdigitalgarage01.azurecr.io/kt-event/user-service:latest
|
|
||||||
|
|
||||||
# Event Service
|
|
||||||
docker run -d \
|
|
||||||
--name event-service \
|
|
||||||
-p 8080:8080 \
|
|
||||||
-e DB_HOST=host.docker.internal \
|
|
||||||
-e DB_PORT=5433 \
|
|
||||||
-e DB_NAME=event_db \
|
|
||||||
-e REDIS_HOST=host.docker.internal \
|
|
||||||
-e KAFKA_BOOTSTRAP_SERVERS=host.docker.internal:9092 \
|
|
||||||
acrdigitalgarage01.azurecr.io/kt-event/event-service:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
자세한 컨테이너 실행 가이드는 [run-container-guide-back.md](deployment/container/run-container-guide-back.md)를 참조하세요.
|
|
||||||
|
|
||||||
## 6. Kubernetes 배포
|
|
||||||
|
|
||||||
### 6.1 사전 요구사항
|
|
||||||
- Azure CLI 설치 및 로그인
|
|
||||||
- kubectl 설치
|
|
||||||
- AKS 클러스터 접근 권한
|
|
||||||
|
|
||||||
### 6.2 Azure 로그인 및 AKS 연결
|
|
||||||
```bash
|
|
||||||
# Azure 로그인
|
|
||||||
az login
|
|
||||||
|
|
||||||
# AKS Credential 가져오기
|
|
||||||
az aks get-credentials \
|
|
||||||
--resource-group rg-digitalgarage-01 \
|
|
||||||
--name aks-digitalgarage-01
|
|
||||||
|
|
||||||
# 연결 확인
|
|
||||||
kubectl cluster-info
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 네임스페이스 생성
|
|
||||||
```bash
|
|
||||||
# 네임스페이스 생성
|
|
||||||
kubectl create namespace kt-event-marketing
|
|
||||||
|
|
||||||
# 네임스페이스 확인
|
|
||||||
kubectl get namespace kt-event-marketing
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.4 백킹 서비스 설치 (Kubernetes)
|
|
||||||
|
|
||||||
#### 6.4.1 PostgreSQL 설치
|
|
||||||
```bash
|
|
||||||
# Helm 저장소 추가
|
|
||||||
helm repo add bitnami https://charts.bitnami.com/bitnami
|
|
||||||
helm repo update
|
|
||||||
|
|
||||||
# User Service용 DB
|
|
||||||
helm install user-postgresql bitnami/postgresql \
|
|
||||||
--namespace kt-event-marketing \
|
|
||||||
--set global.postgresql.auth.postgresPassword=Passw0rd \
|
|
||||||
--set global.postgresql.auth.username=admin \
|
|
||||||
--set global.postgresql.auth.password=Passw0rd \
|
|
||||||
--set global.postgresql.auth.database=user_db
|
|
||||||
|
|
||||||
# Event Service용 DB
|
|
||||||
helm install event-postgresql bitnami/postgresql \
|
|
||||||
--namespace kt-event-marketing \
|
|
||||||
--set global.postgresql.auth.postgresPassword=Passw0rd \
|
|
||||||
--set global.postgresql.auth.username=admin \
|
|
||||||
--set global.postgresql.auth.password=Passw0rd \
|
|
||||||
--set global.postgresql.auth.database=event_db
|
|
||||||
|
|
||||||
# Participation Service용 DB
|
|
||||||
helm install participation-postgresql bitnami/postgresql \
|
|
||||||
--namespace kt-event-marketing \
|
|
||||||
--set global.postgresql.auth.postgresPassword=Passw0rd \
|
|
||||||
--set global.postgresql.auth.username=admin \
|
|
||||||
--set global.postgresql.auth.password=Passw0rd \
|
|
||||||
--set global.postgresql.auth.database=participation_db
|
|
||||||
|
|
||||||
# Analytics Service용 DB
|
|
||||||
helm install analytics-postgresql bitnami/postgresql \
|
|
||||||
--namespace kt-event-marketing \
|
|
||||||
--set global.postgresql.auth.postgresPassword=Passw0rd \
|
|
||||||
--set global.postgresql.auth.username=admin \
|
|
||||||
--set global.postgresql.auth.password=Passw0rd \
|
|
||||||
--set global.postgresql.auth.database=analytics_db
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 6.4.2 Redis 설치
|
|
||||||
```bash
|
|
||||||
helm install redis bitnami/redis \
|
|
||||||
--namespace kt-event-marketing \
|
|
||||||
--set auth.password=Passw0rd \
|
|
||||||
--set master.persistence.enabled=false \
|
|
||||||
--set replica.replicaCount=0
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 6.4.3 Kafka 설치
|
|
||||||
```bash
|
|
||||||
helm install kafka bitnami/kafka \
|
|
||||||
--namespace kt-event-marketing \
|
|
||||||
--set persistence.enabled=false \
|
|
||||||
--set zookeeper.persistence.enabled=false
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.5 컨테이너 이미지 푸시
|
|
||||||
|
|
||||||
#### 6.5.1 ACR 로그인
|
|
||||||
```bash
|
|
||||||
az acr login --name acrdigitalgarage01
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 6.5.2 이미지 푸시
|
|
||||||
```bash
|
|
||||||
# 모든 서비스 이미지 푸시
|
|
||||||
docker push acrdigitalgarage01.azurecr.io/kt-event/user-service:latest
|
|
||||||
docker push acrdigitalgarage01.azurecr.io/kt-event/event-service:latest
|
|
||||||
docker push acrdigitalgarage01.azurecr.io/kt-event/content-service:latest
|
|
||||||
docker push acrdigitalgarage01.azurecr.io/kt-event/ai-service:latest
|
|
||||||
docker push acrdigitalgarage01.azurecr.io/kt-event/participation-service:latest
|
|
||||||
docker push acrdigitalgarage01.azurecr.io/kt-event/analytics-service:latest
|
|
||||||
docker push acrdigitalgarage01.azurecr.io/kt-event/distribution-service:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.6 Kubernetes 매니페스트 배포
|
|
||||||
|
|
||||||
#### 6.6.1 Secret 및 ConfigMap 생성
|
|
||||||
```bash
|
|
||||||
# Common Secret 생성
|
|
||||||
kubectl apply -f deployment/k8s/common/secret-common.yaml -n kt-event-marketing
|
|
||||||
|
|
||||||
# Common ConfigMap 생성
|
|
||||||
kubectl apply -f deployment/k8s/common/cm-common.yaml -n kt-event-marketing
|
|
||||||
|
|
||||||
# Image Pull Secret 생성
|
|
||||||
kubectl apply -f deployment/k8s/common/secret-imagepull.yaml -n kt-event-marketing
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 6.6.2 서비스별 배포
|
|
||||||
```bash
|
|
||||||
# User Service
|
|
||||||
kubectl apply -f deployment/k8s/user-service/ -n kt-event-marketing
|
|
||||||
|
|
||||||
# Event Service
|
|
||||||
kubectl apply -f deployment/k8s/event-service/ -n kt-event-marketing
|
|
||||||
|
|
||||||
# Content Service
|
|
||||||
kubectl apply -f deployment/k8s/content-service/ -n kt-event-marketing
|
|
||||||
|
|
||||||
# AI Service
|
|
||||||
kubectl apply -f deployment/k8s/ai-service/ -n kt-event-marketing
|
|
||||||
|
|
||||||
# Participation Service
|
|
||||||
kubectl apply -f deployment/k8s/participation-service/ -n kt-event-marketing
|
|
||||||
|
|
||||||
# Analytics Service
|
|
||||||
kubectl apply -f deployment/k8s/analytics-service/ -n kt-event-marketing
|
|
||||||
|
|
||||||
# Distribution Service
|
|
||||||
kubectl apply -f deployment/k8s/distribution-service/ -n kt-event-marketing
|
|
||||||
|
|
||||||
# Ingress 생성
|
|
||||||
kubectl apply -f deployment/k8s/common/ingress.yaml -n kt-event-marketing
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 6.6.3 배포 확인
|
|
||||||
```bash
|
|
||||||
# Pod 상태 확인
|
|
||||||
kubectl get pods -n kt-event-marketing
|
|
||||||
|
|
||||||
# Service 확인
|
|
||||||
kubectl get svc -n kt-event-marketing
|
|
||||||
|
|
||||||
# Ingress 확인
|
|
||||||
kubectl get ingress -n kt-event-marketing
|
|
||||||
|
|
||||||
# Pod 로그 확인
|
|
||||||
kubectl logs -f <pod-name> -n kt-event-marketing
|
|
||||||
```
|
|
||||||
|
|
||||||
자세한 Kubernetes 배포 가이드는 [deploy-k8s-guide.md](deployment/k8s/deploy-k8s-guide.md)를 참조하세요.
|
|
||||||
|
|
||||||
## 7. CI/CD
|
|
||||||
|
|
||||||
### 7.1 GitHub Actions CI/CD
|
|
||||||
GitHub Actions를 사용한 자동 빌드 및 배포 파이프라인이 구성되어 있습니다.
|
|
||||||
|
|
||||||
**워크플로우 파일**: `.github/workflows/`
|
|
||||||
|
|
||||||
**주요 단계**:
|
|
||||||
1. 코드 체크아웃
|
|
||||||
2. Java 환경 설정
|
|
||||||
3. Gradle 빌드
|
|
||||||
4. Docker 이미지 빌드
|
|
||||||
5. ACR에 이미지 푸시
|
|
||||||
6. AKS에 배포
|
|
||||||
|
|
||||||
### 7.2 Jenkins CI/CD
|
|
||||||
Jenkins를 사용한 CI/CD 파이프라인 구성이 가능합니다.
|
|
||||||
|
|
||||||
자세한 CI/CD 가이드는 [CICD-GUIDE.md](deployment/cicd/CICD-GUIDE.md)를 참조하세요.
|
|
||||||
|
|
||||||
## 8. 테스트
|
|
||||||
|
|
||||||
### 8.1 프론트엔드 접근
|
|
||||||
```bash
|
|
||||||
# Ingress External IP 확인
|
|
||||||
kubectl get ingress -n kt-event-marketing
|
|
||||||
|
|
||||||
# 브라우저에서 접근
|
|
||||||
http://<INGRESS_EXTERNAL_IP>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.2 API 테스트
|
|
||||||
```bash
|
|
||||||
# User Service API 테스트
|
|
||||||
curl http://<INGRESS_EXTERNAL_IP>/api/v1/users/health
|
|
||||||
|
|
||||||
# Event Service API 테스트
|
|
||||||
curl http://<INGRESS_EXTERNAL_IP>/api/v1/events
|
|
||||||
|
|
||||||
# API 문서 확인
|
|
||||||
http://<INGRESS_EXTERNAL_IP>/api/v1/users/swagger-ui.html
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8.3 테스트 계정
|
|
||||||
- **ID**: test@example.com
|
|
||||||
- **PW**: Test123!@#
|
|
||||||
|
|
||||||
## 9. 모니터링 및 로깅
|
|
||||||
|
|
||||||
### 9.1 애플리케이션 로그 확인
|
|
||||||
```bash
|
|
||||||
# 특정 서비스 로그 확인
|
|
||||||
kubectl logs -f deployment/user-service -n kt-event-marketing
|
|
||||||
|
|
||||||
# 전체 로그 스트리밍
|
|
||||||
kubectl logs -f -l app=kt-event-marketing -n kt-event-marketing
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.2 Health Check
|
|
||||||
각 서비스는 Spring Boot Actuator를 통해 헬스 체크를 제공합니다:
|
|
||||||
- `/actuator/health`: 서비스 헬스 상태
|
|
||||||
- `/actuator/info`: 서비스 정보
|
|
||||||
- `/actuator/metrics`: 메트릭 정보
|
|
||||||
|
|
||||||
## 10. 트러블슈팅
|
|
||||||
|
|
||||||
### 10.1 일반적인 문제
|
|
||||||
|
|
||||||
**문제: Pod가 CrashLoopBackOff 상태**
|
|
||||||
```bash
|
|
||||||
# Pod 상세 정보 확인
|
|
||||||
kubectl describe pod <pod-name> -n kt-event-marketing
|
|
||||||
|
|
||||||
# 로그 확인
|
|
||||||
kubectl logs <pod-name> -n kt-event-marketing
|
|
||||||
|
|
||||||
# 이전 컨테이너 로그 확인
|
|
||||||
kubectl logs <pod-name> -n kt-event-marketing --previous
|
|
||||||
```
|
|
||||||
|
|
||||||
**문제: 데이터베이스 연결 실패**
|
|
||||||
- Secret 및 ConfigMap의 환경 변수 확인
|
|
||||||
- 데이터베이스 서비스 상태 확인
|
|
||||||
- 네트워크 정책 및 방화벽 규칙 확인
|
|
||||||
|
|
||||||
**문제: 이미지 Pull 실패**
|
|
||||||
- ACR 로그인 확인
|
|
||||||
- Image Pull Secret 확인
|
|
||||||
- 이미지 이름 및 태그 확인
|
|
||||||
|
|
||||||
### 10.2 유용한 명령어
|
|
||||||
```bash
|
|
||||||
# 전체 리소스 확인
|
|
||||||
kubectl get all -n kt-event-marketing
|
|
||||||
|
|
||||||
# 특정 리소스 재시작
|
|
||||||
kubectl rollout restart deployment/<service-name> -n kt-event-marketing
|
|
||||||
|
|
||||||
# ConfigMap 확인
|
|
||||||
kubectl get configmap -n kt-event-marketing
|
|
||||||
|
|
||||||
# Secret 확인
|
|
||||||
kubectl get secret -n kt-event-marketing
|
|
||||||
```
|
|
||||||
|
|
||||||
## 11. 개발 참고 자료
|
|
||||||
|
|
||||||
### 11.1 프로젝트 문서
|
|
||||||
- [유저스토리](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/userstory.md)
|
|
||||||
- [UI/UX 설계서](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/uiux/uiux.md)
|
|
||||||
- [아키텍처 패턴](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/pattern/architecture-pattern.md)
|
|
||||||
- [High-Level 아키텍처](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/high-level-architecture.md)
|
|
||||||
- [API 설계서](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/backend/api/)
|
|
||||||
- [논리 아키텍처](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/backend/logical/)
|
|
||||||
- [시퀀스 설계](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/backend/sequence/)
|
|
||||||
- [클래스 설계](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/backend/class/)
|
|
||||||
- [데이터베이스 설계서](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/backend/database/)
|
|
||||||
- [프론트엔드 설계](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/design/frontend/)
|
|
||||||
- [개발 변경 이력](https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing/src/branch/main/DEVELOP_CHANGELOG.md)
|
|
||||||
|
|
||||||
### 11.2 가이드 문서
|
|
||||||
- [백킹 서비스 설치 방법](backing-service/)
|
|
||||||
- [컨테이너 이미지 빌드](deployment/container/build-image.md)
|
|
||||||
- [Kubernetes 배포 가이드](deployment/k8s/deploy-k8s-guide.md)
|
|
||||||
- [CI/CD 가이드](deployment/cicd/CICD-GUIDE.md)
|
|
||||||
|
|
||||||
## 12. 팀
|
|
||||||
|
|
||||||
### 12.1 Product Owner
|
|
||||||
- **갑빠** - Value Oriented, Interactive, Iterative를 중시하는 애자일 전문가
|
|
||||||
|
|
||||||
### 12.2 Scrum Master
|
|
||||||
- **한준석 "퍼실리테이터"** - 소통과 팀워크를 중시하는 스크럼 마스터
|
|
||||||
|
|
||||||
### 12.3 개발팀
|
|
||||||
- **이미준 "도그냥"** - 서비스 기획자 (Lead)
|
|
||||||
- **Flynn "플린"** - 플랫폼 기획자
|
|
||||||
- **이소영 "그로스해커"** - 마케팅 전략가
|
|
||||||
- **박민지 "픽셀"** - UI/UX 디자이너
|
|
||||||
- **김태현 "리액트킹"** - 프론트엔드 개발자
|
|
||||||
- **최수연 "아키텍처"** - 백엔드 개발자
|
|
||||||
- **정현우 "데이터마법사"** - 데이터 사이언티스트
|
|
||||||
- **박영자 "전문 아키텍트"** - 시스템 아키텍트
|
|
||||||
- **송근정 "데브옵스 마스터"** - DevOps 엔지니어
|
|
||||||
|
|
||||||
### 12.4 사용자 대표
|
|
||||||
- **정우진 "사장님"** - 소상공인 대표 (테스트 및 피드백)
|
|
||||||
|
|
||||||
## 13. 라이선스
|
|
||||||
이 프로젝트는 교육 목적으로 작성되었습니다.
|
|
||||||
|
|
||||||
## 14. 문의
|
|
||||||
프로젝트 관련 문의사항은 GitHub Issues를 통해 등록해 주세요.
|
|
||||||
@ -7,9 +7,6 @@ RUN java -Djarmode=layertools -jar app.jar extract
|
|||||||
FROM eclipse-temurin:21-jre-alpine
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install gcompat for Snappy compression library compatibility
|
|
||||||
RUN apk add --no-cache gcompat
|
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN addgroup -S spring && adduser -S spring -G spring
|
RUN addgroup -S spring && adduser -S spring -G spring
|
||||||
USER spring:spring
|
USER spring:spring
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package com.kt.ai.config;
|
package com.kt.ai.config;
|
||||||
|
|
||||||
import com.kt.ai.kafka.message.AIJobMessage;
|
import com.kt.ai.kafka.message.AIJobMessage;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.kafka.clients.consumer.ConsumerConfig;
|
import org.apache.kafka.clients.consumer.ConsumerConfig;
|
||||||
import org.apache.kafka.common.serialization.StringDeserializer;
|
import org.apache.kafka.common.serialization.StringDeserializer;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@ -27,7 +26,6 @@ import java.util.Map;
|
|||||||
* @author AI Service Team
|
* @author AI Service Team
|
||||||
* @since 1.0.0
|
* @since 1.0.0
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
|
||||||
@EnableKafka
|
@EnableKafka
|
||||||
@Configuration
|
@Configuration
|
||||||
public class KafkaConsumerConfig {
|
public class KafkaConsumerConfig {
|
||||||
@ -43,12 +41,6 @@ public class KafkaConsumerConfig {
|
|||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public ConsumerFactory<String, AIJobMessage> consumerFactory() {
|
public ConsumerFactory<String, AIJobMessage> consumerFactory() {
|
||||||
log.info("========================================");
|
|
||||||
log.info("Kafka Consumer Factory 초기화 시작");
|
|
||||||
log.info("Bootstrap Servers: {}", bootstrapServers);
|
|
||||||
log.info("Consumer Group ID: {}", groupId);
|
|
||||||
log.info("========================================");
|
|
||||||
|
|
||||||
Map<String, Object> props = new HashMap<>();
|
Map<String, Object> props = new HashMap<>();
|
||||||
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
||||||
props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
|
props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
|
||||||
@ -65,9 +57,7 @@ public class KafkaConsumerConfig {
|
|||||||
props.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JsonDeserializer.class.getName());
|
props.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JsonDeserializer.class.getName());
|
||||||
props.put(JsonDeserializer.VALUE_DEFAULT_TYPE, AIJobMessage.class.getName());
|
props.put(JsonDeserializer.VALUE_DEFAULT_TYPE, AIJobMessage.class.getName());
|
||||||
props.put(JsonDeserializer.TRUSTED_PACKAGES, "*");
|
props.put(JsonDeserializer.TRUSTED_PACKAGES, "*");
|
||||||
props.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false);
|
|
||||||
|
|
||||||
log.info("✅ Kafka Consumer Factory 설정 완료");
|
|
||||||
return new DefaultKafkaConsumerFactory<>(props);
|
return new DefaultKafkaConsumerFactory<>(props);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,22 +67,10 @@ public class KafkaConsumerConfig {
|
|||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public ConcurrentKafkaListenerContainerFactory<String, AIJobMessage> kafkaListenerContainerFactory() {
|
public ConcurrentKafkaListenerContainerFactory<String, AIJobMessage> kafkaListenerContainerFactory() {
|
||||||
log.info("Kafka Listener Container Factory 초기화");
|
|
||||||
|
|
||||||
ConcurrentKafkaListenerContainerFactory<String, AIJobMessage> factory =
|
ConcurrentKafkaListenerContainerFactory<String, AIJobMessage> factory =
|
||||||
new ConcurrentKafkaListenerContainerFactory<>();
|
new ConcurrentKafkaListenerContainerFactory<>();
|
||||||
factory.setConsumerFactory(consumerFactory());
|
factory.setConsumerFactory(consumerFactory());
|
||||||
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL);
|
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL);
|
||||||
|
|
||||||
// 에러 핸들러 추가
|
|
||||||
factory.setCommonErrorHandler(new org.springframework.kafka.listener.DefaultErrorHandler((record, exception) -> {
|
|
||||||
log.error("❌ Kafka 메시지 처리 중 에러 발생");
|
|
||||||
log.error("Topic: {}, Partition: {}, Offset: {}",
|
|
||||||
record.topic(), record.partition(), record.offset());
|
|
||||||
log.error("Error: ", exception);
|
|
||||||
}));
|
|
||||||
|
|
||||||
log.info("✅ Kafka Listener Container Factory 설정 완료");
|
|
||||||
return factory;
|
return factory;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,18 +45,17 @@ public class RedisConfig {
|
|||||||
private long redisTimeout;
|
private long redisTimeout;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redis 연결 팩토리 설정 (Standalone 모드)
|
* Redis 연결 팩토리 설정
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public RedisConnectionFactory redisConnectionFactory() {
|
public RedisConnectionFactory redisConnectionFactory() {
|
||||||
RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration();
|
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
|
||||||
redisConfig.setHostName(redisHost);
|
config.setHostName(redisHost);
|
||||||
redisConfig.setPort(redisPort);
|
config.setPort(redisPort);
|
||||||
redisConfig.setDatabase(redisDatabase);
|
|
||||||
|
|
||||||
if (redisPassword != null && !redisPassword.isEmpty()) {
|
if (redisPassword != null && !redisPassword.isEmpty()) {
|
||||||
redisConfig.setPassword(redisPassword);
|
config.setPassword(redisPassword);
|
||||||
}
|
}
|
||||||
|
config.setDatabase(redisDatabase);
|
||||||
|
|
||||||
// Lettuce Client 설정: Timeout 및 Connection 옵션
|
// Lettuce Client 설정: Timeout 및 Connection 옵션
|
||||||
SocketOptions socketOptions = SocketOptions.builder()
|
SocketOptions socketOptions = SocketOptions.builder()
|
||||||
@ -74,7 +73,8 @@ public class RedisConfig {
|
|||||||
.clientOptions(clientOptions)
|
.clientOptions(clientOptions)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
return new LettuceConnectionFactory(redisConfig, clientConfig);
|
// afterPropertiesSet() 제거: Spring이 자동으로 호출함
|
||||||
|
return new LettuceConnectionFactory(config, clientConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
package com.kt.ai.exception;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 추천 생성 중 발생한 예외
|
|
||||||
*
|
|
||||||
* @author AI Service Team
|
|
||||||
* @since 1.0.0
|
|
||||||
*/
|
|
||||||
public class RecommendationGenerationException extends AIServiceException {
|
|
||||||
public RecommendationGenerationException(String message) {
|
|
||||||
super("RECOMMENDATION_GENERATION_FAILED", message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public RecommendationGenerationException(String message, Throwable cause) {
|
|
||||||
super("RECOMMENDATION_GENERATION_FAILED", message, cause);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -38,28 +38,21 @@ public class AIJobConsumer {
|
|||||||
@Payload AIJobMessage message,
|
@Payload AIJobMessage message,
|
||||||
@Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
|
@Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
|
||||||
@Header(KafkaHeaders.OFFSET) Long offset,
|
@Header(KafkaHeaders.OFFSET) Long offset,
|
||||||
@Header(KafkaHeaders.RECEIVED_PARTITION) Integer partition,
|
|
||||||
Acknowledgment acknowledgment
|
Acknowledgment acknowledgment
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
log.info("========================================");
|
log.info("Kafka 메시지 수신: topic={}, offset={}, jobId={}, eventId={}",
|
||||||
log.info("Kafka 메시지 수신 시작");
|
topic, offset, message.getJobId(), message.getEventId());
|
||||||
log.info("Topic: {}, Partition: {}, Offset: {}", topic, partition, offset);
|
|
||||||
log.info("JobId: {}, EventId: {}", message.getJobId(), message.getEventId());
|
|
||||||
log.info("Industry: {}, Region: {}", message.getIndustry(), message.getRegion());
|
|
||||||
log.info("Objective: {}, StoreName: {}", message.getObjective(), message.getStoreName());
|
|
||||||
log.info("========================================");
|
|
||||||
|
|
||||||
// AI 추천 생성
|
// AI 추천 생성
|
||||||
aiRecommendationService.generateRecommendations(message);
|
aiRecommendationService.generateRecommendations(message);
|
||||||
|
|
||||||
// Manual ACK
|
// Manual ACK
|
||||||
acknowledgment.acknowledge();
|
acknowledgment.acknowledge();
|
||||||
log.info("✅ Kafka 메시지 처리 완료: jobId={}", message.getJobId());
|
log.info("Kafka 메시지 처리 완료: jobId={}", message.getJobId());
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("❌ Kafka 메시지 처리 실패: jobId={}, errorMessage={}",
|
log.error("Kafka 메시지 처리 실패: jobId={}", message.getJobId(), e);
|
||||||
message != null ? message.getJobId() : "NULL", e.getMessage(), e);
|
|
||||||
// DLQ로 이동하거나 재시도 로직 추가 가능
|
// DLQ로 이동하거나 재시도 로직 추가 가능
|
||||||
acknowledgment.acknowledge(); // 실패한 메시지도 ACK (DLQ로 이동)
|
acknowledgment.acknowledge(); // 실패한 메시지도 ACK (DLQ로 이동)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,11 +25,6 @@ public class AIJobMessage {
|
|||||||
*/
|
*/
|
||||||
private String jobId;
|
private String jobId;
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자 ID (UUID String)
|
|
||||||
*/
|
|
||||||
private String userId;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 ID (Event Service에서 생성)
|
* 이벤트 ID (Event Service에서 생성)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import jakarta.annotation.PreDestroy;
|
|||||||
import jakarta.persistence.EntityManager;
|
import jakarta.persistence.EntityManager;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import lombok.Data;
|
|
||||||
import org.apache.kafka.clients.admin.AdminClient;
|
import org.apache.kafka.clients.admin.AdminClient;
|
||||||
import org.apache.kafka.clients.admin.DeleteConsumerGroupOffsetsResult;
|
import org.apache.kafka.clients.admin.DeleteConsumerGroupOffsetsResult;
|
||||||
import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsResult;
|
import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsResult;
|
||||||
@ -19,7 +18,6 @@ import org.apache.kafka.common.TopicPartition;
|
|||||||
import org.springframework.boot.ApplicationArguments;
|
import org.springframework.boot.ApplicationArguments;
|
||||||
import org.springframework.boot.ApplicationRunner;
|
import org.springframework.boot.ApplicationRunner;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
|
||||||
import org.springframework.data.redis.core.RedisTemplate;
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.kafka.core.KafkaAdmin;
|
import org.springframework.kafka.core.KafkaAdmin;
|
||||||
@ -27,7 +25,6 @@ import org.springframework.kafka.core.KafkaTemplate;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
@ -72,8 +69,6 @@ public class SampleDataLoader implements ApplicationRunner {
|
|||||||
private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered";
|
private static final String PARTICIPANT_REGISTERED_TOPIC = "sample.participant.registered";
|
||||||
private static final String DISTRIBUTION_COMPLETED_TOPIC = "sample.distribution.completed";
|
private static final String DISTRIBUTION_COMPLETED_TOPIC = "sample.distribution.completed";
|
||||||
|
|
||||||
private SampleDataConfig sampleDataConfig;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void run(ApplicationArguments args) {
|
public void run(ApplicationArguments args) {
|
||||||
@ -109,36 +104,28 @@ public class SampleDataLoader implements ApplicationRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// JSON 파일에서 샘플 데이터 로드
|
// 1. EventCreated 이벤트 발행 (3개 이벤트)
|
||||||
log.info("📄 sample-data.json 파일 로드 중...");
|
|
||||||
sampleDataConfig = loadSampleData();
|
|
||||||
log.info("✅ sample-data.json 로드 완료: 이벤트 {}건, 배포 {}건, 참여자 패턴 {}건",
|
|
||||||
sampleDataConfig.getEvents().size(),
|
|
||||||
sampleDataConfig.getDistributions().size(),
|
|
||||||
sampleDataConfig.getParticipants().size());
|
|
||||||
|
|
||||||
// 1. EventCreated 이벤트 발행
|
|
||||||
publishEventCreatedEvents();
|
publishEventCreatedEvents();
|
||||||
log.info("⏳ EventStats 생성 대기 중... (5초)");
|
log.info("⏳ EventStats 생성 대기 중... (5초)");
|
||||||
Thread.sleep(5000); // EventCreatedConsumer가 EventStats 생성할 시간
|
Thread.sleep(5000); // EventCreatedConsumer가 EventStats 생성할 시간
|
||||||
|
|
||||||
// 2. DistributionCompleted 이벤트 발행
|
// 2. DistributionCompleted 이벤트 발행 (각 이벤트당 4개 채널)
|
||||||
publishDistributionCompletedEvents();
|
publishDistributionCompletedEvents();
|
||||||
log.info("⏳ ChannelStats 생성 대기 중... (3초)");
|
log.info("⏳ ChannelStats 생성 대기 중... (3초)");
|
||||||
Thread.sleep(3000); // DistributionCompletedConsumer가 ChannelStats 생성할 시간
|
Thread.sleep(3000); // DistributionCompletedConsumer가 ChannelStats 생성할 시간
|
||||||
|
|
||||||
// 3. ParticipantRegistered 이벤트 발행
|
// 3. ParticipantRegistered 이벤트 발행 (각 이벤트당 다수 참여자)
|
||||||
int totalParticipants = publishParticipantRegisteredEvents();
|
publishParticipantRegisteredEvents();
|
||||||
log.info("⏳ 참여자 등록 이벤트 처리 대기 중... (20초)");
|
log.info("⏳ 참여자 등록 이벤트 처리 대기 중... (20초)");
|
||||||
Thread.sleep(20000); // ParticipantRegisteredConsumer가 이벤트 처리할 시간 (비관적 락 고려)
|
Thread.sleep(20000); // ParticipantRegisteredConsumer가 180개 이벤트 처리할 시간 (비관적 락 고려)
|
||||||
|
|
||||||
log.info("========================================");
|
log.info("========================================");
|
||||||
log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)");
|
log.info("🎉 Kafka 이벤트 발행 완료! (Consumer가 처리 중...)");
|
||||||
log.info("========================================");
|
log.info("========================================");
|
||||||
log.info("발행된 이벤트:");
|
log.info("발행된 이벤트:");
|
||||||
log.info(" - EventCreated: {}건", sampleDataConfig.getEvents().size());
|
log.info(" - EventCreated: 3건");
|
||||||
log.info(" - DistributionCompleted: {}건", sampleDataConfig.getDistributions().size());
|
log.info(" - DistributionCompleted: 3건 (각 이벤트당 4개 채널 배열)");
|
||||||
log.info(" - ParticipantRegistered: {}건", totalParticipants);
|
log.info(" - ParticipantRegistered: 180건 (MVP 테스트용)");
|
||||||
log.info("========================================");
|
log.info("========================================");
|
||||||
|
|
||||||
// Consumer 처리 대기 (5초)
|
// Consumer 처리 대기 (5초)
|
||||||
@ -233,128 +220,182 @@ public class SampleDataLoader implements ApplicationRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EventCreated 이벤트 발행 (JSON 기반)
|
* EventCreated 이벤트 발행
|
||||||
*/
|
*/
|
||||||
private void publishEventCreatedEvents() throws Exception {
|
private void publishEventCreatedEvents() throws Exception {
|
||||||
for (EventData eventData : sampleDataConfig.getEvents()) {
|
// 이벤트 1: 신년맞이 할인 이벤트 (진행중, 높은 성과 - ROI 200%)
|
||||||
EventCreatedEvent event = EventCreatedEvent.builder()
|
EventCreatedEvent event1 = EventCreatedEvent.builder()
|
||||||
.eventId(eventData.getEventId())
|
.eventId("1")
|
||||||
.eventTitle(eventData.getEventTitle())
|
.eventTitle("신년맞이 20% 할인 이벤트")
|
||||||
.storeId(eventData.getStoreId())
|
.storeId("store_001")
|
||||||
.totalInvestment(eventData.getTotalInvestment())
|
.totalInvestment(new BigDecimal("5000000"))
|
||||||
.expectedRevenue(eventData.getExpectedRevenue())
|
.expectedRevenue(new BigDecimal("15000000")) // 투자 대비 3배 수익
|
||||||
.status(eventData.getStatus())
|
.status("ACTIVE")
|
||||||
.startDate(parseDateTime(eventData.getStartDate()))
|
.startDate(java.time.LocalDateTime.of(2025, 1, 23, 0, 0)) // 2025-01-23 시작
|
||||||
.endDate(eventData.getEndDate() != null ? parseDateTime(eventData.getEndDate()) : null)
|
.endDate(null) // 진행중
|
||||||
.build();
|
.build();
|
||||||
|
publishEvent(EVENT_CREATED_TOPIC, event1);
|
||||||
|
|
||||||
publishEvent(EVENT_CREATED_TOPIC, event);
|
// 이벤트 2: 설날 특가 이벤트 (진행중, 중간 성과 - ROI 100%)
|
||||||
log.info(" → EventCreated 발행: eventId={}, title={}",
|
EventCreatedEvent event2 = EventCreatedEvent.builder()
|
||||||
eventData.getEventId(), eventData.getEventTitle());
|
.eventId("2")
|
||||||
}
|
.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);
|
||||||
|
|
||||||
log.info("✅ EventCreated 이벤트 {}건 발행 완료", sampleDataConfig.getEvents().size());
|
// 이벤트 3: 겨울 신메뉴 런칭 이벤트 (종료, 저조한 성과 - ROI 50%)
|
||||||
|
EventCreatedEvent event3 = EventCreatedEvent.builder()
|
||||||
|
.eventId("3")
|
||||||
|
.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);
|
||||||
|
|
||||||
|
log.info("✅ EventCreated 이벤트 3건 발행 완료");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ISO 8601 형식 문자열을 LocalDateTime으로 파싱
|
* DistributionCompleted 이벤트 발행 (설계서 기준 - 이벤트당 1번 발행, 여러 채널 배열)
|
||||||
*/
|
|
||||||
private java.time.LocalDateTime parseDateTime(String dateTimeStr) {
|
|
||||||
return java.time.LocalDateTime.parse(dateTimeStr);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DistributionCompleted 이벤트 발행 (JSON 기반)
|
|
||||||
*/
|
*/
|
||||||
private void publishDistributionCompletedEvents() throws Exception {
|
private void publishDistributionCompletedEvents() throws Exception {
|
||||||
double channelBudgetRatio = sampleDataConfig.getConfig().getChannelBudgetRatio();
|
String[] eventIds = {"1", "2", "3"};
|
||||||
|
int[][] expectedViews = {
|
||||||
|
{5000, 10000, 3000, 2000}, // 이벤트1: 우리동네TV, 지니TV, 링고비즈, SNS
|
||||||
|
{3500, 7000, 2000, 1500}, // 이벤트2
|
||||||
|
{1500, 3000, 1000, 500} // 이벤트3
|
||||||
|
};
|
||||||
|
|
||||||
for (DistributionData distributionData : sampleDataConfig.getDistributions()) {
|
// 각 이벤트의 총 투자 금액
|
||||||
String eventId = distributionData.getEventId();
|
BigDecimal[] totalInvestments = {
|
||||||
|
new BigDecimal("5000000"), // 이벤트1: 500만원
|
||||||
|
new BigDecimal("3500000"), // 이벤트2: 350만원
|
||||||
|
new BigDecimal("2000000") // 이벤트3: 200만원
|
||||||
|
};
|
||||||
|
|
||||||
// 해당 이벤트의 총 투자 금액 조회
|
// 채널 배포는 총 투자의 50%만 사용 (나머지는 경품/콘텐츠/운영비용)
|
||||||
EventData eventData = sampleDataConfig.getEvents().stream()
|
double channelBudgetRatio = 0.50;
|
||||||
.filter(e -> e.getEventId().equals(eventId))
|
|
||||||
.findFirst()
|
|
||||||
.orElseThrow(() -> new IllegalStateException("이벤트를 찾을 수 없습니다: " + eventId));
|
|
||||||
|
|
||||||
BigDecimal totalInvestment = eventData.getTotalInvestment();
|
// 채널별 비용 비율 (채널 예산 내에서: 우리동네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));
|
BigDecimal channelBudget = totalInvestment.multiply(BigDecimal.valueOf(channelBudgetRatio));
|
||||||
|
|
||||||
// 채널 배열 생성
|
// 4개 채널을 배열로 구성
|
||||||
List<DistributionCompletedEvent.ChannelDistribution> channels = new ArrayList<>();
|
List<DistributionCompletedEvent.ChannelDistribution> channels = new ArrayList<>();
|
||||||
|
|
||||||
for (ChannelData channelData : distributionData.getChannels()) {
|
// 1. 우리동네TV (TV) - 채널 예산의 30%
|
||||||
DistributionCompletedEvent.ChannelDistribution channel =
|
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
|
||||||
DistributionCompletedEvent.ChannelDistribution.builder()
|
.channel("우리동네TV")
|
||||||
.channel(channelData.getChannel())
|
.channelType("TV")
|
||||||
.channelType(channelData.getChannelType())
|
.status("SUCCESS")
|
||||||
.status(channelData.getStatus())
|
.expectedViews(expectedViews[i][0])
|
||||||
.expectedViews(channelData.getExpectedViews())
|
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[0])))
|
||||||
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(channelData.getDistributionCostRatio())))
|
.build());
|
||||||
.build();
|
|
||||||
|
|
||||||
channels.add(channel);
|
// 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) - 채널 예산의 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) - 채널 예산의 15%
|
||||||
|
channels.add(DistributionCompletedEvent.ChannelDistribution.builder()
|
||||||
|
.channel("SNS")
|
||||||
|
.channelType("SNS")
|
||||||
|
.status("SUCCESS")
|
||||||
|
.expectedViews(expectedViews[i][3])
|
||||||
|
.distributionCost(channelBudget.multiply(BigDecimal.valueOf(costRatios[3])))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
// 이벤트 발행 (채널 배열 포함)
|
||||||
DistributionCompletedEvent event = DistributionCompletedEvent.builder()
|
DistributionCompletedEvent event = DistributionCompletedEvent.builder()
|
||||||
.eventId(eventId)
|
.eventId(eventId)
|
||||||
.distributedChannels(channels)
|
.distributedChannels(channels)
|
||||||
.completedAt(parseDateTime(distributionData.getCompletedAt()))
|
.completedAt(java.time.LocalDateTime.now())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
publishEvent(DISTRIBUTION_COMPLETED_TOPIC, event);
|
publishEvent(DISTRIBUTION_COMPLETED_TOPIC, event);
|
||||||
log.info(" → DistributionCompleted 발행: eventId={}, 채널={}개",
|
|
||||||
eventId, channels.size());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("✅ DistributionCompleted 이벤트 {}건 발행 완료", sampleDataConfig.getDistributions().size());
|
log.info("✅ DistributionCompleted 이벤트 3건 발행 완료 (3 이벤트 × 4 채널 배열)");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ParticipantRegistered 이벤트 발행 (JSON 기반)
|
* ParticipantRegistered 이벤트 발행
|
||||||
|
*
|
||||||
|
* 현실적인 참여 패턴 반영:
|
||||||
|
* - 총 120명의 고유 참여자 풀 생성
|
||||||
|
* - 일부 참여자는 여러 이벤트에 중복 참여
|
||||||
|
* - 이벤트1: 100명 (user001~user100)
|
||||||
|
* - 이벤트2: 50명 (user051~user100) → 50명이 이벤트1과 중복
|
||||||
|
* - 이벤트3: 30명 (user071~user100) → 30명이 이전 이벤트들과 중복
|
||||||
*/
|
*/
|
||||||
private int publishParticipantRegisteredEvents() throws Exception {
|
private void publishParticipantRegisteredEvents() throws Exception {
|
||||||
String participantIdPrefix = sampleDataConfig.getConfig().getParticipantIdPrefix();
|
String[] eventIds = {"1", "2", "3"};
|
||||||
int participantIdPadding = sampleDataConfig.getConfig().getParticipantIdPadding();
|
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;
|
int totalPublished = 0;
|
||||||
|
|
||||||
for (ParticipantData participantData : sampleDataConfig.getParticipants()) {
|
for (int i = 0; i < eventIds.length; i++) {
|
||||||
String eventId = participantData.getEventId();
|
String eventId = eventIds[i];
|
||||||
int startUser = participantData.getParticipantRange().getStart();
|
int startUser = participantRanges[i][0];
|
||||||
int endUser = participantData.getParticipantRange().getEnd();
|
int endUser = participantRanges[i][1];
|
||||||
int eventParticipants = endUser - startUser + 1;
|
int eventParticipants = endUser - startUser + 1;
|
||||||
|
|
||||||
log.info("이벤트 {} 참여자 발행 시작: {}{:0" + participantIdPadding + "d}~{}{:0" + participantIdPadding + "d} ({}명)",
|
log.info("이벤트 {} 참여자 발행 시작: user{:03d}~user{:03d} ({}명)",
|
||||||
eventId, participantIdPrefix, startUser, participantIdPrefix, endUser, eventParticipants);
|
eventId, startUser, endUser, eventParticipants);
|
||||||
|
|
||||||
// 채널별 가중치 누적 합계 계산 (예: SNS=45, 우리동네TV=70, 지니TV=90, 링고비즈=100)
|
|
||||||
Map<String, Integer> channelWeights = participantData.getChannelWeights();
|
|
||||||
List<String> channels = new ArrayList<>(channelWeights.keySet());
|
|
||||||
int[] cumulativeWeights = new int[channels.size()];
|
|
||||||
int cumulative = 0;
|
|
||||||
|
|
||||||
for (int i = 0; i < channels.size(); i++) {
|
|
||||||
cumulative += channelWeights.get(channels.get(i));
|
|
||||||
cumulativeWeights[i] = cumulative;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 각 참여자에 대해 ParticipantRegistered 이벤트 발행
|
// 각 참여자에 대해 ParticipantRegistered 이벤트 발행
|
||||||
for (int userId = startUser; userId <= endUser; userId++) {
|
for (int userId = startUser; userId <= endUser; userId++) {
|
||||||
String participantId = String.format("%s%0" + participantIdPadding + "d",
|
String participantId = String.format("user%03d", userId); // user001, user002, ...
|
||||||
participantIdPrefix, userId);
|
|
||||||
|
|
||||||
// 채널별 가중치 기반 랜덤 배정
|
// 채널별 가중치 기반 랜덤 배정
|
||||||
int randomValue = random.nextInt(cumulative);
|
// SNS: 45%, 우리동네TV: 25%, 지니TV: 20%, 링고비즈: 10%
|
||||||
String channel = channels.get(0); // 기본값
|
int randomValue = random.nextInt(100);
|
||||||
|
String channel;
|
||||||
for (int i = 0; i < cumulativeWeights.length; i++) {
|
if (randomValue < 45) {
|
||||||
if (randomValue < cumulativeWeights[i]) {
|
channel = "SNS"; // 0~44: 45%
|
||||||
channel = channels.get(i);
|
} else if (randomValue < 70) {
|
||||||
break;
|
channel = "우리동네TV"; // 45~69: 25%
|
||||||
}
|
} else if (randomValue < 90) {
|
||||||
|
channel = "지니TV"; // 70~89: 20%
|
||||||
|
} else {
|
||||||
|
channel = "링고비즈"; // 90~99: 10%
|
||||||
}
|
}
|
||||||
|
|
||||||
ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder()
|
ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder()
|
||||||
@ -377,13 +418,24 @@ public class SampleDataLoader implements ApplicationRunner {
|
|||||||
|
|
||||||
log.info("========================================");
|
log.info("========================================");
|
||||||
log.info("✅ ParticipantRegistered 이벤트 {}건 발행 완료", totalPublished);
|
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("========================================");
|
log.info("========================================");
|
||||||
|
|
||||||
return totalPublished;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TimelineData 생성 (시간대별 샘플 데이터) - JSON 기반
|
* TimelineData 생성 (시간대별 샘플 데이터)
|
||||||
*
|
*
|
||||||
* - 각 이벤트마다 30일 × 24시간 = 720시간 치 hourly 데이터 생성
|
* - 각 이벤트마다 30일 × 24시간 = 720시간 치 hourly 데이터 생성
|
||||||
* - interval=hourly: 시간별 표시 (최근 7일 적합)
|
* - interval=hourly: 시간별 표시 (최근 7일 적합)
|
||||||
@ -393,32 +445,24 @@ public class SampleDataLoader implements ApplicationRunner {
|
|||||||
private void createTimelineData() {
|
private void createTimelineData() {
|
||||||
log.info("📊 TimelineData 생성 시작...");
|
log.info("📊 TimelineData 생성 시작...");
|
||||||
|
|
||||||
// 각 이벤트별 시간당 기준 참여자 수 계산 (참여자 범위 기반)
|
String[] eventIds = {"evt_2025012301", "evt_2025012302", "evt_2025012303"};
|
||||||
List<EventData> events = sampleDataConfig.getEvents();
|
|
||||||
List<ParticipantData> participants = sampleDataConfig.getParticipants();
|
|
||||||
|
|
||||||
for (int eventIndex = 0; eventIndex < events.size(); eventIndex++) {
|
// 각 이벤트별 시간당 기준 참여자 수 (이벤트 성과에 따라 다름)
|
||||||
EventData event = events.get(eventIndex);
|
int[] baseParticipantsPerHour = {4, 2, 1}; // 이벤트1(높음), 이벤트2(중간), 이벤트3(낮음)
|
||||||
String eventId = event.getEventId();
|
|
||||||
|
|
||||||
// 해당 이벤트의 총 참여자 수 계산
|
for (int eventIndex = 0; eventIndex < eventIds.length; eventIndex++) {
|
||||||
ParticipantData participantData = participants.stream()
|
String eventId = eventIds[eventIndex];
|
||||||
.filter(p -> p.getEventId().equals(eventId))
|
int baseParticipant = baseParticipantsPerHour[eventIndex];
|
||||||
.findFirst()
|
|
||||||
.orElse(null);
|
|
||||||
|
|
||||||
int totalParticipants = 100; // 기본값
|
|
||||||
if (participantData != null) {
|
|
||||||
totalParticipants = participantData.getParticipantRange().getEnd()
|
|
||||||
- participantData.getParticipantRange().getStart() + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 30일 × 24시간 = 720시간 치 데이터로 나눔
|
|
||||||
int baseParticipant = Math.max(1, totalParticipants / (30 * 24));
|
|
||||||
int cumulativeParticipants = 0;
|
int cumulativeParticipants = 0;
|
||||||
|
|
||||||
// 이벤트 시작일 파싱
|
// 이벤트 ID에서 날짜 파싱 (evt_2025012301 → 2025-01-23)
|
||||||
java.time.LocalDateTime startDate = parseDateTime(event.getStartDate());
|
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
|
||||||
|
|
||||||
|
// 이벤트 시작일부터 30일 치 hourly 데이터 생성
|
||||||
|
java.time.LocalDateTime startDate = java.time.LocalDateTime.of(year, month, day, 0, 0);
|
||||||
|
|
||||||
for (int dayOffset = 0; dayOffset < 30; dayOffset++) {
|
for (int dayOffset = 0; dayOffset < 30; dayOffset++) {
|
||||||
for (int hour = 0; hour < 24; hour++) {
|
for (int hour = 0; hour < 24; hour++) {
|
||||||
@ -455,12 +499,11 @@ public class SampleDataLoader implements ApplicationRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("✅ TimelineData 생성 완료: eventId={}, 시작일={}, 30일 × 24시간 = 720건",
|
log.info("✅ TimelineData 생성 완료: eventId={}, 시작일={}-{:02d}-{:02d}, 30일 × 24시간 = 720건",
|
||||||
eventId, startDate.toLocalDate());
|
eventId, year, month, day);
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("✅ 전체 TimelineData 생성 완료: {}개 이벤트 × 30일 × 24시간 = {}건",
|
log.info("✅ 전체 TimelineData 생성 완료: 3개 이벤트 × 30일 × 24시간 = 2,160건");
|
||||||
events.size(), events.size() * 30 * 24);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -470,73 +513,4 @@ public class SampleDataLoader implements ApplicationRunner {
|
|||||||
String jsonMessage = objectMapper.writeValueAsString(event);
|
String jsonMessage = objectMapper.writeValueAsString(event);
|
||||||
kafkaTemplate.send(topic, jsonMessage);
|
kafkaTemplate.send(topic, jsonMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* JSON 파일에서 샘플 데이터 로드
|
|
||||||
*/
|
|
||||||
private SampleDataConfig loadSampleData() throws IOException {
|
|
||||||
ClassPathResource resource = new ClassPathResource("sample-data.json");
|
|
||||||
return objectMapper.readValue(resource.getInputStream(), SampleDataConfig.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// JSON 데이터 구조 (Inner Classes)
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
@Data
|
|
||||||
static class SampleDataConfig {
|
|
||||||
private List<EventData> events;
|
|
||||||
private List<DistributionData> distributions;
|
|
||||||
private List<ParticipantData> participants;
|
|
||||||
private ConfigData config;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Data
|
|
||||||
static class EventData {
|
|
||||||
private String eventId;
|
|
||||||
private String eventTitle;
|
|
||||||
private String storeId;
|
|
||||||
private BigDecimal totalInvestment;
|
|
||||||
private BigDecimal expectedRevenue;
|
|
||||||
private String status;
|
|
||||||
private String startDate; // ISO 8601 형식: "2025-01-23T00:00:00"
|
|
||||||
private String endDate; // null 가능
|
|
||||||
private String createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Data
|
|
||||||
static class DistributionData {
|
|
||||||
private String eventId;
|
|
||||||
private String completedAt;
|
|
||||||
private List<ChannelData> channels;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Data
|
|
||||||
static class ChannelData {
|
|
||||||
private String channel;
|
|
||||||
private String channelType;
|
|
||||||
private String status;
|
|
||||||
private Integer expectedViews;
|
|
||||||
private Double distributionCostRatio;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Data
|
|
||||||
static class ParticipantData {
|
|
||||||
private String eventId;
|
|
||||||
private ParticipantRange participantRange;
|
|
||||||
private Map<String, Integer> channelWeights;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Data
|
|
||||||
static class ParticipantRange {
|
|
||||||
private Integer start;
|
|
||||||
private Integer end;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Data
|
|
||||||
static class ConfigData {
|
|
||||||
private Double channelBudgetRatio;
|
|
||||||
private String participantIdPrefix;
|
|
||||||
private Integer participantIdPadding;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,47 +1,70 @@
|
|||||||
package com.kt.event.analytics.config;
|
package com.kt.event.analytics.config;
|
||||||
|
|
||||||
|
import com.kt.event.common.security.JwtAuthenticationFilter;
|
||||||
|
import com.kt.event.common.security.JwtTokenProvider;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
|
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spring Security 설정
|
* Spring Security 설정
|
||||||
* API 테스트를 위해 일단 모든 요청 허용
|
* JWT 기반 인증 및 API 보안 설정
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
|
||||||
|
@Value("${cors.allowed-origins:http://localhost:*}")
|
||||||
|
private String allowedOrigins;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
return http
|
||||||
// CSRF 비활성화 (REST API는 CSRF 불필요)
|
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
// 세션 사용 안 함 (JWT 기반 인증)
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.sessionManagement(session ->
|
|
||||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
|
||||||
)
|
|
||||||
|
|
||||||
// 모든 요청 허용 (테스트용)
|
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
);
|
)
|
||||||
|
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
|
||||||
return http.build();
|
UsernamePasswordAuthenticationFilter.class)
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Chrome DevTools 요청 등 정적 리소스 요청을 Spring Security에서 제외
|
|
||||||
*/
|
|
||||||
@Bean
|
@Bean
|
||||||
public WebSecurityCustomizer webSecurityCustomizer() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
return (web) -> web.ignoring()
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
.requestMatchers("/.well-known/**");
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,245 +0,0 @@
|
|||||||
{
|
|
||||||
"events": [
|
|
||||||
{
|
|
||||||
"eventId": "evt_2025012301",
|
|
||||||
"eventTitle": "신규 고객 환영 이벤트",
|
|
||||||
"storeId": "store_001",
|
|
||||||
"totalInvestment": 5000000,
|
|
||||||
"expectedRevenue": 15000000,
|
|
||||||
"status": "ACTIVE",
|
|
||||||
"startDate": "2025-01-23T00:00:00",
|
|
||||||
"endDate": "2025-02-23T23:59:59",
|
|
||||||
"createdAt": "2025-01-23T10:00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"eventId": "evt_2025011502",
|
|
||||||
"eventTitle": "재방문 고객 감사 이벤트",
|
|
||||||
"storeId": "store_001",
|
|
||||||
"totalInvestment": 3500000,
|
|
||||||
"expectedRevenue": 7000000,
|
|
||||||
"status": "ACTIVE",
|
|
||||||
"startDate": "2025-01-15T00:00:00",
|
|
||||||
"endDate": "2025-02-15T23:59:59",
|
|
||||||
"createdAt": "2025-01-15T14:30:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"eventId": "evt_2025010803",
|
|
||||||
"eventTitle": "신년 특별 할인 이벤트",
|
|
||||||
"storeId": "store_001",
|
|
||||||
"totalInvestment": 2000000,
|
|
||||||
"expectedRevenue": 3000000,
|
|
||||||
"status": "COMPLETED",
|
|
||||||
"startDate": "2025-01-01T00:00:00",
|
|
||||||
"endDate": "2025-01-08T23:59:00",
|
|
||||||
"createdAt": "2024-12-28T09:00:00"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"eventId": "evt_2025020104",
|
|
||||||
"eventTitle": "2월 신메뉴 출시 기념",
|
|
||||||
"storeId": "store_001",
|
|
||||||
"totalInvestment": 2000000,
|
|
||||||
"expectedRevenue": 3000000,
|
|
||||||
"status": "DRAFT",
|
|
||||||
"startDate": "2025-02-01T00:00:00",
|
|
||||||
"endDate": "2025-02-28T23:59:00",
|
|
||||||
"createdAt": "2025-01-25T09:00:00"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"distributions": [
|
|
||||||
{
|
|
||||||
"eventId": "evt_2025012301",
|
|
||||||
"completedAt": "2025-01-23T12:00:00",
|
|
||||||
"channels": [
|
|
||||||
{
|
|
||||||
"channel": "우리동네TV",
|
|
||||||
"channelType": "TV",
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"expectedViews": 5000,
|
|
||||||
"distributionCostRatio": 0.30
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"channel": "지니TV",
|
|
||||||
"channelType": "TV",
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"expectedViews": 10000,
|
|
||||||
"distributionCostRatio": 0.30
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"channel": "링고비즈",
|
|
||||||
"channelType": "CALL",
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"expectedViews": 3000,
|
|
||||||
"distributionCostRatio": 0.25
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"channel": "SNS",
|
|
||||||
"channelType": "SNS",
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"expectedViews": 2000,
|
|
||||||
"distributionCostRatio": 0.15
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"eventId": "evt_2025011502",
|
|
||||||
"completedAt": "2025-02-01T12:00:00",
|
|
||||||
"channels": [
|
|
||||||
{
|
|
||||||
"channel": "우리동네TV",
|
|
||||||
"channelType": "TV",
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"expectedViews": 3500,
|
|
||||||
"distributionCostRatio": 0.30
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"channel": "지니TV",
|
|
||||||
"channelType": "TV",
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"expectedViews": 7000,
|
|
||||||
"distributionCostRatio": 0.30
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"channel": "링고비즈",
|
|
||||||
"channelType": "CALL",
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"expectedViews": 2000,
|
|
||||||
"distributionCostRatio": 0.25
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"channel": "SNS",
|
|
||||||
"channelType": "SNS",
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"expectedViews": 1500,
|
|
||||||
"distributionCostRatio": 0.15
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"eventId": "evt_2025010803",
|
|
||||||
"completedAt": "2025-01-15T12:00:00",
|
|
||||||
"channels": [
|
|
||||||
{
|
|
||||||
"channel": "우리동네TV",
|
|
||||||
"channelType": "TV",
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"expectedViews": 1500,
|
|
||||||
"distributionCostRatio": 0.30
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"channel": "지니TV",
|
|
||||||
"channelType": "TV",
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"expectedViews": 3000,
|
|
||||||
"distributionCostRatio": 0.30
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"channel": "링고비즈",
|
|
||||||
"channelType": "CALL",
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"expectedViews": 1000,
|
|
||||||
"distributionCostRatio": 0.25
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"channel": "SNS",
|
|
||||||
"channelType": "SNS",
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"expectedViews": 500,
|
|
||||||
"distributionCostRatio": 0.15
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"eventId": "evt_2025020104",
|
|
||||||
"completedAt": null,
|
|
||||||
"channels": [
|
|
||||||
{
|
|
||||||
"channel": "우리동네TV",
|
|
||||||
"channelType": "TV",
|
|
||||||
"status": "DRAFT",
|
|
||||||
"expectedViews": 0,
|
|
||||||
"distributionCostRatio": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"channel": "지니TV",
|
|
||||||
"channelType": "TV",
|
|
||||||
"status": "DRAFT",
|
|
||||||
"expectedViews": 0,
|
|
||||||
"distributionCostRatio": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"channel": "링고비즈",
|
|
||||||
"channelType": "CALL",
|
|
||||||
"status": "DRAFT",
|
|
||||||
"expectedViews": 0,
|
|
||||||
"distributionCostRatio": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"channel": "SNS",
|
|
||||||
"channelType": "SNS",
|
|
||||||
"status": "DRAFT",
|
|
||||||
"expectedViews": 0,
|
|
||||||
"distributionCostRatio": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"participants": [
|
|
||||||
{
|
|
||||||
"eventId": "evt_2025012301",
|
|
||||||
"participantRange": {
|
|
||||||
"start": 1,
|
|
||||||
"end": 100
|
|
||||||
},
|
|
||||||
"channelWeights": {
|
|
||||||
"SNS": 45,
|
|
||||||
"우리동네TV": 25,
|
|
||||||
"지니TV": 20,
|
|
||||||
"링고비즈": 10
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"eventId": "evt_2025011502",
|
|
||||||
"participantRange": {
|
|
||||||
"start": 51,
|
|
||||||
"end": 100
|
|
||||||
},
|
|
||||||
"channelWeights": {
|
|
||||||
"SNS": 45,
|
|
||||||
"우리동네TV": 25,
|
|
||||||
"지니TV": 20,
|
|
||||||
"링고비즈": 10
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"eventId": "evt_2025010803",
|
|
||||||
"participantRange": {
|
|
||||||
"start": 71,
|
|
||||||
"end": 100
|
|
||||||
},
|
|
||||||
"channelWeights": {
|
|
||||||
"SNS": 45,
|
|
||||||
"우리동네TV": 25,
|
|
||||||
"지니TV": 20,
|
|
||||||
"링고비즈": 10
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"eventId": "evt_2025020104",
|
|
||||||
"participantRange": {
|
|
||||||
"start": 0,
|
|
||||||
"end": 0
|
|
||||||
},
|
|
||||||
"channelWeights": {
|
|
||||||
"SNS": 0,
|
|
||||||
"우리동네TV": 0,
|
|
||||||
"지니TV": 0,
|
|
||||||
"링고비즈": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"config": {
|
|
||||||
"channelBudgetRatio": 0.50,
|
|
||||||
"participantIdPrefix": "user",
|
|
||||||
"participantIdPadding": 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
404: Not Found
|
|
||||||
@ -1,168 +0,0 @@
|
|||||||
# 마이구독 서비스 (LifeSub)
|
|
||||||
|
|
||||||
## 1. 소개
|
|
||||||
마이구독은 다양한, 증가하는 생활 구독 서비스를 한 곳에서 편리하게 관리할 수 있는 애플리케이션입니다.
|
|
||||||
사용자가 구독 중인 서비스를 한눈에 확인하고, 월별 구독료를 관리하며, 새로운 구독 서비스를 추천받을 수 있습니다.
|
|
||||||
|
|
||||||
### 1.1 핵심 기능
|
|
||||||
- **구독 관리**: 다양한 구독 서비스를 한 곳에서 관리
|
|
||||||
- **비용 분석**: 월별 구독료 총액 및 수준 확인 (Liker, Collector, Addict)
|
|
||||||
- **맞춤형 추천**: 사용자의 지출 패턴 기반 새로운 구독 서비스 추천
|
|
||||||
|
|
||||||
### 1.2 MVP 산출물
|
|
||||||
- **발표자료**: {발표자료 링크}
|
|
||||||
- **설계결과**: {설계결과 링크}
|
|
||||||
- **Git Repo**:
|
|
||||||
- **프론트엔드**: https://github.com/cna-bootcamp/lifesub-web.git
|
|
||||||
- **백엔드**: https://github.com/cna-bootcamp/lifesub.git
|
|
||||||
- **manifest**: https://github.com/cna-bootcamp/lifesub-manifest.git
|
|
||||||
- **시연 동영상**: {시연 동영상 링크}
|
|
||||||
|
|
||||||
## 2. 시스템 아키텍처
|
|
||||||
|
|
||||||
### 2.1 전체 구조
|
|
||||||
프론트엔드와 마이크로서비스 백엔드로 구성된 웹 애플리케이션
|
|
||||||
{전체 서비스와 관계를 표현한 Context Map이나 논리아키텍처}
|
|
||||||
|
|
||||||
### 2.2 마이크로서비스 구성
|
|
||||||
- **회원 서비스 (Member)**: 사용자 인증 및 토큰 관리
|
|
||||||
- **구독 서비스 (MySub)**: 구독 정보 관리 및 카테고리 관리
|
|
||||||
- **추천 서비스 (Recommend)**: 사용자 맞춤형 구독 서비스 추천
|
|
||||||
|
|
||||||
### 2.3 기술 스택
|
|
||||||
- **프론트엔드**: React, Material UI, React Router
|
|
||||||
- **백엔드**: Spring Boot, Java
|
|
||||||
- **인프라**: Azure Kubernetes Service (AKS), Azure Container Registry
|
|
||||||
- **CI/CD**: Jenkins, Podman (컨테이너 빌드)
|
|
||||||
- **코드 품질**: SonarQube
|
|
||||||
- **백킹 서비스**:
|
|
||||||
- **Database**: PostgreSQL
|
|
||||||
- **Message Queue**: RabbitMQ
|
|
||||||
- **기타**: Redis
|
|
||||||
|
|
||||||
## 3. 백킹 서비스 설치
|
|
||||||
1. Database 설치
|
|
||||||
```bash
|
|
||||||
# Helm 저장소 추가
|
|
||||||
helm repo add bitnami https://charts.bitnami.com/bitnami
|
|
||||||
helm repo update
|
|
||||||
|
|
||||||
# Member 서비스용 DB
|
|
||||||
helm install member bitnami/postgresql \
|
|
||||||
--set global.postgresql.auth.postgresPassword=Passw0rd \
|
|
||||||
--set global.postgresql.auth.username=admin \
|
|
||||||
--set global.postgresql.auth.password=Passw0rd \
|
|
||||||
--set global.postgresql.auth.database=member
|
|
||||||
|
|
||||||
# MySub 서비스용 DB
|
|
||||||
helm install mysub bitnami/postgresql \
|
|
||||||
--set global.postgresql.auth.postgresPassword=Passw0rd \
|
|
||||||
--set global.postgresql.auth.username=admin \
|
|
||||||
--set global.postgresql.auth.password=Passw0rd \
|
|
||||||
--set global.postgresql.auth.database=mysub
|
|
||||||
|
|
||||||
# Recommend 서비스용 DB
|
|
||||||
helm install recommend bitnami/postgresql \
|
|
||||||
--set global.postgresql.auth.postgresPassword=Passw0rd \
|
|
||||||
--set global.postgresql.auth.username=admin \
|
|
||||||
--set global.postgresql.auth.password=Passw0rd \
|
|
||||||
--set global.postgresql.auth.database=recommend
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Message Queue 설치
|
|
||||||
설치 방법
|
|
||||||
{MQ별 설치 방법 안내}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 4. 빌드 및 배포
|
|
||||||
|
|
||||||
### 4.1 프론트엔드 빌드 및 배포
|
|
||||||
1. 컨테이너 이미지 빌드
|
|
||||||
```bash
|
|
||||||
docker build \
|
|
||||||
--build-arg PROJECT_FOLDER="." \
|
|
||||||
--build-arg REACT_APP_MEMBER_URL="http://api.example.com/member" \
|
|
||||||
--build-arg REACT_APP_MYSUB_URL="http://api.example.com/mysub" \
|
|
||||||
--build-arg REACT_APP_RECOMMEND_URL="http://api.example.com/recommend" \
|
|
||||||
--build-arg BUILD_FOLDER="deployment/container" \
|
|
||||||
--build-arg EXPORT_PORT="18080" \
|
|
||||||
-f deployment/container/Dockerfile-lifesub-web \
|
|
||||||
-t {Image Registry주소}/lifesub/lifesub-web:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 이미지 푸시
|
|
||||||
```bash
|
|
||||||
docker push {Image Registry주소}/lifesub/lifesub-web:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Kubernetes 배포
|
|
||||||
```bash
|
|
||||||
kubectl apply -f deployment/manifest/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 백엔드 빌드 및 배포
|
|
||||||
1. 애플리케이션 빌드
|
|
||||||
```bash
|
|
||||||
# 각 서비스 모듈을 개별적으로 빌드
|
|
||||||
./gradlew :member:clean :member:build -x test
|
|
||||||
./gradlew :mysub-infra:clean :mysub-infra:build -x test
|
|
||||||
./gradlew :recommend:clean :recommend:build -x test
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 컨테이너 이미지 빌드 (각 서비스별로 수행)
|
|
||||||
```bash
|
|
||||||
# Member 서비스
|
|
||||||
docker build \
|
|
||||||
--build-arg BUILD_LIB_DIR="member/build/libs" \
|
|
||||||
--build-arg ARTIFACTORY_FILE="member.jar" \
|
|
||||||
-f deployment/Dockerfile \
|
|
||||||
-t {Image Registry주소}/lifesub/member:latest .
|
|
||||||
|
|
||||||
# MySub 서비스
|
|
||||||
docker build \
|
|
||||||
--build-arg BUILD_LIB_DIR="mysub-infra/build/libs" \
|
|
||||||
--build-arg ARTIFACTORY_FILE="mysub.jar" \
|
|
||||||
-f deployment/Dockerfile \
|
|
||||||
-t {Image Registry주소}/lifesub/mysub:latest .
|
|
||||||
|
|
||||||
# Recommend 서비스
|
|
||||||
docker build \
|
|
||||||
--build-arg BUILD_LIB_DIR="recommend/build/libs" \
|
|
||||||
--build-arg ARTIFACTORY_FILE="recommend.jar" \
|
|
||||||
-f deployment/Dockerfile \
|
|
||||||
-t {Image Registry주소}/lifesub/recommend:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 이미지 푸시
|
|
||||||
```bash
|
|
||||||
docker push {Image Registry주소}/lifesub/member:latest
|
|
||||||
docker push {Image Registry주소}/lifesub/mysub:latest
|
|
||||||
docker push {Image Registry주소}/lifesub/recommend:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Kubernetes 배포
|
|
||||||
```bash
|
|
||||||
kubectl apply -f deployment/manifest/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 테스트
|
|
||||||
1) 프론트 페이지 주소 구하기
|
|
||||||
```
|
|
||||||
kubens {namespace}
|
|
||||||
k get svc
|
|
||||||
```
|
|
||||||
|
|
||||||
2) 로그인
|
|
||||||
- ID: user01 ~ user05
|
|
||||||
- PW: P@ssw0rd$
|
|
||||||
|
|
||||||
## 5. 팀
|
|
||||||
|
|
||||||
- 오유진 "피오" - Product Owner
|
|
||||||
- 강동훈 "테키" - Tech Lead
|
|
||||||
- 김민지 "유엑스" - UX Designer
|
|
||||||
- 이준혁 "백개" - Backend Developer
|
|
||||||
- 박소연 "프개" - Frontend Developer
|
|
||||||
- 최진우 "큐에이" - QA Engineer
|
|
||||||
- 정해린 "데브옵스" - DevOps Engineer
|
|
||||||
@ -40,24 +40,24 @@ spec:
|
|||||||
limits:
|
limits:
|
||||||
cpu: "1024m"
|
cpu: "1024m"
|
||||||
memory: "1024Mi"
|
memory: "1024Mi"
|
||||||
# startupProbe:
|
startupProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/ai/actuator/health
|
path: /api/v1/ai/actuator/health
|
||||||
# port: 8083
|
port: 8083
|
||||||
# initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
# periodSeconds: 10
|
periodSeconds: 10
|
||||||
# failureThreshold: 30
|
failureThreshold: 30
|
||||||
# readinessProbe:
|
readinessProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/ai/actuator/health/readiness
|
path: /api/v1/ai/actuator/health/readiness
|
||||||
# port: 8083
|
port: 8083
|
||||||
# initialDelaySeconds: 10
|
initialDelaySeconds: 10
|
||||||
# periodSeconds: 5
|
periodSeconds: 5
|
||||||
# failureThreshold: 3
|
failureThreshold: 3
|
||||||
# livenessProbe:
|
livenessProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/ai/actuator/health/liveness
|
path: /api/v1/ai/actuator/health/liveness
|
||||||
# port: 8083
|
port: 8083
|
||||||
# initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
# periodSeconds: 10
|
periodSeconds: 10
|
||||||
# failureThreshold: 3
|
failureThreshold: 3
|
||||||
|
|||||||
@ -40,24 +40,24 @@ spec:
|
|||||||
limits:
|
limits:
|
||||||
cpu: "1024m"
|
cpu: "1024m"
|
||||||
memory: "1024Mi"
|
memory: "1024Mi"
|
||||||
# startupProbe:
|
startupProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/analytics/actuator/health/liveness
|
path: /api/v1/analytics/actuator/health/liveness
|
||||||
# port: 8086
|
port: 8086
|
||||||
# initialDelaySeconds: 60
|
initialDelaySeconds: 60
|
||||||
# periodSeconds: 10
|
periodSeconds: 10
|
||||||
# failureThreshold: 30
|
failureThreshold: 30
|
||||||
# livenessProbe:
|
livenessProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/analytics/actuator/health/liveness
|
path: /api/v1/analytics/actuator/health/liveness
|
||||||
# port: 8086
|
port: 8086
|
||||||
# initialDelaySeconds: 0
|
initialDelaySeconds: 0
|
||||||
# periodSeconds: 10
|
periodSeconds: 10
|
||||||
# failureThreshold: 3
|
failureThreshold: 3
|
||||||
# readinessProbe:
|
readinessProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/analytics/actuator/health/readiness
|
path: /api/v1/analytics/actuator/health/readiness
|
||||||
# port: 8086
|
port: 8086
|
||||||
# initialDelaySeconds: 0
|
initialDelaySeconds: 0
|
||||||
# periodSeconds: 10
|
periodSeconds: 10
|
||||||
# failureThreshold: 3
|
failureThreshold: 3
|
||||||
|
|||||||
@ -30,40 +30,7 @@ spec:
|
|||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
|
|
||||||
# Event Service - Swagger UI (must be before /api/v1/events path)
|
# Event Service
|
||||||
- path: /api/v1/swagger-ui
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: event-service
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
|
|
||||||
- path: /api/v1/swagger-ui.html
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: event-service
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
|
|
||||||
- path: /api/v1/v3/api-docs
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: event-service
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
|
|
||||||
- path: /api/v1/api-docs
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: event-service
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
|
|
||||||
# Event Service - API endpoints
|
|
||||||
- path: /api/v1/events
|
- path: /api/v1/events
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
@ -40,24 +40,24 @@ spec:
|
|||||||
limits:
|
limits:
|
||||||
cpu: "1024m"
|
cpu: "1024m"
|
||||||
memory: "1024Mi"
|
memory: "1024Mi"
|
||||||
# startupProbe:
|
startupProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/content/actuator/health
|
path: /api/v1/content/actuator/health
|
||||||
# port: 8084
|
port: 8084
|
||||||
# initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
# periodSeconds: 10
|
periodSeconds: 10
|
||||||
# failureThreshold: 30
|
failureThreshold: 30
|
||||||
# readinessProbe:
|
readinessProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/content/actuator/health/readiness
|
path: /api/v1/content/actuator/health/readiness
|
||||||
# port: 8084
|
port: 8084
|
||||||
# initialDelaySeconds: 10
|
initialDelaySeconds: 10
|
||||||
# periodSeconds: 5
|
periodSeconds: 5
|
||||||
# failureThreshold: 3
|
failureThreshold: 3
|
||||||
# livenessProbe:
|
livenessProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/content/actuator/health/liveness
|
path: /api/v1/content/actuator/health/liveness
|
||||||
# port: 8084
|
port: 8084
|
||||||
# initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
# periodSeconds: 10
|
periodSeconds: 10
|
||||||
# failureThreshold: 3
|
failureThreshold: 3
|
||||||
|
|||||||
@ -40,24 +40,24 @@ spec:
|
|||||||
limits:
|
limits:
|
||||||
cpu: "1024m"
|
cpu: "1024m"
|
||||||
memory: "1024Mi"
|
memory: "1024Mi"
|
||||||
# startupProbe:
|
startupProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/distribution/actuator/health
|
path: /api/v1/distribution/actuator/health
|
||||||
# port: 8085
|
port: 8085
|
||||||
# initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
# periodSeconds: 10
|
periodSeconds: 10
|
||||||
# failureThreshold: 30
|
failureThreshold: 30
|
||||||
# readinessProbe:
|
readinessProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/distribution/actuator/health/readiness
|
path: /api/v1/distribution/actuator/health/readiness
|
||||||
# port: 8085
|
port: 8085
|
||||||
# initialDelaySeconds: 10
|
initialDelaySeconds: 10
|
||||||
# periodSeconds: 5
|
periodSeconds: 5
|
||||||
# failureThreshold: 3
|
failureThreshold: 3
|
||||||
# livenessProbe:
|
livenessProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/distribution/actuator/health/liveness
|
path: /api/v1/distribution/actuator/health/liveness
|
||||||
# port: 8085
|
port: 8085
|
||||||
# initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
# periodSeconds: 10
|
periodSeconds: 10
|
||||||
# failureThreshold: 3
|
failureThreshold: 3
|
||||||
|
|||||||
@ -19,7 +19,7 @@ spec:
|
|||||||
- name: kt-event-marketing
|
- name: kt-event-marketing
|
||||||
containers:
|
containers:
|
||||||
- name: event-service
|
- name: event-service
|
||||||
image: acrdigitalgarage01.azurecr.io/kt-event-marketing/event-service:dev
|
image: acrdigitalgarage01.azurecr.io/kt-event-marketing/event-service:latest
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
@ -40,24 +40,24 @@ spec:
|
|||||||
limits:
|
limits:
|
||||||
cpu: "1024m"
|
cpu: "1024m"
|
||||||
memory: "1024Mi"
|
memory: "1024Mi"
|
||||||
# startupProbe:
|
startupProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/actuator/health
|
path: /api/v1/events/actuator/health
|
||||||
# port: 8080
|
port: 8080
|
||||||
# initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
# periodSeconds: 10
|
periodSeconds: 10
|
||||||
# failureThreshold: 30
|
failureThreshold: 30
|
||||||
# readinessProbe:
|
readinessProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/actuator/health/readiness
|
path: /api/v1/events/actuator/health/readiness
|
||||||
# port: 8080
|
port: 8080
|
||||||
# initialDelaySeconds: 10
|
initialDelaySeconds: 10
|
||||||
# periodSeconds: 5
|
periodSeconds: 5
|
||||||
# failureThreshold: 3
|
failureThreshold: 3
|
||||||
# livenessProbe:
|
livenessProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/actuator/health/liveness
|
path: /api/v1/events/actuator/health/liveness
|
||||||
# port: 8080
|
port: 8080
|
||||||
# initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
# periodSeconds: 10
|
periodSeconds: 10
|
||||||
# failureThreshold: 3
|
failureThreshold: 3
|
||||||
|
|||||||
@ -40,24 +40,24 @@ spec:
|
|||||||
limits:
|
limits:
|
||||||
cpu: "1024m"
|
cpu: "1024m"
|
||||||
memory: "1024Mi"
|
memory: "1024Mi"
|
||||||
# startupProbe:
|
startupProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/participations/actuator/health/liveness
|
path: /api/v1/participations/actuator/health/liveness
|
||||||
# port: 8084
|
port: 8084
|
||||||
# initialDelaySeconds: 60
|
initialDelaySeconds: 60
|
||||||
# periodSeconds: 10
|
periodSeconds: 10
|
||||||
# failureThreshold: 30
|
failureThreshold: 30
|
||||||
# livenessProbe:
|
livenessProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/participations/actuator/health/liveness
|
path: /api/v1/participations/actuator/health/liveness
|
||||||
# port: 8084
|
port: 8084
|
||||||
# initialDelaySeconds: 0
|
initialDelaySeconds: 0
|
||||||
# periodSeconds: 10
|
periodSeconds: 10
|
||||||
# failureThreshold: 3
|
failureThreshold: 3
|
||||||
# readinessProbe:
|
readinessProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/participations/actuator/health/readiness
|
path: /api/v1/participations/actuator/health/readiness
|
||||||
# port: 8084
|
port: 8084
|
||||||
# initialDelaySeconds: 0
|
initialDelaySeconds: 0
|
||||||
# periodSeconds: 10
|
periodSeconds: 10
|
||||||
# failureThreshold: 3
|
failureThreshold: 3
|
||||||
|
|||||||
@ -40,24 +40,24 @@ spec:
|
|||||||
limits:
|
limits:
|
||||||
cpu: "1024m"
|
cpu: "1024m"
|
||||||
memory: "1024Mi"
|
memory: "1024Mi"
|
||||||
# startupProbe:
|
startupProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/users/actuator/health
|
path: /api/v1/users/actuator/health
|
||||||
# port: 8081
|
port: 8081
|
||||||
# initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
# periodSeconds: 10
|
periodSeconds: 10
|
||||||
# failureThreshold: 30
|
failureThreshold: 30
|
||||||
# readinessProbe:
|
readinessProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/users/actuator/health/readiness
|
path: /api/v1/users/actuator/health/readiness
|
||||||
# port: 8081
|
port: 8081
|
||||||
# initialDelaySeconds: 10
|
initialDelaySeconds: 10
|
||||||
# periodSeconds: 5
|
periodSeconds: 5
|
||||||
# failureThreshold: 3
|
failureThreshold: 3
|
||||||
# livenessProbe:
|
livenessProbe:
|
||||||
# httpGet:
|
httpGet:
|
||||||
# path: /api/v1/users/actuator/health/liveness
|
path: /api/v1/users/actuator/health/liveness
|
||||||
# port: 8081
|
port: 8081
|
||||||
# initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
# periodSeconds: 10
|
periodSeconds: 10
|
||||||
# failureThreshold: 3
|
failureThreshold: 3
|
||||||
|
|||||||
@ -11,11 +11,6 @@
|
|||||||
<entry key="KAKAO_API_URL" value="http://localhost:9006/api/kakao" />
|
<entry key="KAKAO_API_URL" value="http://localhost:9006/api/kakao" />
|
||||||
<entry key="LOG_FILE" value="logs/distribution-service.log" />
|
<entry key="LOG_FILE" value="logs/distribution-service.log" />
|
||||||
<entry key="NAVER_API_URL" value="http://localhost:9005/api/naver" />
|
<entry key="NAVER_API_URL" value="http://localhost:9005/api/naver" />
|
||||||
<entry key="NAVER_BLOG_BLOG_ID" value="bokchi_13" />
|
|
||||||
<entry key="NAVER_BLOG_HEADLESS" value="false" />
|
|
||||||
<entry key="NAVER_BLOG_PASSWORD" value="" />
|
|
||||||
<entry key="NAVER_BLOG_SESSION_PATH" value="playwright-sessions" />
|
|
||||||
<entry key="NAVER_BLOG_USERNAME" value="" />
|
|
||||||
<entry key="RINGOBIZ_API_URL" value="http://localhost:9002/api/ringobiz" />
|
<entry key="RINGOBIZ_API_URL" value="http://localhost:9002/api/ringobiz" />
|
||||||
<entry key="SERVER_PORT" value="8085" />
|
<entry key="SERVER_PORT" value="8085" />
|
||||||
<entry key="URIDONGNETV_API_URL" value="http://localhost:9001/api/uridongnetv" />
|
<entry key="URIDONGNETV_API_URL" value="http://localhost:9001/api/uridongnetv" />
|
||||||
|
|||||||
@ -1,40 +1,15 @@
|
|||||||
# Multi-stage build for Spring Boot application
|
# Multi-stage build for Spring Boot application
|
||||||
FROM eclipse-temurin:21-jre AS builder
|
FROM eclipse-temurin:21-jre-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY build/libs/*.jar app.jar
|
COPY build/libs/*.jar app.jar
|
||||||
RUN java -Djarmode=layertools -jar app.jar extract
|
RUN java -Djarmode=layertools -jar app.jar extract
|
||||||
|
|
||||||
FROM eclipse-temurin:21-jre
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Playwright essential dependencies only
|
# Create non-root user
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN addgroup -S spring && adduser -S spring -G spring
|
||||||
wget \
|
USER spring:spring
|
||||||
libnss3 \
|
|
||||||
libnspr4 \
|
|
||||||
libatk1.0-0 \
|
|
||||||
libatk-bridge2.0-0 \
|
|
||||||
libcups2 \
|
|
||||||
libdrm2 \
|
|
||||||
libdbus-1-3 \
|
|
||||||
libxkbcommon0 \
|
|
||||||
libxcomposite1 \
|
|
||||||
libxdamage1 \
|
|
||||||
libxfixes3 \
|
|
||||||
libxrandr2 \
|
|
||||||
libgbm1 \
|
|
||||||
libasound2t64 \
|
|
||||||
libpango-1.0-0 \
|
|
||||||
libcairo2 \
|
|
||||||
libatspi2.0-0 \
|
|
||||||
libxshmfence1 \
|
|
||||||
fonts-liberation \
|
|
||||||
libappindicator3-1 \
|
|
||||||
xdg-utils \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Create browser installation directory with proper permissions
|
|
||||||
RUN mkdir -p /app/playwright && chmod 777 /app/playwright
|
|
||||||
|
|
||||||
# Copy layers from builder
|
# Copy layers from builder
|
||||||
COPY --from=builder /app/dependencies/ ./
|
COPY --from=builder /app/dependencies/ ./
|
||||||
@ -42,17 +17,6 @@ COPY --from=builder /app/spring-boot-loader/ ./
|
|||||||
COPY --from=builder /app/snapshot-dependencies/ ./
|
COPY --from=builder /app/snapshot-dependencies/ ./
|
||||||
COPY --from=builder /app/application/ ./
|
COPY --from=builder /app/application/ ./
|
||||||
|
|
||||||
# Set Playwright browsers path
|
|
||||||
ENV PLAYWRIGHT_BROWSERS_PATH=/app/playwright
|
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN groupadd -r spring && useradd -r -g spring spring
|
|
||||||
|
|
||||||
# Change ownership to spring user
|
|
||||||
RUN chown -R spring:spring /app
|
|
||||||
|
|
||||||
USER spring:spring
|
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8085/distribution/actuator/health || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8085/distribution/actuator/health || exit 1
|
||||||
|
|||||||
@ -1,248 +0,0 @@
|
|||||||
# 네이버 블로그 포스팅 설정 가이드
|
|
||||||
|
|
||||||
## 개요
|
|
||||||
Distribution Service는 Playwright를 사용하여 네이버 블로그에 자동으로 포스팅합니다.
|
|
||||||
|
|
||||||
## 사전 준비
|
|
||||||
|
|
||||||
### 1. Playwright 설치
|
|
||||||
처음 실행 시 Playwright 브라우저가 자동으로 다운로드됩니다. 수동으로 설치하려면:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Windows (PowerShell)
|
|
||||||
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install chromium"
|
|
||||||
|
|
||||||
# Linux/Mac
|
|
||||||
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install chromium"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 네이버 계정 준비
|
|
||||||
- 네이버 계정 (아이디/비밀번호)
|
|
||||||
- 네이버 블로그 개설 (blog.naver.com에서 블로그 만들기)
|
|
||||||
- 블로그 ID 확인 (예: blog.naver.com/YOUR_BLOG_ID)
|
|
||||||
|
|
||||||
## 환경 변수 설정
|
|
||||||
|
|
||||||
### IntelliJ 실행 프로파일 설정
|
|
||||||
`.run/DistributionServiceApplication.run.xml` 파일에서 다음 환경 변수를 설정:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<env name="NAVER_BLOG_USERNAME" value="your_naver_id" />
|
|
||||||
<env name="NAVER_BLOG_PASSWORD" value="your_password" />
|
|
||||||
<env name="NAVER_BLOG_BLOG_ID" value="your_blog_id" />
|
|
||||||
<env name="NAVER_BLOG_HEADLESS" value="false" /> <!-- 브라우저 표시 여부 -->
|
|
||||||
<env name="NAVER_BLOG_SESSION_PATH" value="playwright-sessions" />
|
|
||||||
```
|
|
||||||
|
|
||||||
### 환경 변수 설명
|
|
||||||
|
|
||||||
| 환경 변수 | 설명 | 기본값 | 필수 |
|
|
||||||
|----------|------|--------|------|
|
|
||||||
| `NAVER_BLOG_USERNAME` | 네이버 아이디 | - | ✅ |
|
|
||||||
| `NAVER_BLOG_PASSWORD` | 네이버 비밀번호 | - | ✅ |
|
|
||||||
| `NAVER_BLOG_BLOG_ID` | 네이버 블로그 ID | - | ✅ |
|
|
||||||
| `NAVER_BLOG_HEADLESS` | Headless 모드 (true/false) | true | ❌ |
|
|
||||||
| `NAVER_BLOG_SESSION_PATH` | 세션 저장 경로 | playwright-sessions | ❌ |
|
|
||||||
|
|
||||||
### Headless 모드
|
|
||||||
- **false**: 브라우저 창이 표시되어 디버깅에 유용 (개발 환경 권장)
|
|
||||||
- **true**: 백그라운드 실행, 서버 환경에 적합 (운영 환경 권장)
|
|
||||||
|
|
||||||
## 사용 방법
|
|
||||||
|
|
||||||
### API 호출 예시
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 배포 요청
|
|
||||||
curl -X POST http://localhost:8085/distribution/api/v1/distributions \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"eventId": "EVT001",
|
|
||||||
"title": "신규 이벤트 안내",
|
|
||||||
"content": "이벤트 상세 내용입니다.",
|
|
||||||
"imageUrl": "https://example.com/event.jpg",
|
|
||||||
"channels": ["NAVER"]
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 응답 예시
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"eventId": "EVT001",
|
|
||||||
"status": "SUCCESS",
|
|
||||||
"totalChannels": 1,
|
|
||||||
"successCount": 1,
|
|
||||||
"failureCount": 0,
|
|
||||||
"channels": [
|
|
||||||
{
|
|
||||||
"channel": "NAVER",
|
|
||||||
"success": true,
|
|
||||||
"distributionId": "NAVER-abc123",
|
|
||||||
"distributionUrl": "https://blog.naver.com/your_blog_id/222999999999",
|
|
||||||
"estimatedReach": 2000,
|
|
||||||
"executionTimeMs": 5234
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"distributedAt": "2025-10-29T10:30:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 세션 관리
|
|
||||||
|
|
||||||
### 자동 로그인
|
|
||||||
- 최초 실행 시 네이버에 로그인하고 세션이 저장됩니다
|
|
||||||
- 이후 요청은 저장된 세션을 사용하여 로그인 없이 진행됩니다
|
|
||||||
- 세션 파일 위치: `playwright-sessions/naver-blog-session.json`
|
|
||||||
|
|
||||||
### 세션 만료 시
|
|
||||||
세션이 만료되면 자동으로 재로그인을 시도합니다.
|
|
||||||
|
|
||||||
### 수동 세션 초기화
|
|
||||||
```bash
|
|
||||||
# 세션 파일 삭제
|
|
||||||
rm -rf playwright-sessions/naver-blog-session.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## 문제 해결
|
|
||||||
|
|
||||||
### 1. 로그인 실패
|
|
||||||
**증상**: "Login failed" 에러 발생
|
|
||||||
|
|
||||||
**해결 방법**:
|
|
||||||
- 네이버 아이디/비밀번호 확인
|
|
||||||
- 네이버 로그인 보안 설정 확인 (캡차, 2단계 인증 등)
|
|
||||||
- Headless 모드를 false로 설정하여 브라우저 동작 확인
|
|
||||||
- 세션 파일 삭제 후 재시도
|
|
||||||
|
|
||||||
### 2. 브라우저 실행 실패
|
|
||||||
**증상**: "Failed to initialize Playwright" 에러
|
|
||||||
|
|
||||||
**해결 방법**:
|
|
||||||
```bash
|
|
||||||
# Playwright 브라우저 재설치
|
|
||||||
mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install chromium"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 포스팅 실패
|
|
||||||
**증상**: 포스팅 URL이 반환되지 않음
|
|
||||||
|
|
||||||
**해결 방법**:
|
|
||||||
- Headless 모드를 false로 설정하여 UI 확인
|
|
||||||
- 네이버 블로그 에디터 구조 변경 여부 확인
|
|
||||||
- 로그 확인: `logs/distribution-service.log`
|
|
||||||
|
|
||||||
### 4. 성능 이슈
|
|
||||||
브라우저 자동화는 리소스를 많이 사용하므로:
|
|
||||||
- Resilience4j Bulkhead 설정으로 동시 실행 제한 (현재 10개)
|
|
||||||
- Circuit Breaker로 반복 실패 방지
|
|
||||||
- 실패 시 자동 재시도 (최대 3회)
|
|
||||||
|
|
||||||
## 보안 고려사항
|
|
||||||
|
|
||||||
### 1. 비밀번호 관리
|
|
||||||
- **절대로** 소스 코드에 비밀번호를 하드코딩하지 마세요
|
|
||||||
- 환경 변수 또는 시크릿 관리 서비스 사용
|
|
||||||
- Git에 `.run/*.xml` 파일을 커밋하지 마세요 (`.gitignore` 추가)
|
|
||||||
|
|
||||||
### 2. 세션 파일 보안
|
|
||||||
- `playwright-sessions/` 디렉토리를 `.gitignore`에 추가
|
|
||||||
- 서버 환경에서 파일 권한 설정 (chmod 600)
|
|
||||||
|
|
||||||
### 3. 네트워크 보안
|
|
||||||
- HTTPS만 사용
|
|
||||||
- 프록시 사용 시 안전한 프록시 설정
|
|
||||||
|
|
||||||
## 운영 환경 배포
|
|
||||||
|
|
||||||
### Docker 환경
|
|
||||||
```dockerfile
|
|
||||||
# Dockerfile에 Playwright 설치 추가
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
libnss3 \
|
|
||||||
libatk-bridge2.0-0 \
|
|
||||||
libdrm2 \
|
|
||||||
libxkbcommon0 \
|
|
||||||
libgbm1 \
|
|
||||||
libasound2
|
|
||||||
|
|
||||||
# Playwright 브라우저 설치
|
|
||||||
RUN mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI -D exec.args="install chromium"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kubernetes 환경
|
|
||||||
```yaml
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: naver-blog-credentials
|
|
||||||
type: Opaque
|
|
||||||
stringData:
|
|
||||||
username: your_naver_id
|
|
||||||
password: your_password
|
|
||||||
blog-id: your_blog_id
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
spec:
|
|
||||||
template:
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: distribution-service
|
|
||||||
env:
|
|
||||||
- name: NAVER_BLOG_USERNAME
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: naver-blog-credentials
|
|
||||||
key: username
|
|
||||||
- name: NAVER_BLOG_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: naver-blog-credentials
|
|
||||||
key: password
|
|
||||||
- name: NAVER_BLOG_BLOG_ID
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: naver-blog-credentials
|
|
||||||
key: blog-id
|
|
||||||
- name: NAVER_BLOG_HEADLESS
|
|
||||||
value: "true"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 제약사항
|
|
||||||
|
|
||||||
1. **동시 실행 제한**: Bulkhead 설정으로 최대 10개 동시 실행
|
|
||||||
2. **실행 시간**: 브라우저 자동화는 API 호출보다 느림 (평균 5-10초)
|
|
||||||
3. **네이버 정책**: 네이버 블로그 정책 변경 시 업데이트 필요
|
|
||||||
4. **UI 변경**: 네이버 블로그 UI 변경 시 코드 수정 필요
|
|
||||||
|
|
||||||
## 모니터링
|
|
||||||
|
|
||||||
### 로그 확인
|
|
||||||
```bash
|
|
||||||
# 실시간 로그
|
|
||||||
tail -f logs/distribution-service.log
|
|
||||||
|
|
||||||
# 에러만 필터
|
|
||||||
grep ERROR logs/distribution-service.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### 주요 로그 메시지
|
|
||||||
- `Initializing Playwright for Naver Blog`: Playwright 초기화
|
|
||||||
- `Starting Naver login process`: 로그인 시작
|
|
||||||
- `Naver login successful`: 로그인 성공
|
|
||||||
- `Post published successfully`: 포스팅 성공
|
|
||||||
- `Failed to post to Naver blog`: 포스팅 실패
|
|
||||||
|
|
||||||
## 참고 자료
|
|
||||||
|
|
||||||
- [Playwright for Java](https://playwright.dev/java/)
|
|
||||||
- [네이버 블로그 고객센터](https://help.naver.com/service/5614/)
|
|
||||||
- [Resilience4j 문서](https://resilience4j.readme.io/)
|
|
||||||
|
|
||||||
## 지원
|
|
||||||
|
|
||||||
문제 발생 시:
|
|
||||||
1. 로그 파일 확인: `logs/distribution-service.log`
|
|
||||||
2. Headless 모드를 false로 설정하여 브라우저 동작 확인
|
|
||||||
3. GitHub Issue 등록 (로그 첨부)
|
|
||||||
@ -15,9 +15,6 @@ dependencies {
|
|||||||
implementation "io.github.resilience4j:resilience4j-retry:${resilience4jVersion}"
|
implementation "io.github.resilience4j:resilience4j-retry:${resilience4jVersion}"
|
||||||
implementation "io.github.resilience4j:resilience4j-bulkhead:${resilience4jVersion}"
|
implementation "io.github.resilience4j:resilience4j-bulkhead:${resilience4jVersion}"
|
||||||
|
|
||||||
// Playwright for browser automation
|
|
||||||
implementation 'com.microsoft.playwright:playwright:1.41.0'
|
|
||||||
|
|
||||||
// Jackson for JSON
|
// Jackson for JSON
|
||||||
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +1,27 @@
|
|||||||
package com.kt.distribution.adapter;
|
package com.kt.distribution.adapter;
|
||||||
|
|
||||||
import com.kt.distribution.client.NaverBlogClient;
|
|
||||||
import com.kt.distribution.dto.ChannelDistributionResult;
|
import com.kt.distribution.dto.ChannelDistributionResult;
|
||||||
import com.kt.distribution.dto.ChannelType;
|
import com.kt.distribution.dto.ChannelType;
|
||||||
import com.kt.distribution.dto.DistributionRequest;
|
import com.kt.distribution.dto.DistributionRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Naver Blog Adapter
|
* Naver Blog Adapter
|
||||||
* Naver Blog 포스팅 (Playwright 기반)
|
* Naver Blog 포스팅 API 호출
|
||||||
*
|
*
|
||||||
* @author Backend Developer
|
* @author System Architect
|
||||||
* @since 2025-10-29
|
* @since 2025-10-23
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
|
||||||
@ConditionalOnProperty(name = "naver.blog.enabled", havingValue = "true", matchIfMissing = false)
|
|
||||||
public class NaverAdapter extends AbstractChannelAdapter {
|
public class NaverAdapter extends AbstractChannelAdapter {
|
||||||
|
|
||||||
private final NaverBlogClient naverBlogClient;
|
@Value("${channel.apis.naver.url}")
|
||||||
|
private String apiUrl;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ChannelType getChannelType() {
|
public ChannelType getChannelType() {
|
||||||
@ -33,35 +30,16 @@ public class NaverAdapter extends AbstractChannelAdapter {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected ChannelDistributionResult executeDistribution(DistributionRequest request) {
|
protected ChannelDistributionResult executeDistribution(DistributionRequest request) {
|
||||||
log.debug("Posting to Naver Blog: eventId={}, title={}",
|
log.debug("Calling Naver API: url={}, eventId={}", apiUrl, request.getEventId());
|
||||||
request.getEventId(), request.getTitle());
|
|
||||||
|
|
||||||
try {
|
// TODO: 실제 API 호출 (현재는 Mock)
|
||||||
// 네이버 블로그에 포스팅
|
|
||||||
String postUrl = naverBlogClient.postToBlog(request);
|
|
||||||
String distributionId = "NAVER-" + UUID.randomUUID().toString();
|
String distributionId = "NAVER-" + UUID.randomUUID().toString();
|
||||||
|
|
||||||
log.info("Naver blog post created successfully: eventId={}, postUrl={}",
|
|
||||||
request.getEventId(), postUrl);
|
|
||||||
|
|
||||||
return ChannelDistributionResult.builder()
|
return ChannelDistributionResult.builder()
|
||||||
.channel(ChannelType.NAVER)
|
.channel(ChannelType.NAVER)
|
||||||
.success(true)
|
.success(true)
|
||||||
.distributionId(distributionId)
|
.distributionId(distributionId)
|
||||||
.postUrl(postUrl)
|
|
||||||
.estimatedReach(2000) // 블로그 방문자 수 기반
|
.estimatedReach(2000) // 블로그 방문자 수 기반
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to post to Naver blog: eventId={}, error={}",
|
|
||||||
request.getEventId(), e.getMessage(), e);
|
|
||||||
|
|
||||||
return ChannelDistributionResult.builder()
|
|
||||||
.channel(ChannelType.NAVER)
|
|
||||||
.success(false)
|
|
||||||
.errorMessage("Naver blog posting failed: " + e.getMessage())
|
|
||||||
.estimatedReach(0)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,319 +0,0 @@
|
|||||||
package com.kt.distribution.client;
|
|
||||||
|
|
||||||
import com.kt.distribution.dto.DistributionRequest;
|
|
||||||
import com.microsoft.playwright.*;
|
|
||||||
import com.microsoft.playwright.options.LoadState;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import jakarta.annotation.PreDestroy;
|
|
||||||
import java.io.File;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.nio.file.Paths;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Naver Blog Client using Playwright
|
|
||||||
* 네이버 블로그 포스팅 자동화 클라이언트
|
|
||||||
*
|
|
||||||
* @author Backend Developer
|
|
||||||
* @since 2025-10-29
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
@ConditionalOnProperty(name = "naver.blog.enabled", havingValue = "true", matchIfMissing = false)
|
|
||||||
public class NaverBlogClient {
|
|
||||||
|
|
||||||
@Value("${naver.blog.username:}")
|
|
||||||
private String username;
|
|
||||||
|
|
||||||
@Value("${naver.blog.password:}")
|
|
||||||
private String password;
|
|
||||||
|
|
||||||
@Value("${naver.blog.blog-id:}")
|
|
||||||
private String blogId;
|
|
||||||
|
|
||||||
@Value("${naver.blog.headless:false}")
|
|
||||||
private boolean headless;
|
|
||||||
|
|
||||||
@Value("${naver.blog.session-path:playwright-sessions}")
|
|
||||||
private String sessionPath;
|
|
||||||
|
|
||||||
private Playwright playwright;
|
|
||||||
private Browser browser;
|
|
||||||
private BrowserContext context;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Playwright 초기화
|
|
||||||
*/
|
|
||||||
@PostConstruct
|
|
||||||
public void init() {
|
|
||||||
try {
|
|
||||||
log.info("Initializing Playwright for Naver Blog");
|
|
||||||
playwright = Playwright.create();
|
|
||||||
|
|
||||||
browser = playwright.chromium().launch(new BrowserType.LaunchOptions()
|
|
||||||
.setHeadless(headless)
|
|
||||||
.setSlowMo(100)); // 안정성을 위한 느린 실행
|
|
||||||
|
|
||||||
// 세션 디렉토리 생성
|
|
||||||
File sessionDir = new File(sessionPath);
|
|
||||||
if (!sessionDir.exists()) {
|
|
||||||
sessionDir.mkdirs();
|
|
||||||
log.info("Created session directory: {}", sessionPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 세션 파일 경로
|
|
||||||
Path sessionFilePath = Paths.get(sessionPath, "naver-blog-session.json");
|
|
||||||
|
|
||||||
// 세션 파일이 있으면 로드, 없으면 새로운 컨텍스트 생성
|
|
||||||
if (Files.exists(sessionFilePath)) {
|
|
||||||
log.info("Loading existing session from: {}", sessionFilePath);
|
|
||||||
context = browser.newContext(new Browser.NewContextOptions()
|
|
||||||
.setStorageStatePath(sessionFilePath));
|
|
||||||
} else {
|
|
||||||
log.info("No existing session found, creating new context");
|
|
||||||
context = browser.newContext();
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Playwright initialized successfully");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to initialize Playwright", e);
|
|
||||||
throw new RuntimeException("Playwright initialization failed", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 네이버 블로그에 포스팅
|
|
||||||
*
|
|
||||||
* @param request DistributionRequest
|
|
||||||
* @return 포스팅 URL
|
|
||||||
* @throws Exception 포스팅 실패 시
|
|
||||||
*/
|
|
||||||
public String postToBlog(DistributionRequest request) throws Exception {
|
|
||||||
Page page = null;
|
|
||||||
try {
|
|
||||||
page = context.newPage();
|
|
||||||
// 타임아웃을 5분(300000ms)으로 설정
|
|
||||||
page.setDefaultTimeout(300000);
|
|
||||||
|
|
||||||
// 로그인 확인 및 처리
|
|
||||||
if (!isLoggedIn(page)) {
|
|
||||||
login(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 블로그 글쓰기 페이지로 이동
|
|
||||||
String writeUrl = String.format("https://blog.naver.com/%s/postwrite", blogId);
|
|
||||||
page.navigate(writeUrl);
|
|
||||||
page.waitForLoadState(LoadState.NETWORKIDLE);
|
|
||||||
|
|
||||||
|
|
||||||
// 도움말 팝업이 있으면 닫기
|
|
||||||
try {
|
|
||||||
page.waitForTimeout(5000); // 충분히 대기 필요
|
|
||||||
|
|
||||||
Locator helpPanel = page.locator("[class*='help-panel']");
|
|
||||||
|
|
||||||
if (helpPanel.isVisible(new Locator.IsVisibleOptions().setTimeout(2000))) {
|
|
||||||
log.debug("Help dialog detected, closing it");
|
|
||||||
|
|
||||||
// 팝업 안의 닫기 버튼 찾기
|
|
||||||
Locator closeBtn = page.locator("button[class*='se-help-panel-close-button']");
|
|
||||||
closeBtn.click();
|
|
||||||
Thread.sleep(500);
|
|
||||||
log.debug("Help dialog closed");
|
|
||||||
} else{
|
|
||||||
log.debug("--------------------- 도움말 없음");
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug("No help dialog found or already closed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 제목 입력
|
|
||||||
Locator titleInput = page.locator(".se-text-paragraph").first();
|
|
||||||
titleInput.click();
|
|
||||||
titleInput.pressSequentially(request.getTitle(), new Locator.PressSequentiallyOptions().setDelay(50));
|
|
||||||
log.debug("Title entered: {}", request.getTitle());
|
|
||||||
|
|
||||||
// 본문 입력
|
|
||||||
Locator editorInput = page.locator(".se-text-paragraph").nth(1);
|
|
||||||
editorInput.click();
|
|
||||||
titleInput.pressSequentially(request.getDescription(), new Locator.PressSequentiallyOptions().setDelay(50));
|
|
||||||
log.debug("Content entered");
|
|
||||||
|
|
||||||
// 이미지가 있으면 업로드
|
|
||||||
if (request.getImageUrl() != null && !request.getImageUrl().isEmpty()) {
|
|
||||||
uploadImage(page, request.getImageUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 발행 버튼 클릭
|
|
||||||
page.locator("button[class*='publish_btn']").click();
|
|
||||||
page.waitForLoadState(LoadState.NETWORKIDLE);
|
|
||||||
page.locator("button[class*='confirm_btn']").click();
|
|
||||||
page.waitForLoadState(LoadState.NETWORKIDLE);
|
|
||||||
|
|
||||||
page.waitForTimeout(5000); // 충분히 대기 필요
|
|
||||||
|
|
||||||
// 포스팅 URL 가져오기
|
|
||||||
String postUrl = page.url();
|
|
||||||
log.info("Post published successfully: {}", postUrl);
|
|
||||||
|
|
||||||
return postUrl;
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to post to Naver blog: eventId={}, error={}",
|
|
||||||
request.getEventId(), e.getMessage(), e);
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
if (page != null) {
|
|
||||||
page.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그인 상태 확인
|
|
||||||
*
|
|
||||||
* @param page Page
|
|
||||||
* @return 로그인 여부
|
|
||||||
*/
|
|
||||||
private boolean isLoggedIn(Page page) {
|
|
||||||
try {
|
|
||||||
page.navigate("https://blog.naver.com");
|
|
||||||
page.waitForLoadState(LoadState.NETWORKIDLE);
|
|
||||||
|
|
||||||
// 로그인 버튼이 보이지 않으면 로그인된 상태
|
|
||||||
// ID 기반 선택자 사용으로 strict mode violation 방지
|
|
||||||
return !page.locator("#gnb_login_button").isVisible();
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to check login status", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 네이버 로그인 (수동 로그인 대기 방식)
|
|
||||||
*
|
|
||||||
* @param page Page
|
|
||||||
* @throws Exception 로그인 실패 시
|
|
||||||
*/
|
|
||||||
private void login(Page page) throws Exception {
|
|
||||||
try {
|
|
||||||
log.info("Starting Naver manual login process");
|
|
||||||
log.info("=================================================");
|
|
||||||
log.info("Please login manually in the browser window");
|
|
||||||
log.info("브라우저 창에서 수동으로 로그인해주세요");
|
|
||||||
log.info("=================================================");
|
|
||||||
|
|
||||||
// 네이버 로그인 페이지로 이동
|
|
||||||
page.navigate("https://nid.naver.com/nidlogin.login");
|
|
||||||
page.waitForLoadState(LoadState.NETWORKIDLE);
|
|
||||||
|
|
||||||
// 사용자가 수동으로 로그인할 때까지 대기 (URL이 변경될 때까지)
|
|
||||||
// 로그인 성공 시 URL이 nid.naver.com에서 벗어남
|
|
||||||
log.info("Waiting for manual login... (Timeout: 30 seconds)");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 30초 동안 URL이 nid.naver.com을 벗어날 때까지 대기
|
|
||||||
page.waitForURL(url -> !url.contains("nid.naver.com"),
|
|
||||||
new Page.WaitForURLOptions().setTimeout(30000));
|
|
||||||
|
|
||||||
log.info("Login URL changed, assuming login successful");
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Login timeout or failed", e);
|
|
||||||
throw new Exception("Manual login timeout or failed after 30 seconds");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 추가 안정화 대기
|
|
||||||
page.waitForLoadState(LoadState.NETWORKIDLE);
|
|
||||||
Thread.sleep(2000); // 2초 추가 대기
|
|
||||||
|
|
||||||
// 세션 저장
|
|
||||||
context.storageState(new BrowserContext.StorageStateOptions()
|
|
||||||
.setPath(Paths.get(sessionPath, "naver-blog-session.json")));
|
|
||||||
|
|
||||||
log.info("Naver manual login successful, session saved");
|
|
||||||
log.info("Current URL: {}", page.url());
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Naver manual login process failed", e);
|
|
||||||
throw new Exception("Naver manual login failed: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이미지 업로드
|
|
||||||
*
|
|
||||||
* @param page Page
|
|
||||||
* @param imageUrl 이미지 URL
|
|
||||||
*/
|
|
||||||
private void uploadImage(Page page, String imageUrl) {
|
|
||||||
try {
|
|
||||||
log.debug("Uploading image: {}", imageUrl);
|
|
||||||
|
|
||||||
// 이미지 업로드 버튼 클릭
|
|
||||||
page.locator("button[aria-label='사진']").click();
|
|
||||||
|
|
||||||
// URL로 이미지 추가 (실제 구현은 네이버 블로그 UI에 따라 조정 필요)
|
|
||||||
// 여기서는 간단히 로그만 남김
|
|
||||||
log.info("Image upload placeholder - URL: {}", imageUrl);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to upload image: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Playwright 리소스 정리
|
|
||||||
*/
|
|
||||||
@PreDestroy
|
|
||||||
public void cleanup() {
|
|
||||||
try {
|
|
||||||
if (context != null) {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
if (browser != null) {
|
|
||||||
browser.close();
|
|
||||||
}
|
|
||||||
if (playwright != null) {
|
|
||||||
playwright.close();
|
|
||||||
}
|
|
||||||
log.info("Playwright resources cleaned up");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to cleanup Playwright resources", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수동으로 브라우저 컨텍스트 새로고침
|
|
||||||
* 장시간 사용 시 세션 만료 방지용
|
|
||||||
*/
|
|
||||||
public void refreshContext() {
|
|
||||||
try {
|
|
||||||
if (context != null) {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 세션 파일 경로
|
|
||||||
Path sessionFilePath = Paths.get(sessionPath, "naver-blog-session.json");
|
|
||||||
|
|
||||||
// 세션 파일이 있으면 로드, 없으면 새로운 컨텍스트 생성
|
|
||||||
if (Files.exists(sessionFilePath)) {
|
|
||||||
log.info("Refreshing context with existing session");
|
|
||||||
context = browser.newContext(new Browser.NewContextOptions()
|
|
||||||
.setStorageStatePath(sessionFilePath));
|
|
||||||
} else {
|
|
||||||
log.info("Refreshing context without session");
|
|
||||||
context = browser.newContext();
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("Browser context refreshed");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Failed to refresh context", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -32,11 +32,6 @@ public class ChannelDistributionResult {
|
|||||||
*/
|
*/
|
||||||
private String distributionId;
|
private String distributionId;
|
||||||
|
|
||||||
/**
|
|
||||||
* 배포 URL (성공 시) - 실제 포스팅된 URL
|
|
||||||
*/
|
|
||||||
private String postUrl;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 예상 노출 수 (성공 시)
|
* 예상 노출 수 (성공 시)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -225,7 +225,6 @@ public class DistributionService {
|
|||||||
.channel(result.getChannel())
|
.channel(result.getChannel())
|
||||||
.status(result.isSuccess() ? "COMPLETED" : "FAILED")
|
.status(result.isSuccess() ? "COMPLETED" : "FAILED")
|
||||||
.distributionId(result.getDistributionId())
|
.distributionId(result.getDistributionId())
|
||||||
.postUrl(result.getPostUrl())
|
|
||||||
.estimatedViews(result.getEstimatedReach())
|
.estimatedViews(result.getEstimatedReach())
|
||||||
.eventId(eventId)
|
.eventId(eventId)
|
||||||
.completedAt(completedAt)
|
.completedAt(completedAt)
|
||||||
|
|||||||
@ -126,11 +126,10 @@ channel:
|
|||||||
# Naver Blog Configuration (Playwright 기반)
|
# Naver Blog Configuration (Playwright 기반)
|
||||||
naver:
|
naver:
|
||||||
blog:
|
blog:
|
||||||
enabled: ${NAVER_BLOG_ENABLED:false}
|
|
||||||
username: ${NAVER_BLOG_USERNAME:}
|
username: ${NAVER_BLOG_USERNAME:}
|
||||||
password: ${NAVER_BLOG_PASSWORD:}
|
password: ${NAVER_BLOG_PASSWORD:}
|
||||||
blog-id: ${NAVER_BLOG_ID:}
|
blog-id: ${NAVER_BLOG_ID:}
|
||||||
headless: ${NAVER_BLOG_HEADLESS:false}
|
headless: ${NAVER_BLOG_HEADLESS:true}
|
||||||
session-path: ${NAVER_BLOG_SESSION_PATH:playwright-sessions}
|
session-path: ${NAVER_BLOG_SESSION_PATH:playwright-sessions}
|
||||||
|
|
||||||
# Springdoc OpenAPI (Swagger)
|
# Springdoc OpenAPI (Swagger)
|
||||||
|
|||||||
@ -7,9 +7,6 @@ RUN java -Djarmode=layertools -jar app.jar extract
|
|||||||
FROM eclipse-temurin:21-jre-alpine
|
FROM eclipse-temurin:21-jre-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install glibc compatibility for Snappy native library
|
|
||||||
RUN apk add --no-cache gcompat
|
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN addgroup -S spring && adduser -S spring -G spring
|
RUN addgroup -S spring && adduser -S spring -G spring
|
||||||
USER spring:spring
|
USER spring:spring
|
||||||
|
|||||||
@ -141,10 +141,6 @@ feign:
|
|||||||
distribution-service:
|
distribution-service:
|
||||||
url: ${DISTRIBUTION_SERVICE_URL:http://localhost:8085}
|
url: ${DISTRIBUTION_SERVICE_URL:http://localhost:8085}
|
||||||
|
|
||||||
# AI Service Client
|
|
||||||
ai-service:
|
|
||||||
url: ${AI_SERVICE_URL:http://ai-service/api/v1/ai}
|
|
||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
app:
|
app:
|
||||||
kafka:
|
kafka:
|
||||||
|
|||||||
@ -34,9 +34,9 @@ public class ParticipationController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 참여
|
* 이벤트 참여
|
||||||
* POST /{eventId}/participate
|
* POST /events/{eventId}/participate
|
||||||
*/
|
*/
|
||||||
@PostMapping("/{eventId}/participate")
|
@PostMapping("/events/{eventId}/participate")
|
||||||
public ResponseEntity<ApiResponse<ParticipationResponse>> participate(
|
public ResponseEntity<ApiResponse<ParticipationResponse>> participate(
|
||||||
@PathVariable String eventId,
|
@PathVariable String eventId,
|
||||||
@Valid @RequestBody ParticipationRequest request) {
|
@Valid @RequestBody ParticipationRequest request) {
|
||||||
@ -60,14 +60,14 @@ public class ParticipationController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 참여자 목록 조회
|
* 참여자 목록 조회
|
||||||
* GET /{eventId}/participants
|
* GET /events/{eventId}/participants
|
||||||
*/
|
*/
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "참여자 목록 조회",
|
summary = "참여자 목록 조회",
|
||||||
description = "이벤트의 참여자 목록을 페이징하여 조회합니다. " +
|
description = "이벤트의 참여자 목록을 페이징하여 조회합니다. " +
|
||||||
"정렬 가능한 필드: createdAt(기본값), participantId, name, phoneNumber, bonusEntries, isWinner, wonAt"
|
"정렬 가능한 필드: createdAt(기본값), participantId, name, phoneNumber, bonusEntries, isWinner, wonAt"
|
||||||
)
|
)
|
||||||
@GetMapping({"/{eventId}/participants"})
|
@GetMapping({"/events/{eventId}/participants"})
|
||||||
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getParticipants(
|
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getParticipants(
|
||||||
@Parameter(description = "이벤트 ID", example = "evt_20250124_001")
|
@Parameter(description = "이벤트 ID", example = "evt_20250124_001")
|
||||||
@PathVariable String eventId,
|
@PathVariable String eventId,
|
||||||
@ -88,9 +88,9 @@ public class ParticipationController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 참여자 상세 조회
|
* 참여자 상세 조회
|
||||||
* GET /{eventId}/participants/{participantId}
|
* GET /events/{eventId}/participants/{participantId}
|
||||||
*/
|
*/
|
||||||
@GetMapping({"/{eventId}/participants/{participantId}"})
|
@GetMapping({"/events/{eventId}/participants/{participantId}"})
|
||||||
public ResponseEntity<ApiResponse<ParticipationResponse>> getParticipant(
|
public ResponseEntity<ApiResponse<ParticipationResponse>> getParticipant(
|
||||||
@PathVariable String eventId,
|
@PathVariable String eventId,
|
||||||
@PathVariable String participantId) {
|
@PathVariable String participantId) {
|
||||||
|
|||||||
@ -35,9 +35,9 @@ public class WinnerController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 당첨자 추첨
|
* 당첨자 추첨
|
||||||
* POST /{eventId}/draw-winners
|
* POST /events/{eventId}/draw-winners
|
||||||
*/
|
*/
|
||||||
@PostMapping("/{eventId}/draw-winners")
|
@PostMapping("/events/{eventId}/draw-winners")
|
||||||
public ResponseEntity<ApiResponse<DrawWinnersResponse>> drawWinners(
|
public ResponseEntity<ApiResponse<DrawWinnersResponse>> drawWinners(
|
||||||
@PathVariable String eventId,
|
@PathVariable String eventId,
|
||||||
@Valid @RequestBody DrawWinnersRequest request) {
|
@Valid @RequestBody DrawWinnersRequest request) {
|
||||||
@ -50,14 +50,14 @@ public class WinnerController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 당첨자 목록 조회
|
* 당첨자 목록 조회
|
||||||
* GET /{eventId}/winners
|
* GET /participations/{eventId}/winners
|
||||||
*/
|
*/
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "당첨자 목록 조회",
|
summary = "당첨자 목록 조회",
|
||||||
description = "이벤트의 당첨자 목록을 페이징하여 조회합니다. " +
|
description = "이벤트의 당첨자 목록을 페이징하여 조회합니다. " +
|
||||||
"정렬 가능한 필드: winnerRank(기본값), wonAt, participantId, name, phoneNumber, bonusEntries"
|
"정렬 가능한 필드: winnerRank(기본값), wonAt, participantId, name, phoneNumber, bonusEntries"
|
||||||
)
|
)
|
||||||
@GetMapping("/{eventId}/winners")
|
@GetMapping("/events/{eventId}/winners")
|
||||||
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getWinners(
|
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getWinners(
|
||||||
@Parameter(description = "이벤트 ID", example = "evt_20250124_001")
|
@Parameter(description = "이벤트 ID", example = "evt_20250124_001")
|
||||||
@PathVariable String eventId,
|
@PathVariable String eventId,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user