mirror of
https://github.com/ktds-dg0501/kt-event-marketing.git
synced 2025-12-06 21:26:24 +00:00
Content Service Phase 2: Port 인터페이스 구현 및 Gateway 통합
Phase 2 작업으로 Clean Architecture의 의존성 역전 원칙을 적용하여 Service 계층이 Port 인터페이스에만 의존하도록 구조를 개선했습니다. 주요 변경사항: 1. Redis DTO 생성 (Phase 1) - RedisAIEventData: AI 이벤트 데이터 DTO - RedisImageData: 이미지 데이터 DTO - RedisJobData: Job 데이터 DTO 2. Port 인터페이스 생성 - ImageWriter: 이미지 저장 Port - ImageReader: 이미지 조회 Port - JobWriter: Job 저장 Port - JobReader: Job 조회 Port 3. Gateway 구현 - RedisGateway: 4개 Port 인터페이스 구현 (Production용) - MockRedisGateway: 4개 Port 인터페이스 구현 (Local/Test용) - JobGateway: 2개 Port 인터페이스 구현 + @Primary 추가 (Phase 3 삭제 예정) 4. 하위 호환성 유지 - Port 인터페이스에 레거시 메서드 추가 (save, findById) - Service 계층 코드 변경 없이 점진적 마이그레이션 - "Phase 3에서 삭제 예정" 주석 표시 검증 완료: - 컴파일 성공 - 서비스 정상 시작 (포트 8084) - API 정상 작동 확인 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6dc6334c75
commit
5e9e1759ce
@ -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<String, Object> additionalData;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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<RedisImageData> getImage(Long eventDraftId, ImageStyle style, Platform platform);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트의 모든 이미지 조회
|
||||||
|
*
|
||||||
|
* @param eventDraftId 이벤트 초안 ID
|
||||||
|
* @return 이미지 목록
|
||||||
|
*/
|
||||||
|
List<RedisImageData> getImagesByEventId(Long eventDraftId);
|
||||||
|
}
|
||||||
@ -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<RedisImageData> images, long ttlSeconds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 삭제
|
||||||
|
*
|
||||||
|
* @param eventDraftId 이벤트 초안 ID
|
||||||
|
* @param style 이미지 스타일
|
||||||
|
* @param platform 플랫폼
|
||||||
|
*/
|
||||||
|
void deleteImage(Long eventDraftId, ImageStyle style, Platform platform);
|
||||||
|
}
|
||||||
@ -1,19 +1,29 @@
|
|||||||
package com.kt.event.content.biz.usecase.out;
|
package com.kt.event.content.biz.usecase.out;
|
||||||
|
|
||||||
import com.kt.event.content.biz.domain.Job;
|
import com.kt.event.content.biz.domain.Job;
|
||||||
|
import com.kt.event.content.biz.dto.RedisJobData;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job 조회 포트
|
* Job 조회 Port (Output Port)
|
||||||
*/
|
*/
|
||||||
public interface JobReader {
|
public interface JobReader {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job ID로 조회
|
* Job 조회
|
||||||
*
|
*
|
||||||
* @param jobId Job ID
|
* @param jobId Job ID
|
||||||
* @return Job 도메인 모델
|
* @return Job 데이터
|
||||||
|
*/
|
||||||
|
Optional<RedisJobData> getJob(String jobId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 호환성을 위한 메서드 (Phase 3에서 삭제 예정)
|
||||||
|
* JPA 기반 JobGateway에서만 사용
|
||||||
|
*
|
||||||
|
* @param jobId Job ID
|
||||||
|
* @return Job 도메인 객체
|
||||||
*/
|
*/
|
||||||
Optional<Job> findById(String jobId);
|
Optional<Job> findById(String jobId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,51 @@
|
|||||||
package com.kt.event.content.biz.usecase.out;
|
package com.kt.event.content.biz.usecase.out;
|
||||||
|
|
||||||
import com.kt.event.content.biz.domain.Job;
|
import com.kt.event.content.biz.domain.Job;
|
||||||
|
import com.kt.event.content.biz.dto.RedisJobData;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job 저장 포트
|
* Job 저장 Port (Output Port)
|
||||||
*/
|
*/
|
||||||
public interface JobWriter {
|
public interface JobWriter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job 저장
|
* Job 생성/저장
|
||||||
*
|
*
|
||||||
* @param job 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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 호환성을 위한 메서드 (Phase 3에서 삭제 예정)
|
||||||
|
* JPA 기반 JobGateway에서만 사용
|
||||||
|
*
|
||||||
|
* @param job Job 도메인 객체
|
||||||
* @return 저장된 Job
|
* @return 저장된 Job
|
||||||
*/
|
*/
|
||||||
Job save(Job job);
|
Job save(Job job);
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
package com.kt.event.content.infra.gateway;
|
package com.kt.event.content.infra.gateway;
|
||||||
|
|
||||||
import com.kt.event.content.biz.domain.Job;
|
import com.kt.event.content.biz.domain.Job;
|
||||||
|
import com.kt.event.content.biz.dto.RedisJobData;
|
||||||
import com.kt.event.content.biz.usecase.out.JobReader;
|
import com.kt.event.content.biz.usecase.out.JobReader;
|
||||||
import com.kt.event.content.biz.usecase.out.JobWriter;
|
import com.kt.event.content.biz.usecase.out.JobWriter;
|
||||||
import com.kt.event.content.infra.gateway.entity.JobEntity;
|
import com.kt.event.content.infra.gateway.entity.JobEntity;
|
||||||
import com.kt.event.content.infra.gateway.repository.JobJpaRepository;
|
import com.kt.event.content.infra.gateway.repository.JobJpaRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@ -17,9 +19,11 @@ import java.util.stream.Collectors;
|
|||||||
/**
|
/**
|
||||||
* Job 영속성 Gateway
|
* Job 영속성 Gateway
|
||||||
* JobReader, JobWriter outbound port 구현
|
* JobReader, JobWriter outbound port 구현
|
||||||
|
* Phase 3에서 삭제 예정
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
|
@Primary
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class JobGateway implements JobReader, JobWriter {
|
public class JobGateway implements JobReader, JobWriter {
|
||||||
|
|
||||||
@ -31,6 +35,26 @@ public class JobGateway implements JobReader, JobWriter {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<RedisJobData> getJob(String jobId) {
|
||||||
|
log.debug("[JPA] Job 조회: jobId={}", jobId);
|
||||||
|
return jobRepository.findById(jobId)
|
||||||
|
.map(entity -> RedisJobData.builder()
|
||||||
|
.id(entity.getId())
|
||||||
|
.eventDraftId(entity.getEventDraftId())
|
||||||
|
.jobType(entity.getJobType())
|
||||||
|
.status(entity.getStatus().name())
|
||||||
|
.progress(entity.getProgress())
|
||||||
|
.resultMessage(entity.getResultMessage())
|
||||||
|
.errorMessage(entity.getErrorMessage())
|
||||||
|
.createdAt(entity.getCreatedAt())
|
||||||
|
.updatedAt(entity.getUpdatedAt())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 호환성을 위한 메서드 (Phase 3에서 삭제 예정)
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
public Optional<Job> findById(String jobId) {
|
public Optional<Job> findById(String jobId) {
|
||||||
log.debug("Job 조회: jobId={}", jobId);
|
log.debug("Job 조회: jobId={}", jobId);
|
||||||
return jobRepository.findById(jobId)
|
return jobRepository.findById(jobId)
|
||||||
@ -64,6 +88,64 @@ public class JobGateway implements JobReader, JobWriter {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
|
public void saveJob(RedisJobData jobData, long ttlSeconds) {
|
||||||
|
log.debug("[JPA] Job 저장: jobId={}, status={}, ttl={}초", jobData.getId(), jobData.getStatus(), ttlSeconds);
|
||||||
|
|
||||||
|
JobEntity entity = jobRepository.findById(jobData.getId())
|
||||||
|
.orElseGet(() -> JobEntity.create(
|
||||||
|
jobData.getId(),
|
||||||
|
jobData.getEventDraftId(),
|
||||||
|
jobData.getJobType()
|
||||||
|
));
|
||||||
|
|
||||||
|
// Job 상태 업데이트
|
||||||
|
entity.updateStatus(Job.Status.valueOf(jobData.getStatus()), jobData.getProgress());
|
||||||
|
if (jobData.getResultMessage() != null) {
|
||||||
|
entity.setResultMessage(jobData.getResultMessage());
|
||||||
|
}
|
||||||
|
if (jobData.getErrorMessage() != null) {
|
||||||
|
entity.setErrorMessage(jobData.getErrorMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
jobRepository.save(entity);
|
||||||
|
// Note: TTL은 JPA에서는 무시됨 (Redis 전용)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void updateJobStatus(String jobId, String status, Integer progress) {
|
||||||
|
log.debug("[JPA] Job 상태 업데이트: jobId={}, status={}, progress={}", jobId, status, progress);
|
||||||
|
jobRepository.findById(jobId).ifPresent(entity -> {
|
||||||
|
entity.updateStatus(Job.Status.valueOf(status), progress);
|
||||||
|
jobRepository.save(entity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void updateJobResult(String jobId, String resultMessage) {
|
||||||
|
log.debug("[JPA] Job 결과 업데이트: jobId={}, resultMessage={}", jobId, resultMessage);
|
||||||
|
jobRepository.findById(jobId).ifPresent(entity -> {
|
||||||
|
entity.setResultMessage(resultMessage);
|
||||||
|
jobRepository.save(entity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void updateJobError(String jobId, String errorMessage) {
|
||||||
|
log.debug("[JPA] Job 에러 업데이트: jobId={}, errorMessage={}", jobId, errorMessage);
|
||||||
|
jobRepository.findById(jobId).ifPresent(entity -> {
|
||||||
|
entity.setErrorMessage(errorMessage);
|
||||||
|
entity.updateStatus(Job.Status.FAILED, entity.getProgress());
|
||||||
|
jobRepository.save(entity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 호환성을 위한 메서드 (Phase 3에서 삭제 예정)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
public Job save(Job job) {
|
public Job save(Job job) {
|
||||||
log.debug("Job 저장: jobId={}, status={}", job.getId(), job.getStatus());
|
log.debug("Job 저장: jobId={}, status={}", job.getId(), job.getStatus());
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,15 @@ package com.kt.event.content.infra.gateway;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.kt.event.content.biz.domain.GeneratedImage;
|
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.RedisImageData;
|
||||||
|
import com.kt.event.content.biz.dto.RedisJobData;
|
||||||
|
import com.kt.event.content.biz.usecase.out.ImageReader;
|
||||||
|
import com.kt.event.content.biz.usecase.out.ImageWriter;
|
||||||
|
import com.kt.event.content.biz.usecase.out.JobReader;
|
||||||
|
import com.kt.event.content.biz.usecase.out.JobWriter;
|
||||||
import com.kt.event.content.biz.usecase.out.RedisAIDataReader;
|
import com.kt.event.content.biz.usecase.out.RedisAIDataReader;
|
||||||
import com.kt.event.content.biz.usecase.out.RedisImageWriter;
|
import com.kt.event.content.biz.usecase.out.RedisImageWriter;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@ -11,9 +20,12 @@ import org.springframework.data.redis.core.RedisTemplate;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redis Gateway 구현체 (Production 환경용)
|
* Redis Gateway 구현체 (Production 환경용)
|
||||||
@ -24,7 +36,7 @@ import java.util.Optional;
|
|||||||
@Component
|
@Component
|
||||||
@Profile({"!local", "!test"})
|
@Profile({"!local", "!test"})
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class RedisGateway implements RedisAIDataReader, RedisImageWriter {
|
public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageWriter, ImageReader, JobWriter, JobReader {
|
||||||
|
|
||||||
private final RedisTemplate<String, Object> redisTemplate;
|
private final RedisTemplate<String, Object> redisTemplate;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
@ -92,4 +104,287 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter {
|
|||||||
log.error("AI 이벤트 데이터 캐시 삭제 실패: eventDraftId={}", eventDraftId, e);
|
log.error("AI 이벤트 데이터 캐시 삭제 실패: eventDraftId={}", eventDraftId, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 이미지 CRUD ====================
|
||||||
|
|
||||||
|
private static final String IMAGE_KEY_PREFIX = "content:image:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 저장
|
||||||
|
* Key: content:image:{eventDraftId}:{style}:{platform}
|
||||||
|
*/
|
||||||
|
public void saveImage(RedisImageData imageData, long ttlSeconds) {
|
||||||
|
try {
|
||||||
|
String key = buildImageKey(imageData.getEventDraftId(), imageData.getStyle(), imageData.getPlatform());
|
||||||
|
String json = objectMapper.writeValueAsString(imageData);
|
||||||
|
redisTemplate.opsForValue().set(key, json, Duration.ofSeconds(ttlSeconds));
|
||||||
|
log.info("이미지 저장 완료: key={}, ttl={}초", key, ttlSeconds);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("이미지 저장 실패: eventDraftId={}, style={}, platform={}",
|
||||||
|
imageData.getEventDraftId(), imageData.getStyle(), imageData.getPlatform(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 이미지 조회
|
||||||
|
*/
|
||||||
|
public Optional<RedisImageData> getImage(Long eventDraftId, ImageStyle style, Platform platform) {
|
||||||
|
try {
|
||||||
|
String key = buildImageKey(eventDraftId, style, platform);
|
||||||
|
Object data = redisTemplate.opsForValue().get(key);
|
||||||
|
|
||||||
|
if (data == null) {
|
||||||
|
log.warn("이미지를 찾을 수 없음: key={}", key);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
RedisImageData imageData = objectMapper.readValue(data.toString(), RedisImageData.class);
|
||||||
|
return Optional.of(imageData);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("이미지 조회 실패: eventDraftId={}, style={}, platform={}", eventDraftId, style, platform, e);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트의 모든 이미지 조회
|
||||||
|
*/
|
||||||
|
public List<RedisImageData> getImagesByEventId(Long eventDraftId) {
|
||||||
|
try {
|
||||||
|
String pattern = IMAGE_KEY_PREFIX + eventDraftId + ":*";
|
||||||
|
var keys = redisTemplate.keys(pattern);
|
||||||
|
|
||||||
|
if (keys == null || keys.isEmpty()) {
|
||||||
|
log.warn("이벤트 이미지를 찾을 수 없음: eventDraftId={}", eventDraftId);
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RedisImageData> images = new ArrayList<>();
|
||||||
|
for (Object key : keys) {
|
||||||
|
Object data = redisTemplate.opsForValue().get(key);
|
||||||
|
if (data != null) {
|
||||||
|
RedisImageData imageData = objectMapper.readValue(data.toString(), RedisImageData.class);
|
||||||
|
images.add(imageData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("이벤트 이미지 조회 완료: eventDraftId={}, count={}", eventDraftId, images.size());
|
||||||
|
return images;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("이벤트 이미지 조회 실패: eventDraftId={}", eventDraftId, e);
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 삭제
|
||||||
|
*/
|
||||||
|
public void deleteImage(Long eventDraftId, ImageStyle style, Platform platform) {
|
||||||
|
try {
|
||||||
|
String key = buildImageKey(eventDraftId, style, platform);
|
||||||
|
redisTemplate.delete(key);
|
||||||
|
log.info("이미지 삭제 완료: key={}", key);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("이미지 삭제 실패: eventDraftId={}, style={}, platform={}", eventDraftId, style, platform, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 이미지 저장
|
||||||
|
*/
|
||||||
|
public void saveImages(Long eventDraftId, List<RedisImageData> images, long ttlSeconds) {
|
||||||
|
images.forEach(image -> saveImage(image, ttlSeconds));
|
||||||
|
log.info("여러 이미지 저장 완료: eventDraftId={}, count={}", eventDraftId, images.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 Key 생성
|
||||||
|
*/
|
||||||
|
private String buildImageKey(Long eventDraftId, ImageStyle style, Platform platform) {
|
||||||
|
return IMAGE_KEY_PREFIX + eventDraftId + ":" + style.name() + ":" + platform.name();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Job 상태 관리 ====================
|
||||||
|
|
||||||
|
private static final String JOB_KEY_PREFIX = "job:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 생성/저장
|
||||||
|
* Key: job:{jobId}
|
||||||
|
*/
|
||||||
|
public void saveJob(RedisJobData jobData, long ttlSeconds) {
|
||||||
|
try {
|
||||||
|
String key = JOB_KEY_PREFIX + jobData.getId();
|
||||||
|
|
||||||
|
// Hash 형태로 저장
|
||||||
|
Map<String, String> jobFields = Map.of(
|
||||||
|
"id", jobData.getId(),
|
||||||
|
"eventDraftId", String.valueOf(jobData.getEventDraftId()),
|
||||||
|
"jobType", jobData.getJobType(),
|
||||||
|
"status", jobData.getStatus(),
|
||||||
|
"progress", String.valueOf(jobData.getProgress()),
|
||||||
|
"resultMessage", jobData.getResultMessage() != null ? jobData.getResultMessage() : "",
|
||||||
|
"errorMessage", jobData.getErrorMessage() != null ? jobData.getErrorMessage() : "",
|
||||||
|
"createdAt", jobData.getCreatedAt().toString(),
|
||||||
|
"updatedAt", jobData.getUpdatedAt().toString()
|
||||||
|
);
|
||||||
|
|
||||||
|
redisTemplate.opsForHash().putAll(key, jobFields);
|
||||||
|
redisTemplate.expire(key, Duration.ofSeconds(ttlSeconds));
|
||||||
|
|
||||||
|
log.info("Job 저장 완료: jobId={}, status={}, ttl={}초", jobData.getId(), jobData.getStatus(), ttlSeconds);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Job 저장 실패: jobId={}", jobData.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 조회
|
||||||
|
*/
|
||||||
|
public Optional<RedisJobData> getJob(String jobId) {
|
||||||
|
try {
|
||||||
|
String key = JOB_KEY_PREFIX + jobId;
|
||||||
|
Map<Object, Object> jobFields = redisTemplate.opsForHash().entries(key);
|
||||||
|
|
||||||
|
if (jobFields.isEmpty()) {
|
||||||
|
log.warn("Job을 찾을 수 없음: jobId={}", jobId);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
RedisJobData jobData = RedisJobData.builder()
|
||||||
|
.id(getString(jobFields, "id"))
|
||||||
|
.eventDraftId(getLong(jobFields, "eventDraftId"))
|
||||||
|
.jobType(getString(jobFields, "jobType"))
|
||||||
|
.status(getString(jobFields, "status"))
|
||||||
|
.progress(getInteger(jobFields, "progress"))
|
||||||
|
.resultMessage(getString(jobFields, "resultMessage"))
|
||||||
|
.errorMessage(getString(jobFields, "errorMessage"))
|
||||||
|
.createdAt(getLocalDateTime(jobFields, "createdAt"))
|
||||||
|
.updatedAt(getLocalDateTime(jobFields, "updatedAt"))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return Optional.of(jobData);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Job 조회 실패: jobId={}", jobId, e);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 상태 업데이트
|
||||||
|
*/
|
||||||
|
public void updateJobStatus(String jobId, String status, Integer progress) {
|
||||||
|
try {
|
||||||
|
String key = JOB_KEY_PREFIX + jobId;
|
||||||
|
redisTemplate.opsForHash().put(key, "status", status);
|
||||||
|
redisTemplate.opsForHash().put(key, "progress", String.valueOf(progress));
|
||||||
|
redisTemplate.opsForHash().put(key, "updatedAt", LocalDateTime.now().toString());
|
||||||
|
|
||||||
|
log.info("Job 상태 업데이트: jobId={}, status={}, progress={}", jobId, status, progress);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Job 상태 업데이트 실패: jobId={}", jobId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 결과 메시지 업데이트
|
||||||
|
*/
|
||||||
|
public void updateJobResult(String jobId, String resultMessage) {
|
||||||
|
try {
|
||||||
|
String key = JOB_KEY_PREFIX + jobId;
|
||||||
|
redisTemplate.opsForHash().put(key, "resultMessage", resultMessage);
|
||||||
|
redisTemplate.opsForHash().put(key, "updatedAt", LocalDateTime.now().toString());
|
||||||
|
|
||||||
|
log.info("Job 결과 업데이트: jobId={}, resultMessage={}", jobId, resultMessage);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Job 결과 업데이트 실패: jobId={}", jobId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 에러 메시지 업데이트
|
||||||
|
*/
|
||||||
|
public void updateJobError(String jobId, String errorMessage) {
|
||||||
|
try {
|
||||||
|
String key = JOB_KEY_PREFIX + jobId;
|
||||||
|
redisTemplate.opsForHash().put(key, "errorMessage", errorMessage);
|
||||||
|
redisTemplate.opsForHash().put(key, "status", "FAILED");
|
||||||
|
redisTemplate.opsForHash().put(key, "updatedAt", LocalDateTime.now().toString());
|
||||||
|
|
||||||
|
log.info("Job 에러 업데이트: jobId={}, errorMessage={}", jobId, errorMessage);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Job 에러 업데이트 실패: jobId={}", jobId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 호환성을 위한 메서드 (Phase 3에서 삭제 예정)
|
||||||
|
* Job 도메인 객체를 RedisJobData로 변환하여 저장
|
||||||
|
*
|
||||||
|
* @param job Job 도메인 객체
|
||||||
|
* @return 저장된 Job
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Job save(Job job) {
|
||||||
|
log.debug("[Redis] Job 저장 (호환성): jobId={}, status={}", job.getId(), job.getStatus());
|
||||||
|
|
||||||
|
RedisJobData jobData = RedisJobData.builder()
|
||||||
|
.id(job.getId())
|
||||||
|
.eventDraftId(job.getEventDraftId())
|
||||||
|
.jobType(job.getJobType())
|
||||||
|
.status(job.getStatus().name())
|
||||||
|
.progress(job.getProgress())
|
||||||
|
.resultMessage(job.getResultMessage())
|
||||||
|
.errorMessage(job.getErrorMessage())
|
||||||
|
.createdAt(job.getCreatedAt())
|
||||||
|
.updatedAt(job.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
saveJob(jobData, 86400); // 24시간 TTL
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 호환성을 위한 메서드 (Phase 3에서 삭제 예정)
|
||||||
|
*
|
||||||
|
* @param jobId Job ID
|
||||||
|
* @return Job 도메인 객체
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Optional<Job> findById(String jobId) {
|
||||||
|
log.debug("[Redis] Job 조회 (호환성): jobId={}", jobId);
|
||||||
|
return getJob(jobId).map(data -> Job.builder()
|
||||||
|
.id(data.getId())
|
||||||
|
.eventDraftId(data.getEventDraftId())
|
||||||
|
.jobType(data.getJobType())
|
||||||
|
.status(Job.Status.valueOf(data.getStatus()))
|
||||||
|
.progress(data.getProgress())
|
||||||
|
.resultMessage(data.getResultMessage())
|
||||||
|
.errorMessage(data.getErrorMessage())
|
||||||
|
.createdAt(data.getCreatedAt())
|
||||||
|
.updatedAt(data.getUpdatedAt())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Helper Methods ====================
|
||||||
|
|
||||||
|
private String getString(Map<Object, Object> map, String key) {
|
||||||
|
Object value = map.get(key);
|
||||||
|
return value != null ? value.toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long getLong(Map<Object, Object> map, String key) {
|
||||||
|
String value = getString(map, key);
|
||||||
|
return value != null && !value.isEmpty() ? Long.parseLong(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer getInteger(Map<Object, Object> map, String key) {
|
||||||
|
String value = getString(map, key);
|
||||||
|
return value != null && !value.isEmpty() ? Integer.parseInt(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDateTime getLocalDateTime(Map<Object, Object> map, String key) {
|
||||||
|
String value = getString(map, key);
|
||||||
|
return value != null && !value.isEmpty() ? LocalDateTime.parse(value) : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,29 @@
|
|||||||
package com.kt.event.content.infra.gateway.mock;
|
package com.kt.event.content.infra.gateway.mock;
|
||||||
|
|
||||||
import com.kt.event.content.biz.domain.GeneratedImage;
|
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.RedisImageData;
|
||||||
|
import com.kt.event.content.biz.dto.RedisJobData;
|
||||||
|
import com.kt.event.content.biz.usecase.out.ImageReader;
|
||||||
|
import com.kt.event.content.biz.usecase.out.ImageWriter;
|
||||||
|
import com.kt.event.content.biz.usecase.out.JobReader;
|
||||||
|
import com.kt.event.content.biz.usecase.out.JobWriter;
|
||||||
import com.kt.event.content.biz.usecase.out.RedisAIDataReader;
|
import com.kt.event.content.biz.usecase.out.RedisAIDataReader;
|
||||||
import com.kt.event.content.biz.usecase.out.RedisImageWriter;
|
import com.kt.event.content.biz.usecase.out.RedisImageWriter;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock Redis Gateway (테스트용)
|
* Mock Redis Gateway (테스트용)
|
||||||
@ -19,10 +32,14 @@ import java.util.Optional;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@Profile({"local", "test"})
|
@Profile({"local", "test"})
|
||||||
public class MockRedisGateway implements RedisAIDataReader, RedisImageWriter {
|
public class MockRedisGateway implements RedisAIDataReader, RedisImageWriter, ImageWriter, ImageReader, JobWriter, JobReader {
|
||||||
|
|
||||||
private final Map<Long, Map<String, Object>> aiDataCache = new HashMap<>();
|
private final Map<Long, Map<String, Object>> aiDataCache = new HashMap<>();
|
||||||
|
|
||||||
|
// In-memory storage for images and jobs
|
||||||
|
private final Map<String, RedisImageData> imageStorage = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, RedisJobData> jobStorage = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// RedisAIDataReader 구현
|
// RedisAIDataReader 구현
|
||||||
// ========================================
|
// ========================================
|
||||||
@ -49,4 +66,245 @@ public class MockRedisGateway implements RedisAIDataReader, RedisImageWriter {
|
|||||||
log.info("[MOCK] Redis에 이미지 캐싱: eventDraftId={}, count={}, ttl={}초",
|
log.info("[MOCK] Redis에 이미지 캐싱: eventDraftId={}, count={}, ttl={}초",
|
||||||
eventDraftId, images.size(), ttlSeconds);
|
eventDraftId, images.size(), ttlSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 이미지 CRUD ====================
|
||||||
|
|
||||||
|
private static final String IMAGE_KEY_PREFIX = "content:image:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 저장
|
||||||
|
*/
|
||||||
|
public void saveImage(RedisImageData imageData, long ttlSeconds) {
|
||||||
|
try {
|
||||||
|
String key = buildImageKey(imageData.getEventDraftId(), imageData.getStyle(), imageData.getPlatform());
|
||||||
|
imageStorage.put(key, imageData);
|
||||||
|
log.info("[MOCK] 이미지 저장 완료: key={}, ttl={}초", key, ttlSeconds);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[MOCK] 이미지 저장 실패: eventDraftId={}, style={}, platform={}",
|
||||||
|
imageData.getEventDraftId(), imageData.getStyle(), imageData.getPlatform(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 이미지 조회
|
||||||
|
*/
|
||||||
|
public Optional<RedisImageData> getImage(Long eventDraftId, ImageStyle style, Platform platform) {
|
||||||
|
try {
|
||||||
|
String key = buildImageKey(eventDraftId, style, platform);
|
||||||
|
RedisImageData imageData = imageStorage.get(key);
|
||||||
|
|
||||||
|
if (imageData == null) {
|
||||||
|
log.warn("[MOCK] 이미지를 찾을 수 없음: key={}", key);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.of(imageData);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[MOCK] 이미지 조회 실패: eventDraftId={}, style={}, platform={}",
|
||||||
|
eventDraftId, style, platform, e);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트의 모든 이미지 조회
|
||||||
|
*/
|
||||||
|
public List<RedisImageData> getImagesByEventId(Long eventDraftId) {
|
||||||
|
try {
|
||||||
|
String pattern = IMAGE_KEY_PREFIX + eventDraftId + ":";
|
||||||
|
|
||||||
|
List<RedisImageData> images = imageStorage.entrySet().stream()
|
||||||
|
.filter(entry -> entry.getKey().startsWith(pattern))
|
||||||
|
.map(Map.Entry::getValue)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
log.info("[MOCK] 이벤트 이미지 조회 완료: eventDraftId={}, count={}", eventDraftId, images.size());
|
||||||
|
return images;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[MOCK] 이벤트 이미지 조회 실패: eventDraftId={}", eventDraftId, e);
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 삭제
|
||||||
|
*/
|
||||||
|
public void deleteImage(Long eventDraftId, ImageStyle style, Platform platform) {
|
||||||
|
try {
|
||||||
|
String key = buildImageKey(eventDraftId, style, platform);
|
||||||
|
imageStorage.remove(key);
|
||||||
|
log.info("[MOCK] 이미지 삭제 완료: key={}", key);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[MOCK] 이미지 삭제 실패: eventDraftId={}, style={}, platform={}",
|
||||||
|
eventDraftId, style, platform, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 이미지 저장
|
||||||
|
*/
|
||||||
|
public void saveImages(Long eventDraftId, List<RedisImageData> images, long ttlSeconds) {
|
||||||
|
images.forEach(image -> saveImage(image, ttlSeconds));
|
||||||
|
log.info("[MOCK] 여러 이미지 저장 완료: eventDraftId={}, count={}", eventDraftId, images.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 Key 생성
|
||||||
|
*/
|
||||||
|
private String buildImageKey(Long eventDraftId, ImageStyle style, Platform platform) {
|
||||||
|
return IMAGE_KEY_PREFIX + eventDraftId + ":" + style.name() + ":" + platform.name();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Job 상태 관리 ====================
|
||||||
|
|
||||||
|
private static final String JOB_KEY_PREFIX = "job:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 생성/저장
|
||||||
|
*/
|
||||||
|
public void saveJob(RedisJobData jobData, long ttlSeconds) {
|
||||||
|
try {
|
||||||
|
String key = JOB_KEY_PREFIX + jobData.getId();
|
||||||
|
jobStorage.put(key, jobData);
|
||||||
|
log.info("[MOCK] Job 저장 완료: jobId={}, status={}, ttl={}초",
|
||||||
|
jobData.getId(), jobData.getStatus(), ttlSeconds);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[MOCK] Job 저장 실패: jobId={}", jobData.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 조회
|
||||||
|
*/
|
||||||
|
public Optional<RedisJobData> getJob(String jobId) {
|
||||||
|
try {
|
||||||
|
String key = JOB_KEY_PREFIX + jobId;
|
||||||
|
RedisJobData jobData = jobStorage.get(key);
|
||||||
|
|
||||||
|
if (jobData == null) {
|
||||||
|
log.warn("[MOCK] Job을 찾을 수 없음: jobId={}", jobId);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.of(jobData);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[MOCK] Job 조회 실패: jobId={}", jobId, e);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 상태 업데이트
|
||||||
|
*/
|
||||||
|
public void updateJobStatus(String jobId, String status, Integer progress) {
|
||||||
|
try {
|
||||||
|
String key = JOB_KEY_PREFIX + jobId;
|
||||||
|
RedisJobData jobData = jobStorage.get(key);
|
||||||
|
|
||||||
|
if (jobData != null) {
|
||||||
|
jobData.setStatus(status);
|
||||||
|
jobData.setProgress(progress);
|
||||||
|
jobData.setUpdatedAt(LocalDateTime.now());
|
||||||
|
jobStorage.put(key, jobData);
|
||||||
|
log.info("[MOCK] Job 상태 업데이트: jobId={}, status={}, progress={}",
|
||||||
|
jobId, status, progress);
|
||||||
|
} else {
|
||||||
|
log.warn("[MOCK] Job을 찾을 수 없어 상태 업데이트 실패: jobId={}", jobId);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[MOCK] Job 상태 업데이트 실패: jobId={}", jobId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 결과 메시지 업데이트
|
||||||
|
*/
|
||||||
|
public void updateJobResult(String jobId, String resultMessage) {
|
||||||
|
try {
|
||||||
|
String key = JOB_KEY_PREFIX + jobId;
|
||||||
|
RedisJobData jobData = jobStorage.get(key);
|
||||||
|
|
||||||
|
if (jobData != null) {
|
||||||
|
jobData.setResultMessage(resultMessage);
|
||||||
|
jobData.setUpdatedAt(LocalDateTime.now());
|
||||||
|
jobStorage.put(key, jobData);
|
||||||
|
log.info("[MOCK] Job 결과 업데이트: jobId={}, resultMessage={}", jobId, resultMessage);
|
||||||
|
} else {
|
||||||
|
log.warn("[MOCK] Job을 찾을 수 없어 결과 업데이트 실패: jobId={}", jobId);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[MOCK] Job 결과 업데이트 실패: jobId={}", jobId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 에러 메시지 업데이트
|
||||||
|
*/
|
||||||
|
public void updateJobError(String jobId, String errorMessage) {
|
||||||
|
try {
|
||||||
|
String key = JOB_KEY_PREFIX + jobId;
|
||||||
|
RedisJobData jobData = jobStorage.get(key);
|
||||||
|
|
||||||
|
if (jobData != null) {
|
||||||
|
jobData.setErrorMessage(errorMessage);
|
||||||
|
jobData.setStatus("FAILED");
|
||||||
|
jobData.setUpdatedAt(LocalDateTime.now());
|
||||||
|
jobStorage.put(key, jobData);
|
||||||
|
log.info("[MOCK] Job 에러 업데이트: jobId={}, errorMessage={}", jobId, errorMessage);
|
||||||
|
} else {
|
||||||
|
log.warn("[MOCK] Job을 찾을 수 없어 에러 업데이트 실패: jobId={}", jobId);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[MOCK] Job 에러 업데이트 실패: jobId={}", jobId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 호환성을 위한 메서드 (Phase 3에서 삭제 예정)
|
||||||
|
* Job 도메인 객체를 RedisJobData로 변환하여 저장
|
||||||
|
*
|
||||||
|
* @param job Job 도메인 객체
|
||||||
|
* @return 저장된 Job
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Job save(Job job) {
|
||||||
|
log.debug("[MOCK] Job 저장 (호환성): jobId={}, status={}", job.getId(), job.getStatus());
|
||||||
|
|
||||||
|
RedisJobData jobData = RedisJobData.builder()
|
||||||
|
.id(job.getId())
|
||||||
|
.eventDraftId(job.getEventDraftId())
|
||||||
|
.jobType(job.getJobType())
|
||||||
|
.status(job.getStatus().name())
|
||||||
|
.progress(job.getProgress())
|
||||||
|
.resultMessage(job.getResultMessage())
|
||||||
|
.errorMessage(job.getErrorMessage())
|
||||||
|
.createdAt(job.getCreatedAt())
|
||||||
|
.updatedAt(job.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
saveJob(jobData, 86400); // 24시간 TTL
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 호환성을 위한 메서드 (Phase 3에서 삭제 예정)
|
||||||
|
*
|
||||||
|
* @param jobId Job ID
|
||||||
|
* @return Job 도메인 객체
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Optional<Job> findById(String jobId) {
|
||||||
|
log.debug("[MOCK] Job 조회 (호환성): jobId={}", jobId);
|
||||||
|
return getJob(jobId).map(data -> Job.builder()
|
||||||
|
.id(data.getId())
|
||||||
|
.eventDraftId(data.getEventDraftId())
|
||||||
|
.jobType(data.getJobType())
|
||||||
|
.status(Job.Status.valueOf(data.getStatus()))
|
||||||
|
.progress(data.getProgress())
|
||||||
|
.resultMessage(data.getResultMessage())
|
||||||
|
.errorMessage(data.getErrorMessage())
|
||||||
|
.createdAt(data.getCreatedAt())
|
||||||
|
.updatedAt(data.getUpdatedAt())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
785
develop/dev/content-service-modification-plan.md
Normal file
785
develop/dev/content-service-modification-plan.md
Normal file
@ -0,0 +1,785 @@
|
|||||||
|
# Content Service 아키텍처 수정 계획안
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
- **작성일**: 2025-10-24
|
||||||
|
- **작성자**: Backend Developer
|
||||||
|
- **대상 서비스**: Content Service
|
||||||
|
- **수정 사유**: 논리 아키텍처 설계 준수 (Redis 단독 저장소)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 현황 분석
|
||||||
|
|
||||||
|
### 1.1 논리 아키텍처 요구사항
|
||||||
|
|
||||||
|
**Content Service 핵심 책임** (논리 아키텍처 문서 기준):
|
||||||
|
- 3가지 스타일 SNS 이미지 자동 생성
|
||||||
|
- 플랫폼별 이미지 최적화
|
||||||
|
- 이미지 편집 기능
|
||||||
|
|
||||||
|
**데이터 저장 요구사항**:
|
||||||
|
```
|
||||||
|
데이터 저장:
|
||||||
|
- Redis: 이미지 생성 결과 (CDN URL, TTL 7일)
|
||||||
|
- CDN: 생성된 이미지 파일
|
||||||
|
```
|
||||||
|
|
||||||
|
**데이터 읽기 요구사항**:
|
||||||
|
```
|
||||||
|
데이터 읽기:
|
||||||
|
- Redis에서 AI Service가 저장한 이벤트 데이터 읽기
|
||||||
|
```
|
||||||
|
|
||||||
|
**캐시 구조** (논리 아키텍처 4.2절):
|
||||||
|
```
|
||||||
|
| 서비스 | 캐시 키 패턴 | 데이터 타입 | TTL | 예상 크기 |
|
||||||
|
|--------|-------------|-----------|-----|----------|
|
||||||
|
| Content | content:image:{이벤트ID}:{스타일} | String | 7일 | 0.2KB (URL) |
|
||||||
|
| AI | ai:event:{이벤트ID} | Hash | 24시간 | 10KB |
|
||||||
|
| AI/Content | job:{jobId} | Hash | 1시간 | 1KB |
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 현재 구현 문제점
|
||||||
|
|
||||||
|
**문제 1: RDB 사용**
|
||||||
|
- ❌ H2 In-Memory Database 사용 (Local)
|
||||||
|
- ❌ PostgreSQL 설정 (Production)
|
||||||
|
- ❌ Spring Data JPA 의존성 및 설정
|
||||||
|
|
||||||
|
**문제 2: JPA 엔티티 사용**
|
||||||
|
```java
|
||||||
|
// 현재 구현 (잘못됨)
|
||||||
|
@Entity
|
||||||
|
public class Content { ... }
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
public class GeneratedImage { ... }
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
public class Job { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**문제 3: JPA Repository 사용**
|
||||||
|
```java
|
||||||
|
// 현재 구현 (잘못됨)
|
||||||
|
public interface ContentRepository extends JpaRepository<Content, Long> { ... }
|
||||||
|
public interface GeneratedImageRepository extends JpaRepository<GeneratedImage, Long> { ... }
|
||||||
|
public interface JobRepository extends JpaRepository<Job, String> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**문제 4: application-local.yml 설정**
|
||||||
|
```yaml
|
||||||
|
# 현재 구현 (잘못됨)
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:h2:mem:contentdb
|
||||||
|
username: sa
|
||||||
|
password:
|
||||||
|
driver-class-name: org.h2.Driver
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
database-platform: org.hibernate.dialect.H2Dialect
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: create-drop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 올바른 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
[Client]
|
||||||
|
↓
|
||||||
|
[API Gateway]
|
||||||
|
↓
|
||||||
|
[Content Service]
|
||||||
|
├─→ [Redis] ← AI 이벤트 데이터 읽기
|
||||||
|
│ └─ content:image:{eventId}:{style} (이미지 URL 저장, TTL 7일)
|
||||||
|
│ └─ job:{jobId} (Job 상태, TTL 1시간)
|
||||||
|
│
|
||||||
|
└─→ [External Image API] (Stable Diffusion/DALL-E)
|
||||||
|
└─→ [Azure CDN] (이미지 파일 업로드)
|
||||||
|
```
|
||||||
|
|
||||||
|
**핵심 원칙**:
|
||||||
|
1. **Content Service는 Redis에만 데이터 저장**
|
||||||
|
2. **RDB (H2/PostgreSQL) 사용 안 함**
|
||||||
|
3. **JPA 사용 안 함**
|
||||||
|
4. **Redis는 캐시가 아닌 주 저장소로 사용**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 수정 계획
|
||||||
|
|
||||||
|
### 2.1 삭제 대상
|
||||||
|
|
||||||
|
#### 2.1.1 Entity 파일 (3개)
|
||||||
|
```
|
||||||
|
content-service/src/main/java/com/kt/event/content/biz/domain/
|
||||||
|
├─ Content.java ← 삭제
|
||||||
|
├─ GeneratedImage.java ← 삭제
|
||||||
|
└─ Job.java ← 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.1.2 Repository 파일 (3개)
|
||||||
|
```
|
||||||
|
content-service/src/main/java/com/kt/event/content/biz/usecase/out/
|
||||||
|
├─ ContentRepository.java ← 삭제 (또는 이름만 남기고 인터페이스 변경)
|
||||||
|
├─ GeneratedImageRepository.java ← 삭제
|
||||||
|
└─ JobRepository.java ← 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.1.3 JPA Adapter 파일 (있다면)
|
||||||
|
```
|
||||||
|
content-service/src/main/java/com/kt/event/content/infra/adapter/
|
||||||
|
└─ *JpaAdapter.java ← 모두 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.1.4 설정 파일 수정
|
||||||
|
- `application-local.yml`: H2, JPA 설정 제거
|
||||||
|
- `application.yml`: PostgreSQL 설정 제거
|
||||||
|
- `build.gradle`: JPA, H2, PostgreSQL 의존성 제거
|
||||||
|
|
||||||
|
### 2.2 생성/수정 대상
|
||||||
|
|
||||||
|
#### 2.2.1 Redis 데이터 모델 (DTO)
|
||||||
|
|
||||||
|
**파일 위치**: `content-service/src/main/java/com/kt/event/content/biz/dto/`
|
||||||
|
|
||||||
|
**1) RedisImageData.java** (새로 생성)
|
||||||
|
```java
|
||||||
|
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: content:image:{eventDraftId}:{style}:{platform}
|
||||||
|
* Type: String (JSON)
|
||||||
|
* TTL: 7일
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class RedisImageData {
|
||||||
|
private Long id; // 이미지 고유 ID
|
||||||
|
private Long eventDraftId; // 이벤트 초안 ID
|
||||||
|
private ImageStyle style; // 이미지 스타일 (FANCY, SIMPLE, TRENDY)
|
||||||
|
private Platform platform; // 플랫폼 (INSTAGRAM, KAKAO, NAVER)
|
||||||
|
private String cdnUrl; // CDN 이미지 URL
|
||||||
|
private String prompt; // 이미지 생성 프롬프트
|
||||||
|
private Boolean selected; // 선택 여부
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2) RedisJobData.java** (새로 생성)
|
||||||
|
```java
|
||||||
|
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: job:{jobId}
|
||||||
|
* Type: Hash
|
||||||
|
* TTL: 1시간
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class RedisJobData {
|
||||||
|
private String id; // Job ID (예: job-mock-7ada8bd3)
|
||||||
|
private Long eventDraftId; // 이벤트 초안 ID
|
||||||
|
private String jobType; // Job 타입 (image-generation, image-regeneration)
|
||||||
|
private String status; // 상태 (PENDING, IN_PROGRESS, COMPLETED, FAILED)
|
||||||
|
private Integer progress; // 진행률 (0-100)
|
||||||
|
private String resultMessage; // 결과 메시지
|
||||||
|
private String errorMessage; // 에러 메시지
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3) RedisAIEventData.java** (새로 생성 - 읽기 전용)
|
||||||
|
```java
|
||||||
|
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: ai:event:{eventDraftId}
|
||||||
|
* Type: Hash
|
||||||
|
* TTL: 24시간
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class RedisAIEventData {
|
||||||
|
private Long eventDraftId;
|
||||||
|
private String eventTitle;
|
||||||
|
private String eventDescription;
|
||||||
|
private String targetAudience;
|
||||||
|
private String eventObjective;
|
||||||
|
private Map<String, Object> additionalData; // AI가 생성한 추가 데이터
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2.2 Redis Gateway 확장
|
||||||
|
|
||||||
|
**파일**: `content-service/src/main/java/com/kt/event/content/infra/gateway/RedisGateway.java`
|
||||||
|
|
||||||
|
**추가 메서드**:
|
||||||
|
```java
|
||||||
|
// 이미지 CRUD
|
||||||
|
void saveImage(RedisImageData imageData, long ttlSeconds);
|
||||||
|
Optional<RedisImageData> getImage(Long eventDraftId, ImageStyle style, Platform platform);
|
||||||
|
List<RedisImageData> getImagesByEventId(Long eventDraftId);
|
||||||
|
void deleteImage(Long eventDraftId, ImageStyle style, Platform platform);
|
||||||
|
|
||||||
|
// Job 상태 관리
|
||||||
|
void saveJob(RedisJobData jobData, long ttlSeconds);
|
||||||
|
Optional<RedisJobData> getJob(String jobId);
|
||||||
|
void updateJobStatus(String jobId, String status, Integer progress);
|
||||||
|
void updateJobResult(String jobId, String resultMessage);
|
||||||
|
void updateJobError(String jobId, String errorMessage);
|
||||||
|
|
||||||
|
// AI 이벤트 데이터 읽기 (이미 구현됨 - getAIRecommendation)
|
||||||
|
// Optional<Map<String, Object>> getAIRecommendation(Long eventDraftId);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2.3 MockRedisGateway 확장
|
||||||
|
|
||||||
|
**파일**: `content-service/src/main/java/com/kt/event/content/biz/service/mock/MockRedisGateway.java`
|
||||||
|
|
||||||
|
**추가 메서드**:
|
||||||
|
- 위의 RedisGateway와 동일한 메서드들을 In-Memory Map으로 구현
|
||||||
|
- Local/Test 환경에서 Redis 없이 테스트 가능
|
||||||
|
|
||||||
|
#### 2.2.4 Port Interface 수정
|
||||||
|
|
||||||
|
**파일**: `content-service/src/main/java/com/kt/event/content/biz/usecase/out/`
|
||||||
|
|
||||||
|
**1) ContentWriter.java 수정**
|
||||||
|
```java
|
||||||
|
package com.kt.event.content.biz.usecase.out;
|
||||||
|
|
||||||
|
import com.kt.event.content.biz.dto.RedisImageData;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content 저장 Port (Redis 기반)
|
||||||
|
*/
|
||||||
|
public interface ContentWriter {
|
||||||
|
// 이미지 저장 (Redis)
|
||||||
|
void saveImage(RedisImageData imageData, long ttlSeconds);
|
||||||
|
|
||||||
|
// 이미지 삭제 (Redis)
|
||||||
|
void deleteImage(Long eventDraftId, String style, String platform);
|
||||||
|
|
||||||
|
// 여러 이미지 저장 (Redis)
|
||||||
|
void saveImages(Long eventDraftId, List<RedisImageData> images, long ttlSeconds);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2) ContentReader.java 수정**
|
||||||
|
```java
|
||||||
|
package com.kt.event.content.biz.usecase.out;
|
||||||
|
|
||||||
|
import com.kt.event.content.biz.dto.RedisImageData;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content 조회 Port (Redis 기반)
|
||||||
|
*/
|
||||||
|
public interface ContentReader {
|
||||||
|
// 특정 이미지 조회 (Redis)
|
||||||
|
Optional<RedisImageData> getImage(Long eventDraftId, String style, String platform);
|
||||||
|
|
||||||
|
// 이벤트의 모든 이미지 조회 (Redis)
|
||||||
|
List<RedisImageData> getImagesByEventId(Long eventDraftId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3) JobWriter.java 수정**
|
||||||
|
```java
|
||||||
|
package com.kt.event.content.biz.usecase.out;
|
||||||
|
|
||||||
|
import com.kt.event.content.biz.dto.RedisJobData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 상태 저장 Port (Redis 기반)
|
||||||
|
*/
|
||||||
|
public interface JobWriter {
|
||||||
|
// Job 생성 (Redis)
|
||||||
|
void saveJob(RedisJobData jobData, long ttlSeconds);
|
||||||
|
|
||||||
|
// Job 상태 업데이트 (Redis)
|
||||||
|
void updateJobStatus(String jobId, String status, Integer progress);
|
||||||
|
|
||||||
|
// Job 결과 업데이트 (Redis)
|
||||||
|
void updateJobResult(String jobId, String resultMessage);
|
||||||
|
|
||||||
|
// Job 에러 업데이트 (Redis)
|
||||||
|
void updateJobError(String jobId, String errorMessage);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4) JobReader.java 수정**
|
||||||
|
```java
|
||||||
|
package com.kt.event.content.biz.usecase.out;
|
||||||
|
|
||||||
|
import com.kt.event.content.biz.dto.RedisJobData;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 상태 조회 Port (Redis 기반)
|
||||||
|
*/
|
||||||
|
public interface JobReader {
|
||||||
|
// Job 조회 (Redis)
|
||||||
|
Optional<RedisJobData> getJob(String jobId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2.5 Service Layer 수정
|
||||||
|
|
||||||
|
**파일**: `content-service/src/main/java/com/kt/event/content/biz/service/`
|
||||||
|
|
||||||
|
**주요 변경사항**:
|
||||||
|
1. JPA Repository 의존성 제거
|
||||||
|
2. RedisGateway 사용으로 변경
|
||||||
|
3. 도메인 Entity → DTO 변환 로직 추가
|
||||||
|
|
||||||
|
**예시: ContentServiceImpl.java**
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ContentServiceImpl implements ContentService {
|
||||||
|
|
||||||
|
// ❌ 삭제: private final ContentRepository contentRepository;
|
||||||
|
// ✅ 추가: private final RedisGateway redisGateway;
|
||||||
|
|
||||||
|
private final ContentWriter contentWriter; // Redis 기반
|
||||||
|
private final ContentReader contentReader; // Redis 기반
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ImageInfo> getImagesByEventId(Long eventDraftId) {
|
||||||
|
List<RedisImageData> redisData = contentReader.getImagesByEventId(eventDraftId);
|
||||||
|
|
||||||
|
return redisData.stream()
|
||||||
|
.map(this::toImageInfo)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImageInfo toImageInfo(RedisImageData data) {
|
||||||
|
return ImageInfo.builder()
|
||||||
|
.id(data.getId())
|
||||||
|
.eventDraftId(data.getEventDraftId())
|
||||||
|
.style(data.getStyle())
|
||||||
|
.platform(data.getPlatform())
|
||||||
|
.cdnUrl(data.getCdnUrl())
|
||||||
|
.prompt(data.getPrompt())
|
||||||
|
.selected(data.getSelected())
|
||||||
|
.createdAt(data.getCreatedAt())
|
||||||
|
.updatedAt(data.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2.6 설정 파일 수정
|
||||||
|
|
||||||
|
**1) application-local.yml 수정 후**
|
||||||
|
```yaml
|
||||||
|
spring:
|
||||||
|
# ❌ 삭제: datasource, h2, jpa 설정
|
||||||
|
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
repositories:
|
||||||
|
enabled: false
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
|
||||||
|
autoconfigure:
|
||||||
|
exclude:
|
||||||
|
- org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
|
||||||
|
- org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: 8084
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.kt.event: DEBUG
|
||||||
|
```
|
||||||
|
|
||||||
|
**2) build.gradle 수정**
|
||||||
|
```gradle
|
||||||
|
dependencies {
|
||||||
|
// ❌ 삭제
|
||||||
|
// implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
|
// runtimeOnly 'com.h2database:h2'
|
||||||
|
// runtimeOnly 'org.postgresql:postgresql'
|
||||||
|
|
||||||
|
// ✅ 유지
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||||
|
implementation 'io.lettuce:lettuce-core'
|
||||||
|
|
||||||
|
// 기타 의존성 유지
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
|
compileOnly 'org.projectlombok:lombok'
|
||||||
|
annotationProcessor 'org.projectlombok:lombok'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Redis Key 구조 설계
|
||||||
|
|
||||||
|
### 3.1 이미지 데이터
|
||||||
|
|
||||||
|
**Key Pattern**: `content:image:{eventDraftId}:{style}:{platform}`
|
||||||
|
|
||||||
|
**예시**:
|
||||||
|
```
|
||||||
|
content:image:1:FANCY:INSTAGRAM
|
||||||
|
content:image:1:SIMPLE:KAKAO
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data Type**: String (JSON)
|
||||||
|
|
||||||
|
**Value 예시**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"eventDraftId": 1,
|
||||||
|
"style": "FANCY",
|
||||||
|
"platform": "INSTAGRAM",
|
||||||
|
"cdnUrl": "https://cdn.azure.com/images/1/fancy_instagram_7ada8bd3.png",
|
||||||
|
"prompt": "Mock prompt for FANCY style on INSTAGRAM platform",
|
||||||
|
"selected": true,
|
||||||
|
"createdAt": "2025-10-23T21:52:57.524759",
|
||||||
|
"updatedAt": "2025-10-23T21:52:57.524759"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TTL**: 7일 (604800초)
|
||||||
|
|
||||||
|
### 3.2 Job 상태
|
||||||
|
|
||||||
|
**Key Pattern**: `job:{jobId}`
|
||||||
|
|
||||||
|
**예시**:
|
||||||
|
```
|
||||||
|
job:job-mock-7ada8bd3
|
||||||
|
job:job-regen-df2bb3a3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data Type**: Hash
|
||||||
|
|
||||||
|
**Fields**:
|
||||||
|
```
|
||||||
|
id: "job-mock-7ada8bd3"
|
||||||
|
eventDraftId: "1"
|
||||||
|
jobType: "image-generation"
|
||||||
|
status: "COMPLETED"
|
||||||
|
progress: "100"
|
||||||
|
resultMessage: "4개의 이미지가 성공적으로 생성되었습니다."
|
||||||
|
errorMessage: null
|
||||||
|
createdAt: "2025-10-23T21:52:57.511438"
|
||||||
|
updatedAt: "2025-10-23T21:52:58.571923"
|
||||||
|
```
|
||||||
|
|
||||||
|
**TTL**: 1시간 (3600초)
|
||||||
|
|
||||||
|
### 3.3 AI 이벤트 데이터 (읽기 전용)
|
||||||
|
|
||||||
|
**Key Pattern**: `ai:event:{eventDraftId}`
|
||||||
|
|
||||||
|
**예시**:
|
||||||
|
```
|
||||||
|
ai:event:1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data Type**: Hash
|
||||||
|
|
||||||
|
**Fields** (AI Service가 저장):
|
||||||
|
```
|
||||||
|
eventDraftId: "1"
|
||||||
|
eventTitle: "Mock 이벤트 제목 1"
|
||||||
|
eventDescription: "Mock 이벤트 설명입니다."
|
||||||
|
targetAudience: "20-30대 여성"
|
||||||
|
eventObjective: "신규 고객 유치"
|
||||||
|
```
|
||||||
|
|
||||||
|
**TTL**: 24시간 (86400초)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 마이그레이션 전략
|
||||||
|
|
||||||
|
### 4.1 단계별 마이그레이션
|
||||||
|
|
||||||
|
**Phase 1: Redis 구현 추가** (기존 JPA 유지)
|
||||||
|
1. RedisImageData, RedisJobData DTO 생성
|
||||||
|
2. RedisGateway에 이미지/Job CRUD 메서드 추가
|
||||||
|
3. MockRedisGateway 확장
|
||||||
|
4. 단위 테스트 작성 및 검증
|
||||||
|
|
||||||
|
**Phase 2: Service Layer 전환**
|
||||||
|
1. 새로운 Port Interface 생성 (Redis 기반)
|
||||||
|
2. Service에서 Redis Port 사용하도록 수정
|
||||||
|
3. 통합 테스트로 기능 검증
|
||||||
|
|
||||||
|
**Phase 3: JPA 제거**
|
||||||
|
1. Entity, Repository, Adapter 파일 삭제
|
||||||
|
2. JPA 설정 및 의존성 제거
|
||||||
|
3. 전체 테스트 재실행
|
||||||
|
|
||||||
|
**Phase 4: 문서화 및 배포**
|
||||||
|
1. API 테스트 결과서 업데이트
|
||||||
|
2. 수정 내역 commit & push
|
||||||
|
3. Production 배포
|
||||||
|
|
||||||
|
### 4.2 롤백 전략
|
||||||
|
|
||||||
|
각 Phase마다 별도 branch 생성:
|
||||||
|
```
|
||||||
|
feature/content-redis-phase1
|
||||||
|
feature/content-redis-phase2
|
||||||
|
feature/content-redis-phase3
|
||||||
|
```
|
||||||
|
|
||||||
|
문제 발생 시 이전 Phase branch로 롤백 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 테스트 계획
|
||||||
|
|
||||||
|
### 5.1 단위 테스트
|
||||||
|
|
||||||
|
**RedisGatewayTest.java**:
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void saveAndGetImage_성공() {
|
||||||
|
// Given
|
||||||
|
RedisImageData imageData = RedisImageData.builder()
|
||||||
|
.id(1L)
|
||||||
|
.eventDraftId(1L)
|
||||||
|
.style(ImageStyle.FANCY)
|
||||||
|
.platform(Platform.INSTAGRAM)
|
||||||
|
.cdnUrl("https://cdn.azure.com/test.png")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// When
|
||||||
|
redisGateway.saveImage(imageData, 604800);
|
||||||
|
Optional<RedisImageData> result = redisGateway.getImage(1L, ImageStyle.FANCY, Platform.INSTAGRAM);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isPresent();
|
||||||
|
assertThat(result.get().getCdnUrl()).isEqualTo("https://cdn.azure.com/test.png");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 통합 테스트
|
||||||
|
|
||||||
|
**ContentServiceIntegrationTest.java**:
|
||||||
|
```java
|
||||||
|
@SpringBootTest
|
||||||
|
@Testcontainers
|
||||||
|
class ContentServiceIntegrationTest {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
static GenericContainer<?> redis = new GenericContainer<>("redis:7.2")
|
||||||
|
.withExposedPorts(6379);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 이미지_생성_및_조회_전체_플로우() {
|
||||||
|
// 1. AI 이벤트 데이터 Redis 저장 (Mock)
|
||||||
|
// 2. 이미지 생성 Job 요청
|
||||||
|
// 3. Job 상태 폴링
|
||||||
|
// 4. 이미지 조회
|
||||||
|
// 5. 검증
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 API 테스트
|
||||||
|
|
||||||
|
기존 test-backend.md의 7개 API 테스트 재실행:
|
||||||
|
1. POST /content/images/generate
|
||||||
|
2. GET /content/images/jobs/{jobId}
|
||||||
|
3. GET /content/events/{eventDraftId}
|
||||||
|
4. GET /content/events/{eventDraftId}/images
|
||||||
|
5. GET /content/images/{imageId}
|
||||||
|
6. POST /content/images/{imageId}/regenerate
|
||||||
|
7. DELETE /content/images/{imageId}
|
||||||
|
|
||||||
|
**예상 결과**: 모든 API 정상 동작 (Redis 기반)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 성능 및 용량 산정
|
||||||
|
|
||||||
|
### 6.1 Redis 메모리 사용량
|
||||||
|
|
||||||
|
**이미지 데이터**:
|
||||||
|
- 1개 이미지: 약 0.5KB (JSON)
|
||||||
|
- 1개 이벤트당 이미지: 최대 9개 (3 style × 3 platform)
|
||||||
|
- 1개 이벤트당 용량: 4.5KB
|
||||||
|
|
||||||
|
**Job 데이터**:
|
||||||
|
- 1개 Job: 약 1KB (Hash)
|
||||||
|
- 동시 처리 Job: 최대 50개
|
||||||
|
- Job 총 용량: 50KB
|
||||||
|
|
||||||
|
**예상 총 메모리**:
|
||||||
|
- 동시 이벤트 50개 × 4.5KB = 225KB
|
||||||
|
- Job 50KB
|
||||||
|
- 버퍼 (20%): 55KB
|
||||||
|
- **총 메모리**: 약 330KB (여유 충분)
|
||||||
|
|
||||||
|
### 6.2 TTL 전략
|
||||||
|
|
||||||
|
| 데이터 타입 | TTL | 이유 |
|
||||||
|
|------------|-----|------|
|
||||||
|
| 이미지 URL | 7일 (604800초) | 이벤트 기간 동안 재사용 |
|
||||||
|
| Job 상태 | 1시간 (3600초) | 완료 후 빠른 정리 |
|
||||||
|
| AI 이벤트 데이터 | 24시간 (86400초) | AI Service 관리 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 체크리스트
|
||||||
|
|
||||||
|
### 7.1 구현 체크리스트
|
||||||
|
|
||||||
|
- [ ] RedisImageData DTO 생성
|
||||||
|
- [ ] RedisJobData DTO 생성
|
||||||
|
- [ ] RedisAIEventData DTO 생성
|
||||||
|
- [ ] RedisGateway 이미지 CRUD 메서드 추가
|
||||||
|
- [ ] RedisGateway Job 상태 관리 메서드 추가
|
||||||
|
- [ ] MockRedisGateway 확장
|
||||||
|
- [ ] Port Interface 수정 (ContentWriter, ContentReader, JobWriter, JobReader)
|
||||||
|
- [ ] Service Layer JPA → Redis 전환
|
||||||
|
- [ ] JPA Entity 파일 삭제
|
||||||
|
- [ ] JPA Repository 파일 삭제
|
||||||
|
- [ ] application-local.yml H2/JPA 설정 제거
|
||||||
|
- [ ] build.gradle JPA/H2/PostgreSQL 의존성 제거
|
||||||
|
- [ ] 단위 테스트 작성
|
||||||
|
- [ ] 통합 테스트 작성
|
||||||
|
- [ ] API 테스트 재실행 (7개 엔드포인트)
|
||||||
|
|
||||||
|
### 7.2 검증 체크리스트
|
||||||
|
|
||||||
|
- [ ] Redis 연결 정상 동작 확인
|
||||||
|
- [ ] 이미지 저장/조회 정상 동작
|
||||||
|
- [ ] Job 상태 업데이트 정상 동작
|
||||||
|
- [ ] TTL 자동 만료 확인
|
||||||
|
- [ ] 모든 API 테스트 통과 (100%)
|
||||||
|
- [ ] 서버 기동 시 에러 없음
|
||||||
|
- [ ] JPA 관련 로그 완전히 사라짐
|
||||||
|
|
||||||
|
### 7.3 문서화 체크리스트
|
||||||
|
|
||||||
|
- [ ] 수정 계획안 작성 완료 (이 문서)
|
||||||
|
- [ ] API 테스트 결과서 업데이트
|
||||||
|
- [ ] Redis Key 구조 문서화
|
||||||
|
- [ ] 개발 가이드 업데이트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 예상 이슈 및 대응 방안
|
||||||
|
|
||||||
|
### 8.1 Redis 장애 시 대응
|
||||||
|
|
||||||
|
**문제**: Redis 서버 다운 시 서비스 중단
|
||||||
|
|
||||||
|
**대응 방안**:
|
||||||
|
- **Local/Test**: MockRedisGateway로 대체 (자동)
|
||||||
|
- **Production**: Redis Sentinel을 통한 자동 Failover
|
||||||
|
- **Circuit Breaker**: Redis 실패 시 임시 In-Memory 캐시 사용
|
||||||
|
|
||||||
|
### 8.2 TTL 만료 후 데이터 복구
|
||||||
|
|
||||||
|
**문제**: 이미지 URL이 TTL 만료로 삭제됨
|
||||||
|
|
||||||
|
**대응 방안**:
|
||||||
|
- **Event Service가 최종 승인 시**: Redis → Event DB 영구 저장 (논리 아키텍처 설계)
|
||||||
|
- **TTL 연장 API**: 필요 시 TTL 연장 가능한 API 제공
|
||||||
|
- **이미지 재생성 API**: 이미 구현되어 있음 (POST /content/images/{id}/regenerate)
|
||||||
|
|
||||||
|
### 8.3 ID 생성 전략
|
||||||
|
|
||||||
|
**문제**: RDB auto-increment 없이 ID 생성 필요
|
||||||
|
|
||||||
|
**대응 방안**:
|
||||||
|
- **이미지 ID**: Redis INCR 명령으로 순차 ID 생성
|
||||||
|
```
|
||||||
|
INCR content:image:id:counter
|
||||||
|
```
|
||||||
|
- **Job ID**: UUID 기반 (기존 방식 유지)
|
||||||
|
```java
|
||||||
|
String jobId = "job-mock-" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 결론
|
||||||
|
|
||||||
|
### 9.1 수정 필요성
|
||||||
|
|
||||||
|
Content Service는 논리 아키텍처 설계에 따라 **Redis를 주 저장소로 사용**해야 하며, RDB (H2/PostgreSQL)는 사용하지 않아야 합니다. 현재 구현은 설계와 불일치하므로 전면 수정이 필요합니다.
|
||||||
|
|
||||||
|
### 9.2 기대 효과
|
||||||
|
|
||||||
|
**아키텍처 준수**:
|
||||||
|
- ✅ 논리 아키텍처 설계 100% 준수
|
||||||
|
- ✅ Redis 단독 저장소 전략
|
||||||
|
- ✅ 불필요한 RDB 의존성 제거
|
||||||
|
|
||||||
|
**성능 개선**:
|
||||||
|
- ✅ 메모리 기반 Redis로 응답 속도 향상
|
||||||
|
- ✅ TTL 자동 만료로 메모리 관리 최적화
|
||||||
|
|
||||||
|
**운영 간소화**:
|
||||||
|
- ✅ Content Service DB 운영 불필요
|
||||||
|
- ✅ 백업/복구 절차 간소화
|
||||||
|
|
||||||
|
### 9.3 다음 단계
|
||||||
|
|
||||||
|
1. **승인 요청**: 이 수정 계획안 검토 및 승인
|
||||||
|
2. **Phase 1 착수**: Redis 구현 추가 (기존 코드 유지)
|
||||||
|
3. **단계별 진행**: Phase 1 → 2 → 3 순차 진행
|
||||||
|
4. **테스트 및 배포**: 각 Phase마다 검증 후 다음 단계 진행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**문서 버전**: 1.0
|
||||||
|
**최종 수정일**: 2025-10-24
|
||||||
|
**작성자**: Backend Developer
|
||||||
Loading…
x
Reference in New Issue
Block a user