Phase 3: content-service JPA 완전 제거 및 Redis 전용 전환

주요 변경사항:
- JPA Entity 3개 삭제 (JobEntity, GeneratedImageEntity, ContentEntity)
- JPA Repository 3개 삭제 (JobJpaRepository, GeneratedImageJpaRepository, ContentJpaRepository)
- JPA Gateway 2개 삭제 (JobGateway, ContentGateway)
- Port 인터페이스 정리: backward compatibility 메서드 제거
- Service 레이어 Redis DTO 전환 (JobManagementService, MockGenerateImagesService, MockRegenerateImageService)
- MockRedisGateway에 ContentReader/ContentWriter 구현 추가 및 Immutable 패턴 처리
- application.yml에서 JPA/H2 설정 제거
- build.gradle에서 JPA 의존성 exclude 처리
- ContentApplication에서 JPA 어노테이션 제거

서비스는 이제 순수 Redis 기반 스토리지로 동작합니다.

🤖 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:46:33 +09:00
parent 5e9e1759ce
commit ff83dca1a1
18 changed files with 184 additions and 1036 deletions

View File

@ -1,3 +1,9 @@
configurations {
// Exclude JPA and PostgreSQL from inherited dependencies (Phase 3: Redis migration)
implementation.exclude group: 'org.springframework.boot', module: 'spring-boot-starter-data-jpa'
implementation.exclude group: 'org.postgresql', module: 'postgresql'
}
dependencies { dependencies {
// Kafka Consumer // Kafka Consumer
implementation 'org.springframework.kafka:spring-kafka' implementation 'org.springframework.kafka:spring-kafka'
@ -17,7 +23,4 @@ dependencies {
// Jackson for JSON // Jackson for JSON
implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'com.fasterxml.jackson.core:jackson-databind'
// H2 Database for local testing
runtimeOnly 'com.h2database:h2'
} }

View File

@ -4,6 +4,7 @@ import com.kt.event.common.exception.BusinessException;
import com.kt.event.common.exception.ErrorCode; import com.kt.event.common.exception.ErrorCode;
import com.kt.event.content.biz.domain.Job; import com.kt.event.content.biz.domain.Job;
import com.kt.event.content.biz.dto.JobInfo; import com.kt.event.content.biz.dto.JobInfo;
import com.kt.event.content.biz.dto.RedisJobData;
import com.kt.event.content.biz.usecase.in.GetJobStatusUseCase; import com.kt.event.content.biz.usecase.in.GetJobStatusUseCase;
import com.kt.event.content.biz.usecase.out.JobReader; import com.kt.event.content.biz.usecase.out.JobReader;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -25,9 +26,22 @@ public class JobManagementService implements GetJobStatusUseCase {
@Override @Override
public JobInfo execute(String jobId) { public JobInfo execute(String jobId) {
Job job = jobReader.findById(jobId) RedisJobData jobData = jobReader.getJob(jobId)
.orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "Job을 찾을 수 없습니다")); .orElseThrow(() -> new BusinessException(ErrorCode.COMMON_001, "Job을 찾을 수 없습니다"));
// RedisJobData를 Job 도메인 객체로 변환
Job job = Job.builder()
.id(jobData.getId())
.eventDraftId(jobData.getEventDraftId())
.jobType(jobData.getJobType())
.status(Job.Status.valueOf(jobData.getStatus()))
.progress(jobData.getProgress())
.resultMessage(jobData.getResultMessage())
.errorMessage(jobData.getErrorMessage())
.createdAt(jobData.getCreatedAt())
.updatedAt(jobData.getUpdatedAt())
.build();
return JobInfo.from(job); return JobInfo.from(job);
} }
} }

View File

