Merge pull request #12 from ktds-dg0501/feature/content

Feature/content
This commit is contained in:
Cherry Kim 2025-10-27 17:10:48 +09:00 committed by GitHub
commit 397a23063d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1832 additions and 2 deletions

View File

@ -21,3 +21,8 @@ dependencies {
// Jackson for JSON
implementation 'com.fasterxml.jackson.core:jackson-databind'
}
// JAR
bootJar {
archiveFileName = 'content-service.jar'
}

View File

@ -23,6 +23,22 @@ public class ContentCommand {
private Long eventDraftId;
private String eventTitle;
private String eventDescription;
/**
* 업종 (: "고깃집", "카페", "베이커리")
*/
private String industry;
/**
* 지역 (: "강남", "홍대", "서울")
*/
private String location;
/**
* 트렌드 키워드 (최대 3개 권장, : ["할인", "신메뉴", "이벤트"])
*/
private List<String> trends;
private List<ImageStyle> styles;
private List<Platform> platforms;
}

View File

@ -0,0 +1,288 @@
package com.kt.event.content.biz.service;
import com.kt.event.content.biz.domain.Content;
import com.kt.event.content.biz.domain.GeneratedImage;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Job;
import com.kt.event.content.biz.domain.Platform;
import com.kt.event.content.biz.dto.ContentCommand;
import com.kt.event.content.biz.dto.JobInfo;
import com.kt.event.content.biz.dto.RedisJobData;
import com.kt.event.content.biz.usecase.in.GenerateImagesUseCase;
import com.kt.event.content.biz.usecase.out.CDNUploader;
import com.kt.event.content.biz.usecase.out.ContentWriter;
import com.kt.event.content.biz.usecase.out.JobWriter;
import com.kt.event.content.infra.gateway.client.HuggingFaceApiClient;
import com.kt.event.content.infra.gateway.client.dto.HuggingFaceRequest;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Hugging Face Inference API 이미지 생성 서비스
*
* Hugging Face Inference API를 사용하여 Stable Diffusion으로 이미지 생성 (무료)
*/
@Slf4j
@Service
@Profile({"prod", "dev"}) // production dev 환경에서 활성화 (local은 Mock 사용)
public class HuggingFaceImageGenerator implements GenerateImagesUseCase {
private final HuggingFaceApiClient huggingFaceClient;
private final CDNUploader cdnUploader;
private final JobWriter jobWriter;
private final ContentWriter contentWriter;
private final CircuitBreaker circuitBreaker;
public HuggingFaceImageGenerator(
HuggingFaceApiClient huggingFaceClient,
CDNUploader cdnUploader,
JobWriter jobWriter,
ContentWriter contentWriter,
@Qualifier("huggingfaceCircuitBreaker") CircuitBreaker circuitBreaker) {
this.huggingFaceClient = huggingFaceClient;
this.cdnUploader = cdnUploader;
this.jobWriter = jobWriter;
this.contentWriter = contentWriter;
this.circuitBreaker = circuitBreaker;
}
@Override
public JobInfo execute(ContentCommand.GenerateImages command) {
log.info("Hugging Face 이미지 생성 요청: eventDraftId={}, styles={}, platforms={}",
command.getEventDraftId(), command.getStyles(), command.getPlatforms());
// Job 생성
String jobId = "job-" + UUID.randomUUID().toString().substring(0, 8);
Job job = Job.builder()
.id(jobId)
.eventDraftId(command.getEventDraftId())
.jobType("image-generation")
.status(Job.Status.PENDING)
.progress(0)
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
// Job 저장
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("Job 생성 완료: jobId={}", jobId);
// 비동기로 이미지 생성
processImageGeneration(jobId, command);
return JobInfo.from(job);
}
@Async
private void processImageGeneration(String jobId, ContentCommand.GenerateImages command) {
try {
log.info("Hugging Face 이미지 생성 시작: jobId={}", jobId);
// Content 생성 또는 조회
Content content = Content.builder()
.eventDraftId(command.getEventDraftId())
.eventTitle(command.getEventDraftId() + " 이벤트")
.eventDescription("AI 생성 이벤트 이미지")
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
Content savedContent = contentWriter.save(content);
log.info("Content 생성 완료: contentId={}", savedContent.getId());
// 스타일 x 플랫폼 조합으로 이미지 생성
List<ImageStyle> styles = command.getStyles() != null && !command.getStyles().isEmpty()
? command.getStyles()
: List.of(ImageStyle.FANCY, ImageStyle.SIMPLE);
List<Platform> platforms = command.getPlatforms() != null && !command.getPlatforms().isEmpty()
? command.getPlatforms()
: List.of(Platform.INSTAGRAM, Platform.KAKAO);
List<GeneratedImage> images = new ArrayList<>();
int totalCount = styles.size() * platforms.size();
int currentCount = 0;
for (ImageStyle style : styles) {
for (Platform platform : platforms) {
currentCount++;
// 진행률 업데이트
int progress = (currentCount * 100) / totalCount;
jobWriter.updateJobStatus(jobId, "IN_PROGRESS", progress);
// Hugging Face로 이미지 생성
String prompt = buildPrompt(command, style, platform);
String imageUrl = generateImage(prompt, platform);
// GeneratedImage 저장
GeneratedImage image = GeneratedImage.builder()
.eventDraftId(command.getEventDraftId())
.style(style)
.platform(platform)
.cdnUrl(imageUrl)
.prompt(prompt)
.selected(currentCount == 1) // 번째 이미지를 선택
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
if (currentCount == 1) {
image.select();
}
GeneratedImage savedImage = contentWriter.saveImage(image);
images.add(savedImage);
log.info("이미지 생성 완료: imageId={}, style={}, platform={}, url={}",
savedImage.getId(), style, platform, imageUrl);
}
}
// Job 완료
String resultMessage = String.format("%d개의 이미지가 성공적으로 생성되었습니다.", images.size());
jobWriter.updateJobStatus(jobId, "COMPLETED", 100);
jobWriter.updateJobResult(jobId, resultMessage);
log.info("Hugging Face Job 완료: jobId={}, 생성된 이미지 수={}", jobId, images.size());
} catch (Exception e) {
log.error("Hugging Face 이미지 생성 실패: jobId={}", jobId, e);
jobWriter.updateJobError(jobId, e.getMessage());
}
}
/**
* Hugging Face로 이미지 생성
*
* @param prompt 이미지 생성 프롬프트
* @param platform 플랫폼 (이미지 크기 결정)
* @return 생성된 이미지 URL
*/
private String generateImage(String prompt, Platform platform) {
try {
// 플랫폼별 이미지 크기 설정
int width = platform.getWidth();
int height = platform.getHeight();
// Hugging Face API 요청
HuggingFaceRequest request = HuggingFaceRequest.builder()
.inputs(prompt)
.parameters(HuggingFaceRequest.Parameters.builder()
.negative_prompt("blurry, bad quality, distorted, ugly, low resolution")
.width(width)
.height(height)
.guidance_scale(7.5)
.num_inference_steps(50)
.build())
.build();
log.info("Hugging Face API 호출: prompt={}, size={}x{}", prompt, width, height);
// 이미지 생성 (동기 방식)
byte[] imageData = generateImageWithCircuitBreaker(request);
log.info("Hugging Face 이미지 생성 완료: size={} bytes", imageData.length);
// Azure Blob Storage에 업로드
String fileName = String.format("event-%s-%s-%s.png",
platform.name().toLowerCase(),
UUID.randomUUID().toString().substring(0, 8),
System.currentTimeMillis());
String azureCdnUrl = cdnUploader.upload(imageData, fileName);
log.info("Azure CDN 업로드 완료: fileName={}, url={}", fileName, azureCdnUrl);
return azureCdnUrl;
} catch (Exception e) {
log.error("Hugging Face 이미지 생성 실패: prompt={}", prompt, e);
throw new RuntimeException("이미지 생성 실패: " + e.getMessage(), e);
}
}
/**
* 이미지 생성 프롬프트 구성
*/
private String buildPrompt(ContentCommand.GenerateImages command, ImageStyle style, Platform platform) {
StringBuilder prompt = new StringBuilder();
// 업종 정보 추가
if (command.getIndustry() != null && !command.getIndustry().trim().isEmpty()) {
prompt.append(command.getIndustry()).append(" ");
}
// 기본 프롬프트
prompt.append("event promotion image");
// 지역 정보 추가
if (command.getLocation() != null && !command.getLocation().trim().isEmpty()) {
prompt.append(" in ").append(command.getLocation());
}
// 트렌드 키워드 추가 (최대 3개)
if (command.getTrends() != null && !command.getTrends().isEmpty()) {
prompt.append(", featuring ");
int count = Math.min(3, command.getTrends().size());
for (int i = 0; i < count; i++) {
if (i > 0) prompt.append(", ");
prompt.append(command.getTrends().get(i));
}
}
prompt.append(", ");
// 스타일별 프롬프트
switch (style) {
case FANCY:
prompt.append("elegant, luxurious, premium design, vibrant colors, ");
break;
case SIMPLE:
prompt.append("minimalist, clean design, simple layout, modern, ");
break;
case TRENDY:
prompt.append("trendy, contemporary, stylish, modern design, ");
break;
}
// 플랫폼별 특성 추가
prompt.append("optimized for ").append(platform.name().toLowerCase()).append(" platform, ");
prompt.append("high quality, detailed, 4k resolution");
return prompt.toString();
}
/**
* Circuit Breaker로 보호된 Hugging Face 이미지 생성
*
* @param request Hugging Face 요청
* @return 생성된 이미지 바이트 데이터
*/
private byte[] generateImageWithCircuitBreaker(HuggingFaceRequest request) {
try {
return circuitBreaker.executeSupplier(() -> huggingFaceClient.generateImage(request));
} catch (CallNotPermittedException e) {
log.error("Hugging Face Circuit Breaker가 OPEN 상태입니다. 이미지 생성 차단");
throw new RuntimeException("Hugging Face API에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.", e);
} catch (Exception e) {
log.error("Hugging Face 이미지 생성 실패", e);
throw new RuntimeException("이미지 생성 실패: " + e.getMessage(), e);
}
}
}

