Merge pull request #12 from ktds-dg0501/feature/content
Feature/content
This commit is contained in:
commit
397a23063d
@ -21,3 +21,8 @@ dependencies {
|
||||
// Jackson for JSON
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
||||
}
|
||||
|
||||
// 실행 JAR 파일명 설정
|
||||
bootJar {
|
||||
archiveFileName = 'content-service.jar'
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
25
deployment/container/Dockerfile-backend
Normal file
25
deployment/container/Dockerfile-backend
Normal 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"]
|
||||
67
deployment/container/build-and-run.sh
Executable file
67
deployment/container/build-and-run.sh
Executable 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"
|
||||
287
deployment/container/build-image.md
Normal file
287
deployment/container/build-image.md
Normal 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)
|
||||
58
deployment/container/docker-compose.yml
Normal file
58
deployment/container/docker-compose.yml
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user