@ -7,6 +7,7 @@ import com.kt.event.content.biz.domain.Job;
import com.kt.event.content.biz.domain.Platform; import com.kt.event.content.biz.domain.Platform;
import com.kt.event.content.biz.dto.ContentCommand; import com.kt.event.content.biz.dto.ContentCommand;
import com.kt.event.content.biz.dto.JobInfo; import com.kt.event.content.biz.dto.JobInfo;
import com.kt.event.content.biz.dto.RedisJobData;
import com.kt.event.content.biz.usecase.in.GenerateImagesUseCase; import com.kt.event.content.biz.usecase.in.GenerateImagesUseCase;
import com.kt.event.content.biz.usecase.out.ContentWriter; import com.kt.event.content.biz.usecase.out.ContentWriter;
import com.kt.event.content.biz.usecase.out.JobWriter; import com.kt.event.content.biz.usecase.out.JobWriter;
@ -53,14 +54,24 @@ public class MockGenerateImagesService implements GenerateImagesUseCase {
.updatedAt(java.time.LocalDateTime.now()) .updatedAt(java.time.LocalDateTime.now())
.build(); .build();
// Job 저장 // Job 저장 (Job 도메인을 RedisJobData로 변환)
Job savedJob = jobWriter.save(job); RedisJobData jobData = RedisJobData.builder()
.id(job.getId())
.eventDraftId(job.getEventDraftId())
.jobType(job.getJobType())
.status(job.getStatus().name())
.progress(job.getProgress())
.createdAt(job.getCreatedAt())
.updatedAt(job.getUpdatedAt())
.build();
jobWriter.saveJob(jobData, 3600); // TTL 1시간
log.info("[MOCK] Job 생성 완료: jobId={}", jobId); log.info("[MOCK] Job 생성 완료: jobId={}", jobId);
// 비동기로 이미지 생성 시뮬레이션 // 비동기로 이미지 생성 시뮬레이션
processImageGeneration(jobId, command); processImageGeneration(jobId, command);
return JobInfo.from(savedJob); return JobInfo.from(job);
} }
@Async @Async
@ -128,36 +139,16 @@ public class MockGenerateImagesService implements GenerateImagesUseCase {
} }
// Job 상태 업데이트: COMPLETED // Job 상태 업데이트: COMPLETED
Job completedJob = Job.builder() String resultMessage = String.format("%d개의 이미지가 성공적으로 생성되었습니다.", images.size());
.id(jobId) jobWriter.updateJobStatus(jobId, "COMPLETED", 100);
.eventDraftId(command.getEventDraftId()) jobWriter.updateJobResult(jobId, resultMessage);
.jobType("image-generation")
.status(Job.Status.COMPLETED)
.progress(100)
.resultMessage(String.format("%d개의 이미지가 성공적으로 생성되었습니다.", images.size()))
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
jobWriter.save(completedJob);
log.info("[MOCK] Job 완료: jobId={}, 생성된 이미지 수={}", jobId, images.size()); log.info("[MOCK] Job 완료: jobId={}, 생성된 이미지 수={}", jobId, images.size());
} catch (Exception e) { } catch (Exception e) {
log.error("[MOCK] 이미지 생성 실패: jobId={}", jobId, e); log.error("[MOCK] 이미지 생성 실패: jobId={}", jobId, e);
// Job 상태 업데이트: FAILED // Job 상태 업데이트: FAILED
Job failedJob = Job.builder() jobWriter.updateJobError(jobId, e.getMessage());
.id(jobId)
.eventDraftId(command.getEventDraftId())
.jobType("image-generation")
.status(Job.Status.FAILED)
.progress(0)
.errorMessage(e.getMessage())
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
jobWriter.save(failedJob);
} }
} }
} }

View File

@ -3,6 +3,7 @@ package com.kt.event.content.biz.service.mock;
import com.kt.event.content.biz.domain.Job; import com.kt.event.content.biz.domain.Job;
import com.kt.event.content.biz.dto.ContentCommand; import com.kt.event.content.biz.dto.ContentCommand;
import com.kt.event.content.biz.dto.JobInfo; import com.kt.event.content.biz.dto.JobInfo;
import com.kt.event.content.biz.dto.RedisJobData;
import com.kt.event.content.biz.usecase.in.RegenerateImageUseCase; import com.kt.event.content.biz.usecase.in.RegenerateImageUseCase;
import com.kt.event.content.biz.usecase.out.JobWriter; import com.kt.event.content.biz.usecase.out.JobWriter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -41,11 +42,21 @@ public class MockRegenerateImageService implements RegenerateImageUseCase {
.updatedAt(java.time.LocalDateTime.now()) .updatedAt(java.time.LocalDateTime.now())
.build(); .build();
// Job 저장 // Job 저장 (Job 도메인을 RedisJobData로 변환)
Job savedJob = jobWriter.save(job); RedisJobData jobData = RedisJobData.builder()
.id(job.getId())
.eventDraftId(job.getEventDraftId())
.jobType(job.getJobType())
.status(job.getStatus().name())
.progress(job.getProgress())
.createdAt(job.getCreatedAt())
.updatedAt(job.getUpdatedAt())
.build();
jobWriter.saveJob(jobData, 3600); // TTL 1시간
log.info("[MOCK] 재생성 Job 생성 완료: jobId={}", jobId); log.info("[MOCK] 재생성 Job 생성 완료: jobId={}", jobId);
return JobInfo.from(savedJob); return JobInfo.from(job);
} }
} }

View File

@ -1,6 +1,5 @@
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.dto.RedisJobData; import com.kt.event.content.biz.dto.RedisJobData;
import java.util.Optional; import java.util.Optional;
@ -17,13 +16,4 @@ public interface JobReader {
* @return Job 데이터 * @return Job 데이터
*/ */
Optional<RedisJobData> getJob(String jobId); Optional<RedisJobData> getJob(String jobId);
/**
* 기존 호환성을 위한 메서드 (Phase 3에서 삭제 예정)
* JPA 기반 JobGateway에서만 사용
*
* @param jobId Job ID
* @return Job 도메인 객체
*/
Optional<Job> findById(String jobId);
} }