View File

@ -0,0 +1,398 @@
package com.kt.event.content.biz.service;
import com.kt.event.content.biz.domain.Content;
import com.kt.event.content.biz.domain.GeneratedImage;
import com.kt.event.content.biz.domain.ImageStyle;
import com.kt.event.content.biz.domain.Job;
import com.kt.event.content.biz.domain.Platform;
import com.kt.event.content.biz.dto.ContentCommand;
import com.kt.event.content.biz.dto.JobInfo;
import com.kt.event.content.biz.dto.RedisJobData;
import com.kt.event.content.biz.usecase.in.GenerateImagesUseCase;
import com.kt.event.content.biz.usecase.out.CDNUploader;
import com.kt.event.content.biz.usecase.out.ContentWriter;
import com.kt.event.content.biz.usecase.out.JobWriter;
import com.kt.event.content.infra.gateway.client.ReplicateApiClient;
import com.kt.event.content.infra.gateway.client.dto.ReplicateRequest;
import com.kt.event.content.infra.gateway.client.dto.ReplicateResponse;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Stable Diffusion 이미지 생성 서비스
*
* Replicate API를 사용하여 Stable Diffusion XL 1.0으로 이미지 생성
*/
@Slf4j
@Service
@Primary
@Profile({"prod", "dev"}) // production dev 환경에서 활성화 (local은 Mock 사용)
public class StableDiffusionImageGenerator implements GenerateImagesUseCase {
private final ReplicateApiClient replicateClient;
private final CDNUploader cdnUploader;
private final JobWriter jobWriter;
private final ContentWriter contentWriter;
private final CircuitBreaker circuitBreaker;
@Value("${replicate.model.version:stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b}")
private String modelVersion;
public StableDiffusionImageGenerator(
ReplicateApiClient replicateClient,
CDNUploader cdnUploader,
JobWriter jobWriter,
ContentWriter contentWriter,
@Qualifier("replicateCircuitBreaker") CircuitBreaker circuitBreaker) {
this.replicateClient = replicateClient;
this.cdnUploader = cdnUploader;
this.jobWriter = jobWriter;
this.contentWriter = contentWriter;
this.circuitBreaker = circuitBreaker;
}
@Override
public JobInfo execute(ContentCommand.GenerateImages command) {
log.info("Stable Diffusion 이미지 생성 요청: eventDraftId={}, styles={}, platforms={}",
command.getEventDraftId(), command.getStyles(), command.getPlatforms());
// Job 생성
String jobId = "job-" + UUID.randomUUID().toString().substring(0, 8);
Job job = Job.builder()
.id(jobId)
.eventDraftId(command.getEventDraftId())
.jobType("image-generation")
.status(Job.Status.PENDING)
.progress(0)
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
// Job 저장
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("Job 생성 완료: jobId={}", jobId);
// 비동기로 이미지 생성
processImageGeneration(jobId, command);
return JobInfo.from(job);
}
@Async
private void processImageGeneration(String jobId, ContentCommand.GenerateImages command) {
try {
log.info("Stable Diffusion 이미지 생성 시작: jobId={}", jobId);
// Content 생성 또는 조회
Content content = Content.builder()
.eventDraftId(command.getEventDraftId())
.eventTitle(command.getEventDraftId() + " 이벤트")
.eventDescription("AI 생성 이벤트 이미지")
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
Content savedContent = contentWriter.save(content);
log.info("Content 생성 완료: contentId={}", savedContent.getId());
// 스타일 x 플랫폼 조합으로 이미지 생성
List<ImageStyle> styles = command.getStyles() != null && !command.getStyles().isEmpty()
? command.getStyles()
: List.of(ImageStyle.FANCY, ImageStyle.SIMPLE);
List<Platform> platforms = command.getPlatforms() != null && !command.getPlatforms().isEmpty()
? command.getPlatforms()
: List.of(Platform.INSTAGRAM, Platform.KAKAO);
List<GeneratedImage> images = new ArrayList<>();
int totalCount = styles.size() * platforms.size();
int currentCount = 0;
for (ImageStyle style : styles) {
for (Platform platform : platforms) {
currentCount++;
// 진행률 업데이트
int progress = (currentCount * 100) / totalCount;
jobWriter.updateJobStatus(jobId, "IN_PROGRESS", progress);
// Stable Diffusion으로 이미지 생성
String prompt = buildPrompt(command, style, platform);
String imageUrl = generateImage(prompt, platform);
// GeneratedImage 저장
GeneratedImage image = GeneratedImage.builder()
.eventDraftId(command.getEventDraftId())
.style(style)
.platform(platform)
.cdnUrl(imageUrl)
.prompt(prompt)
.selected(currentCount == 1) // 번째 이미지를 선택
.createdAt(java.time.LocalDateTime.now())
.updatedAt(java.time.LocalDateTime.now())
.build();
if (currentCount == 1) {
image.select();
}
GeneratedImage savedImage = contentWriter.saveImage(image);
images.add(savedImage);
log.info("이미지 생성 완료: imageId={}, style={}, platform={}, url={}",
savedImage.getId(), style, platform, imageUrl);
}
}
// Job 완료
String resultMessage = String.format("%d개의 이미지가 성공적으로 생성되었습니다.", images.size());
jobWriter.updateJobStatus(jobId, "COMPLETED", 100);
jobWriter.updateJobResult(jobId, resultMessage);
log.info("Stable Diffusion Job 완료: jobId={}, 생성된 이미지 수={}", jobId, images.size());
} catch (Exception e) {
log.error("Stable Diffusion 이미지 생성 실패: jobId={}", jobId, e);
jobWriter.updateJobError(jobId, e.getMessage());
}
}
/**
* Stable Diffusion으로 이미지 생성
*
* @param prompt 이미지 생성 프롬프트
* @param platform 플랫폼 (이미지 크기 결정)
* @return 생성된 이미지 URL
*/
private String generateImage(String prompt, Platform platform) {
try {
// 플랫폼별 이미지 크기 설정 (Platform enum에서 가져옴)
int width = platform.getWidth();
int height = platform.getHeight();
// Replicate API 요청
ReplicateRequest request = ReplicateRequest.builder()
.version(modelVersion)
.input(ReplicateRequest.Input.builder()
.prompt(prompt)
.negativePrompt("blurry, bad quality, distorted, ugly, low resolution, text, letters, words, typography, writing, numbers, characters, labels, watermark, logo, signage")
.width(width)
.height(height)
.numOutputs(1)
.guidanceScale(7.5)
.numInferenceSteps(50)
.seed(System.currentTimeMillis()) // 랜덤 시드 생성
.build())
.build();
log.info("Replicate API 호출 시작: prompt={}, size={}x{}", prompt, width, height);
ReplicateResponse response = createPredictionWithCircuitBreaker(request);
String predictionId = response.getId();
log.info("Replicate 예측 생성: predictionId={}, status={}", predictionId, response.getStatus());
// 이미지 생성 완료까지 대기 (폴링)
String replicateUrl = waitForCompletion(predictionId);
log.info("Replicate 이미지 생성 완료: predictionId={}, url={}", predictionId, replicateUrl);
// Replicate URL에서 이미지 다운로드
byte[] imageData = downloadImage(replicateUrl);
log.info("이미지 다운로드 완료: size={} bytes", imageData.length);
// Azure Blob Storage에 업로드
String fileName = String.format("event-%s-%s-%s.png",
platform.name().toLowerCase(),
predictionId.substring(0, 8),
System.currentTimeMillis());
String azureCdnUrl = cdnUploader.upload(imageData, fileName);
log.info("Azure CDN 업로드 완료: fileName={}, url={}", fileName, azureCdnUrl);
return azureCdnUrl;
} catch (Exception e) {
log.error("Stable Diffusion 이미지 생성 실패: prompt={}", prompt, e);
throw new RuntimeException("이미지 생성 실패: " + e.getMessage(), e);
}
}
/**
* Replicate API 예측 완료 대기 (폴링)
*
* @param predictionId 예측 ID
* @return 생성된 이미지 URL
*/
private String waitForCompletion(String predictionId) throws InterruptedException {
int maxRetries = 60; // 최대 5분 (5초 x 60회)
int retryCount = 0;
while (retryCount < maxRetries) {
ReplicateResponse response = getPredictionWithCircuitBreaker(predictionId);
String status = response.getStatus();
log.debug("Replicate 상태 조회: predictionId={}, status={}, retry={}/{}",
predictionId, status, retryCount, maxRetries);
if ("succeeded".equals(status)) {
List<String> output = response.getOutput();
if (output != null && !output.isEmpty()) {
return output.get(0);
}
throw new RuntimeException("이미지 URL이 없습니다");
} else if ("failed".equals(status) || "canceled".equals(status)) {
String error = response.getError() != null ? response.getError() : "알 수 없는 오류";
throw new RuntimeException("이미지 생성 실패: " + error);
}
// 5초 대기 재시도
Thread.sleep(5000);
retryCount++;
}
throw new RuntimeException("이미지 생성 타임아웃 (5분 초과)");
}
/**
* 이미지 생성 프롬프트 구성
*/
private String buildPrompt(ContentCommand.GenerateImages command, ImageStyle style, Platform platform) {
StringBuilder prompt = new StringBuilder();
// 음식 사진 전문성 강조
prompt.append("professional food photography, appetizing food shot, ");
// 업종 정보 추가
if (command.getIndustry() != null && !command.getIndustry().trim().isEmpty()) {
prompt.append(command.getIndustry()).append(" cuisine, ");
}
// 지역 정보 추가
if (command.getLocation() != null && !command.getLocation().trim().isEmpty()) {
prompt.append(command.getLocation()).append(" style, ");
}
// 트렌드 키워드 추가 (최대 3개)
if (command.getTrends() != null && !command.getTrends().isEmpty()) {
prompt.append("featuring ");
int count = Math.min(3, command.getTrends().size());
for (int i = 0; i < count; i++) {
if (i > 0) prompt.append(", ");
prompt.append(command.getTrends().get(i));
}
prompt.append(", ");
}
// 스타일별 프롬프트
switch (style) {
case FANCY:
prompt.append("elegant plating, luxurious presentation, premium dish, vibrant colors, ");
break;
case SIMPLE:
prompt.append("minimalist plating, clean presentation, simple arrangement, modern style, ");
break;
case TRENDY:
prompt.append("trendy plating, contemporary style, stylish presentation, modern gastronomy, ");
break;
}
// 플랫폼별 특성 추가
prompt.append("optimized for ").append(platform.name().toLowerCase()).append(" platform, ");
// 고품질 음식 사진 + 텍스트 제외 명시
prompt.append("high quality, detailed, 4k resolution, professional lighting, no text overlay, text-free, clean image");
return prompt.toString();
}
/**
* URL에서 이미지 다운로드
*
* @param imageUrl 이미지 URL
* @return 이미지 바이트 데이터
*/
private byte[] downloadImage(String imageUrl) throws Exception {
log.info("이미지 다운로드 시작: url={}", imageUrl);
URL url = new URL(imageUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setConnectTimeout(30000); // 30초
connection.setReadTimeout(30000); // 30초
int responseCode = connection.getResponseCode();
if (responseCode != HttpURLConnection.HTTP_OK) {
throw new RuntimeException("이미지 다운로드 실패: HTTP " + responseCode);
}
// 이미지 데이터 읽기
try (InputStream inputStream = connection.getInputStream();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
return outputStream.toByteArray();
}
}
/**
* Circuit Breaker로 보호된 Replicate 예측 생성
*
* @param request Replicate 요청
* @return Replicate 응답
*/
private ReplicateResponse createPredictionWithCircuitBreaker(ReplicateRequest request) {
try {
return circuitBreaker.executeSupplier(() -> replicateClient.createPrediction(request));
} catch (CallNotPermittedException e) {
log.error("Replicate Circuit Breaker가 OPEN 상태입니다. 예측 생성 차단");
throw new RuntimeException("Replicate API에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.", e);
} catch (Exception e) {
log.error("Replicate 예측 생성 실패", e);
throw new RuntimeException("이미지 생성 요청 실패: " + e.getMessage(), e);
}
}
/**
* Circuit Breaker로 보호된 Replicate 예측 조회
*
* @param predictionId 예측 ID
* @return Replicate 응답
*/
private ReplicateResponse getPredictionWithCircuitBreaker(String predictionId) {
try {
return circuitBreaker.executeSupplier(() -> replicateClient.getPrediction(predictionId));
} catch (CallNotPermittedException e) {
log.error("Replicate Circuit Breaker가 OPEN 상태입니다. 예측 조회 차단: predictionId={}", predictionId);
throw new RuntimeException("Replicate API에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.", e);
} catch (Exception e) {
log.error("Replicate 예측 조회 실패: predictionId={}", predictionId, e);
throw new RuntimeException("이미지 생성 상태 확인 실패: " + e.getMessage(), e);
}
}
}

View File

@ -23,13 +23,13 @@ import java.util.UUID;
/**
* Mock 이미지 생성 서비스 (테스트용)
* 실제 Kafka 연동 전까지 사용
* local test 환경에서만 사용
*
* 테스트를 위해 실제로 Content와 GeneratedImage를 생성합니다.
*/
@Slf4j
@Service
@Profile({"local", "test", "dev"})
@Profile({"local", "test"})
@RequiredArgsConstructor
public class MockGenerateImagesService implements GenerateImagesUseCase {

View File

@ -2,6 +2,7 @@ package com.kt.event.content.infra;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableAsync;
/**
@ -13,6 +14,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
"com.kt.event.common"
})
@EnableAsync
@EnableFeignClients(basePackages = "com.kt.event.content.infra.gateway.client")
public class ContentApplication {
public static void main(String[] args) {

View File

@ -0,0 +1,128 @@
package com.kt.event.content.infra.config;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
/**
* Resilience4j Circuit Breaker 설정
*
* Hugging Face API, Replicate API Azure Blob Storage에 대한 Circuit Breaker 패턴 적용
*/
@Slf4j
@Configuration
public class Resilience4jConfig {
/**
* Replicate API Circuit Breaker
*
* - 실패율 50% 이상 Open
* - 최소 5개 요청 평가
* - Open 60초 대기 (Half-Open 전환)
* - Half-Open 상태에서 3개 요청으로 평가
*/
@Bean
public CircuitBreaker replicateCircuitBreaker() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 실패율 50% 초과 Open
.slowCallRateThreshold(50) // 느린 호출 50% 초과 Open
.slowCallDurationThreshold(Duration.ofSeconds(120)) // 120초 이상 걸리면 느린 호출로 판단
.waitDurationInOpenState(Duration.ofSeconds(60)) // Open 60초 대기
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) // 횟수 기반 평가
.slidingWindowSize(10) // 최근 10개 요청 평가
.minimumNumberOfCalls(5) // 최소 5개 요청 평가
.permittedNumberOfCallsInHalfOpenState(3) // Half-Open에서 3개 요청으로 평가
.automaticTransitionFromOpenToHalfOpenEnabled(true) // 자동 Half-Open 전환
.build();
CircuitBreaker circuitBreaker = CircuitBreakerRegistry.of(config).circuitBreaker("replicate");
// Circuit Breaker 이벤트 로깅
circuitBreaker.getEventPublisher()
.onSuccess(event -> log.debug("Replicate Circuit Breaker: Success"))
.onError(event -> log.warn("Replicate Circuit Breaker: Error - {}", event.getThrowable().getMessage()))
.onStateTransition(event -> log.warn("Replicate Circuit Breaker: State transition from {} to {}",
event.getStateTransition().getFromState(), event.getStateTransition().getToState()))
.onSlowCallRateExceeded(event -> log.warn("Replicate Circuit Breaker: Slow call rate exceeded"))
.onFailureRateExceeded(event -> log.warn("Replicate Circuit Breaker: Failure rate exceeded"));
return circuitBreaker;
}
/**
* Azure Blob Storage Circuit Breaker
*
* - 실패율 50% 이상 Open
* - 최소 3개 요청 평가
* - Open 30초 대기 (Half-Open 전환)
* - Half-Open 상태에서 2개 요청으로 평가
*/
@Bean
public CircuitBreaker azureCircuitBreaker() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 실패율 50% 초과 Open
.slowCallRateThreshold(50) // 느린 호출 50% 초과 Open
.slowCallDurationThreshold(Duration.ofSeconds(30)) // 30초 이상 걸리면 느린 호출로 판단
.waitDurationInOpenState(Duration.ofSeconds(30)) // Open 30초 대기
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) // 횟수 기반 평가
.slidingWindowSize(10) // 최근 10개 요청 평가
.minimumNumberOfCalls(3) // 최소 3개 요청 평가
.permittedNumberOfCallsInHalfOpenState(2) // Half-Open에서 2개 요청으로 평가
.automaticTransitionFromOpenToHalfOpenEnabled(true) // 자동 Half-Open 전환
.build();
CircuitBreaker circuitBreaker = CircuitBreakerRegistry.of(config).circuitBreaker("azure");
// Circuit Breaker 이벤트 로깅
circuitBreaker.getEventPublisher()
.onSuccess(event -> log.debug("Azure Circuit Breaker: Success"))
.onError(event -> log.warn("Azure Circuit Breaker: Error - {}", event.getThrowable().getMessage()))
.onStateTransition(event -> log.warn("Azure Circuit Breaker: State transition from {} to {}",
event.getStateTransition().getFromState(), event.getStateTransition().getToState()))
.onSlowCallRateExceeded(event -> log.warn("Azure Circuit Breaker: Slow call rate exceeded"))
.onFailureRateExceeded(event -> log.warn("Azure Circuit Breaker: Failure rate exceeded"));
return circuitBreaker;
}
/**
* Hugging Face API Circuit Breaker
*
* - 실패율 50% 이상 Open
* - 최소 3개 요청 평가
* - Open 30초 대기 (Half-Open 전환)
* - Half-Open 상태에서 2개 요청으로 평가
*/
@Bean
public CircuitBreaker huggingfaceCircuitBreaker() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // 실패율 50% 초과 Open
.slowCallRateThreshold(50) // 느린 호출 50% 초과 Open
.slowCallDurationThreshold(Duration.ofSeconds(60)) // 60초 이상 걸리면 느린 호출로 판단
.waitDurationInOpenState(Duration.ofSeconds(30)) // Open 30초 대기
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) // 횟수 기반 평가
.slidingWindowSize(10) // 최근 10개 요청 평가
.minimumNumberOfCalls(3) // 최소 3개 요청 평가
.permittedNumberOfCallsInHalfOpenState(2) // Half-Open에서 2개 요청으로 평가
.automaticTransitionFromOpenToHalfOpenEnabled(true) // 자동 Half-Open 전환
.build();
CircuitBreaker circuitBreaker = CircuitBreakerRegistry.of(config).circuitBreaker("huggingface");
// Circuit Breaker 이벤트 로깅
circuitBreaker.getEventPublisher()
.onSuccess(event -> log.debug("Hugging Face Circuit Breaker: Success"))
.onError(event -> log.warn("Hugging Face Circuit Breaker: Error - {}", event.getThrowable().getMessage()))
.onStateTransition(event -> log.warn("Hugging Face Circuit Breaker: State transition from {} to {}",
event.getStateTransition().getFromState(), event.getStateTransition().getToState()))
.onSlowCallRateExceeded(event -> log.warn("Hugging Face Circuit Breaker: Slow call rate exceeded"))
.onFailureRateExceeded(event -> log.warn("Hugging Face Circuit Breaker: Failure rate exceeded"));
return circuitBreaker;
}
}

