Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public class Scenario extends BaseEntity {
@Column(columnDefinition = "TEXT")
private String timelineTitles; // {"2020": "대학원 진학", "2022": "연구실 변경", "2025": "해외 학회"} 형태

// 시나리오 대표 이미지 URL
// 시나리오 이미지 파일명
private String img;

// 대표 시나리오 여부
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
45 changes: 19 additions & 26 deletions back/src/main/java/com/back/global/storage/S3StorageService.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
*
* CompletableFuture.supplyAsync() 사용하여 메모리 효율적 처리
* S3Client는 기본 Connection Pool 사용 (리소스 최소화)
* 파일명 저장
* 파일명: UUID 기반으로 충돌 방지
*/
@Slf4j
Expand Down Expand Up @@ -58,15 +59,8 @@ public CompletableFuture<String> 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());
Expand All @@ -82,26 +76,33 @@ public CompletableFuture<String> uploadBase64Image(String base64Data) {
}

@Override
public CompletableFuture<Void> deleteImage(String imageUrl) {
public CompletableFuture<Void> 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());
}
}
});
}
Expand All @@ -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];
}
}
5 changes: 3 additions & 2 deletions back/src/main/resources/application-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
s3-region: ${AWS_REGION}
cloud-front-domain: ${AWS_CLOUD_FRONT_DOMAIN} # 향후 확장성 고려, 현재는 미사용
104 changes: 33 additions & 71 deletions back/src/test/java/com/back/global/storage/S3StorageServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 호출 검증
Expand Down Expand Up @@ -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");
}
}

Expand All @@ -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<Void> deleteFuture = s3StorageService.deleteImage(s3Url);
CompletableFuture<Void> 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<Void> 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<Void> 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")
Expand All @@ -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));
Expand All @@ -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("스토리지 타입")
Expand Down