mirror of
https://github.com/ktds-dg0501/kt-event-marketing.git
synced 2026-06-12 23:19:10 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
+288
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+128
@@ -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;
|
||||
}
|
||||
}
|
||||
+149
@@ -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);
|
||||
}
|
||||
}
|
||||
+53
@@ -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);
|
||||
}
|
||||
}
|
||||
+46
@@ -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);
|
||||
}
|
||||
+40
@@ -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());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+59
@@ -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;
|
||||
}
|
||||
}
|
||||
+92
@@ -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;
|
||||
}
|
||||
}
|
||||
+101
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user