View File

@ -0,0 +1,149 @@
package com.kt.event.content.infra.gateway.client;
import com.azure.storage.blob.BlobClient;
import com.azure.storage.blob.BlobContainerClient;
import com.azure.storage.blob.BlobServiceClient;
import com.azure.storage.blob.BlobServiceClientBuilder;
import com.kt.event.content.biz.usecase.out.CDNUploader;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
/**
* Azure Blob Storage 업로더
*
* Azure Blob Storage에 이미지를 업로드하고 CDN URL을 반환
*/
@Slf4j
@Component
@Profile({"prod", "dev"}) // production dev 환경에서 활성화 (local은 Mock 사용)
public class AzureBlobStorageUploader implements CDNUploader {
@Value("${azure.storage.connection-string}")
private String connectionString;
@Value("${azure.storage.container-name}")
private String containerName;
private final CircuitBreaker circuitBreaker;
private BlobServiceClient blobServiceClient;
private BlobContainerClient containerClient;
public AzureBlobStorageUploader(@Qualifier("azureCircuitBreaker") CircuitBreaker circuitBreaker) {
this.circuitBreaker = circuitBreaker;
}
/**
* Azure Blob Storage 클라이언트 초기화
*/
@PostConstruct
public void init() {
// Connection string이 비어있으면 초기화 건너뛰기
if (connectionString == null || connectionString.trim().isEmpty()) {
log.warn("Azure Blob Storage connection string이 설정되지 않았습니다. Azure 업로드 기능을 사용할 수 없습니다.");
return;
}
try {
log.info("Azure Blob Storage 클라이언트 초기화 시작");
// BlobServiceClient 생성
blobServiceClient = new BlobServiceClientBuilder()
.connectionString(connectionString)
.buildClient();
// Container 클라이언트 생성 (없으면 생성)
containerClient = blobServiceClient.getBlobContainerClient(containerName);
if (!containerClient.exists()) {
containerClient.create();
log.info("Azure Blob Container 생성 완료: {}", containerName);
}
log.info("Azure Blob Storage 클라이언트 초기화 완료: container={}", containerName);
} catch (Exception e) {
log.error("Azure Blob Storage 클라이언트 초기화 실패", e);
throw new RuntimeException("Azure Blob Storage 초기화 실패: " + e.getMessage(), e);
}
}
/**
* 이미지 업로드
*
* @param imageData 이미지 바이트 데이터
* @param fileName 파일명 (확장자 포함)
* @return CDN URL
*/
@Override
public String upload(byte[] imageData, String fileName) {
try {
// Circuit Breaker로 업로드 메서드 실행
return circuitBreaker.executeSupplier(() -> doUpload(imageData, fileName));
} catch (CallNotPermittedException e) {
log.error("Azure Circuit Breaker가 OPEN 상태입니다. 업로드 차단: fileName={}", fileName);
throw new RuntimeException("Azure Blob Storage에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.", e);
} catch (Exception e) {
log.error("Azure Blob Storage 업로드 실패: fileName={}", fileName, e);
throw new RuntimeException("이미지 업로드 실패: " + e.getMessage(), e);
}
}
/**
* 실제 업로드 수행 (Circuit Breaker로 보호됨)
*/
private String doUpload(byte[] imageData, String fileName) {
// Container 초기화 확인
if (containerClient == null) {
throw new RuntimeException("Azure Blob Storage가 초기화되지 않았습니다. Connection string을 확인해주세요.");
}
// 고유한 Blob 이름 생성 (날짜 폴더 구조 + UUID)
String blobName = generateBlobName(fileName);
log.info("Azure Blob Storage 업로드 시작: blobName={}, size={} bytes", blobName, imageData.length);
// BlobClient 생성
BlobClient blobClient = containerClient.getBlobClient(blobName);
// 이미지 업로드 (덮어쓰기 허용)
blobClient.upload(new ByteArrayInputStream(imageData), imageData.length, true);
// CDN URL 생성
String cdnUrl = blobClient.getBlobUrl();
log.info("Azure Blob Storage 업로드 완료: blobName={}, url={}", blobName, cdnUrl);
return cdnUrl;
}
/**
* Blob 이름 생성
*
* 형식: {YYYY}/{MM}/{DD}/{UUID}-{fileName}
* 예시: 2025/01/27/a1b2c3d4-e5f6-7890-abcd-ef1234567890-event-image.png
*
* @param fileName 원본 파일명
* @return Blob 이름
*/
private String generateBlobName(String fileName) {
// 현재 날짜로 폴더 구조 생성
LocalDateTime now = LocalDateTime.now();
String dateFolder = now.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
// UUID 생성
String uuid = UUID.randomUUID().toString();
// Blob 이름 생성: {날짜폴더}/{UUID}-{파일명}
return String.format("%s/%s-%s", dateFolder, uuid, fileName);
}
}