View File

@ -1,6 +1,5 @@
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.dto.RedisJobData; import com.kt.event.content.biz.dto.RedisJobData;
/** /**
@ -40,13 +39,4 @@ public interface JobWriter {
* @param errorMessage 에러 메시지 * @param errorMessage 에러 메시지
*/ */
void updateJobError(String jobId, String errorMessage); void updateJobError(String jobId, String errorMessage);
/**
* 기존 호환성을 위한 메서드 (Phase 3에서 삭제 예정)
* JPA 기반 JobGateway에서만 사용
*
* @param job Job 도메인 객체
* @return 저장된 Job
*/
Job save(Job job);
} }

View File

@ -2,24 +2,16 @@ package com.kt.event.content.infra;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableAsync;
/** /**
* Content Service Application * Content Service Application
* Phase 3: JPA removed, using Redis for storage
*/ */
@SpringBootApplication(scanBasePackages = { @SpringBootApplication(scanBasePackages = {
"com.kt.event.content", "com.kt.event.content",
"com.kt.event.common" "com.kt.event.common"
}) })
@EntityScan(basePackages = {
"com.kt.event.content.infra.gateway.entity",
"com.kt.event.common.entity"
})
@EnableJpaRepositories(basePackages = "com.kt.event.content.infra.gateway.repository")
@EnableJpaAuditing
@EnableAsync @EnableAsync
public class ContentApplication { public class ContentApplication {

View File

@ -1,119 +0,0 @@
package com.kt.event.content.infra.gateway;
import com.kt.event.content.biz.domain.Content;
import com.kt.event.content.biz.domain.GeneratedImage;
import com.kt.event.content.biz.usecase.out.ContentReader;
import com.kt.event.content.biz.usecase.out.ContentWriter;
import com.kt.event.content.infra.gateway.entity.ContentEntity;
import com.kt.event.content.infra.gateway.entity.GeneratedImageEntity;
import com.kt.event.content.infra.gateway.repository.ContentJpaRepository;
import com.kt.event.content.infra.gateway.repository.GeneratedImageJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Content 영속성 Gateway
* ContentReader, ContentWriter outbound port 구현
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ContentGateway implements ContentReader, ContentWriter {
private final ContentJpaRepository contentRepository;
private final GeneratedImageJpaRepository imageRepository;
// ========================================
// ContentReader 구현
// ========================================
@Override
@Transactional(readOnly = true)
public Optional<Content> findByEventDraftIdWithImages(Long eventDraftId) {
log.debug("이벤트 콘텐츠 조회 (with images): eventDraftId={}", eventDraftId);
return contentRepository.findByEventDraftIdWithImages(eventDraftId)
.map(ContentEntity::toDomain);
}
@Override
@Transactional(readOnly = true)
public Optional<GeneratedImage> findImageById(Long imageId) {
log.debug("이미지 조회: imageId={}", imageId);
return imageRepository.findById(imageId)
.map(GeneratedImageEntity::toDomain);
}
@Override
@Transactional(readOnly = true)
public List<GeneratedImage> findImagesByEventDraftId(Long eventDraftId) {
log.debug("이미지 목록 조회: eventDraftId={}", eventDraftId);
return imageRepository.findByEventDraftId(eventDraftId).stream()
.map(GeneratedImageEntity::toDomain)
.collect(Collectors.toList());
}
// ========================================
// ContentWriter 구현
// ========================================
@Override
@Transactional
public Content save(Content content) {
log.debug("콘텐츠 저장: eventDraftId={}", content.getEventDraftId());
// Content Entity 조회 또는 생성
ContentEntity contentEntity = contentRepository.findByEventDraftId(content.getEventDraftId())
.orElseGet(() -> ContentEntity.create(
content.getEventDraftId(),
content.getEventTitle(),
content.getEventDescription()
));
// Content 업데이트
contentEntity.update(content.getEventTitle(), content.getEventDescription());
// 저장
ContentEntity saved = contentRepository.save(contentEntity);
return saved.toDomain();
}
@Override
@Transactional
public GeneratedImage saveImage(GeneratedImage image) {
log.debug("이미지 저장: eventDraftId={}, style={}, platform={}",
image.getEventDraftId(), image.getStyle(), image.getPlatform());
// Content Entity 조회
ContentEntity contentEntity = contentRepository.findByEventDraftId(image.getEventDraftId())
.orElseThrow(() -> new IllegalStateException("Content를 먼저 저장해야 합니다"));
// GeneratedImageEntity 생성
GeneratedImageEntity imageEntity = GeneratedImageEntity.create(
image.getEventDraftId(),
image.getStyle(),
image.getPlatform(),
image.getCdnUrl(),
image.getPrompt()
);
// Content와 연결
contentEntity.addImage(imageEntity);
// 선택 상태 설정
if (image.isSelected()) {
imageEntity.select();
}
// 저장
GeneratedImageEntity saved = imageRepository.save(imageEntity);
return saved.toDomain();
}
}

View File

@ -1,180 +0,0 @@
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;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Job 영속성 Gateway
* JobReader, JobWriter outbound port 구현
* Phase 3에서 삭제 예정
*/
@Slf4j
@Component
@Primary
@RequiredArgsConstructor
public class JobGateway implements JobReader, JobWriter {
private final JobJpaRepository jobRepository;
// ========================================
// JobReader 구현
// ========================================
@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)
.map(JobEntity::toDomain);
}
/**
* 이벤트별 Job 조회 (추가 메서드)
*/
@Transactional(readOnly = true)
public List<Job> findByEventDraftId(Long eventDraftId) {
log.debug("이벤트별 Job 조회: eventDraftId={}", eventDraftId);
return jobRepository.findByEventDraftId(eventDraftId).stream()
.map(JobEntity::toDomain)
.collect(Collectors.toList());
}
/**
* 최신 Job 조회 (추가 메서드)
*/
@Transactional(readOnly = true)
public Optional<Job> findLatestByEventDraftIdAndJobType(Long eventDraftId, String jobType) {
log.debug("최신 Job 조회: eventDraftId={}, jobType={}", eventDraftId, jobType);
return jobRepository.findFirstByEventDraftIdAndJobTypeOrderByCreatedAtDesc(eventDraftId, jobType)
.map(JobEntity::toDomain);
}
// ========================================
// 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());
JobEntity entity = jobRepository.findById(job.getId())
.orElseGet(() -> JobEntity.create(
job.getId(),
job.getEventDraftId(),
job.getJobType()
));
// Job 상태 업데이트
entity.updateStatus(job.getStatus(), job.getProgress());
if (job.getResultMessage() != null) {
entity.setResultMessage(job.getResultMessage());
}
if (job.getErrorMessage() != null) {
entity.setErrorMessage(job.getErrorMessage());
}
JobEntity saved = jobRepository.save(entity);
return saved.toDomain();
}
/**
* Job 삭제 (추가 메서드)
*/
@Transactional
public void delete(String jobId) {
log.debug("Job 삭제: jobId={}", jobId);
jobRepository.deleteById(jobId);
}
}

