From a97b42dc3d558accd77ac37fbd4a0480fd52ee8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=EC=98=88=EC=A7=84?= <96688810+kwonyj1022@users.noreply.github.com> Date: Fri, 17 Nov 2023 17:09:55 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20S3=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#707)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: aws, s3, cloudfront 의존성 추가 * feat: AWS S3 설정 추가 * feat: StoreImageProcessor 상수 추가 * feat: S3 이미지 업로드 기능 추가 * test: S3 이미지 업로드 기능 테스트 추가 * feat: S3 이미지 업로드 기능 사용을 제어할 수 있는 설정 추가 * feat: 테스트 환경에서 S3 이미지 업로드 기능 사용을 제어할 수 있는 설정의 설정값 변경 * remove: 테스트용 AwsConfiguration 삭제 * feat: 프로덕션 환경에서만 동작하는 Profile 어노테이션 추가 * test: 누락된 s3 지역 설정값 추가 * feat: 프로덕션 환경에 s3 관련 yml 설정 추가 * refactor: 누락된 final 추가 --- backend/ddang/build.gradle | 5 + .../configuration/AwsConfiguration.java | 25 +++++ .../ddang/configuration/ProductProfile.java | 14 +++ .../fcm/ProdFcmConfiguration.java | 4 +- .../image/domain/StoreImageProcessor.java | 7 +- .../local/LocalStoreImageProcessor.java | 25 +++-- .../s3/S3StoreImageProcessor.java | 103 +++++++++++++++++ .../src/main/resources/application-local.yml | 7 ++ .../s3/S3StoreImageProcessorTest.java | 106 ++++++++++++++++++ .../fixture/S3StoreImageProcessorFixture.java | 14 +++ .../ddang/src/test/resources/application.yml | 7 ++ 11 files changed, 301 insertions(+), 16 deletions(-) create mode 100644 backend/ddang/src/main/java/com/ddang/ddang/auction/configuration/AwsConfiguration.java create mode 100644 backend/ddang/src/main/java/com/ddang/ddang/configuration/ProductProfile.java create mode 100644 backend/ddang/src/main/java/com/ddang/ddang/image/infrastructure/s3/S3StoreImageProcessor.java create mode 100644 backend/ddang/src/test/java/com/ddang/ddang/image/infrastructure/s3/S3StoreImageProcessorTest.java create mode 100644 backend/ddang/src/test/java/com/ddang/ddang/image/infrastructure/s3/fixture/S3StoreImageProcessorFixture.java diff --git a/backend/ddang/build.gradle b/backend/ddang/build.gradle index 76ceb430d..14fd58159 100644 --- a/backend/ddang/build.gradle +++ b/backend/ddang/build.gradle @@ -77,6 +77,11 @@ dependencies { implementation 'ch.qos.logback.contrib:logback-json-classic:0.1.5' implementation 'net.logstash.logback:logstash-logback-encoder:6.1' + // aws + implementation platform('software.amazon.awssdk:bom:2.20.56') + implementation 'software.amazon.awssdk:s3' + implementation 'software.amazon.awssdk:cloudfront' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/backend/ddang/src/main/java/com/ddang/ddang/auction/configuration/AwsConfiguration.java b/backend/ddang/src/main/java/com/ddang/ddang/auction/configuration/AwsConfiguration.java new file mode 100644 index 000000000..f2b829e96 --- /dev/null +++ b/backend/ddang/src/main/java/com/ddang/ddang/auction/configuration/AwsConfiguration.java @@ -0,0 +1,25 @@ +package com.ddang.ddang.auction.configuration; + +import com.ddang.ddang.configuration.ProductProfile; +import com.google.api.client.util.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@ProductProfile +@Configuration +public class AwsConfiguration { + + @Value("${aws.s3.region}") + private String s3Region; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(s3Region)) + .credentialsProvider(InstanceProfileCredentialsProvider.create()) + .build(); + } +} diff --git a/backend/ddang/src/main/java/com/ddang/ddang/configuration/ProductProfile.java b/backend/ddang/src/main/java/com/ddang/ddang/configuration/ProductProfile.java new file mode 100644 index 000000000..99ffc02a4 --- /dev/null +++ b/backend/ddang/src/main/java/com/ddang/ddang/configuration/ProductProfile.java @@ -0,0 +1,14 @@ +package com.ddang.ddang.configuration; + +import org.springframework.context.annotation.Profile; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Profile("!local && !test") +public @interface ProductProfile { +} diff --git a/backend/ddang/src/main/java/com/ddang/ddang/configuration/fcm/ProdFcmConfiguration.java b/backend/ddang/src/main/java/com/ddang/ddang/configuration/fcm/ProdFcmConfiguration.java index ac0848a4b..89e69f731 100644 --- a/backend/ddang/src/main/java/com/ddang/ddang/configuration/fcm/ProdFcmConfiguration.java +++ b/backend/ddang/src/main/java/com/ddang/ddang/configuration/fcm/ProdFcmConfiguration.java @@ -1,5 +1,6 @@ package com.ddang.ddang.configuration.fcm; +import com.ddang.ddang.configuration.ProductProfile; import com.ddang.ddang.configuration.fcm.exception.FcmNotFoundException; import com.google.auth.oauth2.GoogleCredentials; import com.google.firebase.FirebaseApp; @@ -13,10 +14,9 @@ import java.io.IOException; import java.io.InputStream; import java.util.List; -import org.springframework.context.annotation.Profile; @Configuration -@Profile("!test && !local") +@ProductProfile public class ProdFcmConfiguration { @Value("${fcm.key.path}") diff --git a/backend/ddang/src/main/java/com/ddang/ddang/image/domain/StoreImageProcessor.java b/backend/ddang/src/main/java/com/ddang/ddang/image/domain/StoreImageProcessor.java index 19158bfd1..5485586f2 100644 --- a/backend/ddang/src/main/java/com/ddang/ddang/image/domain/StoreImageProcessor.java +++ b/backend/ddang/src/main/java/com/ddang/ddang/image/domain/StoreImageProcessor.java @@ -7,7 +7,10 @@ public interface StoreImageProcessor { - StoreImageDto storeImageFile(MultipartFile imageFile); + List WHITE_IMAGE_EXTENSION = List.of("jpg", "jpeg", "png"); + String EXTENSION_FILE_CHARACTER = "."; - List storeImageFiles(List imageFiles); + StoreImageDto storeImageFile(final MultipartFile imageFile); + + List storeImageFiles(final List imageFiles); } diff --git a/backend/ddang/src/main/java/com/ddang/ddang/image/infrastructure/local/LocalStoreImageProcessor.java b/backend/ddang/src/main/java/com/ddang/ddang/image/infrastructure/local/LocalStoreImageProcessor.java index 136b348a4..75a4de328 100644 --- a/backend/ddang/src/main/java/com/ddang/ddang/image/infrastructure/local/LocalStoreImageProcessor.java +++ b/backend/ddang/src/main/java/com/ddang/ddang/image/infrastructure/local/LocalStoreImageProcessor.java @@ -5,21 +5,21 @@ import com.ddang.ddang.image.infrastructure.local.exception.EmptyImageException; import com.ddang.ddang.image.infrastructure.local.exception.StoreImageFailureException; import com.ddang.ddang.image.infrastructure.local.exception.UnsupportedImageFileExtensionException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.UUID; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; @Component +@ConditionalOnProperty(name = "aws.s3.enabled", havingValue = "false") public class LocalStoreImageProcessor implements StoreImageProcessor { - private static final List WHITE_IMAGE_EXTENSION = List.of("jpg", "jpeg", "png"); - private static final String EXTENSION_FILE_CHARACTER = "."; - @Value("${image.store.dir}") private String imageStoreDir; @@ -27,7 +27,7 @@ public class LocalStoreImageProcessor implements StoreImageProcessor { public List storeImageFiles(final List imageFiles) { final List storeImageDtos = new ArrayList<>(); - for (MultipartFile imageFile : imageFiles) { + for (final MultipartFile imageFile : imageFiles) { if (imageFile.isEmpty()) { throw new EmptyImageException("이미지 파일의 데이터가 비어 있습니다."); } @@ -38,7 +38,8 @@ public List storeImageFiles(final List imageFiles) return storeImageDtos; } - public StoreImageDto storeImageFile(MultipartFile imageFile) { + @Override + public StoreImageDto storeImageFile(final MultipartFile imageFile) { try { final String originalImageFileName = imageFile.getOriginalFilename(); final String storeImageFileName = createStoreImageFileName(originalImageFileName); @@ -47,16 +48,16 @@ public StoreImageDto storeImageFile(MultipartFile imageFile) { imageFile.transferTo(new File(fullPath)); return new StoreImageDto(originalImageFileName, storeImageFileName); - } catch (IOException ex) { + } catch (final IOException ex) { throw new StoreImageFailureException("이미지 저장에 실패했습니다.", ex); } } - private String findFullPath(String storeImageFileName) { + private String findFullPath(final String storeImageFileName) { return imageStoreDir + storeImageFileName; } - private String createStoreImageFileName(String originalFilename) { + private String createStoreImageFileName(final String originalFilename) { final String extension = extractExtension(originalFilename); validateImageFileExtension(extension); @@ -66,7 +67,7 @@ private String createStoreImageFileName(String originalFilename) { return uuid + EXTENSION_FILE_CHARACTER + extension; } - private String extractExtension(String originalFilename) { + private String extractExtension(final String originalFilename) { int position = originalFilename.lastIndexOf(EXTENSION_FILE_CHARACTER); return originalFilename.substring(position + 1); diff --git a/backend/ddang/src/main/java/com/ddang/ddang/image/infrastructure/s3/S3StoreImageProcessor.java b/backend/ddang/src/main/java/com/ddang/ddang/image/infrastructure/s3/S3StoreImageProcessor.java new file mode 100644 index 000000000..a7e4eaffe --- /dev/null +++ b/backend/ddang/src/main/java/com/ddang/ddang/image/infrastructure/s3/S3StoreImageProcessor.java @@ -0,0 +1,103 @@ +package com.ddang.ddang.image.infrastructure.s3; + +import com.ddang.ddang.configuration.ProductProfile; +import com.ddang.ddang.image.domain.StoreImageProcessor; +import com.ddang.ddang.image.domain.dto.StoreImageDto; +import com.ddang.ddang.image.infrastructure.local.exception.EmptyImageException; +import com.ddang.ddang.image.infrastructure.local.exception.StoreImageFailureException; +import com.ddang.ddang.image.infrastructure.local.exception.UnsupportedImageFileExtensionException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.exception.SdkException; +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.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Component +@ProductProfile +@ConditionalOnProperty(name = "aws.s3.enabled", havingValue = "true") +@RequiredArgsConstructor +public class S3StoreImageProcessor implements StoreImageProcessor { + + @Value("${aws.s3.bucket-name}") + private String bucketName; + + @Value("${aws.s3.image-path}") + private String path; + + private final S3Client s3Client; + + @Override + public List storeImageFiles(final List imageFiles) { + final List storeImageDtos = new ArrayList<>(); + + for (final MultipartFile imageFile : imageFiles) { + if (imageFile.isEmpty()) { + throw new EmptyImageException("이미지 파일의 데이터가 비어 있습니다."); + } + + storeImageDtos.add(storeImageFile(imageFile)); + } + + return storeImageDtos; + } + + @Override + public StoreImageDto storeImageFile(final MultipartFile imageFile) { + try { + final String originalImageFileName = imageFile.getOriginalFilename(); + final String storeImageFileName = createStoreImageFileName(originalImageFileName); + final String fullPath = findFullPath(storeImageFileName); + final PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .key(fullPath) + .bucket(bucketName) + .contentType(imageFile.getContentType()) + .build(); + + s3Client.putObject( + putObjectRequest, + RequestBody.fromInputStream(imageFile.getInputStream(), imageFile.getSize()) + ); + + return new StoreImageDto(originalImageFileName, storeImageFileName); + } catch (final IOException ex) { + throw new StoreImageFailureException("이미지 저장에 실패했습니다.", ex); + } catch (final SdkException ex) { + throw new StoreImageFailureException("AWS 이미지 저장에 실패했습니다.", ex); + } + } + + private String findFullPath(final String storeImageFileName) { + return path + storeImageFileName; + } + + private String createStoreImageFileName(final String originalFilename) { + final String extension = extractExtension(originalFilename); + + validateImageFileExtension(extension); + + final String uuid = UUID.randomUUID().toString(); + + return uuid + EXTENSION_FILE_CHARACTER + extension; + } + + private String extractExtension(final String originalFilename) { + int position = originalFilename.lastIndexOf(EXTENSION_FILE_CHARACTER); + + return originalFilename.substring(position + 1); + } + + private void validateImageFileExtension(final String extension) { + if (!WHITE_IMAGE_EXTENSION.contains(extension)) { + throw new UnsupportedImageFileExtensionException("지원하지 않는 확장자입니다. : " + extension); + } + } +} diff --git a/backend/ddang/src/main/resources/application-local.yml b/backend/ddang/src/main/resources/application-local.yml index fd586f349..e6c8bb7d1 100644 --- a/backend/ddang/src/main/resources/application-local.yml +++ b/backend/ddang/src/main/resources/application-local.yml @@ -53,3 +53,10 @@ fcm: enabled: false key: path: firebase/private-key.json + +aws: + s3: + enabled: false + region: region + bucket-name: awsbucketname + image-path: image/path diff --git a/backend/ddang/src/test/java/com/ddang/ddang/image/infrastructure/s3/S3StoreImageProcessorTest.java b/backend/ddang/src/test/java/com/ddang/ddang/image/infrastructure/s3/S3StoreImageProcessorTest.java new file mode 100644 index 000000000..635ad022b --- /dev/null +++ b/backend/ddang/src/test/java/com/ddang/ddang/image/infrastructure/s3/S3StoreImageProcessorTest.java @@ -0,0 +1,106 @@ +package com.ddang.ddang.image.infrastructure.s3; + +import com.ddang.ddang.image.domain.dto.StoreImageDto; +import com.ddang.ddang.image.infrastructure.local.exception.EmptyImageException; +import com.ddang.ddang.image.infrastructure.local.exception.StoreImageFailureException; +import com.ddang.ddang.image.infrastructure.local.exception.UnsupportedImageFileExtensionException; +import com.ddang.ddang.image.infrastructure.s3.fixture.S3StoreImageProcessorFixture; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@ExtendWith({MockitoExtension.class}) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class S3StoreImageProcessorTest extends S3StoreImageProcessorFixture { + + @InjectMocks + S3StoreImageProcessor imageProcessor; + + @Mock + S3Client s3Client; + + @Test + void 이미지_파일이_비어_있는_경우_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> imageProcessor.storeImageFiles(List.of(빈_이미지_파일))) + .isInstanceOf(EmptyImageException.class) + .hasMessage("이미지 파일의 데이터가 비어 있습니다."); + } + + @Test + void 허용되지_않은_확장자의_이미지_파일인_경우_예외가_발생한다() { + // given + given(이미지_파일.getOriginalFilename()).willReturn(지원하지_않는_확장자를_가진_이미지_파일명); + + // when & then + assertThatThrownBy(() -> imageProcessor.storeImageFiles(List.of(이미지_파일))) + .isInstanceOf(UnsupportedImageFileExtensionException.class) + .hasMessageContaining("지원하지 않는 확장자입니다."); + } + + @Test + void 이미지_저장에_실패한_경우_예외가_발생한다() throws IOException { + // given + given(이미지_파일.getOriginalFilename()).willReturn(기존_이미지_파일명); + given(이미지_파일.getInputStream()).willThrow(new IOException()); + + // when & then + assertThatThrownBy(() -> imageProcessor.storeImageFiles(List.of(이미지_파일))) + .isInstanceOf(StoreImageFailureException.class) + .hasMessage("이미지 저장에 실패했습니다."); + } + + @Test + void AWS_이미지_저장에_실패한_경우_예외가_발생한다() throws IOException { + // given + final ByteArrayInputStream fakeInputStream = new ByteArrayInputStream("가짜 이미지 데이터".getBytes()); + given(이미지_파일.getOriginalFilename()).willReturn(기존_이미지_파일명); + given(이미지_파일.getInputStream()).willReturn(fakeInputStream); + given(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .willThrow(SdkException.class); + + // when & then + assertThatThrownBy(() -> imageProcessor.storeImageFiles(List.of(이미지_파일))) + .isInstanceOf(StoreImageFailureException.class) + .hasMessage("AWS 이미지 저장에 실패했습니다."); + } + + @Test + void 유효한_이미지_파일인_경우_이미지_파일을_저장한다() throws Exception { + // given + final ByteArrayInputStream fakeInputStream = new ByteArrayInputStream("가짜 이미지 데이터".getBytes()); + given(이미지_파일.getOriginalFilename()).willReturn(기존_이미지_파일명); + given(이미지_파일.getInputStream()).willReturn(fakeInputStream); + given(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) + .willReturn(PutObjectResponse.builder().build()); + + // when + final List actual = imageProcessor.storeImageFiles(List.of(이미지_파일)); + + // then + SoftAssertions.assertSoftly(softAssertions -> { + softAssertions.assertThat(actual).hasSize(1); + softAssertions.assertThat(actual.get(0).storeName()).isNotBlank(); + softAssertions.assertThat(actual.get(0).uploadName()).isEqualTo(기존_이미지_파일명); + }); + } +} diff --git a/backend/ddang/src/test/java/com/ddang/ddang/image/infrastructure/s3/fixture/S3StoreImageProcessorFixture.java b/backend/ddang/src/test/java/com/ddang/ddang/image/infrastructure/s3/fixture/S3StoreImageProcessorFixture.java new file mode 100644 index 000000000..662463161 --- /dev/null +++ b/backend/ddang/src/test/java/com/ddang/ddang/image/infrastructure/s3/fixture/S3StoreImageProcessorFixture.java @@ -0,0 +1,14 @@ +package com.ddang.ddang.image.infrastructure.s3.fixture; + +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import static org.mockito.Mockito.mock; + +public class S3StoreImageProcessorFixture { + + protected MockMultipartFile 빈_이미지_파일 = new MockMultipartFile("image.png", new byte[0]); + protected MultipartFile 이미지_파일 = mock(MultipartFile.class); + protected String 기존_이미지_파일명 = "image.png"; + protected String 지원하지_않는_확장자를_가진_이미지_파일명 = "image.gif"; +} diff --git a/backend/ddang/src/test/resources/application.yml b/backend/ddang/src/test/resources/application.yml index 1b0aeb253..875006e98 100644 --- a/backend/ddang/src/test/resources/application.yml +++ b/backend/ddang/src/test/resources/application.yml @@ -49,3 +49,10 @@ fcm: enabled: false key: path: firebase/private-key.json + +aws: + s3: + enabled: false + region: region + bucket-name: awsbucketname + image-path: image/path