diff --git a/content-service/build.gradle b/content-service/build.gradle index 3518c28..17c9c23 100644 --- a/content-service/build.gradle +++ b/content-service/build.gradle @@ -21,3 +21,8 @@ dependencies { // Jackson for JSON implementation 'com.fasterxml.jackson.core:jackson-databind' } + +// 실행 JAR 파일명 설정 +bootJar { + archiveFileName = 'content-service.jar' +} diff --git a/content-service/src/main/java/com/kt/event/content/biz/dto/ContentCommand.java b/content-service/src/main/java/com/kt/event/content/biz/dto/ContentCommand.java index a017182..6b06318 100644 --- a/content-service/src/main/java/com/kt/event/content/biz/dto/ContentCommand.java +++ b/content-service/src/main/java/com/kt/event/content/biz/dto/ContentCommand.java @@ -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 trends; + private List styles; private List platforms; } diff --git a/content-service/src/main/java/com/kt/event/content/biz/service/HuggingFaceImageGenerator.java b/content-service/src/main/java/com/kt/event/content/biz/service/HuggingFaceImageGenerator.java new file mode 100644 index 0000000..09c864a --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/biz/service/HuggingFaceImageGenerator.java @@ -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 styles = command.getStyles() != null && !command.getStyles().isEmpty() + ? command.getStyles() + : List.of(ImageStyle.FANCY, ImageStyle.SIMPLE); + + List platforms = command.getPlatforms() != null && !command.getPlatforms().isEmpty() + ? command.getPlatforms() + : List.of(Platform.INSTAGRAM, Platform.KAKAO); + + List 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); + } + } +} diff --git a/content-service/src/main/java/com/kt/event/content/biz/service/mock/MockGenerateImagesService.java b/content-service/src/main/java/com/kt/event/content/biz/service/mock/MockGenerateImagesService.java index 5841a18..5fd1ab7 100644 --- a/content-service/src/main/java/com/kt/event/content/biz/service/mock/MockGenerateImagesService.java +++ b/content-service/src/main/java/com/kt/event/content/biz/service/mock/MockGenerateImagesService.java @@ -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 { diff --git a/content-service/src/main/java/com/kt/event/content/infra/ContentApplication.java b/content-service/src/main/java/com/kt/event/content/infra/ContentApplication.java index da40634..31a8d57 100644 --- a/content-service/src/main/java/com/kt/event/content/infra/ContentApplication.java +++ b/content-service/src/main/java/com/kt/event/content/infra/ContentApplication.java @@ -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) { diff --git a/content-service/src/main/java/com/kt/event/content/infra/config/Resilience4jConfig.java b/content-service/src/main/java/com/kt/event/content/infra/config/Resilience4jConfig.java new file mode 100644 index 0000000..93ec6a7 --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/infra/config/Resilience4jConfig.java @@ -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; + } +} diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/client/AzureBlobStorageUploader.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/client/AzureBlobStorageUploader.java new file mode 100644 index 0000000..a977eeb --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/client/AzureBlobStorageUploader.java @@ -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); + } +} diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/client/HuggingFaceApiClient.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/client/HuggingFaceApiClient.java new file mode 100644 index 0000000..dd53ef0 --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/client/HuggingFaceApiClient.java @@ -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); + } +} diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/client/ReplicateApiClient.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/client/ReplicateApiClient.java new file mode 100644 index 0000000..7272a6d --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/client/ReplicateApiClient.java @@ -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); +} diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/client/ReplicateApiConfig.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/client/ReplicateApiConfig.java new file mode 100644 index 0000000..a017082 --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/client/ReplicateApiConfig.java @@ -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()); + } + }; + } +} diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/client/dto/HuggingFaceRequest.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/client/dto/HuggingFaceRequest.java new file mode 100644 index 0000000..94827c8 --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/client/dto/HuggingFaceRequest.java @@ -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; + } +} diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/client/dto/ReplicateRequest.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/client/dto/ReplicateRequest.java new file mode 100644 index 0000000..cc532ab --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/client/dto/ReplicateRequest.java @@ -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; + } +} diff --git a/content-service/src/main/java/com/kt/event/content/infra/gateway/client/dto/ReplicateResponse.java b/content-service/src/main/java/com/kt/event/content/infra/gateway/client/dto/ReplicateResponse.java new file mode 100644 index 0000000..298aa9a --- /dev/null +++ b/content-service/src/main/java/com/kt/event/content/infra/gateway/client/dto/ReplicateResponse.java @@ -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 input; + + /** + * 출력 결과 (이미지 URL 리스트) + */ + @JsonProperty("output") + private List 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; + } +} diff --git a/content-service/src/main/resources/application-dev.yml b/content-service/src/main/resources/application-dev.yml index a58c15c..6c0abb8 100644 --- a/content-service/src/main/resources/application-dev.yml +++ b/content-service/src/main/resources/application-dev.yml @@ -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} diff --git a/content-service/src/main/resources/application.yml b/content-service/src/main/resources/application.yml index 9da4c98..e5d3f3d 100644 --- a/content-service/src/main/resources/application.yml +++ b/content-service/src/main/resources/application.yml @@ -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} diff --git a/deployment/container/Dockerfile-backend b/deployment/container/Dockerfile-backend new file mode 100644 index 0000000..37da239 --- /dev/null +++ b/deployment/container/Dockerfile-backend @@ -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"] diff --git a/deployment/container/build-and-run.sh b/deployment/container/build-and-run.sh new file mode 100755 index 0000000..36d58e9 --- /dev/null +++ b/deployment/container/build-and-run.sh @@ -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" diff --git a/deployment/container/build-image.md b/deployment/container/build-image.md new file mode 100644 index 0000000..f2c8b54 --- /dev/null +++ b/deployment/container/build-image.md @@ -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) diff --git a/deployment/container/docker-compose.yml b/deployment/container/docker-compose.yml new file mode 100644 index 0000000..b0ef5f5 --- /dev/null +++ b/deployment/container/docker-compose.yml @@ -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 diff --git a/tools/run-intellij-service-profile.py b/tools/run-intellij-service-profile.py new file mode 100644 index 0000000..2278686 --- /dev/null +++ b/tools/run-intellij-service-profile.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Tripgen Service Runner Script +Reads execution profiles from {service-name}/.run/{service-name}.run.xml and runs services accordingly. + +Usage: + python run-config.py + +Examples: + python run-config.py user-service + python run-config.py location-service + python run-config.py trip-service + python run-config.py ai-service +""" + +import os +import sys +import subprocess +import xml.etree.ElementTree as ET +from pathlib import Path +import argparse + + +def get_project_root(): + """Find project root directory""" + current_dir = Path(__file__).parent.absolute() + while current_dir.parent != current_dir: + if (current_dir / 'gradlew').exists() or (current_dir / 'gradlew.bat').exists(): + return current_dir + current_dir = current_dir.parent + + # If gradlew not found, assume parent directory of develop as project root + return Path(__file__).parent.parent.absolute() + + +def parse_run_configurations(project_root, service_name=None): + """Parse run configuration files from .run directories""" + configurations = {} + + if service_name: + # Parse specific service configuration + run_config_path = project_root / service_name / '.run' / f'{service_name}.run.xml' + if run_config_path.exists(): + config = parse_single_run_config(run_config_path, service_name) + if config: + configurations[service_name] = config + else: + print(f"[ERROR] Cannot find run configuration: {run_config_path}") + else: + # Find all service directories + service_dirs = ['user-service', 'location-service', 'trip-service', 'ai-service'] + for service in service_dirs: + run_config_path = project_root / service / '.run' / f'{service}.run.xml' + if run_config_path.exists(): + config = parse_single_run_config(run_config_path, service) + if config: + configurations[service] = config + + return configurations + + +def parse_single_run_config(config_path, service_name): + """Parse a single run configuration file""" + try: + tree = ET.parse(config_path) + root = tree.getroot() + + # Find configuration element + config = root.find('.//configuration[@type="GradleRunConfiguration"]') + if config is None: + print(f"[WARNING] No Gradle configuration found in {config_path}") + return None + + # Extract environment variables + env_vars = {} + env_option = config.find('.//option[@name="env"]') + if env_option is not None: + env_map = env_option.find('map') + if env_map is not None: + for entry in env_map.findall('entry'): + key = entry.get('key') + value = entry.get('value') + if key and value: + env_vars[key] = value + + # Extract task names + task_names = [] + task_names_option = config.find('.//option[@name="taskNames"]') + if task_names_option is not None: + task_list = task_names_option.find('list') + if task_list is not None: + for option in task_list.findall('option'): + value = option.get('value') + if value: + task_names.append(value) + + if env_vars or task_names: + return { + 'env_vars': env_vars, + 'task_names': task_names, + 'config_path': str(config_path) + } + + return None + + except ET.ParseError as e: + print(f"[ERROR] XML parsing error in {config_path}: {e}") + return None + except Exception as e: + print(f"[ERROR] Error reading {config_path}: {e}") + return None + + +def get_gradle_command(project_root): + """Return appropriate Gradle command for OS""" + if os.name == 'nt': # Windows + gradle_bat = project_root / 'gradlew.bat' + if gradle_bat.exists(): + return str(gradle_bat) + return 'gradle.bat' + else: # Unix-like (Linux, macOS) + gradle_sh = project_root / 'gradlew' + if gradle_sh.exists(): + return str(gradle_sh) + return 'gradle' + + +def run_service(service_name, config, project_root): + """Run service""" + print(f"[START] Starting {service_name} service...") + + # Set environment variables + env = os.environ.copy() + for key, value in config['env_vars'].items(): + env[key] = value + print(f" [ENV] {key}={value}") + + # Prepare Gradle command + gradle_cmd = get_gradle_command(project_root) + + # Execute tasks + for task_name in config['task_names']: + print(f"\n[RUN] Executing: {task_name}") + + cmd = [gradle_cmd, task_name] + + try: + # Execute from project root directory + process = subprocess.Popen( + cmd, + cwd=project_root, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + bufsize=1, + encoding='utf-8', + errors='replace' + ) + + print(f"[CMD] Command: {' '.join(cmd)}") + print(f"[DIR] Working directory: {project_root}") + print("=" * 50) + + # Real-time output + for line in process.stdout: + print(line.rstrip()) + + # Wait for process completion + process.wait() + + if process.returncode == 0: + print(f"\n[SUCCESS] {task_name} execution completed") + else: + print(f"\n[FAILED] {task_name} execution failed (exit code: {process.returncode})") + return False + + except KeyboardInterrupt: + print(f"\n[STOP] Interrupted by user") + process.terminate() + return False + except Exception as e: + print(f"\n[ERROR] Execution error: {e}") + return False + + return True + + +def list_available_services(configurations): + """List available services""" + print("[LIST] Available services:") + print("=" * 40) + + for service_name, config in configurations.items(): + if config['task_names']: + print(f" [SERVICE] {service_name}") + if 'config_path' in config: + print(f" +-- Config: {config['config_path']}") + for task in config['task_names']: + print(f" +-- Task: {task}") + print(f" +-- {len(config['env_vars'])} environment variables") + print() + + +def main(): + """Main function""" + parser = argparse.ArgumentParser( + description='Tripgen Service Runner Script', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python run-config.py user-service + python run-config.py location-service + python run-config.py trip-service + python run-config.py ai-service + python run-config.py --list + """ + ) + + parser.add_argument( + 'service_name', + nargs='?', + help='Service name to run' + ) + + parser.add_argument( + '--list', '-l', + action='store_true', + help='List available services' + ) + + args = parser.parse_args() + + # Find project root + project_root = get_project_root() + print(f"[INFO] Project root: {project_root}") + + # Parse run configurations + print("[INFO] Reading run configuration files...") + configurations = parse_run_configurations(project_root) + + if not configurations: + print("[ERROR] No execution configurations found") + return 1 + + print(f"[INFO] Found {len(configurations)} execution configurations") + + # List services request + if args.list: + list_available_services(configurations) + return 0 + + # If service name not provided + if not args.service_name: + print("\n[ERROR] Please provide service name") + list_available_services(configurations) + print("Usage: python run-config.py ") + return 1 + + # Find service + service_name = args.service_name + + # Try to parse specific service configuration if not found + if service_name not in configurations: + print(f"[INFO] Trying to find configuration for '{service_name}'...") + configurations = parse_run_configurations(project_root, service_name) + + if service_name not in configurations: + print(f"[ERROR] Cannot find '{service_name}' service") + list_available_services(configurations) + return 1 + + config = configurations[service_name] + + if not config['task_names']: + print(f"[ERROR] No executable tasks found for '{service_name}' service") + return 1 + + # Execute service + print(f"\n[TARGET] Starting '{service_name}' service execution") + print("=" * 50) + + success = run_service(service_name, config, project_root) + + if success: + print(f"\n[COMPLETE] '{service_name}' service started successfully!") + return 0 + else: + print(f"\n[FAILED] Failed to start '{service_name}' service") + return 1 + + +if __name__ == '__main__': + try: + exit_code = main() + sys.exit(exit_code) + except KeyboardInterrupt: + print("\n[STOP] Interrupted by user") + sys.exit(1) + except Exception as e: + print(f"\n[ERROR] Unexpected error occurred: {e}") + sys.exit(1) \ No newline at end of file