View File

@ -317,55 +317,6 @@ public class RedisGateway implements RedisAIDataReader, RedisImageWriter, ImageW
} }
} }
/**
* 기존 호환성을 위한 메서드 (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 ==================== // ==================== Helper Methods ====================
private String getString(Map<Object, Object> map, String key) { private String getString(Map<Object, Object> map, String key) {

View File

@ -1,109 +0,0 @@
package com.kt.event.content.infra.gateway.entity;
import com.kt.event.common.entity.BaseTimeEntity;
import com.kt.event.content.biz.domain.Content;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 콘텐츠 엔티티
* 이벤트에 대한 전체 콘텐츠 정보를 저장
*/
@Entity
@Table(name = "contents")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class ContentEntity extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 이벤트 ID (이벤트 초안 ID)
*/
@Column(name = "event_draft_id", nullable = false)
private Long eventDraftId;
/**
* 이벤트 제목
*/
@Column(name = "event_title", nullable = false, length = 200)
private String eventTitle;
/**
* 이벤트 설명
*/
@Column(name = "event_description", columnDefinition = "TEXT")
private String eventDescription;
/**
* 생성된 이미지 목록
*/
@OneToMany(mappedBy = "content", cascade = CascadeType.ALL, orphanRemoval = true)
private List<GeneratedImageEntity> images = new ArrayList<>();
/**
* 정적 팩토리 메서드: 콘텐츠 생성
*
* @param eventDraftId 이벤트 초안 ID
* @param eventTitle 이벤트 제목
* @param eventDescription 이벤트 설명
* @return ContentEntity
*/
public static ContentEntity create(Long eventDraftId, String eventTitle, String eventDescription) {
ContentEntity entity = new ContentEntity();
entity.eventDraftId = eventDraftId;
entity.eventTitle = eventTitle;
entity.eventDescription = eventDescription;
return entity;
}
/**
* 도메인 모델로 변환
*
* @return Content 도메인 모델
*/
public Content toDomain() {
return Content.builder()
.id(id)
.eventDraftId(eventDraftId)
.eventTitle(eventTitle)
.eventDescription(eventDescription)
.images(images.stream()
.map(GeneratedImageEntity::toDomain)
.collect(Collectors.toList()))
.createdAt(getCreatedAt())
.updatedAt(getUpdatedAt())
.build();
}
/**
* 콘텐츠 정보 업데이트
*
* @param eventTitle 이벤트 제목
* @param eventDescription 이벤트 설명
*/
public void update(String eventTitle, String eventDescription) {
this.eventTitle = eventTitle;
this.eventDescription = eventDescription;
}
/**
* 이미지 추가
*
* @param image 생성된 이미지 엔티티
*/
public void addImage(GeneratedImageEntity image) {
images.add(image);
image.assignContent(this);
}
}

