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/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 index 976ff90..de6f982 100644 --- 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 @@ -1,19 +1,29 @@ package com.kt.event.content.biz.usecase.out; import com.kt.event.content.biz.domain.Job; +import com.kt.event.content.biz.dto.RedisJobData; import java.util.Optional; /** - * Job 조회 포트 + * Job 조회 Port (Output Port) */ public interface JobReader { /** - * Job ID로 조회 + * Job 조회 * * @param jobId Job ID - * @return Job 도메인 모델 + * @return Job 데이터 + */ + Optional getJob(String jobId); + + /** + * 기존 호환성을 위한 메서드 (Phase 3에서 삭제 예정) + * JPA 기반 JobGateway에서만 사용 + * + * @param jobId Job ID + * @return Job 도메인 객체 */ Optional findById(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 index b39404a..3286f4a 100644 --- 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 @@ -1,16 +1,51 @@ package com.kt.event.content.biz.usecase.out; import com.kt.event.content.biz.domain.Job; +import com.kt.event.content.biz.dto.RedisJobData; /** - * Job 저장 포트 + * Job 저장 Port (Output Port) */ 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 */ Job save(Job job); diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/JobGateway.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/JobGateway.java index f176cc1..54efe06 100644 --- a/content-service/src/main/java/com/kt/event/content/infra/gateway/JobGateway.java +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/JobGateway.java @@ -1,12 +1,14 @@ package com.kt.event.content.infra.gateway; 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.JobWriter; import com.kt.event.content.infra.gateway.entity.JobEntity; import com.kt.event.content.infra.gateway.repository.JobJpaRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Primary; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -17,9 +19,11 @@ import java.util.stream.Collectors; /** * Job 영속성 Gateway * JobReader, JobWriter outbound port 구현 + * Phase 3에서 삭제 예정 */ @Slf4j @Component +@Primary @RequiredArgsConstructor public class JobGateway implements JobReader, JobWriter { @@ -31,6 +35,26 @@ public class JobGateway implements JobReader, JobWriter { @Override @Transactional(readOnly = true) + public Optional 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 findById(String jobId) { log.debug("Job 조회: jobId={}", jobId); return jobRepository.findById(jobId) @@ -64,6 +88,64 @@ public class JobGateway implements JobReader, JobWriter { @Override @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) { log.debug("Job 저장: jobId={}, status={}", job.getId(), job.getStatus()); diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/RedisGateway.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/RedisGateway.java index cc9eef1..bcae8fb 100644 --- a/content-service/src/main/java/com/kt/event/content/infra/gateway/RedisGateway.java +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/RedisGateway.java @@ -2,6 +2,15 @@ package com.kt.event.content.infra.gateway; import com.fasterxml.jackson.databind.ObjectMapper; 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.RedisImageWriter; import lombok.RequiredArgsConstructor; @@ -11,9 +20,12 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; /** * Redis Gateway 구현체 (Production 환경용) @@ -24,7 +36,7 @@ import java.util.Optional; @Component @Profile({"!local", "!test"}) @RequiredArgsConstructor -public class RedisGateway implements RedisAIDataReader, RedisImageWriter { +public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageWriter, ImageReader, JobWriter, JobReader { private final RedisTemplate redisTemplate; private final ObjectMapper objectMapper; @@ -92,4 +104,287 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter { 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 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 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 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 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 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 getJob(String jobId) { + try { + String key = JOB_KEY_PREFIX + jobId; + Map 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 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 map, String key) { + Object value = map.get(key); + return value != null ? value.toString() : null; + } + + private Long getLong(Map map, String key) { + String value = getString(map, key); + return value != null && !value.isEmpty() ? Long.parseLong(value) : null; + } + + private Integer getInteger(Map map, String key) { + String value = getString(map, key); + return value != null && !value.isEmpty() ? Integer.parseInt(value) : null; + } + + private LocalDateTime getLocalDateTime(Map map, String key) { + String value = getString(map, key); + return value != null && !value.isEmpty() ? LocalDateTime.parse(value) : null; + } } diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/mock/MockRedisGateway.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/mock/MockRedisGateway.java index f4ef24b..dd09350 100644 --- a/content-service/src/main/java/com/kt/event/content/infra/gateway/mock/MockRedisGateway.java +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/mock/MockRedisGateway.java @@ -1,16 +1,29 @@ package com.kt.event.content.infra.gateway.mock; 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.RedisImageWriter; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; +import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; /** * Mock Redis Gateway (테스트용) @@ -19,10 +32,14 @@ import java.util.Optional; @Slf4j @Component @Profile({"local", "test"}) -public class MockRedisGateway implements RedisAIDataReader, RedisImageWriter { +public class MockRedisGateway implements RedisAIDataReader, RedisImageWriter, ImageWriter, ImageReader, JobWriter, JobReader { private final Map> aiDataCache = new HashMap<>(); + // In-memory storage for images and jobs + private final Map imageStorage = new ConcurrentHashMap<>(); + private final Map jobStorage = new ConcurrentHashMap<>(); + // ======================================== // RedisAIDataReader 구현 // ======================================== @@ -49,4 +66,245 @@ public class MockRedisGateway implements RedisAIDataReader, RedisImageWriter { log.info("[MOCK] Redis에 이미지 캐싱: eventDraftId={}, count={}, ttl={}초", 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 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 getImagesByEventId(Long eventDraftId) { + try { + String pattern = IMAGE_KEY_PREFIX + eventDraftId + ":"; + + List 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 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 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 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()); + } } diff --git a/develop/dev/content-service-modification-plan.md b/develop/dev/content-service-modification-plan.md new file mode 100644 index 0000000..18d3737 --- /dev/null +++ b/develop/dev/content-service-modification-plan.md @@ -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 { ... } +public interface GeneratedImageRepository extends JpaRepository { ... } +public interface JobRepository extends JpaRepository { ... } +``` + +**문제 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 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 getImage(Long eventDraftId, ImageStyle style, Platform platform); +List getImagesByEventId(Long eventDraftId); +void deleteImage(Long eventDraftId, ImageStyle style, Platform platform); + +// Job 상태 관리 +void saveJob(RedisJobData jobData, long ttlSeconds); +Optional 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> 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 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 getImage(Long eventDraftId, String style, String platform); + + // 이벤트의 모든 이미지 조회 (Redis) + List 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 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 getImagesByEventId(Long eventDraftId) { + List 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 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