diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index 5d56e9d..0000000
Binary files a/.DS_Store and /dev/null differ
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 8d1f14d..0c539cf 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -15,7 +15,35 @@
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push)",
- "Bash(git pull:*)"
+ "Bash(git pull:*)",
+ "Bash(./gradlew participation-service:compileJava:*)",
+ "Bash(find:*)",
+ "Bash(netstat:*)",
+ "Bash(findstr:*)",
+ "Bash(docker-compose up:*)",
+ "Bash(docker --version:*)",
+ "Bash(timeout 60 bash:*)",
+ "Bash(docker ps:*)",
+ "Bash(docker exec:*)",
+ "Bash(docker-compose down:*)",
+ "Bash(git rm:*)",
+ "Bash(git restore:*)",
+ "Bash(./gradlew participation-service:test:*)",
+ "Bash(timeout 30 bash:*)",
+ "Bash(helm list:*)",
+ "Bash(helm upgrade:*)",
+ "Bash(helm repo add:*)",
+ "Bash(helm repo update:*)",
+ "Bash(kubectl get:*)",
+ "Bash(python3:*)",
+ "Bash(timeout 120 bash -c 'while true; do sleep 5; kubectl get pods -n kt-event-marketing | grep kafka | grep -v Running && continue; echo \"\"\"\"All Kafka pods are Running!\"\"\"\"; break; done')",
+ "Bash(kubectl delete:*)",
+ "Bash(kubectl logs:*)",
+ "Bash(kubectl describe:*)",
+ "Bash(kubectl exec:*)",
+ "mcp__context7__resolve-library-id",
+ "mcp__context7__get-library-docs",
+ "Bash(python -m json.tool:*)"
],
"deny": [],
"ask": []
diff --git a/.gitignore b/.gitignore
index 32a0a86..04ee081 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,6 +20,8 @@ Thumbs.db
dist/
build/
*.log
+.gradle/
+logs/
# Environment
.env
diff --git a/.gradle/8.10/checksums/checksums.lock b/.gradle/8.10/checksums/checksums.lock
deleted file mode 100644
index 837e5b9..0000000
Binary files a/.gradle/8.10/checksums/checksums.lock and /dev/null differ
diff --git a/.gradle/8.10/checksums/md5-checksums.bin b/.gradle/8.10/checksums/md5-checksums.bin
deleted file mode 100644
index 04c6d00..0000000
Binary files a/.gradle/8.10/checksums/md5-checksums.bin and /dev/null differ
diff --git a/.gradle/8.10/checksums/sha1-checksums.bin b/.gradle/8.10/checksums/sha1-checksums.bin
deleted file mode 100644
index 19a5410..0000000
Binary files a/.gradle/8.10/checksums/sha1-checksums.bin and /dev/null differ
diff --git a/.gradle/8.10/dependencies-accessors/gc.properties b/.gradle/8.10/dependencies-accessors/gc.properties
deleted file mode 100644
index e69de29..0000000
diff --git a/.gradle/8.10/executionHistory/executionHistory.bin b/.gradle/8.10/executionHistory/executionHistory.bin
deleted file mode 100644
index 2177cdd..0000000
Binary files a/.gradle/8.10/executionHistory/executionHistory.bin and /dev/null differ
diff --git a/.gradle/8.10/executionHistory/executionHistory.lock b/.gradle/8.10/executionHistory/executionHistory.lock
deleted file mode 100644
index 0ce4c96..0000000
Binary files a/.gradle/8.10/executionHistory/executionHistory.lock and /dev/null differ
diff --git a/.gradle/8.10/fileChanges/last-build.bin b/.gradle/8.10/fileChanges/last-build.bin
deleted file mode 100644
index f76dd23..0000000
Binary files a/.gradle/8.10/fileChanges/last-build.bin and /dev/null differ
diff --git a/.gradle/8.10/fileHashes/fileHashes.bin b/.gradle/8.10/fileHashes/fileHashes.bin
deleted file mode 100644
index 8088fbb..0000000
Binary files a/.gradle/8.10/fileHashes/fileHashes.bin and /dev/null differ
diff --git a/.gradle/8.10/fileHashes/fileHashes.lock b/.gradle/8.10/fileHashes/fileHashes.lock
deleted file mode 100644
index 340e0dd..0000000
Binary files a/.gradle/8.10/fileHashes/fileHashes.lock and /dev/null differ
diff --git a/.gradle/8.10/fileHashes/resourceHashesCache.bin b/.gradle/8.10/fileHashes/resourceHashesCache.bin
deleted file mode 100644
index 3d21896..0000000
Binary files a/.gradle/8.10/fileHashes/resourceHashesCache.bin and /dev/null differ
diff --git a/.gradle/8.10/gc.properties b/.gradle/8.10/gc.properties
deleted file mode 100644
index e69de29..0000000
diff --git a/.gradle/9.1.0/checksums/checksums.lock b/.gradle/9.1.0/checksums/checksums.lock
deleted file mode 100644
index 3d9ab52..0000000
Binary files a/.gradle/9.1.0/checksums/checksums.lock and /dev/null differ
diff --git a/.gradle/9.1.0/executionHistory/executionHistory.bin b/.gradle/9.1.0/executionHistory/executionHistory.bin
deleted file mode 100644
index c3b4cb1..0000000
Binary files a/.gradle/9.1.0/executionHistory/executionHistory.bin and /dev/null differ
diff --git a/.gradle/9.1.0/executionHistory/executionHistory.lock b/.gradle/9.1.0/executionHistory/executionHistory.lock
deleted file mode 100644
index 4cc7cd5..0000000
Binary files a/.gradle/9.1.0/executionHistory/executionHistory.lock and /dev/null differ
diff --git a/.gradle/9.1.0/fileChanges/last-build.bin b/.gradle/9.1.0/fileChanges/last-build.bin
deleted file mode 100644
index f76dd23..0000000
Binary files a/.gradle/9.1.0/fileChanges/last-build.bin and /dev/null differ
diff --git a/.gradle/9.1.0/fileHashes/fileHashes.bin b/.gradle/9.1.0/fileHashes/fileHashes.bin
deleted file mode 100644
index 5c96b1a..0000000
Binary files a/.gradle/9.1.0/fileHashes/fileHashes.bin and /dev/null differ
diff --git a/.gradle/9.1.0/fileHashes/fileHashes.lock b/.gradle/9.1.0/fileHashes/fileHashes.lock
deleted file mode 100644
index abbb4d0..0000000
Binary files a/.gradle/9.1.0/fileHashes/fileHashes.lock and /dev/null differ
diff --git a/.gradle/9.1.0/gc.properties b/.gradle/9.1.0/gc.properties
deleted file mode 100644
index e69de29..0000000
diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock
deleted file mode 100644
index 0350ff2..0000000
Binary files a/.gradle/buildOutputCleanup/buildOutputCleanup.lock and /dev/null differ
diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties
deleted file mode 100644
index 80e1268..0000000
--- a/.gradle/buildOutputCleanup/cache.properties
+++ /dev/null
@@ -1,2 +0,0 @@
-#Thu Oct 23 17:51:21 KST 2025
-gradle.version=8.10
diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin
deleted file mode 100644
index 4ed6f06..0000000
Binary files a/.gradle/buildOutputCleanup/outputFiles.bin and /dev/null differ
diff --git a/.gradle/file-system.probe b/.gradle/file-system.probe
deleted file mode 100644
index ac4beb4..0000000
Binary files a/.gradle/file-system.probe and /dev/null differ
diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties
deleted file mode 100644
index e69de29..0000000
diff --git a/.run/ParticipationServiceApplication.run.xml b/.run/ParticipationServiceApplication.run.xml
new file mode 100644
index 0000000..a323100
--- /dev/null
+++ b/.run/ParticipationServiceApplication.run.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ false
+
+
+
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index 6b665aa..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "liveServer.settings.port": 5501
-}
diff --git a/backing-service/docker-compose.yml b/backing-service/docker-compose.yml
new file mode 100644
index 0000000..32c7ec3
--- /dev/null
+++ b/backing-service/docker-compose.yml
@@ -0,0 +1,53 @@
+version: '3.8'
+
+services:
+ # PostgreSQL - Participation Service
+ postgres-participation:
+ image: postgres:15-alpine
+ container_name: participation-db
+ environment:
+ POSTGRES_DB: participation_db
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres-participation-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ # Kafka
+ zookeeper:
+ image: confluentinc/cp-zookeeper:7.5.0
+ container_name: zookeeper
+ environment:
+ ZOOKEEPER_CLIENT_PORT: 2181
+ ZOOKEEPER_TICK_TIME: 2000
+ ports:
+ - "2181:2181"
+
+ kafka:
+ image: confluentinc/cp-kafka:7.5.0
+ container_name: kafka
+ depends_on:
+ - zookeeper
+ ports:
+ - "9092:9092"
+ environment:
+ KAFKA_BROKER_ID: 1
+ KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
+ KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
+ KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
+ KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
+ KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
+ healthcheck:
+ test: ["CMD-SHELL", "kafka-broker-api-versions --bootstrap-server localhost:9092"]
+ interval: 10s
+ timeout: 10s
+ retries: 5
+
+volumes:
+ postgres-participation-data:
diff --git a/backing-service/install/postgres/values-user.yaml b/backing-service/install/postgres/values-user.yaml
index 665a2fa..af3a323 100644
--- a/backing-service/install/postgres/values-user.yaml
+++ b/backing-service/install/postgres/values-user.yaml
@@ -18,7 +18,7 @@ primary:
enabled: true
storageClass: "managed-premium"
size: 10Gi
-
+
resources:
limits:
memory: "4Gi"
@@ -26,12 +26,14 @@ primary:
requests:
memory: "2Gi"
cpu: "0.5"
-
- # 성능 최적화 설정
+
+ # 성능 최적화 설정
extraEnvVars:
+ - name: POSTGRESQL_READ_ONLY_MODE
+ value: "no"
- name: POSTGRESQL_SHARED_BUFFERS
value: "1GB"
- - name: POSTGRESQL_EFFECTIVE_CACHE_SIZE
+ - name: POSTGRESQL_EFFECTIVE_CACHE_SIZE
value: "3GB"
- name: POSTGRESQL_MAX_CONNECTIONS
value: "200"
diff --git a/claude/check-mermaid.sh b/claude/check-mermaid.sh
old mode 100755
new mode 100644
diff --git a/claude/make-run-profile.md b/claude/make-run-profile.md
new file mode 100644
index 0000000..420fb4e
--- /dev/null
+++ b/claude/make-run-profile.md
@@ -0,0 +1,178 @@
+ % Total % Received % Xferd Average Speed Time Time Time Current
+ Dload Upload Total Spent Left Speed
+
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0# 서비스실행파일작성가이드
+
+[요청사항]
+- <수행원칙>을 준용하여 수행
+- <수행순서>에 따라 수행
+- [결과파일] 안내에 따라 파일 작성
+
+[가이드]
+<수행원칙>
+- 설정 Manifest(src/main/resources/application*.yml)의 각 항목의 값은 하드코딩하지 않고 환경변수 처리
+- Kubernetes에 배포된 데이터베이스는 LoadBalacer유형의 Service를 만들어 연결
+- MQ 이용 시 'MQ설치결과서'의 연결 정보를 실행 프로파일의 환경변수로 등록
+<수행순서>
+- 준비:
+ - 데이터베이스설치결과서(develop/database/exec/db-exec-dev.md) 분석
+ - 캐시설치결과서(develop/database/exec/cache-exec-dev.md) 분석
+ - MQ설치결과서(develop/mq/mq-exec-dev.md) 분석 - 연결 정보 확인
+ - kubectl get svc -n tripgen-dev | grep LoadBalancer 실행하여 External IP 목록 확인
+- 실행:
+ - 각 서비스별를 서브에이젼트로 병렬 수행
+ - 설정 Manifest 수정
+ - 하드코딩 되어 있는 값이 있으면 환경변수로 변환
+ - 특히, 데이터베이스, MQ 등의 연결 정보는 반드시 환경변수로 변환해야 함
+ - 민감한 정보의 디퐅트값은 생략하거나 간략한 값으로 지정
+ - '<로그설정>'을 참조하여 Log 파일 설정
+ - '<실행프로파일 작성 가이드>'에 따라 서비스 실행프로파일 작성
+ - LoadBalancer External IP를 DB_HOST, REDIS_HOST로 설정
+ - MQ 연결 정보를 application.yml의 환경변수명에 맞춰 설정
+ - 서비스 실행 및 오류 수정
+ - 'IntelliJ서비스실행기'를 'tools' 디렉토리에 다운로드
+ - python 또는 python3 명령으로 백그라우드로 실행하고 결과 로그를 분석
+ nohup python3 tools/run-intellij-service-profile.py {service-name} > logs/{service-name}.log 2>&1 & echo "Started {service-name} with PID: $!"
+ - 서비스 실행은 다른 방법 사용하지 말고 **반드시 python 프로그램 이용**
+ - 오류 수정 후 필요 시 실행파일의 환경변수를 올바르게 변경
+ - 서비스 정상 시작 확인 후 서비스 중지
+ - 결과: {service-name}/.run
+<서비스 중지 방법>
+- Window
+ - netstat -ano | findstr :{PORT}
+ - powershell "Stop-Process -Id {Process number} -Force"
+- Linux/Mac
+ - netstat -ano | grep {PORT}
+ - kill -9 {Process number}
+<로그설정>
+- **application.yml 로그 파일 설정**:
+ ```yaml
+ logging:
+ file:
+ name: ${LOG_FILE:logs/trip-service.log}
+ logback:
+ rollingpolicy:
+ max-file-size: 10MB
+ max-history: 7
+ total-size-cap: 100MB
+ ```
+
+<실행프로파일 작성 가이드>
+- {service-name}/.run/{service-name}.run.xml 파일로 작성
+- Spring Boot가 아니고 **Gradle 실행 프로파일**이어야 함: '[실행프로파일 예시]' 참조
+- Kubernetes에 배포된 데이터베이스의 LoadBalancer Service 확인:
+ - kubectl get svc -n {namespace} | grep LoadBalancer 명령으로 LoadBalancer IP 확인
+ - 각 서비스별 데이터베이스의 LoadBalancer External IP를 DB_HOST로 사용
+ - 캐시(Redis)의 LoadBalancer External IP를 REDIS_HOST로 사용
+- MQ 연결 설정:
+ - MQ설치결과서(develop/mq/mq-exec-dev.md)에서 연결 정보 확인
+ - MQ 유형에 따른 연결 정보 설정 예시:
+ - RabbitMQ: RABBITMQ_HOST, RABBITMQ_PORT, RABBITMQ_USERNAME, RABBITMQ_PASSWORD
+ - Kafka: KAFKA_BOOTSTRAP_SERVERS, KAFKA_SECURITY_PROTOCOL
+ - Azure Service Bus: SERVICE_BUS_CONNECTION_STRING
+ - AWS SQS: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
+ - Redis (Pub/Sub): REDIS_HOST, REDIS_PORT, REDIS_PASSWORD
+ - ActiveMQ: ACTIVEMQ_BROKER_URL, ACTIVEMQ_USER, ACTIVEMQ_PASSWORD
+ - 기타 MQ: 해당 MQ의 연결에 필요한 호스트, 포트, 인증정보, 연결문자열 등을 환경변수로 설정
+ - application.yml에 정의된 환경변수명 확인 후 매핑
+- 백킹서비스 연결 정보 매핑:
+ - 데이터베이스설치결과서에서 각 서비스별 DB 인증 정보 확인
+ - 캐시설치결과서에서 각 서비스별 Redis 인증 정보 확인
+ - LoadBalancer의 External IP를 호스트로 사용 (내부 DNS 아님)
+- 개발모드의 DDL_AUTO값은 update로 함
+- JWT Secret Key는 모든 서비스가 동일해야 함
+- application.yaml의 환경변수와 일치하도록 환경변수 설정
+- application.yaml의 민감 정보는 기본값으로 지정하지 않고 실제 백킹서비스 정보로 지정
+- 백킹서비스 연결 확인 결과를 바탕으로 정확한 값을 지정
+- 기존에 파일이 있으면 내용을 분석하여 항목 추가/수정/삭제
+
+[실행프로파일 예시]
+```
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ false
+
+
+
+```
+
+[참고자료]
+- 데이터베이스설치결과서: develop/database/exec/db-exec-dev.md
+ - 각 서비스별 DB 연결 정보 (사용자명, 비밀번호, DB명)
+ - LoadBalancer Service External IP 목록
+- 캐시설치결과서: develop/database/exec/cache-exec-dev.md
+ - 각 서비스별 Redis 연결 정보
+ - LoadBalancer Service External IP 목록
+- MQ설치결과서: develop/mq/mq-exec-dev.md
+ - MQ 유형 및 연결 정보
+ - 연결에 필요한 호스트, 포트, 인증 정보
+ - LoadBalancer Service External IP (해당하는 경우)
+
diff --git a/common/src/main/java/com/kt/event/common/exception/ErrorCode.java b/common/src/main/java/com/kt/event/common/exception/ErrorCode.java
index f43e24b..dbba5c4 100644
--- a/common/src/main/java/com/kt/event/common/exception/ErrorCode.java
+++ b/common/src/main/java/com/kt/event/common/exception/ErrorCode.java
@@ -68,11 +68,14 @@ public enum ErrorCode {
DIST_004("DIST_004", "배포 상태를 찾을 수 없습니다"),
// 참여 에러 (PART_XXX)
- PART_001("PART_001", "이미 참여한 이벤트입니다"),
- PART_002("PART_002", "이벤트 참여 기간이 아닙니다"),
- PART_003("PART_003", "참여자를 찾을 수 없습니다"),
- PART_004("PART_004", "당첨자 추첨에 실패했습니다"),
- PART_005("PART_005", "이벤트가 종료되었습니다"),
+ DUPLICATE_PARTICIPATION("PART_001", "이미 참여한 이벤트입니다"),
+ EVENT_NOT_ACTIVE("PART_002", "이벤트 참여 기간이 아닙니다"),
+ PARTICIPANT_NOT_FOUND("PART_003", "참여자를 찾을 수 없습니다"),
+ DRAW_FAILED("PART_004", "당첨자 추첨에 실패했습니다"),
+ EVENT_ENDED("PART_005", "이벤트가 종료되었습니다"),
+ ALREADY_DRAWN("PART_006", "이미 당첨자 추첨이 완료되었습니다"),
+ INSUFFICIENT_PARTICIPANTS("PART_007", "참여자 수가 당첨자 수보다 적습니다"),
+ NO_WINNERS_YET("PART_008", "아직 당첨자 추첨이 진행되지 않았습니다"),
// 분석 에러 (ANALYTICS_XXX)
ANALYTICS_001("ANALYTICS_001", "분석 데이터를 찾을 수 없습니다"),
diff --git a/common/src/main/java/com/kt/event/common/exception/GlobalExceptionHandler.java b/common/src/main/java/com/kt/event/common/exception/GlobalExceptionHandler.java
index d382813..d5fc76b 100644
--- a/common/src/main/java/com/kt/event/common/exception/GlobalExceptionHandler.java
+++ b/common/src/main/java/com/kt/event/common/exception/GlobalExceptionHandler.java
@@ -2,6 +2,8 @@ package com.kt.event.common.exception;
import com.kt.event.common.dto.ErrorResponse;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.data.mapping.PropertyReferenceException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
@@ -161,6 +163,66 @@ public class GlobalExceptionHandler {
.body(errorResponse);
}
+ /**
+ * 데이터 무결성 제약 위반 예외 처리
+ *
+ * @param ex 데이터 무결성 예외
+ * @return 에러 응답
+ */
+ @ExceptionHandler(DataIntegrityViolationException.class)
+ public ResponseEntity handleDataIntegrityViolationException(DataIntegrityViolationException ex) {
+ log.warn("Data integrity violation: {}", ex.getMessage());
+
+ String message = "데이터 중복 또는 무결성 제약 위반이 발생했습니다";
+ String details = ex.getMessage();
+
+ // 중복 키 에러인 경우 메시지 개선
+ if (ex.getMessage() != null) {
+ if (ex.getMessage().contains("uk_event_phone") || ex.getMessage().contains("phone_number")) {
+ message = "이미 참여하신 이벤트입니다";
+ details = "동일한 전화번호로 이미 참여 기록이 있습니다";
+ } else if (ex.getMessage().contains("participant_id")) {
+ message = "참여 처리 중 오류가 발생했습니다";
+ details = "잠시 후 다시 시도해주세요";
+ }
+ }
+
+ ErrorResponse errorResponse = ErrorResponse.of(
+ ErrorCode.DUPLICATE_PARTICIPATION.getCode(),
+ message,
+ details
+ );
+
+ return ResponseEntity
+ .status(HttpStatus.CONFLICT)
+ .body(errorResponse);
+ }
+
+ /**
+ * 잘못된 정렬 필드 예외 처리
+ *
+ * @param ex 속성 참조 예외
+ * @return 에러 응답
+ */
+ @ExceptionHandler(PropertyReferenceException.class)
+ public ResponseEntity handlePropertyReferenceException(PropertyReferenceException ex) {
+ log.warn("Invalid sort property: {}", ex.getMessage());
+
+ String message = "잘못된 정렬 필드입니다";
+ String details = String.format("'%s' 필드는 존재하지 않습니다. 사용 가능한 필드: id, participantId, eventId, name, phoneNumber, email, storeVisited, bonusEntries, agreeMarketing, agreePrivacy, isWinner, winnerRank, wonAt, createdAt, updatedAt",
+ ex.getPropertyName());
+
+ ErrorResponse errorResponse = ErrorResponse.of(
+ ErrorCode.COMMON_003.getCode(),
+ message,
+ details
+ );
+
+ return ResponseEntity
+ .status(HttpStatus.BAD_REQUEST)
+ .body(errorResponse);
+ }
+
/**
* 일반 예외 처리
*
diff --git a/content-service/build.gradle b/content-service/build.gradle
index aa9be20..3518c28 100644
--- a/content-service/build.gradle
+++ b/content-service/build.gradle
@@ -1,7 +1,10 @@
-dependencies {
- // Kafka Consumer
- implementation 'org.springframework.kafka:spring-kafka'
+configurations {
+ // Exclude JPA and PostgreSQL from inherited dependencies (Phase 3: Redis migration)
+ implementation.exclude group: 'org.springframework.boot', module: 'spring-boot-starter-data-jpa'
+ implementation.exclude group: 'org.postgresql', module: 'postgresql'
+}
+dependencies {
// Redis for AI data reading and image URL caching
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
diff --git a/content-service/src/main/java/com/kt/event/content/biz/domain/Content.java b/content-service/src/main/java/com/kt/event/content/biz/domain/Content.java
new file mode 100644
index 0000000..278c110
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/domain/Content.java
@@ -0,0 +1,99 @@
+package com.kt.event.content.biz.domain;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 콘텐츠 도메인 모델
+ * 이벤트에 대한 전체 콘텐츠 정보 (이미지 목록 포함)
+ */
+@Getter
+@Builder
+@AllArgsConstructor
+public class Content {
+
+ /**
+ * 콘텐츠 ID
+ */
+ private final Long id;
+
+ /**
+ * 이벤트 ID (이벤트 초안 ID)
+ */
+ private final Long eventDraftId;
+
+ /**
+ * 이벤트 제목
+ */
+ private final String eventTitle;
+
+ /**
+ * 이벤트 설명
+ */
+ private final String eventDescription;
+
+ /**
+ * 생성된 이미지 목록
+ */
+ @Builder.Default
+ private final List images = new ArrayList<>();
+
+ /**
+ * 생성일시
+ */
+ private final LocalDateTime createdAt;
+
+ /**
+ * 수정일시
+ */
+ private final LocalDateTime updatedAt;
+
+ /**
+ * 이미지 추가
+ *
+ * @param image 생성된 이미지
+ */
+ public void addImage(GeneratedImage image) {
+ this.images.add(image);
+ }
+
+ /**
+ * 선택된 이미지 조회
+ *
+ * @return 선택된 이미지 목록
+ */
+ public List getSelectedImages() {
+ return images.stream()
+ .filter(GeneratedImage::isSelected)
+ .toList();
+ }
+
+ /**
+ * 특정 스타일의 이미지 조회
+ *
+ * @param style 이미지 스타일
+ * @return 해당 스타일의 이미지 목록
+ */
+ public List getImagesByStyle(ImageStyle style) {
+ return images.stream()
+ .filter(image -> image.getStyle() == style)
+ .toList();
+ }
+
+ /**
+ * 특정 플랫폼의 이미지 조회
+ *
+ * @param platform 플랫폼
+ * @return 해당 플랫폼의 이미지 목록
+ */
+ public List getImagesByPlatform(Platform platform) {
+ return images.stream()
+ .filter(image -> image.getPlatform() == platform)
+ .toList();
+ }
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/domain/GeneratedImage.java b/content-service/src/main/java/com/kt/event/content/biz/domain/GeneratedImage.java
new file mode 100644
index 0000000..2d08b1e
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/domain/GeneratedImage.java
@@ -0,0 +1,76 @@
+package com.kt.event.content.biz.domain;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+/**
+ * 생성된 이미지 도메인 모델
+ * AI가 생성한 이미지의 비즈니스 정보
+ */
+@Getter
+@Builder
+@AllArgsConstructor
+public class GeneratedImage {
+
+ /**
+ * 이미지 ID
+ */
+ private final Long id;
+
+ /**
+ * 이벤트 ID (이벤트 초안 ID)
+ */
+ private final Long eventDraftId;
+
+ /**
+ * 이미지 스타일
+ */
+ private final ImageStyle style;
+
+ /**
+ * 플랫폼
+ */
+ private final Platform platform;
+
+ /**
+ * CDN URL (Azure Blob Storage)
+ */
+ private final String cdnUrl;
+
+ /**
+ * 프롬프트
+ */
+ private final String prompt;
+
+ /**
+ * 선택 여부
+ */
+ private boolean selected;
+
+ /**
+ * 생성일시
+ */
+ private LocalDateTime createdAt;
+
+ /**
+ * 수정일시
+ */
+ private LocalDateTime updatedAt;
+
+ /**
+ * 이미지 선택
+ */
+ public void select() {
+ this.selected = true;
+ }
+
+ /**
+ * 이미지 선택 해제
+ */
+ public void deselect() {
+ this.selected = false;
+ }
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/domain/ImageStyle.java b/content-service/src/main/java/com/kt/event/content/biz/domain/ImageStyle.java
new file mode 100644
index 0000000..dbcb715
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/domain/ImageStyle.java
@@ -0,0 +1,32 @@
+package com.kt.event.content.biz.domain;
+
+/**
+ * 이미지 스타일 enum
+ * AI가 생성하는 이미지의 스타일 유형
+ */
+public enum ImageStyle {
+ /**
+ * 심플 스타일 - 깔끔하고 미니멀한 디자인
+ */
+ SIMPLE("심플"),
+
+ /**
+ * 화려한 스타일 - 화려하고 풍부한 디자인
+ */
+ FANCY("화려한"),
+
+ /**
+ * 트렌디 스타일 - 최신 트렌드를 반영한 디자인
+ */
+ TRENDY("트렌디");
+
+ private final String displayName;
+
+ ImageStyle(String displayName) {
+ this.displayName = displayName;
+ }
+
+ public String getDisplayName() {
+ return displayName;
+ }
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/domain/Job.java b/content-service/src/main/java/com/kt/event/content/biz/domain/Job.java
new file mode 100644
index 0000000..cc67600
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/domain/Job.java
@@ -0,0 +1,140 @@
+package com.kt.event.content.biz.domain;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+/**
+ * Job 도메인 모델
+ * 이미지 생성 작업의 비즈니스 정보
+ */
+@Getter
+@Builder
+@AllArgsConstructor
+public class Job {
+
+ /**
+ * Job 상태 enum
+ */
+ public enum Status {
+ PENDING, // 대기 중
+ PROCESSING, // 처리 중
+ COMPLETED, // 완료
+ FAILED // 실패
+ }
+
+ /**
+ * Job ID
+ */
+ private final String id;
+
+ /**
+ * 이벤트 ID (이벤트 초안 ID)
+ */
+ private final Long eventDraftId;
+
+ /**
+ * Job 타입 (image-generation)
+ */
+ private final String jobType;
+
+ /**
+ * Job 상태
+ */
+ private Status status;
+
+ /**
+ * 진행률 (0-100)
+ */
+ private int progress;
+
+ /**
+ * 결과 메시지
+ */
+ private String resultMessage;
+
+ /**
+ * 에러 메시지
+ */
+ private String errorMessage;
+
+ /**
+ * 생성일시
+ */
+ private final LocalDateTime createdAt;
+
+ /**
+ * 수정일시
+ */
+ private final LocalDateTime updatedAt;
+
+ /**
+ * Job 시작
+ */
+ public void start() {
+ this.status = Status.PROCESSING;
+ this.progress = 0;
+ }
+
+ /**
+ * 진행률 업데이트
+ *
+ * @param progress 진행률 (0-100)
+ */
+ public void updateProgress(int progress) {
+ if (progress < 0 || progress > 100) {
+ throw new IllegalArgumentException("진행률은 0-100 사이여야 합니다");
+ }
+ this.progress = progress;
+ }
+
+ /**
+ * Job 완료 처리
+ *
+ * @param resultMessage 결과 메시지
+ */
+ public void complete(String resultMessage) {
+ this.status = Status.COMPLETED;
+ this.progress = 100;
+ this.resultMessage = resultMessage;
+ }
+
+ /**
+ * Job 실패 처리
+ *
+ * @param errorMessage 에러 메시지
+ */
+ public void fail(String errorMessage) {
+ this.status = Status.FAILED;
+ this.errorMessage = errorMessage;
+ }
+
+ /**
+ * Job 진행 중 여부
+ *
+ * @return 진행 중이면 true
+ */
+ public boolean isProcessing() {
+ return status == Status.PROCESSING;
+ }
+
+ /**
+ * Job 완료 여부
+ *
+ * @return 완료되었으면 true
+ */
+ public boolean isCompleted() {
+ return status == Status.COMPLETED;
+ }
+
+ /**
+ * Job 실패 여부
+ *
+ * @return 실패했으면 true
+ */
+ public boolean isFailed() {
+ return status == Status.FAILED;
+ }
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/domain/Platform.java b/content-service/src/main/java/com/kt/event/content/biz/domain/Platform.java
new file mode 100644
index 0000000..d308f16
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/domain/Platform.java
@@ -0,0 +1,53 @@
+package com.kt.event.content.biz.domain;
+
+/**
+ * 플랫폼 enum
+ * 이미지가 배포될 SNS 플랫폼 유형
+ */
+public enum Platform {
+ /**
+ * Instagram - 1080x1080 정사각형
+ */
+ INSTAGRAM("Instagram", 1080, 1080),
+
+ /**
+ * 네이버 블로그 - 800x600
+ */
+ NAVER("네이버 블로그", 800, 600),
+
+ /**
+ * 카카오 채널 - 800x800 정사각형
+ */
+ KAKAO("카카오 채널", 800, 800);
+
+ private final String displayName;
+ private final int width;
+ private final int height;
+
+ Platform(String displayName, int width, int height) {
+ this.displayName = displayName;
+ this.width = width;
+ this.height = height;
+ }
+
+ public String getDisplayName() {
+ return displayName;
+ }
+
+ public int getWidth() {
+ return width;
+ }
+
+ public int getHeight() {
+ return height;
+ }
+
+ /**
+ * 이미지 크기 문자열 반환
+ *
+ * @return 가로x세로 형식 (예: 1080x1080)
+ */
+ public String getSizeString() {
+ return width + "x" + height;
+ }
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/dto/ContentCommand.java b/content-service/src/main/java/com/kt/event/content/biz/dto/ContentCommand.java
new file mode 100644
index 0000000..a017182
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/dto/ContentCommand.java
@@ -0,0 +1,40 @@
+package com.kt.event.content.biz.dto;
+
+import com.kt.event.content.biz.domain.ImageStyle;
+import com.kt.event.content.biz.domain.Platform;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.util.List;
+
+/**
+ * 콘텐츠 관련 커맨드 DTO
+ */
+public class ContentCommand {
+
+ /**
+ * 이미지 생성 요청 커맨드
+ */
+ @Getter
+ @Builder
+ @AllArgsConstructor
+ public static class GenerateImages {
+ private Long eventDraftId;
+ private String eventTitle;
+ private String eventDescription;
+ private List styles;
+ private List platforms;
+ }
+
+ /**
+ * 이미지 재생성 요청 커맨드
+ */
+ @Getter
+ @Builder
+ @AllArgsConstructor
+ public static class RegenerateImage {
+ private Long imageId;
+ private String newPrompt;
+ }
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/dto/ContentInfo.java b/content-service/src/main/java/com/kt/event/content/biz/dto/ContentInfo.java
new file mode 100644
index 0000000..727b9ec
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/dto/ContentInfo.java
@@ -0,0 +1,47 @@
+package com.kt.event.content.biz.dto;
+
+import com.kt.event.content.biz.domain.Content;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 콘텐츠 정보 DTO
+ */
+@Getter
+@Builder
+@AllArgsConstructor
+public class ContentInfo {
+
+ private Long id;
+ private Long eventDraftId;
+ private String eventTitle;
+ private String eventDescription;
+ private List images;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+ /**
+ * 도메인 모델로부터 생성
+ *
+ * @param content 콘텐츠 도메인 모델
+ * @return ContentInfo
+ */
+ public static ContentInfo from(Content content) {
+ return ContentInfo.builder()
+ .id(content.getId())
+ .eventDraftId(content.getEventDraftId())
+ .eventTitle(content.getEventTitle())
+ .eventDescription(content.getEventDescription())
+ .images(content.getImages().stream()
+ .map(ImageInfo::from)
+ .collect(Collectors.toList()))
+ .createdAt(content.getCreatedAt())
+ .updatedAt(content.getUpdatedAt())
+ .build();
+ }
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/dto/ImageInfo.java b/content-service/src/main/java/com/kt/event/content/biz/dto/ImageInfo.java
new file mode 100644
index 0000000..5aed268
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/dto/ImageInfo.java
@@ -0,0 +1,49 @@
+package com.kt.event.content.biz.dto;
+
+import com.kt.event.content.biz.domain.GeneratedImage;
+import com.kt.event.content.biz.domain.ImageStyle;
+import com.kt.event.content.biz.domain.Platform;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+/**
+ * 이미지 정보 DTO
+ */
+@Getter
+@Builder
+@AllArgsConstructor
+public class ImageInfo {
+
+ private Long id;
+ private Long eventDraftId;
+ private ImageStyle style;
+ private Platform platform;
+ private String cdnUrl;
+ private String prompt;
+ private boolean selected;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+ /**
+ * 도메인 모델로부터 생성
+ *
+ * @param image 이미지 도메인 모델
+ * @return ImageInfo
+ */
+ public static ImageInfo from(GeneratedImage image) {
+ return ImageInfo.builder()
+ .id(image.getId())
+ .eventDraftId(image.getEventDraftId())
+ .style(image.getStyle())
+ .platform(image.getPlatform())
+ .cdnUrl(image.getCdnUrl())
+ .prompt(image.getPrompt())
+ .selected(image.isSelected())
+ .createdAt(image.getCreatedAt())
+ .updatedAt(image.getUpdatedAt())
+ .build();
+ }
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/dto/JobInfo.java b/content-service/src/main/java/com/kt/event/content/biz/dto/JobInfo.java
new file mode 100644
index 0000000..48e4909
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/dto/JobInfo.java
@@ -0,0 +1,47 @@
+package com.kt.event.content.biz.dto;
+
+import com.kt.event.content.biz.domain.Job;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+/**
+ * Job 정보 DTO
+ */
+@Getter
+@Builder
+@AllArgsConstructor
+public class JobInfo {
+
+ private String id;
+ private Long eventDraftId;
+ private String jobType;
+ private Job.Status status;
+ private int progress;
+ private String resultMessage;
+ private String errorMessage;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+ /**
+ * 도메인 모델로부터 생성
+ *
+ * @param job Job 도메인 모델
+ * @return JobInfo
+ */
+ public static JobInfo from(Job job) {
+ return JobInfo.builder()
+ .id(job.getId())
+ .eventDraftId(job.getEventDraftId())
+ .jobType(job.getJobType())
+ .status(job.getStatus())
+ .progress(job.getProgress())
+ .resultMessage(job.getResultMessage())
+ .errorMessage(job.getErrorMessage())
+ .createdAt(job.getCreatedAt())
+ .updatedAt(job.getUpdatedAt())
+ .build();
+ }
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/dto/RedisAIEventData.java b/content-service/src/main/java/com/kt/event/content/biz/dto/RedisAIEventData.java
new file mode 100644
index 0000000..a624bc9
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/dto/RedisAIEventData.java
@@ -0,0 +1,56 @@
+package com.kt.event.content.biz.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Map;
+
+/**
+ * AI Service가 Redis에 저장한 이벤트 데이터 (읽기 전용)
+ *
+ * Key Pattern: ai:event:{eventDraftId}
+ * Data Type: Hash
+ * TTL: 24시간 (86400초)
+ *
+ * 예시:
+ * - ai:event:1
+ *
+ * Note: 이 데이터는 AI Service가 생성하고 Content Service는 읽기만 합니다.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class RedisAIEventData {
+ /**
+ * 이벤트 초안 ID
+ */
+ private Long eventDraftId;
+
+ /**
+ * 이벤트 제목
+ */
+ private String eventTitle;
+
+ /**
+ * 이벤트 설명
+ */
+ private String eventDescription;
+
+ /**
+ * 타겟 고객
+ */
+ private String targetAudience;
+
+ /**
+ * 이벤트 목적
+ */
+ private String eventObjective;
+
+ /**
+ * AI가 생성한 추가 데이터
+ */
+ private Map additionalData;
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/dto/RedisImageData.java b/content-service/src/main/java/com/kt/event/content/biz/dto/RedisImageData.java
new file mode 100644
index 0000000..58fdce2
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/dto/RedisImageData.java
@@ -0,0 +1,72 @@
+package com.kt.event.content.biz.dto;
+
+import com.kt.event.content.biz.domain.ImageStyle;
+import com.kt.event.content.biz.domain.Platform;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * Redis에 저장되는 이미지 데이터 구조
+ *
+ * Key Pattern: content:image:{eventDraftId}:{style}:{platform}
+ * Data Type: String (JSON)
+ * TTL: 7일 (604800초)
+ *
+ * 예시:
+ * - content:image:1:FANCY:INSTAGRAM
+ * - content:image:1:SIMPLE:KAKAO
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class RedisImageData {
+ /**
+ * 이미지 고유 ID
+ */
+ private Long id;
+
+ /**
+ * 이벤트 초안 ID
+ */
+ private Long eventDraftId;
+
+ /**
+ * 이미지 스타일 (FANCY, SIMPLE, TRENDY)
+ */
+ private ImageStyle style;
+
+ /**
+ * 플랫폼 (INSTAGRAM, KAKAO, NAVER)
+ */
+ private Platform platform;
+
+ /**
+ * CDN 이미지 URL
+ */
+ private String cdnUrl;
+
+ /**
+ * 이미지 생성 프롬프트
+ */
+ private String prompt;
+
+ /**
+ * 선택 여부
+ */
+ private Boolean selected;
+
+ /**
+ * 생성 일시
+ */
+ private LocalDateTime createdAt;
+
+ /**
+ * 수정 일시
+ */
+ private LocalDateTime updatedAt;
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/dto/RedisJobData.java b/content-service/src/main/java/com/kt/event/content/biz/dto/RedisJobData.java
new file mode 100644
index 0000000..d65f3f6
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/dto/RedisJobData.java
@@ -0,0 +1,70 @@
+package com.kt.event.content.biz.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * Redis에 저장되는 Job 상태 정보
+ *
+ * Key Pattern: job:{jobId}
+ * Data Type: Hash
+ * TTL: 1시간 (3600초)
+ *
+ * 예시:
+ * - job:job-mock-7ada8bd3
+ * - job:job-regen-df2bb3a3
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class RedisJobData {
+ /**
+ * Job ID (예: job-mock-7ada8bd3)
+ */
+ private String id;
+
+ /**
+ * 이벤트 초안 ID
+ */
+ private Long eventDraftId;
+
+ /**
+ * Job 타입 (image-generation, image-regeneration)
+ */
+ private String jobType;
+
+ /**
+ * 상태 (PENDING, IN_PROGRESS, COMPLETED, FAILED)
+ */
+ private String status;
+
+ /**
+ * 진행률 (0-100)
+ */
+ private Integer progress;
+
+ /**
+ * 결과 메시지
+ */
+ private String resultMessage;
+
+ /**
+ * 에러 메시지
+ */
+ private String errorMessage;
+
+ /**
+ * 생성 일시
+ */
+ private LocalDateTime createdAt;
+
+ /**
+ * 수정 일시
+ */
+ private LocalDateTime updatedAt;
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/service/DeleteImageService.java b/content-service/src/main/java/com/kt/event/content/biz/service/DeleteImageService.java
new file mode 100644
index 0000000..e427c7a
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/service/DeleteImageService.java
@@ -0,0 +1,38 @@
+package com.kt.event.content.biz.service;
+
+import com.kt.event.common.exception.BusinessException;
+import com.kt.event.common.exception.ErrorCode;
+import com.kt.event.content.biz.usecase.in.DeleteImageUseCase;
+import com.kt.event.content.biz.usecase.out.ContentReader;
+import com.kt.event.content.biz.usecase.out.ContentWriter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 이미지 삭제 서비스
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional
+public class DeleteImageService implements DeleteImageUseCase {
+
+ private final ContentReader contentReader;
+ private final ContentWriter contentWriter;
+
+ @Override
+ public void execute(Long imageId) {
+ log.info("[DeleteImageService] 이미지 삭제 요청: imageId={}", imageId);
+
+ // 이미지 존재 확인
+ contentReader.findImageById(imageId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "이미지를 찾을 수 없습니다"));
+
+ // 이미지 삭제
+ contentWriter.deleteImageById(imageId);
+
+ log.info("[DeleteImageService] 이미지 삭제 완료: imageId={}", imageId);
+ }
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/service/GetEventContentService.java b/content-service/src/main/java/com/kt/event/content/biz/service/GetEventContentService.java
new file mode 100644
index 0000000..8ac84bb
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/service/GetEventContentService.java
@@ -0,0 +1,32 @@
+package com.kt.event.content.biz.service;
+
+import com.kt.event.common.exception.BusinessException;
+import com.kt.event.common.exception.ErrorCode;
+import com.kt.event.content.biz.domain.Content;
+import com.kt.event.content.biz.dto.ContentInfo;
+import com.kt.event.content.biz.usecase.in.GetEventContentUseCase;
+import com.kt.event.content.biz.usecase.out.ContentReader;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 이벤트 콘텐츠 조회 서비스
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class GetEventContentService implements GetEventContentUseCase {
+
+ private final ContentReader contentReader;
+
+ @Override
+ public ContentInfo execute(Long eventDraftId) {
+ Content content = contentReader.findByEventDraftIdWithImages(eventDraftId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "콘텐츠를 찾을 수 없습니다"));
+
+ return ContentInfo.from(content);
+ }
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/service/GetImageDetailService.java b/content-service/src/main/java/com/kt/event/content/biz/service/GetImageDetailService.java
new file mode 100644
index 0000000..4465679
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/service/GetImageDetailService.java
@@ -0,0 +1,32 @@
+package com.kt.event.content.biz.service;
+
+import com.kt.event.common.exception.BusinessException;
+import com.kt.event.common.exception.ErrorCode;
+import com.kt.event.content.biz.domain.GeneratedImage;
+import com.kt.event.content.biz.dto.ImageInfo;
+import com.kt.event.content.biz.usecase.in.GetImageDetailUseCase;
+import com.kt.event.content.biz.usecase.out.ContentReader;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 이미지 상세 조회 서비스
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class GetImageDetailService implements GetImageDetailUseCase {
+
+ private final ContentReader contentReader;
+
+ @Override
+ public ImageInfo execute(Long imageId) {
+ GeneratedImage image = contentReader.findImageById(imageId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "이미지를 찾을 수 없습니다"));
+
+ return ImageInfo.from(image);
+ }
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/service/GetImageListService.java b/content-service/src/main/java/com/kt/event/content/biz/service/GetImageListService.java
new file mode 100644
index 0000000..e1c48b5
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/service/GetImageListService.java
@@ -0,0 +1,41 @@
+package com.kt.event.content.biz.service;
+
+import com.kt.event.content.biz.domain.GeneratedImage;
+import com.kt.event.content.biz.domain.ImageStyle;
+import com.kt.event.content.biz.domain.Platform;
+import com.kt.event.content.biz.dto.ImageInfo;
+import com.kt.event.content.biz.usecase.in.GetImageListUseCase;
+import com.kt.event.content.biz.usecase.out.ContentReader;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 이미지 목록 조회 서비스
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class GetImageListService implements GetImageListUseCase {
+
+ private final ContentReader contentReader;
+
+ @Override
+ public List execute(Long eventDraftId, ImageStyle style, Platform platform) {
+ log.info("이미지 목록 조회: eventDraftId={}, style={}, platform={}", eventDraftId, style, platform);
+
+ List images = contentReader.findImagesByEventDraftId(eventDraftId);
+
+ // 필터링 적용
+ return images.stream()
+ .filter(image -> style == null || image.getStyle() == style)
+ .filter(image -> platform == null || image.getPlatform() == platform)
+ .map(ImageInfo::from)
+ .collect(Collectors.toList());
+ }
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/service/JobManagementService.java b/content-service/src/main/java/com/kt/event/content/biz/service/JobManagementService.java
new file mode 100644
index 0000000..798dfdb
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/service/JobManagementService.java
@@ -0,0 +1,47 @@
+package com.kt.event.content.biz.service;
+
+import com.kt.event.common.exception.BusinessException;
+import com.kt.event.common.exception.ErrorCode;
+import com.kt.event.content.biz.domain.Job;
+import com.kt.event.content.biz.dto.JobInfo;
+import com.kt.event.content.biz.dto.RedisJobData;
+import com.kt.event.content.biz.usecase.in.GetJobStatusUseCase;
+import com.kt.event.content.biz.usecase.out.JobReader;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Job 관리 서비스
+ * Job 상태 조회 기능 제공
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+@Transactional(readOnly = true)
+public class JobManagementService implements GetJobStatusUseCase {
+
+ private final JobReader jobReader;
+
+ @Override
+ public JobInfo execute(String jobId) {
+ RedisJobData jobData = jobReader.getJob(jobId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "Job을 찾을 수 없습니다"));
+
+ // RedisJobData를 Job 도메인 객체로 변환
+ Job job = Job.builder()
+ .id(jobData.getId())
+ .eventDraftId(jobData.getEventDraftId())
+ .jobType(jobData.getJobType())
+ .status(Job.Status.valueOf(jobData.getStatus()))
+ .progress(jobData.getProgress())
+ .resultMessage(jobData.getResultMessage())
+ .errorMessage(jobData.getErrorMessage())
+ .createdAt(jobData.getCreatedAt())
+ .updatedAt(jobData.getUpdatedAt())
+ .build();
+
+ return JobInfo.from(job);
+ }
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/service/mock/MockGenerateImagesService.java b/content-service/src/main/java/com/kt/event/content/biz/service/mock/MockGenerateImagesService.java
new file mode 100644
index 0000000..5841a18
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/service/mock/MockGenerateImagesService.java
@@ -0,0 +1,154 @@
+package com.kt.event.content.biz.service.mock;
+
+import com.kt.event.content.biz.domain.Content;
+import com.kt.event.content.biz.domain.GeneratedImage;
+import com.kt.event.content.biz.domain.ImageStyle;
+import com.kt.event.content.biz.domain.Job;
+import com.kt.event.content.biz.domain.Platform;
+import com.kt.event.content.biz.dto.ContentCommand;
+import com.kt.event.content.biz.dto.JobInfo;
+import com.kt.event.content.biz.dto.RedisJobData;
+import com.kt.event.content.biz.usecase.in.GenerateImagesUseCase;
+import com.kt.event.content.biz.usecase.out.ContentWriter;
+import com.kt.event.content.biz.usecase.out.JobWriter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Profile;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Mock 이미지 생성 서비스 (테스트용)
+ * 실제 Kafka 연동 전까지 사용
+ *
+ * 테스트를 위해 실제로 Content와 GeneratedImage를 생성합니다.
+ */
+@Slf4j
+@Service
+@Profile({"local", "test", "dev"})
+@RequiredArgsConstructor
+public class MockGenerateImagesService implements GenerateImagesUseCase {
+
+ private final JobWriter jobWriter;
+ private final ContentWriter contentWriter;
+
+ @Override
+ public JobInfo execute(ContentCommand.GenerateImages command) {
+ log.info("[MOCK] 이미지 생성 요청: eventDraftId={}, styles={}, platforms={}",
+ command.getEventDraftId(), command.getStyles(), command.getPlatforms());
+
+ // Mock Job 생성
+ String jobId = "job-mock-" + UUID.randomUUID().toString().substring(0, 8);
+
+ Job job = Job.builder()
+ .id(jobId)
+ .eventDraftId(command.getEventDraftId())
+ .jobType("image-generation")
+ .status(Job.Status.PENDING)
+ .progress(0)
+ .createdAt(java.time.LocalDateTime.now())
+ .updatedAt(java.time.LocalDateTime.now())
+ .build();
+
+ // Job 저장 (Job 도메인을 RedisJobData로 변환)
+ RedisJobData jobData = RedisJobData.builder()
+ .id(job.getId())
+ .eventDraftId(job.getEventDraftId())
+ .jobType(job.getJobType())
+ .status(job.getStatus().name())
+ .progress(job.getProgress())
+ .createdAt(job.getCreatedAt())
+ .updatedAt(job.getUpdatedAt())
+ .build();
+
+ jobWriter.saveJob(jobData, 3600); // TTL 1시간
+ log.info("[MOCK] Job 생성 완료: jobId={}", jobId);
+
+ // 비동기로 이미지 생성 시뮬레이션
+ processImageGeneration(jobId, command);
+
+ return JobInfo.from(job);
+ }
+
+ @Async
+ private void processImageGeneration(String jobId, ContentCommand.GenerateImages command) {
+ try {
+ log.info("[MOCK] 이미지 생성 시작: jobId={}", jobId);
+
+ // 1초 대기 (이미지 생성 시뮬레이션)
+ Thread.sleep(1000);
+
+ // Content 생성 또는 조회
+ Content content = Content.builder()
+ .eventDraftId(command.getEventDraftId())
+ .eventTitle("Mock 이벤트 제목 " + command.getEventDraftId())
+ .eventDescription("Mock 이벤트 설명입니다. 테스트를 위한 Mock 데이터입니다.")
+ .createdAt(java.time.LocalDateTime.now())
+ .updatedAt(java.time.LocalDateTime.now())
+ .build();
+ Content savedContent = contentWriter.save(content);
+ log.info("[MOCK] Content 생성 완료: contentId={}", savedContent.getId());
+
+ // 스타일 x 플랫폼 조합으로 이미지 생성
+ List styles = command.getStyles() != null && !command.getStyles().isEmpty()
+ ? command.getStyles()
+ : List.of(ImageStyle.FANCY, ImageStyle.SIMPLE);
+
+ List platforms = command.getPlatforms() != null && !command.getPlatforms().isEmpty()
+ ? command.getPlatforms()
+ : List.of(Platform.INSTAGRAM, Platform.KAKAO);
+
+ List images = new ArrayList<>();
+ int count = 0;
+ for (ImageStyle style : styles) {
+ for (Platform platform : platforms) {
+ count++;
+ String mockCdnUrl = String.format(
+ "https://mock-cdn.azure.com/images/%d/%s_%s_%s.png",
+ command.getEventDraftId(),
+ style.name().toLowerCase(),
+ platform.name().toLowerCase(),
+ UUID.randomUUID().toString().substring(0, 8)
+ );
+
+ GeneratedImage image = GeneratedImage.builder()
+ .eventDraftId(command.getEventDraftId())
+ .style(style)
+ .platform(platform)
+ .cdnUrl(mockCdnUrl)
+ .prompt(String.format("Mock prompt for %s style on %s platform", style, platform))
+ .selected(false)
+ .createdAt(java.time.LocalDateTime.now())
+ .updatedAt(java.time.LocalDateTime.now())
+ .build();
+
+ // 첫 번째 이미지를 선택된 이미지로 설정
+ if (count == 1) {
+ image.select();
+ }
+
+ GeneratedImage savedImage = contentWriter.saveImage(image);
+ images.add(savedImage);
+ log.info("[MOCK] 이미지 생성: imageId={}, style={}, platform={}",
+ savedImage.getId(), style, platform);
+ }
+ }
+
+ // Job 상태 업데이트: COMPLETED
+ String resultMessage = String.format("%d개의 이미지가 성공적으로 생성되었습니다.", images.size());
+ jobWriter.updateJobStatus(jobId, "COMPLETED", 100);
+ jobWriter.updateJobResult(jobId, resultMessage);
+ log.info("[MOCK] Job 완료: jobId={}, 생성된 이미지 수={}", jobId, images.size());
+
+ } catch (Exception e) {
+ log.error("[MOCK] 이미지 생성 실패: jobId={}", jobId, e);
+
+ // Job 상태 업데이트: FAILED
+ jobWriter.updateJobError(jobId, e.getMessage());
+ }
+ }
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/service/mock/MockRegenerateImageService.java b/content-service/src/main/java/com/kt/event/content/biz/service/mock/MockRegenerateImageService.java
new file mode 100644
index 0000000..01c9699
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/service/mock/MockRegenerateImageService.java
@@ -0,0 +1,62 @@
+package com.kt.event.content.biz.service.mock;
+
+import com.kt.event.content.biz.domain.Job;
+import com.kt.event.content.biz.dto.ContentCommand;
+import com.kt.event.content.biz.dto.JobInfo;
+import com.kt.event.content.biz.dto.RedisJobData;
+import com.kt.event.content.biz.usecase.in.RegenerateImageUseCase;
+import com.kt.event.content.biz.usecase.out.JobWriter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Service;
+
+import java.util.UUID;
+
+/**
+ * Mock 이미지 재생성 서비스 (테스트용)
+ * 실제 구현 전까지 사용
+ */
+@Slf4j
+@Service
+@Profile({"local", "test", "dev"})
+@RequiredArgsConstructor
+public class MockRegenerateImageService implements RegenerateImageUseCase {
+
+ private final JobWriter jobWriter;
+
+ @Override
+ public JobInfo execute(ContentCommand.RegenerateImage command) {
+ log.info("[MOCK] 이미지 재생성 요청: imageId={}", command.getImageId());
+
+ // Mock Job 생성
+ String jobId = "job-regen-" + UUID.randomUUID().toString().substring(0, 8);
+
+ Job job = Job.builder()
+ .id(jobId)
+ .eventDraftId(999L) // Mock event ID
+ .jobType("image-regeneration")
+ .status(Job.Status.PENDING)
+ .progress(0)
+ .createdAt(java.time.LocalDateTime.now())
+ .updatedAt(java.time.LocalDateTime.now())
+ .build();
+
+ // Job 저장 (Job 도메인을 RedisJobData로 변환)
+ RedisJobData jobData = RedisJobData.builder()
+ .id(job.getId())
+ .eventDraftId(job.getEventDraftId())
+ .jobType(job.getJobType())
+ .status(job.getStatus().name())
+ .progress(job.getProgress())
+ .createdAt(job.getCreatedAt())
+ .updatedAt(job.getUpdatedAt())
+ .build();
+
+ jobWriter.saveJob(jobData, 3600); // TTL 1시간
+
+ log.info("[MOCK] 재생성 Job 생성 완료: jobId={}", jobId);
+
+ return JobInfo.from(job);
+ }
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/usecase/in/DeleteImageUseCase.java b/content-service/src/main/java/com/kt/event/content/biz/usecase/in/DeleteImageUseCase.java
new file mode 100644
index 0000000..09f6eac
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/usecase/in/DeleteImageUseCase.java
@@ -0,0 +1,14 @@
+package com.kt.event.content.biz.usecase.in;
+
+/**
+ * 이미지 삭제 UseCase
+ */
+public interface DeleteImageUseCase {
+
+ /**
+ * 이미지 삭제
+ *
+ * @param imageId 삭제할 이미지 ID
+ */
+ void execute(Long imageId);
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/usecase/in/GenerateImagesUseCase.java b/content-service/src/main/java/com/kt/event/content/biz/usecase/in/GenerateImagesUseCase.java
new file mode 100644
index 0000000..70d89d2
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/usecase/in/GenerateImagesUseCase.java
@@ -0,0 +1,19 @@
+package com.kt.event.content.biz.usecase.in;
+
+import com.kt.event.content.biz.dto.ContentCommand;
+import com.kt.event.content.biz.dto.JobInfo;
+
+/**
+ * 이미지 생성 UseCase
+ * 비동기로 이미지 생성 작업을 시작
+ */
+public interface GenerateImagesUseCase {
+
+ /**
+ * 이미지 생성 요청
+ *
+ * @param command 이미지 생성 커맨드
+ * @return Job 정보
+ */
+ JobInfo execute(ContentCommand.GenerateImages command);
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/usecase/in/GetEventContentUseCase.java b/content-service/src/main/java/com/kt/event/content/biz/usecase/in/GetEventContentUseCase.java
new file mode 100644
index 0000000..9b29d21
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/usecase/in/GetEventContentUseCase.java
@@ -0,0 +1,17 @@
+package com.kt.event.content.biz.usecase.in;
+
+import com.kt.event.content.biz.dto.ContentInfo;
+
+/**
+ * 이벤트 콘텐츠 조회 UseCase
+ */
+public interface GetEventContentUseCase {
+
+ /**
+ * 이벤트 전체 콘텐츠 조회 (이미지 목록 포함)
+ *
+ * @param eventDraftId 이벤트 초안 ID
+ * @return 콘텐츠 정보
+ */
+ ContentInfo execute(Long eventDraftId);
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/usecase/in/GetImageDetailUseCase.java b/content-service/src/main/java/com/kt/event/content/biz/usecase/in/GetImageDetailUseCase.java
new file mode 100644
index 0000000..d30af23
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/usecase/in/GetImageDetailUseCase.java
@@ -0,0 +1,17 @@
+package com.kt.event.content.biz.usecase.in;
+
+import com.kt.event.content.biz.dto.ImageInfo;
+
+/**
+ * 이미지 상세 조회 UseCase
+ */
+public interface GetImageDetailUseCase {
+
+ /**
+ * 이미지 상세 정보 조회
+ *
+ * @param imageId 이미지 ID
+ * @return 이미지 정보
+ */
+ ImageInfo execute(Long imageId);
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/usecase/in/GetImageListUseCase.java b/content-service/src/main/java/com/kt/event/content/biz/usecase/in/GetImageListUseCase.java
new file mode 100644
index 0000000..59e426b
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/usecase/in/GetImageListUseCase.java
@@ -0,0 +1,23 @@
+package com.kt.event.content.biz.usecase.in;
+
+import com.kt.event.content.biz.domain.ImageStyle;
+import com.kt.event.content.biz.domain.Platform;
+import com.kt.event.content.biz.dto.ImageInfo;
+
+import java.util.List;
+
+/**
+ * 이미지 목록 조회 UseCase
+ */
+public interface GetImageListUseCase {
+
+ /**
+ * 이벤트의 이미지 목록 조회 (필터링 지원)
+ *
+ * @param eventDraftId 이벤트 초안 ID
+ * @param style 이미지 스타일 필터 (null이면 전체)
+ * @param platform 플랫폼 필터 (null이면 전체)
+ * @return 이미지 정보 목록
+ */
+ List execute(Long eventDraftId, ImageStyle style, Platform platform);
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/usecase/in/GetJobStatusUseCase.java b/content-service/src/main/java/com/kt/event/content/biz/usecase/in/GetJobStatusUseCase.java
new file mode 100644
index 0000000..97831b2
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/usecase/in/GetJobStatusUseCase.java
@@ -0,0 +1,17 @@
+package com.kt.event.content.biz.usecase.in;
+
+import com.kt.event.content.biz.dto.JobInfo;
+
+/**
+ * Job 상태 조회 UseCase
+ */
+public interface GetJobStatusUseCase {
+
+ /**
+ * Job 상태 조회
+ *
+ * @param jobId Job ID
+ * @return Job 정보
+ */
+ JobInfo execute(String jobId);
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/usecase/in/RegenerateImageUseCase.java b/content-service/src/main/java/com/kt/event/content/biz/usecase/in/RegenerateImageUseCase.java
new file mode 100644
index 0000000..712e73e
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/usecase/in/RegenerateImageUseCase.java
@@ -0,0 +1,18 @@
+package com.kt.event.content.biz.usecase.in;
+
+import com.kt.event.content.biz.dto.ContentCommand;
+import com.kt.event.content.biz.dto.JobInfo;
+
+/**
+ * 이미지 재생성 UseCase
+ */
+public interface RegenerateImageUseCase {
+
+ /**
+ * 이미지 재생성 요청
+ *
+ * @param command 이미지 재생성 커맨드
+ * @return Job 정보
+ */
+ JobInfo execute(ContentCommand.RegenerateImage command);
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/usecase/out/CDNUploader.java b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/CDNUploader.java
new file mode 100644
index 0000000..79b56ca
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/CDNUploader.java
@@ -0,0 +1,17 @@
+package com.kt.event.content.biz.usecase.out;
+
+/**
+ * CDN 업로드 포트
+ * Azure Blob Storage에 이미지 업로드
+ */
+public interface CDNUploader {
+
+ /**
+ * 이미지 업로드
+ *
+ * @param imageData 이미지 바이트 데이터
+ * @param fileName 파일명
+ * @return CDN URL
+ */
+ String upload(byte[] imageData, String fileName);
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/usecase/out/ContentReader.java b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/ContentReader.java
new file mode 100644
index 0000000..1847e1d
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/ContentReader.java
@@ -0,0 +1,37 @@
+package com.kt.event.content.biz.usecase.out;
+
+import com.kt.event.content.biz.domain.Content;
+import com.kt.event.content.biz.domain.GeneratedImage;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 콘텐츠 조회 포트
+ */
+public interface ContentReader {
+
+ /**
+ * 이벤트 초안 ID로 콘텐츠 조회 (이미지 목록 포함)
+ *
+ * @param eventDraftId 이벤트 초안 ID
+ * @return 콘텐츠 도메인 모델
+ */
+ Optional findByEventDraftIdWithImages(Long eventDraftId);
+
+ /**
+ * 이미지 ID로 이미지 조회
+ *
+ * @param imageId 이미지 ID
+ * @return 이미지 도메인 모델
+ */
+ Optional findImageById(Long imageId);
+
+ /**
+ * 이벤트 초안 ID로 이미지 목록 조회
+ *
+ * @param eventDraftId 이벤트 초안 ID
+ * @return 이미지 도메인 모델 목록
+ */
+ List findImagesByEventDraftId(Long eventDraftId);
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/usecase/out/ContentWriter.java b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/ContentWriter.java
new file mode 100644
index 0000000..62bfb47
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/ContentWriter.java
@@ -0,0 +1,33 @@
+package com.kt.event.content.biz.usecase.out;
+
+import com.kt.event.content.biz.domain.Content;
+import com.kt.event.content.biz.domain.GeneratedImage;
+
+/**
+ * 콘텐츠 저장 포트
+ */
+public interface ContentWriter {
+
+ /**
+ * 콘텐츠 저장
+ *
+ * @param content 콘텐츠 도메인 모델
+ * @return 저장된 콘텐츠
+ */
+ Content save(Content content);
+
+ /**
+ * 이미지 저장
+ *
+ * @param image 이미지 도메인 모델
+ * @return 저장된 이미지
+ */
+ GeneratedImage saveImage(GeneratedImage image);
+
+ /**
+ * 이미지 ID로 이미지 삭제
+ *
+ * @param imageId 이미지 ID
+ */
+ void deleteImageById(Long imageId);
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/usecase/out/ImageGeneratorCaller.java b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/ImageGeneratorCaller.java
new file mode 100644
index 0000000..a14210d
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/ImageGeneratorCaller.java
@@ -0,0 +1,21 @@
+package com.kt.event.content.biz.usecase.out;
+
+import com.kt.event.content.biz.domain.ImageStyle;
+import com.kt.event.content.biz.domain.Platform;
+
+/**
+ * 이미지 생성 API 호출 포트
+ * Stable Diffusion, DALL-E 등 외부 이미지 생성 API 호출
+ */
+public interface ImageGeneratorCaller {
+
+ /**
+ * 이미지 생성
+ *
+ * @param prompt 프롬프트
+ * @param style 이미지 스타일
+ * @param platform 플랫폼 (이미지 크기 결정)
+ * @return 생성된 이미지 바이트 데이터
+ */
+ byte[] generateImage(String prompt, ImageStyle style, Platform platform);
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/usecase/out/ImageReader.java b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/ImageReader.java
new file mode 100644
index 0000000..fe7c384
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/ImageReader.java
@@ -0,0 +1,32 @@
+package com.kt.event.content.biz.usecase.out;
+
+import com.kt.event.content.biz.domain.ImageStyle;
+import com.kt.event.content.biz.domain.Platform;
+import com.kt.event.content.biz.dto.RedisImageData;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 이미지 조회 Port (Output Port)
+ */
+public interface ImageReader {
+
+ /**
+ * 특정 이미지 조회
+ *
+ * @param eventDraftId 이벤트 초안 ID
+ * @param style 이미지 스타일
+ * @param platform 플랫폼
+ * @return 이미지 데이터
+ */
+ Optional getImage(Long eventDraftId, ImageStyle style, Platform platform);
+
+ /**
+ * 이벤트의 모든 이미지 조회
+ *
+ * @param eventDraftId 이벤트 초안 ID
+ * @return 이미지 목록
+ */
+ List getImagesByEventId(Long eventDraftId);
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/usecase/out/ImageWriter.java b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/ImageWriter.java
new file mode 100644
index 0000000..9c8f167
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/ImageWriter.java
@@ -0,0 +1,39 @@
+package com.kt.event.content.biz.usecase.out;
+
+import com.kt.event.content.biz.domain.ImageStyle;
+import com.kt.event.content.biz.domain.Platform;
+import com.kt.event.content.biz.dto.RedisImageData;
+
+import java.util.List;
+
+/**
+ * 이미지 저장 Port (Output Port)
+ */
+public interface ImageWriter {
+
+ /**
+ * 단일 이미지 저장
+ *
+ * @param imageData 이미지 데이터
+ * @param ttlSeconds TTL (초 단위)
+ */
+ void saveImage(RedisImageData imageData, long ttlSeconds);
+
+ /**
+ * 여러 이미지 저장
+ *
+ * @param eventDraftId 이벤트 초안 ID
+ * @param images 이미지 목록
+ * @param ttlSeconds TTL (초 단위)
+ */
+ void saveImages(Long eventDraftId, List images, long ttlSeconds);
+
+ /**
+ * 이미지 삭제
+ *
+ * @param eventDraftId 이벤트 초안 ID
+ * @param style 이미지 스타일
+ * @param platform 플랫폼
+ */
+ void deleteImage(Long eventDraftId, ImageStyle style, Platform platform);
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/usecase/out/JobReader.java b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/JobReader.java
new file mode 100644
index 0000000..d5cdf12
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/JobReader.java
@@ -0,0 +1,19 @@
+package com.kt.event.content.biz.usecase.out;
+
+import com.kt.event.content.biz.dto.RedisJobData;
+
+import java.util.Optional;
+
+/**
+ * Job 조회 Port (Output Port)
+ */
+public interface JobReader {
+
+ /**
+ * Job 조회
+ *
+ * @param jobId Job ID
+ * @return Job 데이터
+ */
+ Optional getJob(String jobId);
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/usecase/out/JobWriter.java b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/JobWriter.java
new file mode 100644
index 0000000..e89b89a
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/JobWriter.java
@@ -0,0 +1,42 @@
+package com.kt.event.content.biz.usecase.out;
+
+import com.kt.event.content.biz.dto.RedisJobData;
+
+/**
+ * Job 저장 Port (Output Port)
+ */
+public interface JobWriter {
+
+ /**
+ * Job 생성/저장
+ *
+ * @param jobData Job 데이터
+ * @param ttlSeconds TTL (초 단위)
+ */
+ void saveJob(RedisJobData jobData, long ttlSeconds);
+
+ /**
+ * Job 상태 업데이트
+ *
+ * @param jobId Job ID
+ * @param status 상태
+ * @param progress 진행률 (0-100)
+ */
+ void updateJobStatus(String jobId, String status, Integer progress);
+
+ /**
+ * Job 결과 메시지 업데이트
+ *
+ * @param jobId Job ID
+ * @param resultMessage 결과 메시지
+ */
+ void updateJobResult(String jobId, String resultMessage);
+
+ /**
+ * Job 에러 메시지 업데이트
+ *
+ * @param jobId Job ID
+ * @param errorMessage 에러 메시지
+ */
+ void updateJobError(String jobId, String errorMessage);
+}
diff --git a/content-service/src/main/java/com/kt/event/content/biz/usecase/out/RedisAIDataReader.java b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/RedisAIDataReader.java
new file mode 100644
index 0000000..ee66f12
--- /dev/null
+++ b/content-service/src/main/java/com/kt/event/content/biz/usecase/out/RedisAIDataReader.java
@@ -0,0 +1,19 @@
+package com.kt.event.content.biz.usecase.out;
+
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Redis AI 데이터 조회 포트
+ * Event Service가 저장한 AI 추천 데이터를 읽음
+ */
+public interface RedisAIDataReader {
+
+ /**
+ * AI 추천 데이터 조회
+ *
+ * @param eventDraftId 이벤트 초안 ID
+ * @return AI 추천 데이터 (JSON 형태의 Map)
+ */
+ Optional