View File

@ -1,139 +0,0 @@
package com.kt.event.content.infra.gateway.entity;
import com.kt.event.common.entity.BaseTimeEntity;
import com.kt.event.content.biz.domain.GeneratedImage;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Platform;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 생성된 이미지 엔티티
* AI가 생성한 이미지 정보를 저장
*/
@Entity
@Table(name = "generated_images", indexes = {
@Index(name = "idx_event_draft_id", columnList = "event_draft_id"),
@Index(name = "idx_style_platform", columnList = "style,platform")
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class GeneratedImageEntity extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 콘텐츠 (양방향 관계)
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "content_id")
private ContentEntity content;
/**
* 이벤트 ID (이벤트 초안 ID)
*/
@Column(name = "event_draft_id", nullable = false)
private Long eventDraftId;
/**
* 이미지 스타일
*/
@Enumerated(EnumType.STRING)
@Column(name = "style", nullable = false, length = 20)
private ImageStyle style;
/**
* 플랫폼
*/
@Enumerated(EnumType.STRING)
@Column(name = "platform", nullable = false, length = 20)
private Platform platform;
/**
* CDN URL (Azure Blob Storage)
*/
@Column(name = "cdn_url", nullable = false, length = 500)
private String cdnUrl;
/**
* 프롬프트
*/
@Column(name = "prompt", columnDefinition = "TEXT")
private String prompt;
/**
* 선택 여부
*/
@Column(name = "selected", nullable = false)
private boolean selected;
/**
* 정적 팩토리 메서드: 이미지 생성
*
* @param eventDraftId 이벤트 초안 ID
* @param style 이미지 스타일
* @param platform 플랫폼
* @param cdnUrl CDN URL
* @param prompt 프롬프트
* @return GeneratedImageEntity
*/
public static GeneratedImageEntity create(Long eventDraftId, ImageStyle style, Platform platform,
String cdnUrl, String prompt) {
GeneratedImageEntity entity = new GeneratedImageEntity();
entity.eventDraftId = eventDraftId;
entity.style = style;
entity.platform = platform;
entity.cdnUrl = cdnUrl;
entity.prompt = prompt;
entity.selected = false;
return entity;
}
/**
* 도메인 모델로 변환
*
* @return GeneratedImage 도메인 모델
*/
public GeneratedImage toDomain() {
return GeneratedImage.builder()
.id(id)
.eventDraftId(eventDraftId)
.style(style)
.platform(platform)
.cdnUrl(cdnUrl)
.prompt(prompt)
.selected(selected)
.createdAt(getCreatedAt())
.updatedAt(getUpdatedAt())
.build();
}
/**
* 콘텐츠 할당 (양방향 관계 설정용)
*
* @param content 콘텐츠 엔티티
*/
protected void assignContent(ContentEntity content) {
this.content = content;
}
/**
* 이미지 선택
*/
public void select() {
this.selected = true;
}
/**
* 이미지 선택 해제
*/
public void deselect() {
this.selected = false;
}
}

View File

@ -1,172 +0,0 @@
package com.kt.event.content.infra.gateway.entity;
import com.kt.event.common.entity.BaseTimeEntity;
import com.kt.event.content.biz.domain.Job;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Job 엔티티
* 이미지 생성 작업 정보를 저장
*/
@Entity
@Table(name = "jobs", indexes = {
@Index(name = "idx_event_draft_id", columnList = "event_draft_id"),
@Index(name = "idx_status", columnList = "status")
})
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class JobEntity extends BaseTimeEntity {
@Id
@Column(name = "id", length = 36)
private String id;
/**
* 이벤트 ID (이벤트 초안 ID)
*/
@Column(name = "event_draft_id", nullable = false)
private Long eventDraftId;
/**
* Job 타입
*/
@Column(name = "job_type", nullable = false, length = 50)
private String jobType;
/**
* Job 상태
*/
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private Job.Status status;
/**
* 진행률 (0-100)
*/
@Column(name = "progress", nullable = false)
private int progress;
/**
* 결과 메시지
*/
@Column(name = "result_message", columnDefinition = "TEXT")
private String resultMessage;
/**
* 에러 메시지
*/
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
/**
* 정적 팩토리 메서드: Job 생성
*
* @param id Job ID (UUID)
* @param eventDraftId 이벤트 초안 ID
* @param jobType Job 타입
* @return JobEntity
*/
public static JobEntity create(String id, Long eventDraftId, String jobType) {
JobEntity entity = new JobEntity();
entity.id = id;
entity.eventDraftId = eventDraftId;
entity.jobType = jobType;
entity.status = Job.Status.PENDING;
entity.progress = 0;
return entity;
}
/**
* 도메인 모델로 변환
*
* @return Job 도메인 모델
*/
public Job toDomain() {
return Job.builder()
.id(id)
.eventDraftId(eventDraftId)
.jobType(jobType)
.status(status)
.progress(progress)
.resultMessage(resultMessage)
.errorMessage(errorMessage)
.createdAt(getCreatedAt())
.updatedAt(getUpdatedAt())
.build();
}
/**
* Job 시작
*/
public void start() {
this.status = Job.Status.PROCESSING;
this.progress = 0;
}
/**
* 진행률 업데이트
*
* @param progress 진행률 (0-100)
*/
public void updateProgress(int progress) {
if (progress < 0 || progress > 100) {
throw new IllegalArgumentException("진행률은 0-100 사이여야 합니다");
}
this.progress = progress;
}
/**
* Job 완료 처리
*
* @param resultMessage 결과 메시지
*/
public void complete(String resultMessage) {
this.status = Job.Status.COMPLETED;
this.progress = 100;
this.resultMessage = resultMessage;
}
/**
* Job 실패 처리
*
* @param errorMessage 에러 메시지
*/
public void fail(String errorMessage) {
this.status = Job.Status.FAILED;
this.errorMessage = errorMessage;
}
/**
* Job 상태 업데이트
*
* @param status 상태
* @param progress 진행률
*/
public void updateStatus(Job.Status status, int progress) {
this.status = status;
this.progress = progress;
}
/**
* 결과 메시지 설정
*
* @param resultMessage 결과 메시지
*/
public void setResultMessage(String resultMessage) {
this.resultMessage = resultMessage;
}
/**
* 에러 메시지 설정
*
* @param errorMessage 에러 메시지
*/
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
}

