From c543d831fd0088cb6e2a900efc3ceb866f0405dc Mon Sep 17 00:00:00 2001 From: seojin Yoon <90759319+7zrv@users.noreply.github.com> Date: Thu, 28 Nov 2024 11:23:55 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aws s3 연동을 위한 의존성 추가 - 환경변수 추가 - S3 연결을 위한 config 클래스 구현 - 서비스 레이어에 이미지 업로드 기능 구현 - 업로드 실패 예외와 예외 메세지 추 - test.yml에 테스트에 필요한 환경변수 추가 - 테스트 코드 작성및 검증 완료 --- build.gradle | 4 +- .../somemore/global/configure/S3Config.java | 45 +++++++++++ .../global/exception/ExceptionMessage.java | 3 + .../exception/ImageUploadException.java | 8 ++ .../dto/ImageUploadRequestDto.java | 8 ++ .../service/ImageUploadService.java | 53 +++++++++++++ .../usecase/ImageUploadUseCase.java | 7 ++ .../imageupload/util/ImageUploadUtils.java | 25 ++++++ src/main/resources/application.yml | 20 +++++ .../service/ImageUploadServiceTest.java | 77 +++++++++++++++++++ .../util/ImageUploadUtilsTest.java | 50 ++++++++++++ src/test/resources/application-test.yml | 14 ++++ 12 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/somemore/global/configure/S3Config.java create mode 100644 src/main/java/com/somemore/global/exception/ImageUploadException.java create mode 100644 src/main/java/com/somemore/imageupload/dto/ImageUploadRequestDto.java create mode 100644 src/main/java/com/somemore/imageupload/service/ImageUploadService.java create mode 100644 src/main/java/com/somemore/imageupload/usecase/ImageUploadUseCase.java create mode 100644 src/main/java/com/somemore/imageupload/util/ImageUploadUtils.java create mode 100644 src/test/java/com/somemore/imageupload/service/ImageUploadServiceTest.java create mode 100644 src/test/java/com/somemore/imageupload/util/ImageUploadUtilsTest.java diff --git a/build.gradle b/build.gradle index c49448165..ebb137d36 100644 --- a/build.gradle +++ b/build.gradle @@ -52,6 +52,9 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + // AWS + implementation(platform("software.amazon.awssdk:bom:2.29.20")) + implementation("software.amazon.awssdk:s3") // Web Layer implementation 'org.springframework.boot:spring-boot-starter-web' @@ -61,7 +64,6 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' implementation group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: '2.6.0' - //test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/com/somemore/global/configure/S3Config.java b/src/main/java/com/somemore/global/configure/S3Config.java new file mode 100644 index 000000000..b58ef6755 --- /dev/null +++ b/src/main/java/com/somemore/global/configure/S3Config.java @@ -0,0 +1,45 @@ +package com.somemore.global.configure; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +public class S3Config { + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AwsCredentials basicAWSCredentials() { + return AwsBasicCredentials.create(accessKey, secretKey); + } + + @Bean + public S3Presigner s3Presigner(AwsCredentials awsCredentials) { + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsCredentials)) + .build(); + } + + @Bean + public S3Client s3Client(AwsCredentials awsCredentials) { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(awsCredentials)) + .build(); + } + +} diff --git a/src/main/java/com/somemore/global/exception/ExceptionMessage.java b/src/main/java/com/somemore/global/exception/ExceptionMessage.java index 2c3b040c7..fe420439e 100644 --- a/src/main/java/com/somemore/global/exception/ExceptionMessage.java +++ b/src/main/java/com/somemore/global/exception/ExceptionMessage.java @@ -14,6 +14,9 @@ public enum ExceptionMessage { NOT_EXISTS_LOCATION("존재하지 않는 위치 ID 입니다."), NOT_EXISTS_RECRUIT_BOARD("존재하지 않는 봉사 모집글 ID 입니다."), UNAUTHORIZED_RECRUIT_BOARD("자신이 작성한 봉사 모집글이 아닙니다."), + UPLOAD_FAILED("파일 업로드에 실패했습니다."), + INVALID_FILE_TYPE("지원하지 않는 파일 형식입니다."), + FILE_SIZE_EXCEEDED("파일 크기가 허용된 한도를 초과했습니다."), ; private final String message; diff --git a/src/main/java/com/somemore/global/exception/ImageUploadException.java b/src/main/java/com/somemore/global/exception/ImageUploadException.java new file mode 100644 index 000000000..d314407ea --- /dev/null +++ b/src/main/java/com/somemore/global/exception/ImageUploadException.java @@ -0,0 +1,8 @@ +package com.somemore.global.exception; + +public class ImageUploadException extends RuntimeException{ + + public ImageUploadException(String message) { + super(message); + } +} diff --git a/src/main/java/com/somemore/imageupload/dto/ImageUploadRequestDto.java b/src/main/java/com/somemore/imageupload/dto/ImageUploadRequestDto.java new file mode 100644 index 000000000..8cc01de84 --- /dev/null +++ b/src/main/java/com/somemore/imageupload/dto/ImageUploadRequestDto.java @@ -0,0 +1,8 @@ +package com.somemore.imageupload.dto; + +import org.springframework.web.multipart.MultipartFile; + +public record ImageUploadRequestDto( + MultipartFile imageFile +) { +} diff --git a/src/main/java/com/somemore/imageupload/service/ImageUploadService.java b/src/main/java/com/somemore/imageupload/service/ImageUploadService.java new file mode 100644 index 000000000..4f6a1ea4a --- /dev/null +++ b/src/main/java/com/somemore/imageupload/service/ImageUploadService.java @@ -0,0 +1,53 @@ +package com.somemore.imageupload.service; + +import com.somemore.global.exception.ImageUploadException; +import com.somemore.imageupload.dto.ImageUploadRequestDto; +import com.somemore.imageupload.usecase.ImageUploadUseCase; +import com.somemore.imageupload.util.ImageUploadUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; + +import static com.somemore.global.exception.ExceptionMessage.UPLOAD_FAILED; + +@RequiredArgsConstructor +@Service +public class ImageUploadService implements ImageUploadUseCase { + + private final S3Client s3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Value("${cloud.aws.s3.base-url}") + private String baseUrl; + + @Override + public String uploadImage(ImageUploadRequestDto requestDto) { + + String fileName = ImageUploadUtils.generateUniqueFileName(requestDto.imageFile().getOriginalFilename()); + + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .contentType(requestDto.imageFile().getContentType()) + .build(); + + try { + s3Client.putObject(request, RequestBody.fromInputStream( + requestDto.imageFile().getInputStream(), + requestDto.imageFile().getSize() + )); + + return ImageUploadUtils.generateS3Url(baseUrl, fileName); + } catch (IOException e) { + throw new ImageUploadException(UPLOAD_FAILED.getMessage()); + } + } + +} diff --git a/src/main/java/com/somemore/imageupload/usecase/ImageUploadUseCase.java b/src/main/java/com/somemore/imageupload/usecase/ImageUploadUseCase.java new file mode 100644 index 000000000..acfd3ed2c --- /dev/null +++ b/src/main/java/com/somemore/imageupload/usecase/ImageUploadUseCase.java @@ -0,0 +1,7 @@ +package com.somemore.imageupload.usecase; + +import com.somemore.imageupload.dto.ImageUploadRequestDto; + +public interface ImageUploadUseCase { + String uploadImage(ImageUploadRequestDto requestDto); +} diff --git a/src/main/java/com/somemore/imageupload/util/ImageUploadUtils.java b/src/main/java/com/somemore/imageupload/util/ImageUploadUtils.java new file mode 100644 index 000000000..46dfff3f2 --- /dev/null +++ b/src/main/java/com/somemore/imageupload/util/ImageUploadUtils.java @@ -0,0 +1,25 @@ +package com.somemore.imageupload.util; + +import java.util.UUID; + +public final class ImageUploadUtils { + + private ImageUploadUtils() { + throw new UnsupportedOperationException("인스턴스화 할 수 없는 클래스 입니다."); + } + + public static String generateUniqueFileName(String originalFileName) { + String uuid = UUID.randomUUID().toString(); + String fileExtension = extractFileExtension(originalFileName); + return uuid + fileExtension; + } + + public static String extractFileExtension(String fileName) { + return fileName.substring(fileName.lastIndexOf(".")); + } + + public static String generateS3Url(String baseUrl, String fileName) { + return String.format("%s/%s", baseUrl, fileName); + } + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 73394baa0..91e452a75 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,6 +2,20 @@ app: front-url: ${FRONT_URL} back-url: ${BACK_URL} +# AWS S3 +cloud: + aws: + credentials: + access-key: ${ACCESS_KEY} + secret-key: ${SECRET_KEY} + region: + static: ${BUCKET_REGION} + s3: + bucket: ${BUCKET_NAME} + base-url: ${BASE_URL} + stack: + auto: false + spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver @@ -47,6 +61,12 @@ spring: locale: ko_KR locale-resolver: fixed + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + + #swagger springdoc: swagger-ui: diff --git a/src/test/java/com/somemore/imageupload/service/ImageUploadServiceTest.java b/src/test/java/com/somemore/imageupload/service/ImageUploadServiceTest.java new file mode 100644 index 000000000..da21e1d94 --- /dev/null +++ b/src/test/java/com/somemore/imageupload/service/ImageUploadServiceTest.java @@ -0,0 +1,77 @@ +package com.somemore.imageupload.service; + +import com.somemore.IntegrationTestSupport; +import com.somemore.global.exception.ImageUploadException; +import com.somemore.imageupload.dto.ImageUploadRequestDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; +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.PutObjectRequest; + +import java.io.IOException; +import java.io.InputStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ImageUploadServiceTest extends IntegrationTestSupport { + + @Mock + private S3Client s3Client; + + @InjectMocks + private ImageUploadService imageUploadService; + + @Mock + private MultipartFile multipartFile; + + @BeforeEach + void setUp() throws IOException { + imageUploadService = new ImageUploadService(s3Client); + + ReflectionTestUtils.setField(imageUploadService, "bucket", "test-bucket"); + ReflectionTestUtils.setField(imageUploadService, "baseUrl", "https://amazonaws.com/"); + + when(multipartFile.getOriginalFilename()).thenReturn("testImage.jpg"); + when(multipartFile.getContentType()).thenReturn("image/jpeg"); + when(multipartFile.getInputStream()).thenReturn(mock(InputStream.class)); + when(multipartFile.getSize()).thenReturn(1000L); + } + + @DisplayName("업로드 요청 이미지를 S3에 업로드 할 수 있다.") + @Test + void testUploadImage_success() { + // given + ImageUploadRequestDto requestDto = new ImageUploadRequestDto(multipartFile); + + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .thenReturn(null); + + // when + String result = imageUploadService.uploadImage(requestDto); + + // then + verify(s3Client, times(1)).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + assertNotNull(result); + assertTrue(result.startsWith("https://amazonaws.com/")); + assertTrue(result.endsWith(".jpg")); + } + + @DisplayName("이미지 형식이 올바르지 않다면 업로드 할 수 없다.") + @Test + void testUploadImage_failure() throws IOException { + // given + when(multipartFile.getInputStream()).thenThrow(new IOException()); + + ImageUploadRequestDto requestDto = new ImageUploadRequestDto(multipartFile); + + // when, then + assertThrows(ImageUploadException.class, () -> imageUploadService.uploadImage(requestDto)); + } +} diff --git a/src/test/java/com/somemore/imageupload/util/ImageUploadUtilsTest.java b/src/test/java/com/somemore/imageupload/util/ImageUploadUtilsTest.java new file mode 100644 index 000000000..80d088d0b --- /dev/null +++ b/src/test/java/com/somemore/imageupload/util/ImageUploadUtilsTest.java @@ -0,0 +1,50 @@ +package com.somemore.imageupload.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ImageUploadUtilsTest { + + @DisplayName("이미지 업로드시 유일한 이미지 이름을 만들어줄 수 있다.") + @Test + void testGenerateUniqueFileName() { + //given + String fileName = "image.png"; + + //when + String uniqueName = ImageUploadUtils.generateUniqueFileName(fileName); + + //then + assertTrue(uniqueName.endsWith(".png")); + assertNotEquals(fileName, uniqueName); + } + + @DisplayName("이미지의 확장자를 검증할 수 있다.") + @Test + void testExtractFileExtension() { + //given + String fileName = "example.jpg"; + + //when + String extension = ImageUploadUtils.extractFileExtension(fileName); + + //then + assertEquals(".jpg", extension); + } + + @DisplayName("이미지의 주소를 반환할 수 있다.") + @Test + void testGenerateS3Url() { + //given + String baseUrl = "https://amazonaws.com"; + String fileName = "unique-image.png"; + + //when + String url = ImageUploadUtils.generateS3Url(baseUrl, fileName); + + //then + assertEquals("https://amazonaws.com/unique-image.png", url); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index fab7dd965..50d9922c6 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -44,3 +44,17 @@ server: charset: UTF-8 enabled: true force: true + +cloud: + aws: + credentials: + access-key: test-access-key + secret-key: test-secret-key + region: + static: ap-northeast-2 + s3: + bucket: somemore + base-url: https://somemore-image.s3.ap-northeast-2.amazonaws.com + stack: + auto: false + From 7d3e2bb5e839430d8c8dc5ff169b6e9729faf3fe Mon Sep 17 00:00:00 2001 From: seojin Yoon <90759319+7zrv@users.noreply.github.com> Date: Thu, 28 Nov 2024 11:34:01 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cicd workflow 에 이미지 업로드 관련 환경변수 추가 - appication.yml에 이미지 업로드 관련 환경변수 이름 변경 --- .github/workflows/CD.yml | 5 +++++ .github/workflows/CI.yml | 5 +++++ src/main/resources/application.yml | 4 ++-- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index dabaf0779..52a0544b3 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -27,6 +27,11 @@ jobs: JWT_SECRET: ${{ secrets.JWT_SECRET }} FRONT_URL: ${{secrets.FRONT_URL}} BACK_URL: ${{secrets.BACK_URL}} + BUCKET_NAME: ${{secrets.BUCKET_NAME}} + BUCKET_REGION: ${{secrets.BUCKET_REGION}} + BASE_URL:https: ${{secrets.BASE_URL}} + S3_ACCESS_KEY: ${{secrets.S3_ACCESS_KEY}} + S3_SECRET_KEY: ${{secrets.S3_SECRET_KEY}} steps: - name: Github Repository 파일 불러오기 diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 26939d2b9..e942d346a 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -35,6 +35,11 @@ jobs: JWT_SECRET: ${{ secrets.JWT_SECRET }} FRONT_URL: ${{secrets.FRONT_URL}} BACK_URL: ${{secrets.BACK_URL}} + BUCKET_NAME: ${{secrets.BUCKET_NAME}} + BUCKET_REGION: ${{secrets.BUCKET_REGION}} + BASE_URL:https: ${{secrets.BASE_URL}} + S3_ACCESS_KEY: ${{secrets.S3_ACCESS_KEY}} + S3_SECRET_KEY: ${{secrets.S3_SECRET_KEY}} steps: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 91e452a75..23021120d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -6,8 +6,8 @@ app: cloud: aws: credentials: - access-key: ${ACCESS_KEY} - secret-key: ${SECRET_KEY} + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} region: static: ${BUCKET_REGION} s3: From 38b09b2ff5c5613b6a10eab69b276f7964519426 Mon Sep 17 00:00:00 2001 From: seojin Yoon <90759319+7zrv@users.noreply.github.com> Date: Thu, 28 Nov 2024 12:19:56 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EA=B2=80=EC=A6=9D=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이미지 크기를 8MB로 제한 - 이미지의 크기 초과를 검증하는 기능 구현 - 이미지의 확장자명을 검증하는 기능 구현 - 이미지 업로드시 검증 기능들을 수행하도록 수정 - 전역 예외처리 핸들러에 이미지 업로드 예외 추가 - 예외 메세지 추가 - 테스트 코드 작성및 검증 완 --- .../global/exception/ExceptionMessage.java | 1 + .../handler/GlobalExceptionHandler.java | 14 ++++ .../service/ImageUploadService.java | 4 ++ .../DefaultImageUploadValidator.java | 39 +++++++++++ .../validator/ImageUploadValidator.java | 9 +++ src/main/resources/application.yml | 4 +- .../service/ImageUploadServiceTest.java | 6 +- .../DefaultImageUploadValidatorTest.java | 70 +++++++++++++++++++ 8 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/somemore/imageupload/validator/DefaultImageUploadValidator.java create mode 100644 src/main/java/com/somemore/imageupload/validator/ImageUploadValidator.java create mode 100644 src/test/java/com/somemore/imageupload/validator/DefaultImageUploadValidatorTest.java diff --git a/src/main/java/com/somemore/global/exception/ExceptionMessage.java b/src/main/java/com/somemore/global/exception/ExceptionMessage.java index fe420439e..a89b9bf72 100644 --- a/src/main/java/com/somemore/global/exception/ExceptionMessage.java +++ b/src/main/java/com/somemore/global/exception/ExceptionMessage.java @@ -17,6 +17,7 @@ public enum ExceptionMessage { UPLOAD_FAILED("파일 업로드에 실패했습니다."), INVALID_FILE_TYPE("지원하지 않는 파일 형식입니다."), FILE_SIZE_EXCEEDED("파일 크기가 허용된 한도를 초과했습니다."), + EMPTY_FILE("파일이 존재하지 않습니다.") ; private final String message; diff --git a/src/main/java/com/somemore/global/handler/GlobalExceptionHandler.java b/src/main/java/com/somemore/global/handler/GlobalExceptionHandler.java index 742719d0d..3f6cc0736 100644 --- a/src/main/java/com/somemore/global/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/somemore/global/handler/GlobalExceptionHandler.java @@ -2,10 +2,13 @@ import com.somemore.global.exception.BadRequestException; +import com.somemore.global.exception.ImageUploadException; +import org.springframework.data.crossstore.ChangeSetPersister; import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; @@ -25,4 +28,15 @@ ProblemDetail handleBadRequestException(final BadRequestException e) { return problemDetail; } + @ExceptionHandler(ImageUploadException.class) + ProblemDetail handleImageUploadException(final ImageUploadException e) { + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage()); + + problemDetail.setTitle("이미지 업로드 실패"); + problemDetail.setDetail("업로드 중 문제가 발생했습니다. 파일 크기나 형식이 올바른지 확인해 주세요."); + + return problemDetail; + } + } diff --git a/src/main/java/com/somemore/imageupload/service/ImageUploadService.java b/src/main/java/com/somemore/imageupload/service/ImageUploadService.java index 4f6a1ea4a..974261b86 100644 --- a/src/main/java/com/somemore/imageupload/service/ImageUploadService.java +++ b/src/main/java/com/somemore/imageupload/service/ImageUploadService.java @@ -4,6 +4,7 @@ import com.somemore.imageupload.dto.ImageUploadRequestDto; import com.somemore.imageupload.usecase.ImageUploadUseCase; import com.somemore.imageupload.util.ImageUploadUtils; +import com.somemore.imageupload.validator.ImageUploadValidator; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -20,6 +21,7 @@ public class ImageUploadService implements ImageUploadUseCase { private final S3Client s3Client; + private final ImageUploadValidator imageUploadValidator; @Value("${cloud.aws.s3.bucket}") private String bucket; @@ -29,6 +31,8 @@ public class ImageUploadService implements ImageUploadUseCase { @Override public String uploadImage(ImageUploadRequestDto requestDto) { + imageUploadValidator.validateFileSize(requestDto.imageFile()); + imageUploadValidator.validateFileType(requestDto.imageFile()); String fileName = ImageUploadUtils.generateUniqueFileName(requestDto.imageFile().getOriginalFilename()); diff --git a/src/main/java/com/somemore/imageupload/validator/DefaultImageUploadValidator.java b/src/main/java/com/somemore/imageupload/validator/DefaultImageUploadValidator.java new file mode 100644 index 000000000..865dd2844 --- /dev/null +++ b/src/main/java/com/somemore/imageupload/validator/DefaultImageUploadValidator.java @@ -0,0 +1,39 @@ +package com.somemore.imageupload.validator; + +import com.somemore.global.exception.ImageUploadException; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import static com.somemore.global.exception.ExceptionMessage.*; + +@Component +public class DefaultImageUploadValidator implements ImageUploadValidator { + + private static final long MAX_FILE_SIZE = 8L * 1024 * 1024; // 8MB + + public void validateFileSize(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ImageUploadException(EMPTY_FILE.getMessage()); + } + + if (file.getSize() > MAX_FILE_SIZE) { + throw new ImageUploadException(FILE_SIZE_EXCEEDED.getMessage()); + } + } + + public void validateFileType(MultipartFile file) { + String contentType = file.getContentType(); + if (!isAllowedImageType(contentType)) { + throw new ImageUploadException(INVALID_FILE_TYPE.getMessage()); + } + } + + private boolean isAllowedImageType(String contentType) { + return contentType != null && ( + contentType.equals("image/jpeg") || + contentType.equals("image/png") || + contentType.equals("image/gif") || + contentType.equals("image/webp") + ); + } +} diff --git a/src/main/java/com/somemore/imageupload/validator/ImageUploadValidator.java b/src/main/java/com/somemore/imageupload/validator/ImageUploadValidator.java new file mode 100644 index 000000000..2481c5d48 --- /dev/null +++ b/src/main/java/com/somemore/imageupload/validator/ImageUploadValidator.java @@ -0,0 +1,9 @@ +package com.somemore.imageupload.validator; + +import org.springframework.web.multipart.MultipartFile; + +public interface ImageUploadValidator { + + void validateFileSize(MultipartFile file); + void validateFileType(MultipartFile file); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 23021120d..be9c9580e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -63,8 +63,8 @@ spring: servlet: multipart: - max-file-size: 10MB - max-request-size: 10MB + max-file-size: 8MB + max-request-size: 8MB #swagger diff --git a/src/test/java/com/somemore/imageupload/service/ImageUploadServiceTest.java b/src/test/java/com/somemore/imageupload/service/ImageUploadServiceTest.java index da21e1d94..31e2fbfd6 100644 --- a/src/test/java/com/somemore/imageupload/service/ImageUploadServiceTest.java +++ b/src/test/java/com/somemore/imageupload/service/ImageUploadServiceTest.java @@ -3,6 +3,7 @@ import com.somemore.IntegrationTestSupport; import com.somemore.global.exception.ImageUploadException; import com.somemore.imageupload.dto.ImageUploadRequestDto; +import com.somemore.imageupload.validator.ImageUploadValidator; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -25,6 +26,9 @@ class ImageUploadServiceTest extends IntegrationTestSupport { @Mock private S3Client s3Client; + @Mock + private ImageUploadValidator imageUploadValidator; + @InjectMocks private ImageUploadService imageUploadService; @@ -33,8 +37,6 @@ class ImageUploadServiceTest extends IntegrationTestSupport { @BeforeEach void setUp() throws IOException { - imageUploadService = new ImageUploadService(s3Client); - ReflectionTestUtils.setField(imageUploadService, "bucket", "test-bucket"); ReflectionTestUtils.setField(imageUploadService, "baseUrl", "https://amazonaws.com/"); diff --git a/src/test/java/com/somemore/imageupload/validator/DefaultImageUploadValidatorTest.java b/src/test/java/com/somemore/imageupload/validator/DefaultImageUploadValidatorTest.java new file mode 100644 index 000000000..76c852a2e --- /dev/null +++ b/src/test/java/com/somemore/imageupload/validator/DefaultImageUploadValidatorTest.java @@ -0,0 +1,70 @@ +package com.somemore.imageupload.validator; + +import com.somemore.global.exception.ImageUploadException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import static org.junit.jupiter.api.Assertions.*; + +class DefaultImageUploadValidatorTest { + + private DefaultImageUploadValidator imageUploadValidator; + + @BeforeEach + void setUp() { + imageUploadValidator = new DefaultImageUploadValidator(); + } + + @Test + @DisplayName("파일이 비어있으면 예외가 발생한다.") + void shouldThrowExceptionWhenFileIsEmpty() { + //given + MultipartFile emptyFile = new MockMultipartFile("file", new byte[0]); + + //when&then + assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileSize(emptyFile)); + } + + @Test + @DisplayName("파일 크기가 최대 8MB를 초과하는 경우, 예외가 발생한다.") + void shouldThrowExceptionWhenFileSizeExceeded() { + //given + MultipartFile largeFile = new MockMultipartFile("file", "largeImage.jpg", "image/jpeg", new byte[9 * 1024 * 1024]); + + //when&then + assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileSize(largeFile)); + } + + @Test + @DisplayName("유효한 이미지 타입(JPEG) 파일이 있을 경우, 검증에 통과한다.") + void shouldNotThrowExceptionWhenFileTypeIsValidJpeg() { + //given + MultipartFile validFile = new MockMultipartFile("file", "validImage.jpg", "image/jpeg", new byte[1024]); + + //when&then + assertDoesNotThrow(() -> imageUploadValidator.validateFileType(validFile)); + } + + @Test + @DisplayName("유효하지 않은 이미지 타입 파일이 있을 경우, 예외가 발생한다.") + void shouldThrowExceptionWhenFileTypeIsInvalid() { + //given + MultipartFile invalidFile = new MockMultipartFile("file", "invalidFile.pdf", "application/pdf", new byte[1024]); + + //when&then + assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileType(invalidFile)); + } + + @Test + @DisplayName("파일 타입이 올바르지 않을 경우, 예외가 발생한다.") + void shouldThrowExceptionWhenFileTypeIsNull() { + //given + MultipartFile nullContentTypeFile = new MockMultipartFile("file", "noContentTypeFile.jpg", null, new byte[1024]); + + //when&then + assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileType(nullContentTypeFile)); + } +} From deada6c5c57c231a3713b130f5b00f1a8dfb243e Mon Sep 17 00:00:00 2001 From: seojin Yoon <90759319+7zrv@users.noreply.github.com> Date: Thu, 28 Nov 2024 14:31:43 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BASE_URL을 S3_BASE_URL라는 더 명시적인 이름으로 변경 - ImageUploadUtils private 생성자의 예외처리 메세지를 ExceptionMessage에 등록하여 사용하도록 수정 - 이미지 업로드 메서드의 내부 기능을 메서드로 추출하여 가독성 향상 - ImageUploadUtils의 일부 기능의 접근 제어자를 private으로 수정 - 테스트 코드의 when, then을 더 명확하게 분 --- .github/workflows/CD.yml | 2 +- .github/workflows/CI.yml | 2 +- .../global/exception/ExceptionMessage.java | 3 +- .../service/ImageUploadService.java | 34 +++++++------ .../imageupload/util/ImageUploadUtils.java | 8 +-- src/main/resources/application.yml | 2 +- .../util/ImageUploadUtilsTest.java | 50 ++++++++++++++++--- .../DefaultImageUploadValidatorTest.java | 44 ++++++++++------ 8 files changed, 102 insertions(+), 43 deletions(-) diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml index 52a0544b3..5dee87723 100644 --- a/.github/workflows/CD.yml +++ b/.github/workflows/CD.yml @@ -29,7 +29,7 @@ jobs: BACK_URL: ${{secrets.BACK_URL}} BUCKET_NAME: ${{secrets.BUCKET_NAME}} BUCKET_REGION: ${{secrets.BUCKET_REGION}} - BASE_URL:https: ${{secrets.BASE_URL}} + IMG_BASE_URL: ${{secrets.BASE_URL}} S3_ACCESS_KEY: ${{secrets.S3_ACCESS_KEY}} S3_SECRET_KEY: ${{secrets.S3_SECRET_KEY}} diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index e942d346a..629520d89 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -37,7 +37,7 @@ jobs: BACK_URL: ${{secrets.BACK_URL}} BUCKET_NAME: ${{secrets.BUCKET_NAME}} BUCKET_REGION: ${{secrets.BUCKET_REGION}} - BASE_URL:https: ${{secrets.BASE_URL}} + IMG_BASE_URL: ${{secrets.BASE_URL}} S3_ACCESS_KEY: ${{secrets.S3_ACCESS_KEY}} S3_SECRET_KEY: ${{secrets.S3_SECRET_KEY}} diff --git a/src/main/java/com/somemore/global/exception/ExceptionMessage.java b/src/main/java/com/somemore/global/exception/ExceptionMessage.java index a89b9bf72..de2ac538b 100644 --- a/src/main/java/com/somemore/global/exception/ExceptionMessage.java +++ b/src/main/java/com/somemore/global/exception/ExceptionMessage.java @@ -17,7 +17,8 @@ public enum ExceptionMessage { UPLOAD_FAILED("파일 업로드에 실패했습니다."), INVALID_FILE_TYPE("지원하지 않는 파일 형식입니다."), FILE_SIZE_EXCEEDED("파일 크기가 허용된 한도를 초과했습니다."), - EMPTY_FILE("파일이 존재하지 않습니다.") + EMPTY_FILE("파일이 존재하지 않습니다."), + INSTANTIATION_NOT_ALLOWED("인스턴스화 할 수 없는 클래스 입니다.") ; private final String message; diff --git a/src/main/java/com/somemore/imageupload/service/ImageUploadService.java b/src/main/java/com/somemore/imageupload/service/ImageUploadService.java index 974261b86..1542ac30a 100644 --- a/src/main/java/com/somemore/imageupload/service/ImageUploadService.java +++ b/src/main/java/com/somemore/imageupload/service/ImageUploadService.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; 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.PutObjectRequest; @@ -34,24 +35,29 @@ public String uploadImage(ImageUploadRequestDto requestDto) { imageUploadValidator.validateFileSize(requestDto.imageFile()); imageUploadValidator.validateFileType(requestDto.imageFile()); - String fileName = ImageUploadUtils.generateUniqueFileName(requestDto.imageFile().getOriginalFilename()); - - PutObjectRequest request = PutObjectRequest.builder() - .bucket(bucket) - .key(fileName) - .contentType(requestDto.imageFile().getContentType()) - .build(); - try { - s3Client.putObject(request, RequestBody.fromInputStream( - requestDto.imageFile().getInputStream(), - requestDto.imageFile().getSize() - )); - - return ImageUploadUtils.generateS3Url(baseUrl, fileName); + return uploadToS3(requestDto.imageFile()); } catch (IOException e) { throw new ImageUploadException(UPLOAD_FAILED.getMessage()); } } + private String uploadToS3(MultipartFile file) throws IOException { + String fileName = ImageUploadUtils.generateUniqueFileName(file.getOriginalFilename()); + + PutObjectRequest request = createPutObjectRequest(file, fileName); + + s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + + return ImageUploadUtils.generateS3Url(baseUrl, fileName); + } + + private PutObjectRequest createPutObjectRequest(MultipartFile file, String fileName) { + return PutObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .contentType(file.getContentType()) + .build(); + } + } diff --git a/src/main/java/com/somemore/imageupload/util/ImageUploadUtils.java b/src/main/java/com/somemore/imageupload/util/ImageUploadUtils.java index 46dfff3f2..c72e2c5b0 100644 --- a/src/main/java/com/somemore/imageupload/util/ImageUploadUtils.java +++ b/src/main/java/com/somemore/imageupload/util/ImageUploadUtils.java @@ -2,10 +2,12 @@ import java.util.UUID; -public final class ImageUploadUtils { +import static com.somemore.global.exception.ExceptionMessage.INSTANTIATION_NOT_ALLOWED; + +public class ImageUploadUtils { private ImageUploadUtils() { - throw new UnsupportedOperationException("인스턴스화 할 수 없는 클래스 입니다."); + throw new UnsupportedOperationException(INSTANTIATION_NOT_ALLOWED.getMessage()); } public static String generateUniqueFileName(String originalFileName) { @@ -14,7 +16,7 @@ public static String generateUniqueFileName(String originalFileName) { return uuid + fileExtension; } - public static String extractFileExtension(String fileName) { + private static String extractFileExtension(String fileName) { return fileName.substring(fileName.lastIndexOf(".")); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index be9c9580e..6621c5d18 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,7 +12,7 @@ cloud: static: ${BUCKET_REGION} s3: bucket: ${BUCKET_NAME} - base-url: ${BASE_URL} + base-url: ${IMG_BASE_URL} stack: auto: false diff --git a/src/test/java/com/somemore/imageupload/util/ImageUploadUtilsTest.java b/src/test/java/com/somemore/imageupload/util/ImageUploadUtilsTest.java index 80d088d0b..15180389b 100644 --- a/src/test/java/com/somemore/imageupload/util/ImageUploadUtilsTest.java +++ b/src/test/java/com/somemore/imageupload/util/ImageUploadUtilsTest.java @@ -3,10 +3,26 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; + import static org.junit.jupiter.api.Assertions.*; class ImageUploadUtilsTest { + @Test + void privateConstructorShouldThrowException() throws Exception { + // given + Constructor constructor = ImageUploadUtils.class.getDeclaredConstructor(); + constructor.setAccessible(true); + + // when + InvocationTargetException exception = assertThrows(InvocationTargetException.class, constructor::newInstance); + + // then + assertThrows(UnsupportedOperationException.class, () -> { throw exception.getCause(); }); + } + @DisplayName("이미지 업로드시 유일한 이미지 이름을 만들어줄 수 있다.") @Test void testGenerateUniqueFileName() { @@ -21,19 +37,22 @@ void testGenerateUniqueFileName() { assertNotEquals(fileName, uniqueName); } - @DisplayName("이미지의 확장자를 검증할 수 있다.") + @DisplayName("유니크한 파일 이름을 생성할 때 UUID는 정상적으로 생성된다.") @Test - void testExtractFileExtension() { - //given - String fileName = "example.jpg"; + void testGenerateUniqueFileName_uuid() { + // given + String fileName = "image.png"; - //when - String extension = ImageUploadUtils.extractFileExtension(fileName); + // when + String uniqueName = ImageUploadUtils.generateUniqueFileName(fileName); - //then - assertEquals(".jpg", extension); + // then + assertNotNull(uniqueName); + assertTrue(uniqueName.contains("-")); + assertTrue(uniqueName.endsWith(".png")); } + @DisplayName("이미지의 주소를 반환할 수 있다.") @Test void testGenerateS3Url() { @@ -47,4 +66,19 @@ void testGenerateS3Url() { //then assertEquals("https://amazonaws.com/unique-image.png", url); } + + @DisplayName("baseUrl이 빈 문자열일 경우 URL을 생성할 수 있다.") + @Test + void testGenerateS3Url_emptyBaseUrl() { + // given + String baseUrl = ""; + String fileName = "unique-image.png"; + + // when + String url = ImageUploadUtils.generateS3Url(baseUrl, fileName); + + // then + assertEquals("/unique-image.png", url); + } + } \ No newline at end of file diff --git a/src/test/java/com/somemore/imageupload/validator/DefaultImageUploadValidatorTest.java b/src/test/java/com/somemore/imageupload/validator/DefaultImageUploadValidatorTest.java index 76c852a2e..03c2d6b0e 100644 --- a/src/test/java/com/somemore/imageupload/validator/DefaultImageUploadValidatorTest.java +++ b/src/test/java/com/somemore/imageupload/validator/DefaultImageUploadValidatorTest.java @@ -15,6 +15,7 @@ class DefaultImageUploadValidatorTest { @BeforeEach void setUp() { + // given imageUploadValidator = new DefaultImageUploadValidator(); } @@ -24,47 +25,62 @@ void shouldThrowExceptionWhenFileIsEmpty() { //given MultipartFile emptyFile = new MockMultipartFile("file", new byte[0]); - //when&then - assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileSize(emptyFile)); + //when + Throwable exception = assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileSize(emptyFile)); + + //then + assertEquals(ImageUploadException.class, exception.getClass()); } @Test @DisplayName("파일 크기가 최대 8MB를 초과하는 경우, 예외가 발생한다.") void shouldThrowExceptionWhenFileSizeExceeded() { - //given + // given MultipartFile largeFile = new MockMultipartFile("file", "largeImage.jpg", "image/jpeg", new byte[9 * 1024 * 1024]); - //when&then - assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileSize(largeFile)); + // when + Throwable exception = assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileSize(largeFile)); + + // then + assertEquals(ImageUploadException.class, exception.getClass()); } @Test - @DisplayName("유효한 이미지 타입(JPEG) 파일이 있을 경우, 검증에 통과한다.") + @DisplayName("유효한 이미지 타입(JPEG) 파일은, 검증에 통과한다.") void shouldNotThrowExceptionWhenFileTypeIsValidJpeg() { - //given + // given MultipartFile validFile = new MockMultipartFile("file", "validImage.jpg", "image/jpeg", new byte[1024]); - //when&then + // when + imageUploadValidator.validateFileType(validFile); + + // then assertDoesNotThrow(() -> imageUploadValidator.validateFileType(validFile)); } @Test @DisplayName("유효하지 않은 이미지 타입 파일이 있을 경우, 예외가 발생한다.") void shouldThrowExceptionWhenFileTypeIsInvalid() { - //given + // given MultipartFile invalidFile = new MockMultipartFile("file", "invalidFile.pdf", "application/pdf", new byte[1024]); - //when&then - assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileType(invalidFile)); + // when + Throwable exception = assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileType(invalidFile)); + + // then + assertEquals(ImageUploadException.class, exception.getClass()); } @Test @DisplayName("파일 타입이 올바르지 않을 경우, 예외가 발생한다.") void shouldThrowExceptionWhenFileTypeIsNull() { - //given + // given MultipartFile nullContentTypeFile = new MockMultipartFile("file", "noContentTypeFile.jpg", null, new byte[1024]); - //when&then - assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileType(nullContentTypeFile)); + // when + Throwable exception = assertThrows(ImageUploadException.class, () -> imageUploadValidator.validateFileType(nullContentTypeFile)); + + // then + assertEquals(ImageUploadException.class, exception.getClass()); } }