View File

@ -0,0 +1,53 @@
package com.kt.event.content.infra.gateway.client;
import com.kt.event.content.infra.gateway.client.dto.HuggingFaceRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
/**
* Hugging Face Inference API 클라이언트
*
* API 문서: https://huggingface.co/docs/api-inference/index
* Stable Diffusion 모델: stabilityai/stable-diffusion-2-1
*/
@Component
@Profile({"prod", "dev"})
public class HuggingFaceApiClient {
private final RestClient restClient;
@Value("${huggingface.api.url:https://api-inference.huggingface.co}")
private String apiUrl;
@Value("${huggingface.api.token}")
private String apiToken;
@Value("${huggingface.model:stabilityai/stable-diffusion-2-1}")
private String modelId;
public HuggingFaceApiClient(RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder.build();
}
/**
* 이미지 생성 요청 (동기 방식)
*
* @param request Hugging Face 요청
* @return 생성된 이미지 바이트 데이터
*/
public byte[] generateImage(HuggingFaceRequest request) {
String url = String.format("%s/models/%s", apiUrl, modelId);
return restClient.post()
.uri(url)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + apiToken)
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(byte[].class);
}
}

View File

@ -0,0 +1,46 @@
package com.kt.event.content.infra.gateway.client;
import com.kt.event.content.infra.gateway.client.dto.ReplicateRequest;
import com.kt.event.content.infra.gateway.client.dto.ReplicateResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* Replicate API FeignClient
*
* Stable Diffusion 이미지 생성을 위한 Replicate API 클라이언트
* - API Docs: https://replicate.com/docs/reference/http
* - 인증: Authorization: Token {api_token}
*/
@FeignClient(
name = "replicate-api",
url = "${replicate.api.url:https://api.replicate.com}",
configuration = ReplicateApiConfig.class
)
public interface ReplicateApiClient {
/**
* 예측 생성 (이미지 생성 요청)
*
* POST /v1/predictions
*
* @param request 이미지 생성 요청 (모델 버전, 프롬프트 )
* @return 예측 응답 (예측 ID, 상태)
*/
@PostMapping("/v1/predictions")
ReplicateResponse createPrediction(@RequestBody ReplicateRequest request);
/**
* 예측 상태 조회
*
* GET /v1/predictions/{prediction_id}
*
* @param predictionId 예측 ID
* @return 예측 응답 (상태, 결과 이미지 URL )
*/
@GetMapping("/v1/predictions/{prediction_id}")
ReplicateResponse getPrediction(@PathVariable("prediction_id") String predictionId);
}

