From 25d627cbe3a30db868eb4ef13917b9bd816fb069 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 24 Mar 2026 12:03:14 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20PreSigned=20URL=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EC=8B=9C=20Content-Type=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ImageUploadRequest에 contentType 파라미터 추가 (기본값 image/jpeg) - PreSignUrlProvider에 MIME -> 확장자 허용 목록 적용 (jpg, png, webp, mp4) - GeneratePresignedUrlRequest로 Content-Type 강제 적용 - 허용되지 않은 Content-Type 요청 시 UNSUPPORTED_CONTENT_TYPE 예외 처리 - contentType별 확장자 검증 단위 테스트 추가 - product-api, admin-api RestDocs에 contentType 파라미터 문서화 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../AdminImageUploadControllerDocsTest.kt | 4 +- .../common/file/PreSignUrlProvider.java | 29 ++++++-- .../file/dto/request/ImageUploadRequest.java | 3 +- .../file/exception/FileExceptionCode.java | 3 +- .../file/service/ImageUploadService.java | 17 +++-- .../common/file/ImageUploadUnitTest.java | 10 +-- .../file/upload/ImageUploadServiceTest.java | 74 ++++++++++++++++--- .../file/upload/fixture/FakeAmazonS3.java | 5 +- .../upload/RestImageUploadControllerTest.java | 11 ++- git.environment-variables | 2 +- plan/banner-media-type.md | 58 +++++++++++++++ plan/presigned-url-content-type.md | 69 +++++++++++++++++ 12 files changed, 250 insertions(+), 35 deletions(-) create mode 100644 plan/banner-media-type.md create mode 100644 plan/presigned-url-content-type.md diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/file/AdminImageUploadControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/file/AdminImageUploadControllerDocsTest.kt index 775415163..8e960cbae 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/file/AdminImageUploadControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/file/AdminImageUploadControllerDocsTest.kt @@ -80,6 +80,7 @@ class AdminImageUploadControllerDocsTest { .header("Authorization", "Bearer test_access_token") .param("rootPath", "admin/banner") .param("uploadSize", "2") + .param("contentType", "image/jpeg") ) .hasStatusOk() .apply( @@ -92,7 +93,8 @@ class AdminImageUploadControllerDocsTest { ), queryParameters( parameterWithName("rootPath").description("업로드 경로 (예: admin/banner, admin/alcohol)"), - parameterWithName("uploadSize").description("발급할 URL 개수") + parameterWithName("uploadSize").description("발급할 URL 개수"), + parameterWithName("contentType").description("업로드 파일의 Content-Type (image/jpeg, image/png, image/webp, video/mp4)").optional() ), responseFields( fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/PreSignUrlProvider.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/PreSignUrlProvider.java index 2ea12209b..197365870 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/PreSignUrlProvider.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/PreSignUrlProvider.java @@ -1,17 +1,25 @@ package app.bottlenote.common.file; import static app.bottlenote.common.file.exception.FileExceptionCode.EXPIRY_TIME_RANGE_INVALID; +import static app.bottlenote.common.file.exception.FileExceptionCode.UNSUPPORTED_CONTENT_TYPE; import static java.time.format.DateTimeFormatter.ofPattern; import app.bottlenote.common.file.exception.FileException; import java.time.LocalDate; import java.util.Calendar; +import java.util.Map; import java.util.Objects; import java.util.UUID; public interface PreSignUrlProvider { - String EXTENSION = "jpg"; + Map ALLOWED_CONTENT_TYPES = + Map.of( + "image/jpeg", "jpg", + "image/png", "png", + "image/webp", "webp", + "video/mp4", "mp4"); + String PATH_DELIMITER = "/"; String KEY_DELIMITER = "-"; @@ -19,9 +27,10 @@ public interface PreSignUrlProvider { * PreSignUrl을 생성한다. * * @param imageKey the image key + * @param contentType 업로드할 파일의 Content-Type * @return the string */ - String generatePreSignUrl(String imageKey); + String generatePreSignUrl(String imageKey, String contentType); /** * ViewUrl을 생성한다. cloud front url 에 s3 key를 조합해 반환한다. 실제 오브젝트를 조회하기 위해 사용된다. @@ -33,20 +42,28 @@ public interface PreSignUrlProvider { String generateViewUrl(String cloudFrontUrl, String imageKey); /** - * 루트 경로를 포함한 이미지 키를 생성한다. 확장자의 경우 .jpg로 고정한다. + * 루트 경로를 포함한 오브젝트 키를 생성한다. contentType에 따라 확장자를 결정한다. * * @param rootPath 저장할 루트 경로 - * @return 생성된 이미지 키 + * @param index 업로드 순번 + * @param contentType 업로드할 파일의 Content-Type + * @return 생성된 오브젝트 키 */ - default String getImageKey(String rootPath, Long index) { + default String getImageKey(String rootPath, Long index, String contentType) { if (rootPath.startsWith(PATH_DELIMITER)) { rootPath = rootPath.substring(1); } if (rootPath.endsWith(PATH_DELIMITER)) { rootPath = rootPath.substring(0, rootPath.length() - 1); } + + String extension = ALLOWED_CONTENT_TYPES.get(contentType); + if (extension == null) { + throw new FileException(UNSUPPORTED_CONTENT_TYPE); + } + String uploadAt = LocalDate.now().format(ofPattern("yyyyMMdd")); - String imageId = index + KEY_DELIMITER + UUID.randomUUID() + "." + EXTENSION; + String imageId = index + KEY_DELIMITER + UUID.randomUUID() + "." + extension; return rootPath + PATH_DELIMITER + uploadAt + PATH_DELIMITER + imageId; } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadRequest.java index 9c84f0b97..ef5505dcc 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadRequest.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/dto/request/ImageUploadRequest.java @@ -1,7 +1,8 @@ package app.bottlenote.common.file.dto.request; -public record ImageUploadRequest(String rootPath, Long uploadSize) { +public record ImageUploadRequest(String rootPath, Long uploadSize, String contentType) { public ImageUploadRequest { uploadSize = uploadSize == null ? 1 : uploadSize; + contentType = contentType == null ? "image/jpeg" : contentType; } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/exception/FileExceptionCode.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/exception/FileExceptionCode.java index 87ae014d3..51e69a2e4 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/exception/FileExceptionCode.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/exception/FileExceptionCode.java @@ -4,7 +4,8 @@ import org.springframework.http.HttpStatus; public enum FileExceptionCode implements ExceptionCode { - EXPIRY_TIME_RANGE_INVALID(HttpStatus.BAD_REQUEST, "만료 기간의 범위가 적절하지 않습니다.( 최소 1분 ,최대 10분) "); + EXPIRY_TIME_RANGE_INVALID(HttpStatus.BAD_REQUEST, "만료 기간의 범위가 적절하지 않습니다.( 최소 1분 ,최대 10분) "), + UNSUPPORTED_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 Content-Type입니다."); private final HttpStatus httpStatus; private final String message; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java index 58aa8a412..b31989d1a 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java @@ -9,6 +9,7 @@ import app.bottlenote.global.security.SecurityContextUtil; import com.amazonaws.HttpMethod; import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; import java.util.ArrayList; import java.util.Calendar; import java.util.List; @@ -64,11 +65,12 @@ public ImageUploadResponse getPreSignUrlForAdmin(Long adminId, ImageUploadReques private List generatePreSignUrls(ImageUploadRequest request) { String rootPath = request.rootPath(); Long uploadSize = request.uploadSize(); + String contentType = request.contentType(); List keys = new ArrayList<>(); for (long index = 1; index <= uploadSize; index++) { - String imageKey = getImageKey(rootPath, index); - String preSignUrl = generatePreSignUrl(imageKey); + String imageKey = getImageKey(rootPath, index, contentType); + String preSignUrl = generatePreSignUrl(imageKey, contentType); String viewUrl = generateViewUrl(cloudFrontUrl, imageKey); keys.add( ImageUploadItem.builder().order(index).viewUrl(viewUrl).uploadUrl(preSignUrl).build()); @@ -97,11 +99,14 @@ public String generateViewUrl(String cloudFrontUrl, String imageKey) { } @Override - public String generatePreSignUrl(String imageKey) { + public String generatePreSignUrl(String imageKey, String contentType) { Calendar uploadExpiryTime = getUploadExpiryTime(EXPIRY_TIME); - return amazonS3 - .generatePresignedUrl(imageBucketName, imageKey, uploadExpiryTime.getTime(), HttpMethod.PUT) - .toString(); + GeneratePresignedUrlRequest request = + new GeneratePresignedUrlRequest(imageBucketName, imageKey) + .withMethod(HttpMethod.PUT) + .withExpiration(uploadExpiryTime.getTime()) + .withContentType(contentType); + return amazonS3.generatePresignedUrl(request).toString(); } private void saveImageUploadLogs(String rootPath, List items) { diff --git a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java index c573a6265..b264b05a8 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java @@ -101,7 +101,7 @@ class PreSignedUrlTest { @DisplayName("PreSigned URL 생성 시 MinIO에서 유효한 URL을 반환한다") void test_1() { // given - ImageUploadRequest request = new ImageUploadRequest("review", 1L); + ImageUploadRequest request = new ImageUploadRequest("review", 1L, null); // when ImageUploadResponse response = imageUploadService.getPreSignUrl(request); @@ -120,7 +120,7 @@ void test_1() { @DisplayName("PreSigned URL로 실제 파일 업로드가 가능하다") void test_2() throws Exception { // given - ImageUploadRequest request = new ImageUploadRequest("review", 1L); + ImageUploadRequest request = new ImageUploadRequest("review", 1L, null); ImageUploadResponse response = imageUploadService.getPreSignUrl(request); String uploadUrl = response.imageUploadInfo().get(0).uploadUrl(); byte[] testData = "test image content".getBytes(); @@ -148,7 +148,7 @@ void test_2() throws Exception { @DisplayName("업로드된 파일이 MinIO에 존재한다") void test_3() throws Exception { // given - ImageUploadRequest request = new ImageUploadRequest("review", 1L); + ImageUploadRequest request = new ImageUploadRequest("review", 1L, null); ImageUploadResponse response = imageUploadService.getPreSignUrl(request); String uploadUrl = response.imageUploadInfo().get(0).uploadUrl(); String viewUrl = response.imageUploadInfo().get(0).viewUrl(); @@ -186,7 +186,7 @@ void test_1() { Long userId = 1L; SecurityContextHolder.getContext() .setAuthentication(new TestingAuthenticationToken(userId.toString(), null)); - ImageUploadRequest request = new ImageUploadRequest("review", 2L); + ImageUploadRequest request = new ImageUploadRequest("review", 2L, null); // when imageUploadService.getPreSignUrl(request); @@ -204,7 +204,7 @@ void test_1() { @DisplayName("비로그인 사용자가 PreSigned URL 생성 시 로그가 저장되지 않는다") void test_2() { // given - ImageUploadRequest request = new ImageUploadRequest("review", 2L); + ImageUploadRequest request = new ImageUploadRequest("review", 2L, null); // when imageUploadService.getPreSignUrl(request); diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java index df12b9aa7..993802ab6 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java @@ -10,12 +10,14 @@ import app.bottlenote.common.file.dto.response.ImageUploadItem; import app.bottlenote.common.file.dto.response.ImageUploadResponse; import app.bottlenote.common.file.exception.FileException; +import app.bottlenote.common.file.exception.FileExceptionCode; import app.bottlenote.common.file.service.ImageUploadService; import app.bottlenote.common.file.service.ResourceCommandService; import app.bottlenote.common.file.upload.fixture.FakeAmazonS3; import app.bottlenote.common.file.upload.fixture.InMemoryResourceLogRepository; import java.time.LocalDate; import java.util.Calendar; +import java.util.Map; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -48,14 +50,18 @@ void setUp() { new ImageUploadService( resourceCommandService, new FakeAmazonS3(), BUCKET_NAME, CLOUD_FRONT_URL) { @Override - public String getImageKey(String rootPath, Long index) { + public String getImageKey(String rootPath, Long index, String contentType) { if (rootPath.startsWith(PATH_DELIMITER)) { rootPath = rootPath.substring(1); } if (rootPath.endsWith(PATH_DELIMITER)) { rootPath = rootPath.substring(0, rootPath.length() - 1); } - String imageId = index + KEY_DELIMITER + FAKE_UUID + "." + EXTENSION; + String extension = ALLOWED_CONTENT_TYPES.get(contentType); + if (extension == null) { + throw new FileException(FileExceptionCode.UNSUPPORTED_CONTENT_TYPE); + } + String imageId = index + KEY_DELIMITER + FAKE_UUID + "." + extension; return rootPath + PATH_DELIMITER + UPLOAD_DATE + PATH_DELIMITER + imageId; } }; @@ -69,10 +75,10 @@ class PreSignedUrlTest { @DisplayName("PreSignUrl을 생성할 수 있다") void test_1() { // given - String imageKey = imageUploadService.getImageKey("review", 1L); + String imageKey = imageUploadService.getImageKey("review", 1L, "image/jpeg"); // when - String preSignUrl = imageUploadService.generatePreSignUrl(imageKey); + String preSignUrl = imageUploadService.generatePreSignUrl(imageKey, "image/jpeg"); // then log.info("PreSignUrl: {}", preSignUrl); @@ -84,7 +90,7 @@ void test_1() { @DisplayName("업로드용 인증 URL을 생성할 수 있다") void test_2() { // given - ImageUploadRequest request = new ImageUploadRequest("review", 2L); + ImageUploadRequest request = new ImageUploadRequest("review", 2L, null); // when ImageUploadResponse response = imageUploadService.getPreSignUrl(request); @@ -96,8 +102,8 @@ void test_2() { assertEquals(BUCKET_NAME, response.bucketName()); for (Long index = 1L; index <= response.imageUploadInfo().size(); index++) { - String imageKey = imageUploadService.getImageKey(request.rootPath(), index); - String uploadUrlExpected = imageUploadService.generatePreSignUrl(imageKey); + String imageKey = imageUploadService.getImageKey(request.rootPath(), index, "image/jpeg"); + String uploadUrlExpected = imageUploadService.generatePreSignUrl(imageKey, "image/jpeg"); String viewUrlExpected = imageUploadService.generateViewUrl(CLOUD_FRONT_URL, imageKey); ImageUploadItem info = response.imageUploadInfo().get((int) (index - 1)); @@ -113,7 +119,7 @@ void test_2() { @DisplayName("단건 이미지 업로드 URL을 생성할 수 있다") void test_3() { // given - ImageUploadRequest request = new ImageUploadRequest("review", 1L); + ImageUploadRequest request = new ImageUploadRequest("review", 1L, null); // when ImageUploadResponse response = imageUploadService.getPreSignUrl(request); @@ -137,7 +143,7 @@ class ViewUrlTest { @DisplayName("조회용 URL을 생성할 수 있다") void test_1() { // given - String imageKey = imageUploadService.getImageKey("review", 1L); + String imageKey = imageUploadService.getImageKey("review", 1L, "image/jpeg"); // when String viewUrl = imageUploadService.generateViewUrl(CLOUD_FRONT_URL, imageKey); @@ -157,7 +163,7 @@ class ImageKeyTest { @DisplayName("이미지 루트 경로와 인덱스를 제공해 이미지 키를 생성할 수 있다") void test_1() { // given & when - String imageKey = imageUploadService.getImageKey("review", 1L); + String imageKey = imageUploadService.getImageKey("review", 1L, "image/jpeg"); String expected = "review/" + UPLOAD_DATE + "/1-" + FAKE_UUID + ".jpg"; // then @@ -165,6 +171,54 @@ void test_1() { assertNotNull(imageKey); assertEquals(expected, imageKey); } + + @Test + @DisplayName("video/mp4 contentType으로 키 생성 시 확장자가 .mp4이다") + void test_2() { + // given & when + String imageKey = imageUploadService.getImageKey("review", 1L, "video/mp4"); + + // then + log.info("ImageKey: {}", imageKey); + assertTrue(imageKey.endsWith(".mp4")); + } + + @Test + @DisplayName("허용된 모든 contentType으로 키를 생성할 수 있다") + void test_3() { + // given + Map expectedExtensions = + Map.of( + "image/jpeg", + ".jpg", + "image/png", + ".png", + "image/webp", + ".webp", + "video/mp4", + ".mp4"); + + expectedExtensions.forEach( + (contentType, extension) -> { + // when + String imageKey = imageUploadService.getImageKey("review", 1L, contentType); + + // then + log.info("contentType: {} -> ImageKey: {}", contentType, imageKey); + assertTrue( + imageKey.endsWith(extension), + "contentType " + contentType + "의 확장자는 " + extension + "이어야 한다"); + }); + } + + @Test + @DisplayName("허용되지 않은 contentType으로 키 생성 시 예외가 발생한다") + void test_4() { + // given & when & then + assertThrows( + FileException.class, + () -> imageUploadService.getImageKey("review", 1L, "application/pdf")); + } } @Nested diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeAmazonS3.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeAmazonS3.java index 9e90c998a..c93fdfb3a 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeAmazonS3.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeAmazonS3.java @@ -38,7 +38,10 @@ public URL generatePresignedUrl(GeneratePresignedUrlRequest generatePresignedUrl throws SdkClientException { URL url; try { - url = new URL("http://localhost:8080"); + String bucketName = generatePresignedUrlRequest.getBucketName(); + String key = generatePresignedUrlRequest.getKey(); + url = new URL("https", bucketName + ".s3.amazonaws.com", "/" + key); + System.out.println("Fake url 생성 : " + url); } catch (MalformedURLException e) { throw new RuntimeException(e); } diff --git a/bottlenote-product-api/src/test/java/app/docs/upload/RestImageUploadControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/upload/RestImageUploadControllerTest.java index 13602b44e..df5e361c8 100644 --- a/bottlenote-product-api/src/test/java/app/docs/upload/RestImageUploadControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/upload/RestImageUploadControllerTest.java @@ -36,7 +36,7 @@ protected Object initController() { @Test void test_1() throws Exception { // given - ImageUploadRequest request = new ImageUploadRequest("images", 1L); + ImageUploadRequest request = new ImageUploadRequest("images", 1L, "image/jpeg"); ImageUploadResponse response = ImageUploadResponse.builder() .imageUploadInfo( @@ -59,14 +59,19 @@ void test_1() throws Exception { .perform( get("/api/v1/s3/presign-url") .param("rootPath", request.rootPath()) - .param("uploadSize", String.valueOf(request.uploadSize()))) + .param("uploadSize", String.valueOf(request.uploadSize())) + .param("contentType", "image/jpeg")) .andExpect(status().isOk()) .andDo( document( "file/image/upload/presign-url", queryParameters( parameterWithName("rootPath").description("업로드 파일 경로 (하단 설명 참조)"), - parameterWithName("uploadSize").description("업로드할 이미지의 사이즈 ( 이미지당 1개 )")), + parameterWithName("uploadSize").description("업로드할 이미지의 사이즈 ( 이미지당 1개 )"), + parameterWithName("contentType") + .description( + "업로드 파일의 Content-Type (image/jpeg, image/png, image/webp, video/mp4)") + .optional()), responseFields( fieldWithPath("success").description("응답 성공 여부"), fieldWithPath("code").description("응답 코드(http status code)"), diff --git a/git.environment-variables b/git.environment-variables index cc43c2e4f..9417b58b3 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit cc43c2e4f88c987deb6208330e5911ed8d458e7a +Subproject commit 9417b58b3243b08c6469eeaf8932776bdd253954 diff --git a/plan/banner-media-type.md b/plan/banner-media-type.md new file mode 100644 index 000000000..7c894a300 --- /dev/null +++ b/plan/banner-media-type.md @@ -0,0 +1,58 @@ +# Banner mediaType 필드 추가 + +> Issue: https://github.com/bottle-note/workspace/issues/205 +> 관련: [presigned-url-content-type.md](presigned-url-content-type.md) + +## 배경 + +animated WebP는 모바일에서 하드웨어 디코딩 미지원으로 CPU 소프트웨어 디코딩 → 저사양 기기 버벅임. +mp4는 모바일 VPU 활용으로 부하 없이 재생 가능. Banner에 미디어 유형 구분이 필요하다. + +## 작업 범위 + +1. `MediaType` enum 생성 (`IMAGE`, `VIDEO`) +2. `Banner` 엔티티에 `mediaType` 필드 추가 +3. 배너 등록/수정 API: `mediaType` 파라미터 수신 +4. 배너 조회 API: 응답에 `mediaType` 포함 (FE에서 `` vs `