Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

이미지 S3 업로드 기능 구현 #707

Merged
merged 13 commits into from
Nov 17, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/ddang/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,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'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.ddang.ddang.auction.configuration;

import com.google.api.client.util.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import software.amazon.awssdk.auth.credentials.InstanceProfileCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;

@Profile("!local && !test")
@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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@

public interface StoreImageProcessor {

StoreImageDto storeImageFile(MultipartFile imageFile);
List<String> WHITE_IMAGE_EXTENSION = List.of("jpg", "jpeg", "png");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

질문

전에 클라이언트에서 jpg, jpeg만 와서 png는 넣을 지 말지 얘기했던 것 같은데, png도 그냥 하는 걸로 결정 됐었나요?
사소하긴 하지만, 기억이 나지 않아 여쭤봅니다.

String EXTENSION_FILE_CHARACTER = ".";

List<StoreImageDto> storeImageFiles(List<MultipartFile> imageFiles);
StoreImageDto storeImageFile(final MultipartFile imageFile);

List<StoreImageDto> storeImageFiles(final List<MultipartFile> imageFiles);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.context.annotation.Profile;
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
@Profile("local || test")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

storeImageFile() 메서드 내부에 final이 누락된 것을 발견했습니다
물론 엔초의 작업은 아니겠지만.....
final을 붙여주시면 아주 감사하겠습니다 👍🏻

public class LocalStoreImageProcessor implements StoreImageProcessor {

private static final List<String> WHITE_IMAGE_EXTENSION = List.of("jpg", "jpeg", "png");
private static final String EXTENSION_FILE_CHARACTER = ".";

@Value("${image.store.dir}")
private String imageStoreDir;

Expand All @@ -38,7 +38,8 @@ public List<StoreImageDto> storeImageFiles(final List<MultipartFile> 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);
Expand All @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

칭찬

final 붙이기 감사합니다~

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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.ddang.ddang.image.infrastructure.s3;

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.context.annotation.Profile;
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
@Profile("!local && !test")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

선택

@Profile("!local && !test")이 계속 반복되는 것 같은데 좀 의미있는 커스텀 어노테이션으로 변경할만할 것 같습니다
뭐 대충 @ProductProfile이나 뭐 그런 느낌으로?
물론 선택입니다

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋습니다! 이 생각은 못했네요

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

칭찬

우와 지토 좋은 아이디어 감사합니다

@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<StoreImageDto> storeImageFiles(final List<MultipartFile> imageFiles) {
final List<StoreImageDto> 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);
}
Comment on lines +73 to +75
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

칭찬

예외 처리 👍

}

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);
}
}
}