diff --git a/src/main/java/team/wego/wegobackend/common/response/ApiResponse.java b/src/main/java/team/wego/wegobackend/common/response/ApiResponse.java index ce0f279..d4b40e2 100644 --- a/src/main/java/team/wego/wegobackend/common/response/ApiResponse.java +++ b/src/main/java/team/wego/wegobackend/common/response/ApiResponse.java @@ -9,7 +9,7 @@ public record ApiResponse( ) { public static ApiResponse success(T data) { - return new ApiResponse<>(Boolean.TRUE, data); + return new ApiResponse<>(true, data); } public static ApiResponse success(boolean isSuccess, T data) { @@ -17,7 +17,7 @@ public static ApiResponse success(boolean isSuccess, T data) { } public static ApiResponse success(String message) { - return new ApiResponse<>(Boolean.TRUE, null); + return new ApiResponse<>(true, null); } public static ApiResponse error(String message) { diff --git a/src/main/java/team/wego/wegobackend/image/application/service/ImageUploadService.java b/src/main/java/team/wego/wegobackend/image/application/service/ImageUploadService.java index b332aa3..d193438 100644 --- a/src/main/java/team/wego/wegobackend/image/application/service/ImageUploadService.java +++ b/src/main/java/team/wego/wegobackend/image/application/service/ImageUploadService.java @@ -11,14 +11,16 @@ import javax.imageio.ImageIO; import lombok.RequiredArgsConstructor; import net.coobird.thumbnailator.Thumbnails; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.ObjectCannedACL; import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import team.wego.wegobackend.image.config.AwsS3Properties; +import team.wego.wegobackend.image.config.ImageProperties; import team.wego.wegobackend.image.domain.ImageFile; +import team.wego.wegobackend.image.domain.ImageSize; import team.wego.wegobackend.image.domain.exception.ImageException; import team.wego.wegobackend.image.domain.exception.ImageExceptionCode; @@ -40,27 +42,8 @@ public class ImageUploadService { ); private final S3Client s3Client; - - @Value("${aws.s3.bucket}") - private String bucket; - - @Value("${aws.s3.public-endpoint}") - private String publicEndpoint; - - @Value("${image.max-size-bytes}") - private long maxSizeBytes; - - @Value("${image.max-width}") - private int maxWidth; - - @Value("${image.max-height}") - private int maxHeight; - - @Value("${image.thumb-max-width}") - private int thumbMaxWidth; - - @Value("${image.thumb-max-height}") - private int thumbMaxHeight; + private final AwsS3Properties awsS3Properties; + private final ImageProperties imageProperties; public ImageFile uploadOriginal(String dir, MultipartFile file, int index) { validateDir(dir); @@ -74,7 +57,7 @@ public ImageFile uploadOriginal(String dir, MultipartFile file, int index) { byte[] bytes = resizeIfNeededKeepFormat(file); putToS3(key, bytes, file.getContentType()); - String url = publicEndpoint + "/" + key; + String url = awsS3Properties.getPublicEndpoint() + "/" + key; return new ImageFile(key, url); } @@ -88,51 +71,60 @@ public List uploadAllOriginal(String dir, List files) return result; } - public ImageFile uploadAsWebp(String dir, MultipartFile file, int index) { + public ImageFile uploadAsWebpWithSize( + String dir, + MultipartFile file, + int index, + ImageSize size + ) { validateDir(dir); validateImageSize(file); validateImageContentType(file); validateExtension(file.getOriginalFilename()); String baseName = buildBaseName(index); - String key = dir + "/" + baseName + ".webp"; + String key = dir + "/" + baseName + "_" + size.width() + "x" + size.height() + ".webp"; - byte[] bytes = convertToWebp(file); + byte[] bytes = convertToWebpWithSize(file, size); putToS3(key, bytes, "image/webp"); - String url = publicEndpoint + "/" + key; + String url = awsS3Properties.getPublicEndpoint() + "/" + key; return new ImageFile(key, url); } - public List uploadAllAsWebp(String dir, List files) { - List result = new ArrayList<>(); - for (int i = 0; i < files.size(); i++) { - MultipartFile file = files.get(i); - result.add(uploadAsWebp(dir, file, i)); - } - return result; - } - - public ImageFile uploadThumb(String dir, MultipartFile file, int index) { + public List uploadAsWebpWithSizes( + String dir, + MultipartFile file, + int index, + List sizes + ) { validateDir(dir); validateImageSize(file); validateImageContentType(file); validateExtension(file.getOriginalFilename()); - String originalFilename = file.getOriginalFilename(); - String key = buildKey(dir, originalFilename, index); + String baseName = buildBaseName(index); + List result = new ArrayList<>(); - byte[] bytes = resizeToThumb(file); + for (ImageSize size : sizes) { + String key = dir + "/" + baseName + "_" + size.width() + "x" + size.height() + ".webp"; + byte[] bytes = convertToWebpWithSize(file, size); - putToS3(key, bytes, file.getContentType()); - String url = publicEndpoint + "/" + key; + putToS3(key, bytes, "image/webp"); + String url = awsS3Properties.getPublicEndpoint() + "/" + key; - return new ImageFile(key, url); + result.add(new ImageFile(key, url)); + } + + return result; } public void delete(String key) { - s3Client.deleteObject(builder -> builder.bucket(bucket).key(key)); + s3Client.deleteObject(builder -> builder + .bucket(awsS3Properties.getBucket()) + .key(key) + ); } public void deleteAll(List keys) { @@ -141,7 +133,25 @@ public void deleteAll(List keys) { } } + private byte[] convertToWebpWithSize(MultipartFile file, ImageSize size) { + try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { + Thumbnails.of(file.getInputStream()) + .size(size.width(), size.height()) + .outputFormat("webp") + .toOutputStream(byteArrayOutputStream); + + if (byteArrayOutputStream.size() == 0) { + throw new ImageException(ImageExceptionCode.WEBP_CONVERT_FAILED); + } + + return byteArrayOutputStream.toByteArray(); + } catch (IOException e) { + throw new ImageException(ImageExceptionCode.IMAGE_IO_ERROR, e, "WebP 변환"); + } + } + private void validateImageSize(MultipartFile file) { + long maxSizeBytes = imageProperties.getMaxSizeBytes(); if (file.getSize() > maxSizeBytes) { throw new ImageException(ImageExceptionCode.INVALID_IMAGE_SIZE, maxSizeBytes); } @@ -212,29 +222,13 @@ private String buildBaseName(int index) { return timestamp + "_" + index + "_" + uuid; } - private byte[] convertToWebp(MultipartFile file) { - try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { - Thumbnails.of(file.getInputStream()) - .size(maxWidth, maxHeight) - .outputFormat("webp") - .toOutputStream(byteArrayOutputStream); - - if (byteArrayOutputStream.size() == 0) { - throw new ImageException(ImageExceptionCode.WEBP_CONVERT_FAILED); - } - - return byteArrayOutputStream.toByteArray(); - } catch (IOException e) { - throw new ImageException(ImageExceptionCode.IMAGE_IO_ERROR, e, "WebP 변환"); - } - } - private byte[] resizeIfNeededKeepFormat(MultipartFile file) { - return resizeToBox(file, maxWidth, maxHeight, "이미지 리사이즈"); - } - - private byte[] resizeToThumb(MultipartFile file) { - return resizeToBox(file, thumbMaxWidth, thumbMaxHeight, "썸네일 생성"); + return resizeToBox( + file, + imageProperties.getMaxWidth(), + imageProperties.getMaxHeight(), + "이미지 리사이즈" + ); } private byte[] resizeToBox( @@ -297,7 +291,7 @@ private String getFormatName(String originalFilename) { private void putToS3(String key, byte[] bytes, String contentType) { PutObjectRequest putObjectRequest = PutObjectRequest.builder() - .bucket(bucket) + .bucket(awsS3Properties.getBucket()) .key(key) .contentType(contentType) .acl(ObjectCannedACL.PUBLIC_READ) diff --git a/src/main/java/team/wego/wegobackend/image/config/AwsS3Properties.java b/src/main/java/team/wego/wegobackend/image/config/AwsS3Properties.java new file mode 100644 index 0000000..f6d854a --- /dev/null +++ b/src/main/java/team/wego/wegobackend/image/config/AwsS3Properties.java @@ -0,0 +1,15 @@ +package team.wego.wegobackend.image.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "aws.s3") +public class AwsS3Properties { + + private String bucket; + + private String publicEndpoint; +} diff --git a/src/main/java/team/wego/wegobackend/image/config/ImageProperties.java b/src/main/java/team/wego/wegobackend/image/config/ImageProperties.java new file mode 100644 index 0000000..b86d98c --- /dev/null +++ b/src/main/java/team/wego/wegobackend/image/config/ImageProperties.java @@ -0,0 +1,15 @@ +package team.wego.wegobackend.image.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "image") +public class ImageProperties { + + private long maxSizeBytes; + private int maxWidth; + private int maxHeight; +} diff --git a/src/main/java/team/wego/wegobackend/image/domain/ImageSize.java b/src/main/java/team/wego/wegobackend/image/domain/ImageSize.java new file mode 100644 index 0000000..805bce5 --- /dev/null +++ b/src/main/java/team/wego/wegobackend/image/domain/ImageSize.java @@ -0,0 +1,4 @@ +package team.wego.wegobackend.image.domain; + +public record ImageSize(int width, int height) { +} \ No newline at end of file diff --git a/src/main/java/team/wego/wegobackend/image/presentation/ImageController.java b/src/main/java/team/wego/wegobackend/image/presentation/ImageController.java index 4b62b25..baccbcf 100644 --- a/src/main/java/team/wego/wegobackend/image/presentation/ImageController.java +++ b/src/main/java/team/wego/wegobackend/image/presentation/ImageController.java @@ -1,5 +1,6 @@ package team.wego.wegobackend.image.presentation; +import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -16,6 +17,7 @@ import team.wego.wegobackend.image.application.dto.ImageFileResponse; import team.wego.wegobackend.image.application.service.ImageUploadService; import team.wego.wegobackend.image.domain.ImageFile; +import team.wego.wegobackend.image.domain.ImageSize; @RestController @RequestMapping("/api/v1/images") @@ -37,10 +39,7 @@ public ResponseEntity> uploadOriginal( return ResponseEntity .status(HttpStatus.CREATED) - .body(ApiResponse.success( - "이미지: 원본 업로드가 정상적으로 처리되었습니다.", - response - )); + .body(ApiResponse.success(response)); } @PostMapping( @@ -58,88 +57,73 @@ public ResponseEntity>> uploadOriginals( return ResponseEntity .status(HttpStatus.CREATED) - .body(ApiResponse.success( - "이미지: 여러 원본 업로드가 정상적으로 처리되었습니다.", - responses - )); + .body(ApiResponse.success(responses)); } + /** + * 단일 크기로 WebP 업로드 (예: 440x240, 100x100 등) + */ @PostMapping( value = "/webp", consumes = MediaType.MULTIPART_FORM_DATA_VALUE ) - public ResponseEntity> uploadAsWebp( + public ResponseEntity> uploadAsWebpWithSize( @RequestParam("dir") String dir, + @RequestParam("width") int width, + @RequestParam("height") int height, @RequestPart("file") MultipartFile file ) { - ImageFile image = imageUploadService.uploadAsWebp(dir, file, 0); + ImageSize size = new ImageSize(width, height); + ImageFile image = imageUploadService.uploadAsWebpWithSize(dir, file, 0, size); ImageFileResponse response = ImageFileResponse.from(image); return ResponseEntity .status(HttpStatus.CREATED) - .body(ApiResponse.success( - "이미지: WebP 변환 업로드가 정상적으로 처리되었습니다.", - response - )); + .body(ApiResponse.success(response)); } @PostMapping( - value = "/webps", + value = "/webp/multiple", consumes = MediaType.MULTIPART_FORM_DATA_VALUE ) - public ResponseEntity>> uploadAllAsWebp( + public ResponseEntity>> uploadAsWebpWithSizes( @RequestParam("dir") String dir, - @RequestPart("files") List files + @RequestParam("widths") List widths, + @RequestParam("heights") List heights, + @RequestPart("file") MultipartFile file ) { - List responses = imageUploadService.uploadAllAsWebp(dir, files) + if (widths.size() != heights.size()) { + // 여기서 어떻게 에러를 내려줄지는 프로젝트의 공통 에러 응답 정책에 맞춰서 조정해도 됨 + throw new IllegalArgumentException("widths와 heights의 길이가 일치해야 합니다."); + } + + List sizes = new ArrayList<>(); + for (int i = 0; i < widths.size(); i++) { + sizes.add(new ImageSize(widths.get(i), heights.get(i))); + } + + List responses = imageUploadService + .uploadAsWebpWithSizes(dir, file, 0, sizes) .stream() .map(ImageFileResponse::from) .toList(); return ResponseEntity .status(HttpStatus.CREATED) - .body(ApiResponse.success( - "이미지: 여러 WebP 변환 업로드가 정상적으로 처리되었습니다.", - responses - )); + .body(ApiResponse.success(responses)); } @DeleteMapping("/one") - public ResponseEntity> deleteOne(@RequestParam("key") String key) { + public ResponseEntity deleteOne(@RequestParam("key") String key) { imageUploadService.delete(key); - return ResponseEntity - .ok(ApiResponse.success( - "이미지: 단일 삭제가 정상적으로 처리되었습니다." - )); + return ResponseEntity.noContent().build(); } @DeleteMapping - public ResponseEntity> deleteMany(@RequestParam("keys") List keys) { + public ResponseEntity deleteMany(@RequestParam("keys") List keys) { imageUploadService.deleteAll(keys); - return ResponseEntity - .ok(ApiResponse.success( - "이미지: 여러 건 삭제가 정상적으로 처리되었습니다." - )); - } - - @PostMapping( - value = "/thumb", - consumes = MediaType.MULTIPART_FORM_DATA_VALUE - ) - public ResponseEntity> uploadThumb( - @RequestParam("dir") String dir, - @RequestPart("file") MultipartFile file - ) { - ImageFile image = imageUploadService.uploadThumb(dir, file, 0); - ImageFileResponse response = ImageFileResponse.from(image); - - return ResponseEntity - .status(HttpStatus.CREATED) - .body(ApiResponse.success( - "이미지: 썸네일 업로드가 정상적으로 처리되었습니다.", - response - )); + return ResponseEntity.noContent().build(); } } diff --git a/src/test/http/image/image-api.http b/src/test/http/image/image-api.http index 12f2b24..eba820d 100644 --- a/src/test/http/image/image-api.http +++ b/src/test/http/image/image-api.http @@ -1,5 +1,6 @@ ### 1. 단일 이미지 업로드 (원본) -POST http://localhost:8080/api/v1/images/original?dir=test +POST http://localhost:8080/api/v1/images/original + ?dir=test Content-Type: multipart/form-data; boundary=boundary --boundary @@ -10,8 +11,9 @@ Content-Type: image/jpeg --boundary-- -### 3. 여러 이미지 업로드 (원본) -POST http://localhost:8080/api/v1/images/originals?dir=test +### 2. 여러 이미지 업로드 (원본) +POST http://localhost:8080/api/v1/images/originals + ?dir=test Content-Type: multipart/form-data; boundary=boundary --boundary @@ -27,37 +29,36 @@ Content-Type: image/jpeg --boundary-- -### 2. 단일 이미지 업로드 (WebP 변환 저장) -POST http://localhost:8080/api/v1/images/webp?dir=test +### 3. 단일 이미지 업로드 (WebP 변환 저장, 크기 지정) +# 예: 모임 상세페이지 썸네일 440x240 +POST http://localhost:8080/api/v1/images/webp + ?dir=test + &width=440 + &height=240 Content-Type: multipart/form-data; boundary=boundary --boundary Content-Disposition: form-data; name="file"; filename="any-image.jpg" Content-Type: image/jpeg -< ./resources/test-webp2.webp +< ./resources/img1.png --boundary-- -### 4. 여러 이미지 업로드 (모두 WebP 변환 저장) -POST http://localhost:8080/api/v1/images/webps?dir=test +### 4. 단일 이미지 업로드 (여러 크기 WebP 변환 저장) +# 예: 상세(440x240) + 목록(100x100)을 한 번에 생성 +POST http://localhost:8080/api/v1/images/webp/multiple + ?dir=test&widths=440 + &widths=100 + &heights=240 + &heights=100 Content-Type: multipart/form-data; boundary=boundary --boundary -Content-Disposition: form-data; name="files"; filename="img1.png" -Content-Type: image/png - -< ./resources/img1.png ---boundary -Content-Disposition: form-data; name="files"; filename="img2.jpg" +Content-Disposition: form-data; name="file"; filename="group-thumbnail.jpg" Content-Type: image/jpeg -< ./resources/img2.jpg ---boundary -Content-Disposition: form-data; name="files"; filename="test-webp1.webp" -Content-Type: image/webp - -< ./resources/test-webp2.webp +< ./resources/img1.png --boundary-- @@ -72,18 +73,8 @@ DELETE http://localhost:8080/api/v1/images ?keys=test/20251206141808_0_51d58b84-080c-47d2-9ce0-4e50b2e00b63.png &keys=test/20251206141808_1_3dea121d-7d39-4e3d-8b02-3dce01a8a929.jpg -### 7. 단일 이미지 업로드 (썸네일 변환 저장) -POST http://localhost:8080/api/v1/images/thumb?dir=test -Content-Type: multipart/form-data; boundary=boundary - ---boundary -Content-Disposition: form-data; name="file"; filename="big-image.jpg" -Content-Type: image/jpeg -< ./test-webp2.webp ---boundary-- - -###(예외 발생)dir 비어있음 → DIR_REQUIRED +### (예외) dir 비어있음 → DIR_REQUIRED POST http://localhost:8080/api/v1/images/original?dir= Content-Type: multipart/form-data; boundary=boundary @@ -91,10 +82,11 @@ Content-Type: multipart/form-data; boundary=boundary Content-Disposition: form-data; name="file"; filename="img1.jpg" Content-Type: image/jpeg -< ./resources/img1.png +< ./resources/img1.jpg --boundary-- -### (예외 발생)디렉토리 경로 traversal → DIR_INVALID_TRAVERSAL + +### (예외) 디렉토리 경로 traversal → DIR_INVALID_TRAVERSAL POST http://localhost:8080/api/v1/images/original?dir=../secret Content-Type: multipart/form-data; boundary=boundary @@ -102,10 +94,11 @@ Content-Type: multipart/form-data; boundary=boundary Content-Disposition: form-data; name="file"; filename="img1.jpg" Content-Type: image/jpeg -< ./resources/img1.png +< ./resources/img1.jpg --boundary-- -### (예외 발생)dir이 / 로 끝남 → DIR_TRAILING_SLASH + +### (예외) dir이 / 로 끝남 → DIR_TRAILING_SLASH POST http://localhost:8080/api/v1/images/original?dir=test/ Content-Type: multipart/form-data; boundary=boundary @@ -113,10 +106,10 @@ Content-Type: multipart/form-data; boundary=boundary Content-Disposition: form-data; name="file"; filename="img1.jpg" Content-Type: image/jpeg -< ./resources/img1.png +< ./resources/img1.jpg --boundary-- -### (예외 발생)dir에 허용되지 않는 문자 포함 → DIR_INVALID_PATTERN +### (예외) dir에 허용되지 않는 문자 포함 → DIR_INVALID_PATTERN POST http://localhost:8080/api/v1/images/original?dir=test 이미지 Content-Type: multipart/form-data; boundary=boundary @@ -124,10 +117,11 @@ Content-Type: multipart/form-data; boundary=boundary Content-Disposition: form-data; name="file"; filename="img1.jpg" Content-Type: image/jpeg -< ./resources/img1.png +< ./resources/img1.jpg --boundary-- -### (예외 발생: 이미지 큰 게 없어서 못해봄)너무 큰 이미지 업로드 → INVALID_IMAGE_SIZE + +### (예외) 너무 큰 이미지 업로드 → INVALID_IMAGE_SIZE POST http://localhost:8080/api/v1/images/original?dir=test Content-Type: multipart/form-data; boundary=boundary @@ -138,7 +132,8 @@ Content-Type: image/jpeg < ./resources/very-big-image.jpg --boundary-- -### (예외 발생: 인텔리제이에서부터 찾을 수 없음)Content-Type이 image/* 가 아님 → UNSUPPORTED_IMAGE_CONTENT_TYPE + +### (예외) Content-Type이 image/* 가 아님 → UNSUPPORTED_IMAGE_CONTENT_TYPE POST http://localhost:8080/api/v1/images/original?dir=test Content-Type: multipart/form-data; boundary=boundary @@ -150,7 +145,7 @@ Content-Type: application/pdf --boundary-- -### (예외 발생: 인텔리제이에서부터 찾을 수 없음)확장자는 .xyz, Content-Type 은 image/jpeg → UNSUPPORTED_EXTENSION +### (예외) 확장자는 .xyz, Content-Type 은 image/jpeg → UNSUPPORTED_EXTENSION POST http://localhost:8080/api/v1/images/original?dir=test Content-Type: multipart/form-data; boundary=boundary @@ -161,8 +156,9 @@ Content-Type: image/jpeg < ./resources/img1.jpg --boundary-- -### (예외 발생: 인텔리제이에서부터 찾을 수 없음)내용이 이미지가 아님 → INVALID_IMAGE_FORMAT -POST http://localhost:8080/api/v1/images/thumb?dir=test + +### (예외) 내용이 이미지가 아님 → INVALID_IMAGE_FORMAT +POST http://localhost:8080/api/v1/images/webp?dir=test&width=440&height=240 Content-Type: multipart/form-data; boundary=boundary --boundary @@ -170,4 +166,4 @@ Content-Disposition: form-data; name="file"; filename="fake.jpg" Content-Type: image/jpeg < ./resources/not-image.txt ---boundary-- \ No newline at end of file +--boundary--