diff --git a/src/main/java/com/salemale/domain/item/service/ItemImageAiService.java b/src/main/java/com/salemale/domain/item/service/ItemImageAiService.java index c42fb3b..63aec78 100644 --- a/src/main/java/com/salemale/domain/item/service/ItemImageAiService.java +++ b/src/main/java/com/salemale/domain/item/service/ItemImageAiService.java @@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; +import reactor.util.retry.Retry; import software.amazon.awssdk.core.ResponseBytes; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.GetObjectRequest; @@ -30,36 +31,33 @@ @Service public class ItemImageAiService { - private final WebClient webClient; // WebClient를 인스턴스 필드로 재사용 + private final WebClient geminiWebClient; private final S3Client s3Client; private final ObjectMapper objectMapper; - @Value("${gemini.api-key}") - private String apiKey; - @Value("${gemini.model}") private String model; - @Value("${gemini.api-url}") - private String apiUrl; - @Value("${aws.s3.bucket}") private String bucketName; @Value("${aws.s3.region}") private String region; - // 생성자에서 WebClient 설정 (DI로 생성) - public ItemImageAiService(WebClient.Builder webClientBuilder, S3Client s3Client, ObjectMapper objectMapper, - @Value("${gemini.api-key}") String apiKey, - @Value("${gemini.api-url}") String apiUrl) { + // 이미지 크기 제한 (Base64 인코딩 후 4MB) + private static final int MAX_IMAGE_SIZE = 4 * 1024 * 1024; + + public ItemImageAiService( + S3Client s3Client, + ObjectMapper objectMapper, + @Value("${gemini.api-key}") String apiKey, + @Value("${gemini.api-url}") String apiUrl) { + this.s3Client = s3Client; this.objectMapper = objectMapper; - this.apiKey = apiKey; - this.apiUrl = apiUrl; - // baseUrl과 기본 헤더 설정 - this.webClient = webClientBuilder + // Gemini 전용 WebClient 설정 + this.geminiWebClient = WebClient.builder() .baseUrl(apiUrl) .defaultHeader("x-goog-api-key", apiKey) .defaultHeader("Content-Type", "application/json") @@ -67,130 +65,147 @@ public ItemImageAiService(WebClient.Builder webClientBuilder, S3Client s3Client, } /** - * 이미지 URL을 받아 상품 정보를 분석합니다. - * @param imageUrl S3 temp 폴더의 이미지 URL - * @return AI가 분석한 상품 정보 + * 이미지 분석 */ public ProductAnalysisResponse analyzeProductImage(String imageUrl) { - // 1. temp URL 검증 if (!imageUrl.contains("/temp/")) { throw new GeneralException(ErrorStatus.IMAGE_NOT_TEMP_URL); } - // 2. S3에서 이미지 다운로드 - byte[] imageBytes = downloadImageFromS3(imageUrl); + // 2. S3에서 이미지 다운로드 (MIME 타입 포함) + ImageData imageData = downloadImageFromS3(imageUrl); - // 3. Base64 인코딩 - String base64Image = Base64.getEncoder().encodeToString(imageBytes); + // 3. 이미지 크기 검증 + if (imageData.base64Data.length() > MAX_IMAGE_SIZE) { + throw new GeneralException(ErrorStatus.IMAGE_SIZE_EXCEEDED); + } // 4. Gemini API 호출 - String responseText = callGeminiApi(base64Image); + String responseText = callGeminiApi(imageData); - // 5. 응답 파싱 및 DTO 생성 + // 5. 응답 파싱 return parseGeminiResponse(responseText); } /** - * S3에서 이미지를 다운로드합니다. + * S3에서 이미지와 MIME 타입 다운로드 */ - private byte[] downloadImageFromS3(String imageUrl) { + private ImageData downloadImageFromS3(String imageUrl) { try { - // URL에서 S3 키 추출 String s3Key = extractS3KeyFromUrl(imageUrl); - // S3에서 이미지 가져오기 GetObjectRequest getObjectRequest = GetObjectRequest.builder() .bucket(bucketName) .key(s3Key) .build(); ResponseBytes objectBytes = s3Client.getObjectAsBytes(getObjectRequest); - log.info("S3 이미지 다운로드 성공 - 크기: {} bytes", objectBytes.asByteArray().length); + + // 실제 MIME 타입 추출 + String contentType = objectBytes.response().contentType(); + if (contentType == null || contentType.isEmpty()) { + contentType = detectMimeType(s3Key); + } + + byte[] imageBytes = objectBytes.asByteArray(); + String base64Data = Base64.getEncoder().encodeToString(imageBytes); - return objectBytes.asByteArray(); + return new ImageData(base64Data, contentType); } catch (S3Exception e) { - log.error("S3 이미지 다운로드 실패: {}", e.getMessage()); throw new GeneralException(ErrorStatus.IMAGE_DOWNLOAD_FAILED); } } /** - * Gemini API를 호출하여 이미지 분석 결과를 받습니다. + * 파일 확장자로 MIME 타입 추정 */ - private String callGeminiApi(String base64Image) { + private String detectMimeType(String s3Key) { + String lowerKey = s3Key.toLowerCase(); + if (lowerKey.endsWith(".png")) return "image/png"; + if (lowerKey.endsWith(".jpg") || lowerKey.endsWith(".jpeg")) return "image/jpeg"; + if (lowerKey.endsWith(".webp")) return "image/webp"; + if (lowerKey.endsWith(".gif")) return "image/gif"; + return "image/jpeg"; // 기본값 + } + + /** + * Gemini API 호출 (재시도 로직 포함) + */ + private String callGeminiApi(ImageData imageData) { try { - Map requestBody = createGeminiRequestBody(base64Image); + Map requestBody = createGeminiRequestBody(imageData); - // 인스턴스의 webClient 사용 - String response = webClient.post() - .uri("/" + model + ":generateContent") // baseUrl 이후 경로만 지정 + String response = geminiWebClient.post() + .uri("/" + model + ":generateContent") .bodyValue(requestBody) .retrieve() .bodyToMono(String.class) - .block(Duration.ofSeconds(30)); // 동기 호출 유지 + .timeout(Duration.ofSeconds(30)) + .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)) + .maxBackoff(Duration.ofSeconds(5)) + .filter(throwable -> !(throwable instanceof GeneralException)) + ) + .block(); return response; } catch (Exception e) { - log.error("Gemini API 호출 실패: {}", e.getMessage()); throw new GeneralException(ErrorStatus.GEMINI_API_ERROR); } } /** - * Gemini API 요청 본문을 생성합니다. + * Gemini API 요청 본문 생성 (최적화) */ - private Map createGeminiRequestBody(String base64Image) { - // 프롬프트 설정 - String prompt = """ - 이 상품 이미지를 분석해서 다음 정보를 JSON 형식으로 추출해: - { - "productName": "상품의 전체 이름 (브랜드 + 모델명 포함)", - "category": "카테고리", - "confidence": 신뢰도 - } - - 중요: productName은 반드시 구체적으로 작성해 - 예시: "메리다 스컬트라 100", "삼성 갤럭시 S24", "나이키 에어맥스 270", "아이폰 17PRO" - - 카테고리 목록: - HOME_APPLIANCE, HEALTH_FOOD, BEAUTY, FOOD_PROCESSED, PET, - DIGITAL, LIVING_KITCHEN, WOMEN_ACC, SPORTS, PLANT, - GAME_HOBBY, TICKET, FURNITURE, BOOK, KIDS, CLOTHES, ETC - - JSON 형식만 출력하면돼. - """; - - // 이미지 파트 - Map imagePart = new HashMap<>(); - Map inlineData = new HashMap<>(); - inlineData.put("mime_type", "image/jpeg"); - inlineData.put("data", base64Image); - imagePart.put("inline_data", inlineData); + private Map createGeminiRequestBody(ImageData imageData) { + // 단순하고 명확한 프롬프트 + String prompt = "Analyze this product image and extract information in JSON format:\n" + + "{\n" + + " \"productName\": \"full product name with brand and model\",\n" + + " \"category\": \"one of: HOME_APPLIANCE, HEALTH_FOOD, BEAUTY, FOOD_PROCESSED, PET, DIGITAL, LIVING_KITCHEN, WOMEN_ACC, SPORTS, PLANT, GAME_HOBBY, TICKET, FURNITURE, BOOK, KIDS, CLOTHES, ETC\",\n" + + " \"confidence\": 0.0 to 1.0\n" + + "}\n" + + "Examples: \"Samsung Galaxy S24\", \"Nike Air Max 270\", \"iPhone 15 Pro\"\n" + + "Return ONLY the JSON object, no markdown, no explanation."; // 텍스트 파트 Map textPart = new HashMap<>(); textPart.put("text", prompt); + // 이미지 파트 (정확한 MIME 타입 사용) + Map imagePart = new HashMap<>(); + Map inlineData = new HashMap<>(); + inlineData.put("mime_type", imageData.mimeType); + inlineData.put("data", imageData.base64Data); + imagePart.put("inline_data", inlineData); + // 컨텐츠 구성 Map content = new HashMap<>(); content.put("parts", List.of(textPart, imagePart)); + // generationConfig 추가 (JSON 응답 강제) + Map generationConfig = new HashMap<>(); + generationConfig.put("temperature", 0.4); // 일관성 높이기 + generationConfig.put("topP", 0.8); + generationConfig.put("topK", 40); + generationConfig.put("maxOutputTokens", 512); + generationConfig.put("responseMimeType", "application/json"); // JSON 강제 + // 최종 요청 본문 Map requestBody = new HashMap<>(); requestBody.put("contents", List.of(content)); + requestBody.put("generationConfig", generationConfig); return requestBody; } /** - * Gemini 응답을 파싱하여 DTO로 변환합니다. + * Gemini 응답 파싱 */ private ProductAnalysisResponse parseGeminiResponse(String responseText) { try { - // JSON 파싱 JsonNode rootNode = objectMapper.readTree(responseText); JsonNode candidatesNode = rootNode.path("candidates"); @@ -198,7 +213,6 @@ private ProductAnalysisResponse parseGeminiResponse(String responseText) { throw new GeneralException(ErrorStatus.IMAGE_ANALYSIS_FAILED); } - // 텍스트 추출 String text = candidatesNode.get(0) .path("content") .path("parts") @@ -206,7 +220,7 @@ private ProductAnalysisResponse parseGeminiResponse(String responseText) { .path("text") .asText(); - // JSON 부분만 추출 (마크다운 코드 블록 제거) + // JSON 정제 (마크다운 제거) String jsonText = extractJsonFromMarkdown(text); // 상품 정보 파싱 @@ -219,16 +233,14 @@ private ProductAnalysisResponse parseGeminiResponse(String responseText) { .build(); } catch (Exception e) { - log.error("Gemini 응답 파싱 실패: {}", e.getMessage()); throw new GeneralException(ErrorStatus.IMAGE_ANALYSIS_FAILED); } } /** - * 마크다운 코드 블록에서 JSON 추출 + * 마크다운 제거 */ private String extractJsonFromMarkdown(String text) { - // ```json 또는 ``` 블록 제거 String cleaned = text.trim(); if (cleaned.startsWith("```json")) { cleaned = cleaned.substring(7); @@ -242,29 +254,24 @@ private String extractJsonFromMarkdown(String text) { } /** - * 문자열을 Category enum으로 변환 + * 카테고리 파싱 */ private Category parseCategory(String categoryStr) { try { return Category.valueOf(categoryStr.toUpperCase()); } catch (IllegalArgumentException e) { - log.warn("알 수 없는 카테고리: {}. ETC로 설정합니다.", categoryStr); return Category.ETC; } } /** - * URL에서 S3 키 추출 + * S3 키 추출 */ private String extractS3KeyFromUrl(String url) { try { - // URL 디코딩 String decodedUrl = URLDecoder.decode(url, StandardCharsets.UTF_8); - log.debug("S3 URL 파싱 시작 - URL: {}", decodedUrl); - - // 정규식을 사용한 안전한 파싱 - // Virtual-hosted-style: https://bucket.s3.region.amazonaws.com/key + // Virtual-hosted-style String virtualHostedPattern = "https?://" + Pattern.quote(bucketName) + "\\.s3\\." + Pattern.quote(region) + "\\.amazonaws\\.com/(.+)"; @@ -272,12 +279,10 @@ private String extractS3KeyFromUrl(String url) { Matcher matcher = pattern.matcher(decodedUrl); if (matcher.find()) { - String key = matcher.group(1); - log.debug("추출된 S3 Key: {}", key); - return key; + return matcher.group(1); } - // Path-style: https://s3.region.amazonaws.com/bucket/key + // Path-style String pathStylePattern = "https?://s3\\." + Pattern.quote(region) + "\\.amazonaws\\.com/" + Pattern.quote(bucketName) + "/(.+)"; @@ -285,21 +290,28 @@ private String extractS3KeyFromUrl(String url) { matcher = pattern.matcher(decodedUrl); if (matcher.find()) { - String key = matcher.group(1); - log.debug("추출된 S3 Key (Path-style): {}", key); - return key; + return matcher.group(1); } - // 파싱 실패 - log.error("URL 파싱 실패 - URL: {}, Bucket: {}, Region: {}", - decodedUrl, bucketName, region); throw new GeneralException(ErrorStatus.INVALID_IMAGE_URL); } catch (GeneralException e) { - throw e; // 이미 처리된 예외는 그대로 전달 + throw e; } catch (Exception e) { - log.error("URL 파싱 중 예상치 못한 에러: {}", e.getMessage()); throw new GeneralException(ErrorStatus.INVALID_IMAGE_URL); } } -} \ No newline at end of file + + /** + * 이미지 데이터 클래스 + */ + private static class ImageData { + private final String base64Data; + private final String mimeType; + + public ImageData(String base64Data, String mimeType) { + this.base64Data = base64Data; + this.mimeType = mimeType; + } + } +}