View File

@ -1,11 +1,14 @@
package com.kt.event.content.infra.gateway.mock; package com.kt.event.content.infra.gateway.mock;
import com.kt.event.content.biz.domain.Content;
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.ImageStyle;
import com.kt.event.content.biz.domain.Job; import com.kt.event.content.biz.domain.Job;
import com.kt.event.content.biz.domain.Platform; import com.kt.event.content.biz.domain.Platform;
import com.kt.event.content.biz.dto.RedisImageData; import com.kt.event.content.biz.dto.RedisImageData;
import com.kt.event.content.biz.dto.RedisJobData; import com.kt.event.content.biz.dto.RedisJobData;
import com.kt.event.content.biz.usecase.out.ContentReader;
import com.kt.event.content.biz.usecase.out.ContentWriter;
import com.kt.event.content.biz.usecase.out.ImageReader; 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.ImageWriter;
import com.kt.event.content.biz.usecase.out.JobReader; import com.kt.event.content.biz.usecase.out.JobReader;
@ -13,6 +16,7 @@ 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.Primary;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -31,12 +35,15 @@ import java.util.stream.Collectors;
*/ */
@Slf4j @Slf4j
@Component @Component
@Primary
@Profile({"local", "test"}) @Profile({"local", "test"})
public class MockRedisGateway implements RedisAIDataReader, RedisImageWriter, ImageWriter, ImageReader, JobWriter, JobReader { public class MockRedisGateway implements RedisAIDataReader, RedisImageWriter, ImageWriter, ImageReader, JobWriter, JobReader, ContentReader, ContentWriter {
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 // In-memory storage for contents, images, and jobs
private final Map<Long, Content> contentStorage = new ConcurrentHashMap<>();
private final Map<Long, GeneratedImage> imageByIdStorage = new ConcurrentHashMap<>();
private final Map<String, RedisImageData> imageStorage = new ConcurrentHashMap<>(); private final Map<String, RedisImageData> imageStorage = new ConcurrentHashMap<>();
private final Map<String, RedisJobData> jobStorage = new ConcurrentHashMap<>(); private final Map<String, RedisJobData> jobStorage = new ConcurrentHashMap<>();
@ -259,52 +266,136 @@ public class MockRedisGateway implements RedisAIDataReader, RedisImageWriter, Im
} }
} }
// ==================== ContentReader 구현 ====================
/** /**
* 기존 호환성을 위한 메서드 (Phase 3에서 삭제 예정) * 이벤트 초안 ID로 콘텐츠 조회 (이미지 목록 포함)
* Job 도메인 객체를 RedisJobData로 변환하여 저장
*
* @param job Job 도메인 객체
* @return 저장된 Job
*/ */
@Override @Override
public Job save(Job job) { public Optional<Content> findByEventDraftIdWithImages(Long eventDraftId) {
log.debug("[MOCK] Job 저장 (호환성): jobId={}, status={}", job.getId(), job.getStatus()); try {
Content content = contentStorage.get(eventDraftId);
if (content == null) {
log.warn("[MOCK] Content를 찾을 수 없음: eventDraftId={}", eventDraftId);
return Optional.empty();
}
RedisJobData jobData = RedisJobData.builder() // 이미지 목록 조회 Content 재생성 (immutable pattern)
.id(job.getId()) List<GeneratedImage> images = findImagesByEventDraftId(eventDraftId);
.eventDraftId(job.getEventDraftId()) Content contentWithImages = Content.builder()
.jobType(job.getJobType()) .id(content.getId())
.status(job.getStatus().name()) .eventDraftId(content.getEventDraftId())
.progress(job.getProgress()) .eventTitle(content.getEventTitle())
.resultMessage(job.getResultMessage()) .eventDescription(content.getEventDescription())
.errorMessage(job.getErrorMessage()) .images(images)
.createdAt(job.getCreatedAt()) .createdAt(content.getCreatedAt())
.updatedAt(job.getUpdatedAt()) .updatedAt(content.getUpdatedAt())
.build(); .build();
saveJob(jobData, 86400); // 24시간 TTL return Optional.of(contentWithImages);
return job; } catch (Exception e) {
log.error("[MOCK] Content 조회 실패: eventDraftId={}", eventDraftId, e);
return Optional.empty();
}
} }
/** /**
* 기존 호환성을 위한 메서드 (Phase 3에서 삭제 예정) * 이미지 ID로 이미지 조회
*
* @param jobId Job ID
* @return Job 도메인 객체
*/ */
@Override @Override
public Optional<Job> findById(String jobId) { public Optional<GeneratedImage> findImageById(Long imageId) {
log.debug("[MOCK] Job 조회 (호환성): jobId={}", jobId); try {
return getJob(jobId).map(data -> Job.builder() GeneratedImage image = imageByIdStorage.get(imageId);
.id(data.getId()) if (image == null) {
.eventDraftId(data.getEventDraftId()) log.warn("[MOCK] 이미지를 찾을 수 없음: imageId={}", imageId);
.jobType(data.getJobType()) return Optional.empty();
.status(Job.Status.valueOf(data.getStatus())) }
.progress(data.getProgress()) return Optional.of(image);
.resultMessage(data.getResultMessage()) } catch (Exception e) {
.errorMessage(data.getErrorMessage()) log.error("[MOCK] 이미지 조회 실패: imageId={}", imageId, e);
.createdAt(data.getCreatedAt()) return Optional.empty();
.updatedAt(data.getUpdatedAt()) }
.build()); }
/**
* 이벤트 초안 ID로 이미지 목록 조회
*/
@Override
public List<GeneratedImage> findImagesByEventDraftId(Long eventDraftId) {
try {
return imageByIdStorage.values().stream()
.filter(image -> image.getEventDraftId().equals(eventDraftId))
.collect(Collectors.toList());
} catch (Exception e) {
log.error("[MOCK] 이미지 목록 조회 실패: eventDraftId={}", eventDraftId, e);
return new ArrayList<>();
}
}
// ==================== ContentWriter 구현 ====================
private static Long nextContentId = 1L;
private static Long nextImageId = 1L;
/**
* 콘텐츠 저장
*/
@Override
public Content save(Content content) {
try {
// ID가 없으면 생성하여 Content 객체 생성 (immutable pattern)
Long id = content.getId() != null ? content.getId() : nextContentId++;
Content savedContent = Content.builder()
.id(id)
.eventDraftId(content.getEventDraftId())
.eventTitle(content.getEventTitle())
.eventDescription(content.getEventDescription())
.images(content.getImages())
.createdAt(content.getCreatedAt())
.updatedAt(content.getUpdatedAt())
.build();
contentStorage.put(savedContent.getEventDraftId(), savedContent);
log.info("[MOCK] Content 저장 완료: contentId={}, eventDraftId={}",
savedContent.getId(), savedContent.getEventDraftId());
return savedContent;
} catch (Exception e) {
log.error("[MOCK] Content 저장 실패: eventDraftId={}", content.getEventDraftId(), e);
throw e;
}
}
/**
* 이미지 저장
*/
@Override
public GeneratedImage saveImage(GeneratedImage image) {
try {
// ID가 없으면 생성하여 GeneratedImage 객체 생성 (immutable pattern)
Long id = image.getId() != null ? image.getId() : nextImageId++;
GeneratedImage savedImage = GeneratedImage.builder()
.id(id)
.eventDraftId(image.getEventDraftId())
.style(image.getStyle())
.platform(image.getPlatform())
.cdnUrl(image.getCdnUrl())
.prompt(image.getPrompt())
.selected(image.isSelected())
.createdAt(image.getCreatedAt())
.updatedAt(image.getUpdatedAt())
.build();
imageByIdStorage.put(savedImage.getId(), savedImage);
log.info("[MOCK] 이미지 저장 완료: imageId={}, eventDraftId={}, style={}, platform={}",
savedImage.getId(), savedImage.getEventDraftId(), savedImage.getStyle(), savedImage.getPlatform());
return savedImage;
} catch (Exception e) {
log.error("[MOCK] 이미지 저장 실패: eventDraftId={}", image.getEventDraftId(), e);
throw e;
}
} }
} }