View File

@ -0,0 +1,40 @@
package com.kt.event.content.infra.gateway.client;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Replicate API FeignClient 설정
*
* Authorization 헤더 추가 로깅 설정
*/
@Slf4j
@Configuration
public class ReplicateApiConfig {
@Value("${replicate.api.token}")
private String apiToken;
/**
* Authorization 헤더 추가
*
* Replicate API는 "Authorization: Token {api_token}" 형식 요구
*/
@Bean
public RequestInterceptor requestInterceptor() {
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
// Authorization 헤더 추가
template.header("Authorization", "Token " + apiToken);
template.header("Content-Type", "application/json");
log.debug("Replicate API Request: {} {}", template.method(), template.url());
}
};
}
}

View File

@ -0,0 +1,59 @@
package com.kt.event.content.infra.gateway.client.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Hugging Face Inference API 요청 DTO
*
* API 문서: https://huggingface.co/docs/api-inference/index
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HuggingFaceRequest {
/**
* 이미지 생성 프롬프트
*/
private String inputs;
/**
* 생성 파라미터
*/
private Parameters parameters;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Parameters {
/**
* Negative prompt (생성하지 않을 내용)
*/
private String negative_prompt;
/**
* 이미지 너비
*/
private Integer width;
/**
* 이미지 높이
*/
private Integer height;
/**
* Guidance scale (프롬프트 준수 정도, 기본: 7.5)
*/
private Double guidance_scale;
/**
* Inference steps (품질, 기본: 50)
*/
private Integer num_inference_steps;
}
}

