diff --git a/back/src/main/java/com/back/domain/scenario/entity/Scenario.java b/back/src/main/java/com/back/domain/scenario/entity/Scenario.java index 47fbf3b..4bc0a52 100644 --- a/back/src/main/java/com/back/domain/scenario/entity/Scenario.java +++ b/back/src/main/java/com/back/domain/scenario/entity/Scenario.java @@ -78,7 +78,7 @@ public class Scenario extends BaseEntity { @Column(columnDefinition = "TEXT") private String timelineTitles; // {"2020": "대학원 진학", "2022": "연구실 변경", "2025": "해외 학회"} 형태 - // 시나리오 대표 이미지 URL + // 시나리오 이미지 파일명 private String img; // 대표 시나리오 여부 diff --git a/back/src/main/java/com/back/global/ai/config/ImageAiConfig.java b/back/src/main/java/com/back/global/ai/config/ImageAiConfig.java index 753908c..1553a11 100644 --- a/back/src/main/java/com/back/global/ai/config/ImageAiConfig.java +++ b/back/src/main/java/com/back/global/ai/config/ImageAiConfig.java @@ -40,6 +40,9 @@ public class ImageAiConfig { // AWS S3 리전 (storageType이 s3인 경우) private String s3Region; + // CloudFront 도메인 (storageType이 s3인 경우) + private String cloudFrontDomain; + // 로컬 파일 저장 경로 (storageType="local"인 경우 사용) // 기본값: "./uploads/images" private String localStoragePath = "./uploads/images"; diff --git a/back/src/main/java/com/back/global/storage/S3StorageService.java b/back/src/main/java/com/back/global/storage/S3StorageService.java index 81e4655..61449ec 100644 --- a/back/src/main/java/com/back/global/storage/S3StorageService.java +++ b/back/src/main/java/com/back/global/storage/S3StorageService.java @@ -23,6 +23,7 @@ * * CompletableFuture.supplyAsync() 사용하여 메모리 효율적 처리 * S3Client는 기본 Connection Pool 사용 (리소스 최소화) + * 파일명 저장 * 파일명: UUID 기반으로 충돌 방지 */ @Slf4j @@ -58,15 +59,8 @@ public CompletableFuture uploadBase64Image(String base64Data) { // S3 업로드 s3Client.putObject(putRequest, RequestBody.fromBytes(imageBytes)); - String s3Url = String.format( - "https://%s.s3.%s.amazonaws.com/%s", - imageAiConfig.getS3BucketName(), - imageAiConfig.getS3Region(), - fileName - ); - - log.info("Image uploaded to S3 successfully: {}", s3Url); - return s3Url; + log.info("Image uploaded to S3 successfully with key: {}", fileName); + return fileName; } catch (IllegalArgumentException e) { log.error("Invalid Base64 data: {}", e.getMessage()); @@ -82,26 +76,33 @@ public CompletableFuture uploadBase64Image(String base64Data) { } @Override - public CompletableFuture deleteImage(String imageUrl) { + public CompletableFuture deleteImage(String imageUrl) { // imageUrl is now the filename return CompletableFuture.runAsync(() -> { try { - String fileName = extractFileNameFromUrl(imageUrl); + if (imageUrl == null || imageUrl.isEmpty()) { + throw new ApiException(ErrorCode.STORAGE_INVALID_FILE, "Image key cannot be null or empty for deletion."); + } // S3 삭제 요청 DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder() .bucket(imageAiConfig.getS3BucketName()) - .key(fileName) + .key(imageUrl) .build(); s3Client.deleteObject(deleteRequest); - log.info("Image deleted from S3: {}", fileName); + log.info("Image deleted from S3: {}", String.valueOf(imageUrl)); - } catch (S3Exception e) { - log.error("S3 service error during deletion: {}", e.getMessage(), e); - throw new ApiException(ErrorCode.S3_CONNECTION_FAILED, "S3 delete failed: " + e.awsErrorDetails().errorMessage()); + } catch (ApiException e) { + throw e; } catch (Exception e) { - log.error("Failed to delete image from S3: {}", e.getMessage(), e); - throw new ApiException(ErrorCode.STORAGE_DELETE_FAILED, "Failed to delete image from S3: " + e.getMessage()); + if (e instanceof S3Exception) { + S3Exception s3e = (S3Exception) e; + log.error("S3 service error during deletion: {}", s3e.getMessage(), s3e); + throw new ApiException(ErrorCode.S3_CONNECTION_FAILED, "S3 delete failed: " + s3e.getMessage()); + } else { + log.error("Failed to delete image from S3: {}", e.getMessage(), e); + throw new ApiException(ErrorCode.STORAGE_DELETE_FAILED, "Failed to delete image from S3: " + e.getMessage()); + } } }); } @@ -111,13 +112,5 @@ public String getStorageType() { return "s3"; } - //S3 URL에서 파일명 추출, 예:https://bucket.s3.region.amazonaws.com/scenario-uuid.jpeg → scenario-uuid.jpeg - private String extractFileNameFromUrl(String imageUrl) { - if (imageUrl == null || imageUrl.isEmpty()) { - throw new ApiException(ErrorCode.STORAGE_INVALID_FILE, "Image URL cannot be null or empty"); - } - String[] parts = imageUrl.split("/"); - return parts[parts.length - 1]; - } } diff --git a/back/src/main/resources/application-prod.yml b/back/src/main/resources/application-prod.yml index a31d3fe..00afa12 100644 --- a/back/src/main/resources/application-prod.yml +++ b/back/src/main/resources/application-prod.yml @@ -77,7 +77,8 @@ custom: # AI Services Configuration (Production) ai: image: - enabled: false # 프로덕션 환경에서는 활성화 + enabled: true # 프로덕션 환경에서는 활성화 storage-type: s3 # S3 스토리지 사용 s3-bucket-name: ${AWS_S3_BUCKET_NAME} - s3-region: ${AWS_REGION} \ No newline at end of file + s3-region: ${AWS_REGION} + cloud-front-domain: ${AWS_CLOUD_FRONT_DOMAIN} # 향후 확장성 고려, 현재는 미사용 \ No newline at end of file diff --git a/back/src/test/java/com/back/global/storage/S3StorageServiceTest.java b/back/src/test/java/com/back/global/storage/S3StorageServiceTest.java index 48a1723..c9003bb 100644 --- a/back/src/test/java/com/back/global/storage/S3StorageServiceTest.java +++ b/back/src/test/java/com/back/global/storage/S3StorageServiceTest.java @@ -79,8 +79,7 @@ class UploadBase64ImageTests { // Then assertThat(resultUrl).isNotNull(); - assertThat(resultUrl).startsWith("https://" + TEST_BUCKET_NAME + ".s3." + TEST_REGION + ".amazonaws.com/"); - assertThat(resultUrl).contains("scenario-"); + assertThat(resultUrl).startsWith("scenario-"); assertThat(resultUrl).endsWith(".jpeg"); // S3Client 호출 검증 @@ -171,7 +170,7 @@ class UploadBase64ImageTests { String resultUrl = s3StorageService.uploadBase64Image(base64Data).get(); // Then - S3 표준 URL 형식: https://{bucket}.s3.{region}.amazonaws.com/{key} - assertThat(resultUrl).matches("https://test-bucket\\.s3\\.ap-northeast-2\\.amazonaws\\.com/scenario-[a-f0-9\\-]+\\.jpeg"); + assertThat(resultUrl).matches("scenario-[a-f0-9\\-]+\\.jpeg"); } } @@ -183,46 +182,49 @@ class DeleteImageTests { @DisplayName("성공 - S3 이미지 삭제") void deleteImage_성공_S3_이미지_삭제() throws ExecutionException, InterruptedException { // Given - String s3Url = "https://test-bucket.s3.ap-northeast-2.amazonaws.com/scenario-test-uuid.jpeg"; + String key = "scenario-test-uuid.jpeg"; DeleteObjectResponse mockResponse = DeleteObjectResponse.builder().build(); given(s3Client.deleteObject(any(DeleteObjectRequest.class))) .willReturn(mockResponse); // When - CompletableFuture deleteFuture = s3StorageService.deleteImage(s3Url); + CompletableFuture deleteFuture = s3StorageService.deleteImage(key); deleteFuture.get(); // Then - verify(s3Client, times(1)).deleteObject(any(DeleteObjectRequest.class)); + verify(s3Client, times(1)).deleteObject(argThat((DeleteObjectRequest request) -> + request.key().equals(key) + )); } @Test @DisplayName("실패 - S3 서비스 에러") void deleteImage_실패_S3_서비스_에러() { // Given - String s3Url = "https://test-bucket.s3.ap-northeast-2.amazonaws.com/scenario-test-uuid.jpeg"; - - AwsErrorDetails errorDetails = AwsErrorDetails.builder() - .errorMessage("NoSuchKey") - .build(); - S3Exception s3Exception = (S3Exception) S3Exception.builder() - .awsErrorDetails(errorDetails) - .message("S3 Error") - .build(); - - doThrow(s3Exception).when(s3Client).deleteObject(any(DeleteObjectRequest.class)); - - // When - CompletableFuture deleteFuture = s3StorageService.deleteImage(s3Url); - - // Then - assertThatThrownBy(deleteFuture::get) - .hasCauseInstanceOf(ApiException.class) - .cause() - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.S3_CONNECTION_FAILED); - - verify(s3Client, times(1)).deleteObject(any(DeleteObjectRequest.class)); - } + String key = "scenario-test-uuid.jpeg"; + + AwsErrorDetails errorDetails = AwsErrorDetails.builder() + .errorMessage("NoSuchKey") + .build(); + S3Exception s3Exception = (S3Exception) S3Exception.builder() + .awsErrorDetails(errorDetails) + .message("S3 Error") + .build(); + + doThrow(s3Exception).when(s3Client).deleteObject(any(DeleteObjectRequest.class)); + + // When + CompletableFuture deleteFuture = s3StorageService.deleteImage(key); + + // Then + assertThatThrownBy(deleteFuture::get) + .hasCauseInstanceOf(ApiException.class) + .cause() + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.S3_CONNECTION_FAILED); + + verify(s3Client, times(1)).deleteObject(argThat((DeleteObjectRequest request) -> + request.key().equals(key) + )); } @Test @DisplayName("실패 - null URL") @@ -237,7 +239,7 @@ class DeleteImageTests { assertThatThrownBy(deleteFuture::get) .hasCauseInstanceOf(ApiException.class) .cause() - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.STORAGE_DELETE_FAILED); + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.STORAGE_INVALID_FILE); // S3Client는 호출되지 않아야 함 verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class)); @@ -256,53 +258,13 @@ class DeleteImageTests { assertThatThrownBy(deleteFuture::get) .hasCauseInstanceOf(ApiException.class) .cause() - .hasFieldOrPropertyWithValue("errorCode", ErrorCode.STORAGE_DELETE_FAILED); + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.STORAGE_INVALID_FILE); // S3Client는 호출되지 않아야 함 verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class)); } } - @Nested - @DisplayName("URL 파싱") - class ExtractFileNameTests { - - @Test - @DisplayName("성공 - S3 URL에서 파일명 추출") - void extractFileName_성공_S3_URL에서_파일명_추출() throws ExecutionException, InterruptedException { - // Given - String s3Url = "https://test-bucket.s3.ap-northeast-2.amazonaws.com/scenario-abc-123.jpeg"; - DeleteObjectResponse mockResponse = DeleteObjectResponse.builder().build(); - given(s3Client.deleteObject(any(DeleteObjectRequest.class))) - .willReturn(mockResponse); - - // When - deleteImage 내부에서 extractFileNameFromUrl 호출됨 - s3StorageService.deleteImage(s3Url).get(); - - // Then - 정상적으로 파일명 추출 및 삭제 요청 성공 - verify(s3Client, times(1)).deleteObject(argThat((DeleteObjectRequest request) -> - request.key().equals("scenario-abc-123.jpeg") - )); - } - - @Test - @DisplayName("성공 - 복잡한 S3 URL 파싱") - void extractFileName_성공_복잡한_S3_URL_파싱() throws ExecutionException, InterruptedException { - // Given - 경로가 있는 복잡한 URL - String complexUrl = "https://test-bucket.s3.ap-northeast-2.amazonaws.com/images/2024/scenario-test.jpeg"; - DeleteObjectResponse mockResponse = DeleteObjectResponse.builder().build(); - given(s3Client.deleteObject(any(DeleteObjectRequest.class))) - .willReturn(mockResponse); - - // When - s3StorageService.deleteImage(complexUrl).get(); - - // Then - 마지막 부분만 추출 - verify(s3Client, times(1)).deleteObject(argThat((DeleteObjectRequest request) -> - request.key().equals("scenario-test.jpeg") - )); - } - } @Nested @DisplayName("스토리지 타입")