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:
cherry2250
2025-10-24 10:14:54 +09:00
parent 6dc6334c75
commit 5e9e1759ce
11 changed files with 1742 additions and 8 deletions
@@ -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;
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<RedisJobData> getJob(String jobId);
/**
* 기존 호환성을 위한 메서드 (Phase 3에서 삭제 예정)
* JPA 기반 JobGateway에서만 사용
*
* @param jobId Job ID
* @return Job 도메인 객체
*/
Optional<Job> findById(String jobId);
}
@@ -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);
@@ -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<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) {
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());
@@ -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<String, Object> 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<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;
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<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 구현
// ========================================
@@ -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<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());
}
}