VM 배포를 위한 Docker 컨테이너 설정 추가

- content-service/build.gradle: bootJar 파일명 설정 추가
- deployment/container/Dockerfile-backend: 백엔드 서비스 Docker 이미지 파일
- deployment/container/docker-compose.yml: Docker Compose 설정 (환경변수 포함)
- deployment/container/build-and-run.sh: 자동화 빌드 및 배포 스크립트
- deployment/container/build-image.md: 상세 배포 가이드 문서

주요 환경변수:
- JWT_SECRET: 32자 이상 JWT 서명 키 (JWT 오류 해결)
- REDIS/KAFKA: 외부 서버 연결 정보
- REPLICATE_API_TOKEN: Stable Diffusion API 토큰
- AZURE_STORAGE_CONNECTION_STRING: Azure Blob Storage 연결

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
cherry2250
2025-10-27 17:00:20 +09:00
parent 2da2f124a2
commit 5f8bd7cf68
20 changed files with 1737 additions and 2 deletions
+5
View File
@@ -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);
}
}
}
@@ -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}