View File

@ -0,0 +1,92 @@
package com.kt.event.content.infra.gateway.client.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* Replicate API 요청 DTO
*
* Stable Diffusion 이미지 생성 요청
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReplicateRequest {
/**
* 사용할 모델 버전
*
* Stable Diffusion XL 1.0:
* "stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b"
*/
@JsonProperty("version")
private String version;
/**
* 모델 입력 파라미터
*/
@JsonProperty("input")
private Input input;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Input {
/**
* 이미지 생성 프롬프트
*/
@JsonProperty("prompt")
private String prompt;
/**
* 네거티브 프롬프트 (제외할 요소)
*/
@JsonProperty("negative_prompt")
private String negativePrompt;
/**
* 이미지 너비 (default: 1024)
*/
@JsonProperty("width")
private Integer width;
/**
* 이미지 높이 (default: 1024)
*/
@JsonProperty("height")
private Integer height;
/**
* 생성할 이미지 (default: 1)
*/
@JsonProperty("num_outputs")
private Integer numOutputs;
/**
* Guidance scale (default: 7.5)
* 높을수록 프롬프트에 충실
*/
@JsonProperty("guidance_scale")
private Double guidanceScale;
/**
* 추론 스텝 (default: 50)
* 높을수록 품질 향상, 시간 증가
*/
@JsonProperty("num_inference_steps")
private Integer numInferenceSteps;
/**
* 랜덤 시드 (재현성을 위해 사용)
*/
@JsonProperty("seed")
private Long seed;
}
}

View File

@ -0,0 +1,101 @@
package com.kt.event.content.infra.gateway.client.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* Replicate API 응답 DTO
*
* 이미지 생성 결과
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReplicateResponse {
/**
* 예측 ID
*/
@JsonProperty("id")
private String id;
/**
* 모델 버전
*/
@JsonProperty("version")
private String version;
/**
* 상태: starting, processing, succeeded, failed, canceled
*/
@JsonProperty("status")
private String status;
/**
* 입력 파라미터
*/
@JsonProperty("input")
private Map<String, Object> input;
/**
* 출력 결과 (이미지 URL 리스트)
*/
@JsonProperty("output")
private List<String> output;
/**
* 에러 메시지 (실패시)
*/
@JsonProperty("error")
private String error;
/**
* 로그 메시지
*/
@JsonProperty("logs")
private String logs;
/**
* 메트릭 정보
*/
@JsonProperty("metrics")
private Metrics metrics;
/**
* 생성 시간
*/
@JsonProperty("created_at")
private String createdAt;
/**
* 시작 시간
*/
@JsonProperty("started_at")
private String startedAt;
/**
* 완료 시간
*/
@JsonProperty("completed_at")
private String completedAt;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Metrics {
/**
* 예측 시간 ()
*/
@JsonProperty("predict_time")
private Double predictTime;
}
}

View File