View File

@ -1,41 +0,0 @@
package com.kt.event.content.infra.gateway.repository;
import com.kt.event.content.infra.gateway.entity.ContentEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
/**
* 콘텐츠 JPA 리포지토리
*/
public interface ContentJpaRepository extends JpaRepository<ContentEntity, Long> {
/**
* 이벤트 초안 ID로 콘텐츠 조회 (이미지 목록 포함)
*
* @param eventDraftId 이벤트 초안 ID
* @return 콘텐츠 엔티티
*/
@Query("SELECT DISTINCT c FROM ContentEntity c " +
"LEFT JOIN FETCH c.images " +
"WHERE c.eventDraftId = :eventDraftId")
Optional<ContentEntity> findByEventDraftIdWithImages(@Param("eventDraftId") Long eventDraftId);
/**
* 이벤트 초안 ID로 콘텐츠 조회
*
* @param eventDraftId 이벤트 초안 ID
* @return 콘텐츠 엔티티
*/
Optional<ContentEntity> findByEventDraftId(Long eventDraftId);
/**
* 이벤트 초안 ID로 콘텐츠 존재 여부 확인
*
* @param eventDraftId 이벤트 초안 ID
* @return 존재 여부
*/
boolean existsByEventDraftId(Long eventDraftId);
}