@ -21,6 +21,17 @@ azure:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:}
container-name: ${AZURE_CONTAINER_NAME:event-images}
replicate:
api:
url: ${REPLICATE_API_URL:https://api.replicate.com}
token: ${REPLICATE_API_TOKEN:r8_Q33U00fSnpjYlHNIRglwurV446h7g8V2wkFFa}
huggingface:
api:
url: ${HUGGINGFACE_API_URL:https://api-inference.huggingface.co}
token: ${HUGGINGFACE_API_TOKEN:}
model: ${HUGGINGFACE_MODEL:runwayml/stable-diffusion-v1-5}
logging:
level:
com.kt.event: ${LOG_LEVEL_APP:DEBUG}

View File

@ -21,6 +21,11 @@ azure:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:}
container-name: ${AZURE_CONTAINER_NAME:event-images}
replicate:
api:
url: ${REPLICATE_API_URL:https://api.replicate.com}
token: ${REPLICATE_API_TOKEN:}
logging:
level:
com.kt.event: ${LOG_LEVEL_APP:DEBUG}

View File

@ -0,0 +1,25 @@
# Build stage
FROM openjdk:23-oraclelinux8 AS builder
ARG BUILD_LIB_DIR
ARG ARTIFACTORY_FILE
COPY ${BUILD_LIB_DIR}/${ARTIFACTORY_FILE} app.jar
# Run stage
FROM openjdk:23-slim
ENV USERNAME=k8s
ENV ARTIFACTORY_HOME=/home/${USERNAME}
ENV JAVA_OPTS=""
# Add a non-root user
RUN adduser --system --group ${USERNAME} && \
mkdir -p ${ARTIFACTORY_HOME} && \
chown ${USERNAME}:${USERNAME} ${ARTIFACTORY_HOME}
WORKDIR ${ARTIFACTORY_HOME}
COPY --from=builder app.jar app.jar
RUN chown ${USERNAME}:${USERNAME} app.jar
USER ${USERNAME}
ENTRYPOINT [ "sh", "-c" ]
CMD ["java ${JAVA_OPTS} -jar app.jar"]

View File

@ -0,0 +1,67 @@
#!/bin/bash
# 색상 정의
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}Content Service 빌드 및 배포 스크립트${NC}"
echo -e "${GREEN}========================================${NC}"
# 1. Gradle 빌드
echo -e "\n${YELLOW}1단계: Gradle 빌드 시작...${NC}"
./gradlew clean content-service:bootJar
if [ $? -ne 0 ]; then
echo -e "${RED}❌ Gradle 빌드 실패!${NC}"
exit 1
fi
echo -e "${GREEN}✅ Gradle 빌드 완료${NC}"
# 2. Docker 이미지 빌드
echo -e "\n${YELLOW}2단계: Docker 이미지 빌드 시작...${NC}"
DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="content-service/build/libs" \
--build-arg ARTIFACTORY_FILE="content-service.jar" \
-f ${DOCKER_FILE} \
-t content-service:latest .
if [ $? -ne 0 ]; then
echo -e "${RED}❌ Docker 이미지 빌드 실패!${NC}"
exit 1
fi
echo -e "${GREEN}✅ Docker 이미지 빌드 완료${NC}"
# 3. 이미지 확인
echo -e "\n${YELLOW}3단계: 생성된 이미지 확인...${NC}"
docker images | grep content-service
# 4. 기존 컨테이너 중지 및 삭제
echo -e "\n${YELLOW}4단계: 기존 컨테이너 정리...${NC}"
docker-compose -f deployment/container/docker-compose.yml down
# 5. 컨테이너 실행
echo -e "\n${YELLOW}5단계: Content Service 컨테이너 실행...${NC}"
docker-compose -f deployment/container/docker-compose.yml up -d
if [ $? -ne 0 ]; then
echo -e "${RED}❌ 컨테이너 실행 실패!${NC}"
exit 1
fi
echo -e "\n${GREEN}========================================${NC}"
echo -e "${GREEN}✅ 배포 완료!${NC}"
echo -e "${GREEN}========================================${NC}"
echo -e "\n${YELLOW}컨테이너 로그 확인:${NC}"
echo -e " docker logs -f content-service"
echo -e "\n${YELLOW}컨테이너 상태 확인:${NC}"
echo -e " docker ps"
echo -e "\n${YELLOW}서비스 헬스체크:${NC}"
echo -e " curl http://localhost:8084/actuator/health"
echo -e "\n${YELLOW}Swagger UI:${NC}"
echo -e " http://localhost:8084/swagger-ui/index.html"

View File

@ -0,0 +1,287 @@
# Content Service 컨테이너 이미지 빌드 및 배포 가이드
## 1. 사전 준비사항
### 필수 소프트웨어
- **Docker Desktop**: Docker 컨테이너 실행 환경
- **JDK 23**: Java 애플리케이션 빌드
- **Gradle**: 프로젝트 빌드 도구
### 외부 서비스
- **Redis 서버**: 20.214.210.71:6379
- **Kafka 서버**: 4.230.50.63:9092
- **Replicate API**: Stable Diffusion 이미지 생성
- **Azure Blob Storage**: 이미지 CDN
## 2. 빌드 설정
### build.gradle 설정 (content-service/build.gradle)
```gradle
// 실행 JAR 파일명 설정
bootJar {
archiveFileName = 'content-service.jar'
}
```
## 3. 배포 파일 구조
```
deployment/
└── container/
├── Dockerfile-backend # 백엔드 서비스용 Dockerfile
├── docker-compose.yml # Docker Compose 설정
└── build-and-run.sh # 자동화 배포 스크립트
```
## 4. 수동 빌드 및 배포
### 4.1 Gradle 빌드
```bash
# 프로젝트 루트에서 실행
./gradlew clean content-service:bootJar
```
### 4.2 Docker 이미지 빌드
```bash
DOCKER_FILE=deployment/container/Dockerfile-backend
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="content-service/build/libs" \
--build-arg ARTIFACTORY_FILE="content-service.jar" \
-f ${DOCKER_FILE} \
-t content-service:latest .
```
### 4.3 빌드된 이미지 확인
```bash
docker images | grep content-service
```
예상 출력:
```
content-service latest abc123def456 2 minutes ago 450MB
```
### 4.4 Docker Compose로 컨테이너 실행
```bash
docker-compose -f deployment/container/docker-compose.yml up -d
```
### 4.5 컨테이너 상태 확인
```bash
# 실행 중인 컨테이너 확인
docker ps
# 로그 확인
docker logs -f content-service
# 헬스체크
curl http://localhost:8084/actuator/health
```
## 5. 자동화 배포 스크립트 사용 (권장)
### 5.1 스크립트 실행
```bash
# 프로젝트 루트에서 실행
./deployment/container/build-and-run.sh
```
### 5.2 스크립트 수행 단계
1. Gradle 빌드
2. Docker 이미지 빌드
3. 이미지 확인
4. 기존 컨테이너 정리
5. 새 컨테이너 실행
## 6. 환경변수 설정
`docker-compose.yml`에 다음 환경변수가 설정되어 있습니다:
### 필수 환경변수
- `SPRING_PROFILES_ACTIVE`: Spring Profile (prod)
- `SERVER_PORT`: 서버 포트 (8084)
- `REDIS_HOST`: Redis 호스트
- `REDIS_PORT`: Redis 포트
- `REDIS_PASSWORD`: Redis 비밀번호
- `JWT_SECRET`: JWT 서명 키 (최소 32자)
- `REPLICATE_API_TOKEN`: Replicate API 토큰
- `AZURE_STORAGE_CONNECTION_STRING`: Azure Storage 연결 문자열
- `AZURE_CONTAINER_NAME`: Azure Storage 컨테이너 이름
### JWT_SECRET 요구사항
- **최소 길이**: 32자 이상 (256비트)
- **형식**: 영문자, 숫자 조합
- **예시**: `kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025`
## 7. VM 배포
### 7.1 VM에 파일 전송
```bash
# VM으로 파일 복사 (예시)
scp -r deployment/ user@vm-host:/path/to/project/
scp docker-compose.yml user@vm-host:/path/to/project/deployment/container/
scp content-service/build/libs/content-service.jar user@vm-host:/path/to/project/content-service/build/libs/
```
### 7.2 VM에서 이미지 빌드
```bash
# VM에 SSH 접속 후
cd /path/to/project
# 이미지 빌드
docker build \
--platform linux/amd64 \
--build-arg BUILD_LIB_DIR="content-service/build/libs" \
--build-arg ARTIFACTORY_FILE="content-service.jar" \
-f deployment/container/Dockerfile-backend \
-t content-service:latest .
```
### 7.3 VM에서 컨테이너 실행
```bash
# Docker Compose로 실행
docker-compose -f deployment/container/docker-compose.yml up -d
# 또는 직접 실행
docker run -d \
--name content-service \
-p 8084:8084 \
-e SPRING_PROFILES_ACTIVE=prod \
-e SERVER_PORT=8084 \
-e REDIS_HOST=20.214.210.71 \
-e REDIS_PORT=6379 \
-e REDIS_PASSWORD=Hi5Jessica! \
-e JWT_SECRET=kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025 \
-e REPLICATE_API_TOKEN=r8_Q33U00fSnpjYlHNIRglwurV446h7g8V2wkFFa \
-e AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net" \
-e AZURE_CONTAINER_NAME=content-images \
content-service:latest
```
## 8. 모니터링 및 로그
### 8.1 컨테이너 상태 확인
```bash
docker ps
```
### 8.2 로그 확인
```bash
# 실시간 로그
docker logs -f content-service
# 최근 100줄
docker logs --tail 100 content-service
```
### 8.3 헬스체크
```bash
curl http://localhost:8084/actuator/health
```
예상 응답:
```json
{
"status": "UP",
"components": {
"ping": {
"status": "UP"
},
"redis": {
"status": "UP"
}
}
}
```
## 9. Swagger UI 접근
배포 후 Swagger UI로 API 테스트 가능:
```
http://localhost:8084/swagger-ui/index.html
```
## 10. 이미지 생성 API 테스트
### 10.1 이미지 생성 요청
```bash
curl -X POST "http://localhost:8084/api/v1/content/images/generate" \
-H "Content-Type: application/json" \
-d '{
"eventDraftId": 1001,
"industry": "고깃집",
"location": "강남",
"trends": ["가을", "단풍", "BBQ"],
"styles": ["FANCY"],
"platforms": ["INSTAGRAM"]
}'
```
### 10.2 Job 상태 확인
```bash
curl http://localhost:8084/api/v1/content/jobs/{jobId}
```
## 11. 컨테이너 관리 명령어
### 11.1 컨테이너 중지
```bash
docker-compose -f deployment/container/docker-compose.yml down
```
### 11.2 컨테이너 재시작
```bash
docker-compose -f deployment/container/docker-compose.yml restart
```
### 11.3 컨테이너 삭제
```bash
# 컨테이너만 삭제
docker rm -f content-service
# 이미지도 삭제
docker rmi content-service:latest
```
## 12. 트러블슈팅
### 12.1 JWT 토큰 오류
**증상**: `Error creating bean with name 'jwtTokenProvider'`
**해결방법**:
- `JWT_SECRET` 환경변수가 32자 이상인지 확인
- docker-compose.yml에 올바르게 설정되어 있는지 확인
### 12.2 Redis 연결 오류
**증상**: `Unable to connect to Redis`
**해결방법**:
- Redis 서버(20.214.210.71:6379)가 실행 중인지 확인
- 방화벽 설정 확인
- 비밀번호 확인
### 12.3 Azure Storage 오류
**증상**: `Azure storage connection failed`
**해결방법**:
- `AZURE_STORAGE_CONNECTION_STRING`이 올바른지 확인
- Storage Account가 활성화되어 있는지 확인
- 컨테이너 이름(`content-images`)이 존재하는지 확인
## 13. 빌드 결과
### 빌드 정보
- **서비스명**: content-service
- **JAR 파일**: content-service.jar
- **Docker 이미지**: content-service:latest
- **노출 포트**: 8084
### 빌드 일시
- **빌드 날짜**: 2025-10-27
### 환경
- **Base Image**: openjdk:23-slim
- **Platform**: linux/amd64
- **User**: k8s (non-root)

View File

@ -0,0 +1,58 @@
version: '3.8'
services:
content-service:
image: content-service:latest
container_name: content-service
ports:
- "8084:8084"
environment:
# Spring Profile
SPRING_PROFILES_ACTIVE: prod
# Server Configuration
SERVER_PORT: 8084
# Redis Configuration (외부 Redis 서버)
REDIS_HOST: 20.214.210.71
REDIS_PORT: 6379
REDIS_PASSWORD: Hi5Jessica!
# Kafka Configuration (외부 Kafka 서버)
KAFKA_BOOTSTRAP_SERVERS: 4.230.50.63:9092
KAFKA_CONSUMER_GROUP_ID: content-service-consumers
# JWT Configuration
JWT_SECRET: kt-event-marketing-jwt-secret-key-for-authentication-and-authorization-2025
JWT_ACCESS_TOKEN_VALIDITY: 3600000
JWT_REFRESH_TOKEN_VALIDITY: 604800000
# Replicate API (Stable Diffusion)
REPLICATE_API_TOKEN: r8_Q33U00fSnpjYlHNIRglwurV446h7g8V2wkFFa
# Azure Blob Storage Configuration
AZURE_STORAGE_CONNECTION_STRING: DefaultEndpointsProtocol=https;AccountName=blobkteventstorage;AccountKey=tcBN7mAfojbl0uGsOpU7RNuKNhHnzmwDiWjN31liSMVSrWaEK+HHnYKZrjBXXAC6ZPsuxUDlsf8x+AStd++QYg==;EndpointSuffix=core.windows.net
AZURE_CONTAINER_NAME: content-images
# Logging Configuration
LOG_LEVEL_APP: INFO
LOG_LEVEL_ROOT: INFO
# JVM Options
JAVA_OPTS: "-Xmx512m -Xms256m"
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8084/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- kt-event-network
networks:
kt-event-network:
driver: bridge