View File

@ -1,68 +0,0 @@
package com.kt.event.content.infra.gateway.repository;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Platform;
import com.kt.event.content.infra.gateway.entity.GeneratedImageEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* 생성된 이미지 JPA 리포지토리
*/
public interface GeneratedImageJpaRepository extends JpaRepository<GeneratedImageEntity, Long> {
/**
* 이벤트 초안 ID로 이미지 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @return 이미지 엔티티 목록
*/
List<GeneratedImageEntity> findByEventDraftId(Long eventDraftId);
/**
* 이벤트 초안 ID와 스타일로 이미지 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @param style 이미지 스타일
* @return 이미지 엔티티 목록
*/
List<GeneratedImageEntity> findByEventDraftIdAndStyle(Long eventDraftId, ImageStyle style);
/**
* 이벤트 초안 ID와 플랫폼으로 이미지 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @param platform 플랫폼
* @return 이미지 엔티티 목록
*/
List<GeneratedImageEntity> findByEventDraftIdAndPlatform(Long eventDraftId, Platform platform);
/**
* 이벤트 초안 ID와 선택 여부로 이미지 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @param selected 선택 여부
* @return 이미지 엔티티 목록
*/
List<GeneratedImageEntity> findByEventDraftIdAndSelected(Long eventDraftId, boolean selected);
/**
* 이벤트 초안 ID로 선택된 이미지 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @return 선택된 이미지 엔티티 목록
*/
@Query("SELECT i FROM GeneratedImageEntity i WHERE i.eventDraftId = :eventDraftId AND i.selected = true")
List<GeneratedImageEntity> findSelectedImages(@Param("eventDraftId") Long eventDraftId);
/**
* 이벤트 초안 ID로 모든 이미지 선택 해제
*
* @param eventDraftId 이벤트 초안 ID
*/
@Query("UPDATE GeneratedImageEntity i SET i.selected = false WHERE i.eventDraftId = :eventDraftId")
void deselectAllByEventDraftId(@Param("eventDraftId") Long eventDraftId);
}

View File

@ -1,40 +0,0 @@
package com.kt.event.content.infra.gateway.repository;
import com.kt.event.content.biz.domain.Job;
import com.kt.event.content.infra.gateway.entity.JobEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
/**
* Job JPA 리포지토리
*/
public interface JobJpaRepository extends JpaRepository<JobEntity, String> {
/**
* 이벤트 초안 ID로 Job 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @return Job 엔티티 목록
*/
List<JobEntity> findByEventDraftId(Long eventDraftId);
/**
* 이벤트 초안 ID와 상태로 Job 목록 조회
*
* @param eventDraftId 이벤트 초안 ID
* @param status Job 상태
* @return Job 엔티티 목록
*/
List<JobEntity> findByEventDraftIdAndStatus(Long eventDraftId, Job.Status status);
/**
* 이벤트 초안 ID와 Job 타입으로 최신 Job 조회
*
* @param eventDraftId 이벤트 초안 ID
* @param jobType Job 타입
* @return Job 엔티티
*/
Optional<JobEntity> findFirstByEventDraftIdAndJobTypeOrderByCreatedAtDesc(Long eventDraftId, String jobType);
}

View File

@ -2,22 +2,6 @@ spring:
application: application:
name: content-service name: content-service
datasource:
url: jdbc:postgresql://4.217.131.139:5432/contentdb
username: eventuser
password: Hi5Jessica!
driver-class-name: org.postgresql.Driver
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
use_sql_comments: true
data: data:
redis: redis:
host: 4.217.131.139 host: 4.217.131.139
@ -47,4 +31,3 @@ azure:
logging: logging:
level: level:
com.kt.event: DEBUG com.kt.event: DEBUG
org.hibernate.SQL: DEBUG