이번 포스팅은 AWS S3에 이미지 업로드 기능 에 대해 구현을 진행하며 어려웠던 부분이나, 클래스의 역할등을 다뤄보려 합니다.
📣 이 글을 시작하기전에 미리 말씀드리자면 저는 Controller 부분은 작성하지 않고 오직 업로드를 구현하기 위해 필요한 로직만 작성되었음을 알립니다 ❗️
Java 11
SpringBoot 2.7.16
필요한 의존성을 추가합니다.
// S3
implementation 'com.amazonaws:aws-java-sdk-s3:1.12.518'
testImplementation 'com.amazonaws:aws-java-sdk-s3:1.12.518'
cloud:
aws:
credentials:
access-key: ${aws.credentials.access-key}
secret-key: ${aws.credentials.secret-key}
s3:
bucket: ${aws.s3.bucket}
region:
static: ${aws.region.static}
stack:
auto: false
access-key
나 secret-key
는 노출되면 안되기 때문에 본인은 application-secret.yml
파일을 따로 작성하여 저장했습니다.
따로 secret파일을 두고 사용하실게 아니라면 .gitignore
처리 후 public한 곳으로 업로드 하지 않길❗️ (혹시 해킹당하면 과금이 .. 무섭습니다 😱)
@Getter
@ConfigurationProperties("aws")
public class S3Properties {
private final Credentials credentials;
private final S3 s3;
private final String region;
@ConstructorBinding
public S3Properties(Credentials credentials, S3 s3, Map<String, String> region) {
this.credentials = credentials;
this.s3 = s3;
this.region = region.get("static");
}
@Getter
@RequiredArgsConstructor
public static class Credentials {
private final String accessKey;
private final String secretKey;
}
@Getter
@RequiredArgsConstructor
public static class S3 {
private final String bucket;
}
}
S3Properties
는 .yml파일에 설정해놓은 환경변수를 읽어와 각 필드에 값을 바인딩 해줍니다.
@Configuration
public class S3Config {
@Value("${cloud.aws.credentials.accessKey}")
private String accessKey;
@Value("${cloud.aws.credentials.secretKey}")
private String secretKey;
@Value("${cloud.aws.region.static}")
private String region;
다른 블로그들을 찾아보면 따로 properties를 생성하지 않고 위와 같이 S3Config
필드위에 @Value
애노테이션을 붙여서 사용하시던데 저는 config의 역할과 properties의 역할이 다르다고 생각해 따로 분리하여 사용하였습니다. (물론 지극히 개인적인 생각이기 때문에 위와 같이 사용하셔도 됩니다❗️)
@Configuration
@EnableConfigurationProperties(S3Properties.class)
public class S3Config {
@Bean
public AmazonS3Client amazonS3Client(S3Properties s3Properties) {
BasicAWSCredentials credentials = new BasicAWSCredentials(s3Properties.getCredentials().getAccessKey(),
s3Properties.getCredentials().getSecretKey());
return (AmazonS3Client)AmazonS3ClientBuilder.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(s3Properties.getRegion())
.build();
}
}
S3Config는 S3에 이미지를 올리기 위해 AmazonS3Client
를 bean으로 등록합니다.
@EnableConfigurationProperties
를 사용하여S3Properties
를 spring bean처럼 사용할 수 있습니다.
참조 https://www.baeldung.com/spring-enable-config-properties
@Service
@Transactional
@RequiredArgsConstructor
public class ImageService {
private final ImageUploader imageUploader;
public String uploadImageToS3(MultipartFile multipartFile) {
ImageFile file = ImageFile.from(multipartFile);
return imageUploader.uploadImageToS3(file);
}
public List<String> uploadImagesToS3(List<MultipartFile> multipartFiles) {
List<ImageFile> imageFiles = ImageFile.from(multipartFiles);
return imageUploader.uploadImagesToS3(imageFiles);
}
}
ImageService
는 MultipartFile에서 이미지 업로드할 때 필요한 정보만을 가져와 ImageFile객체로 만들어줍니다. 또한 ImageService
는 이미지를 업로드 시키는 역할이 아니라고 판단해 이미지를 업로드 시키는 역할은 ImageUploader
클래스를 따로 작성했습니다.
@Getter
@RequiredArgsConstructor
public class ImageFile {
private final String fileName;
private final String contentType;
private final Long fileSize;
private final InputStream imageInputStream;
private ImageFile(MultipartFile multipartFile) {
this.fileName = getFileName(multipartFile);
this.contentType = getImageContentType(multipartFile);
this.imageInputStream = getImageInputStream(multipartFile);
this.fileSize = multipartFile.getSize();
}
public static ImageFile from(MultipartFile multipartFile) {
return new ImageFile(multipartFile);
}
public static List<ImageFile> from(List<MultipartFile> multipartFiles) {
List<ImageFile> imageFiles = new ArrayList<>();
for (MultipartFile multipartFile : multipartFiles) {
imageFiles.add(new ImageFile(multipartFile));
}
return imageFiles;
}
public InputStream getImageInputStream(MultipartFile multipartFile) {
try {
return multipartFile.getInputStream();
} catch (IOException e) {
throw new InternalServerException(ErrorCode.FILE_IO_EXCEPTION);
}
}
private String getImageContentType(MultipartFile multipartFile) {
return ImageContentType.findEnum(StringUtils.getFilenameExtension(multipartFile.getOriginalFilename()));
}
private String getFileName(MultipartFile multipartFile) {
String ext = extractExt(multipartFile.getOriginalFilename());
String uuid = UUID.randomUUID().toString();
return uuid + "." + ext;
}
private String extractExt(String originalFilename) {
int pos = originalFilename.lastIndexOf(".");
return originalFilename.substring(pos + 1);
}
@Getter
@RequiredArgsConstructor
enum ImageContentType {
JPEG("jpeg"),
JPG("jpg"),
PNG("png"),
SVG("svg");
private final String contentType;
public static String findEnum(String contentType) {
for (ImageContentType imageContentType : ImageContentType.values()) {
if (imageContentType.getContentType().equals(contentType.toLowerCase())) {
return imageContentType.getContentType();
}
}
throw new BadRequestException(ErrorCode.INVALID_FILE_EXTENSION);
}
}
}
S3에 이미지를 업로드하기 위해 위와 같이 4가지가 필요합니다.
InputStream이란?
바이트 기반 입력 스트림의 최상위 추상클래스입니다. (모든 바이트 기반 입력 스트림은 이 클래스를 상속받습니다.)
파일 데이터를 읽거나 네트워크 소켓을 통해 데이터를 읽거나 키보드에서 입력한 데이터를 읽을 때 사용합니다.
위에서부터 차례대로 설명해보겠습니다.
static from
메서드들은 MultipartFile을 ImageFile로 변경시켜주는 역할을 합니다. (단일 이미지일때와 이미지 여러장을 받았을 때 차이 입니다.)getInputStream
: multipartFile일의 inputStream을 가져옵니다. 이때 IOException이 발생할 수 있어 try, catch
를 사용합니다.getImageContentType
: 이너 클래스에 있는 findEnum
메서드를 통해 multipartFile에서 일치하는 contentType을 가져옵니다.getFilenameExtension
메서드는 파일의 확장자를 반환합니다 ex. image.png -> png를 문자열로 반환합니다.
getImageFileName
: 파일 이름을 가져오기 위한 메서드입니다.extractExt
: 실제 파일 이름만 추출하기 위해 사용된 메서드입니다UUID.randomUUID
: 파일의 이름이 중복되지 않기 위해 사용하였습니다.@Component
public class ImageUploader {
private static final String UPLOADED_IMAGES_DIR = "public/";
private final AmazonS3Client amazonS3Client;
private final String bucket;
public ImageUploader(AmazonS3Client amazonS3Client, S3Properties s3Properties) {
this.amazonS3Client = amazonS3Client;
this.bucket = s3Properties.getS3().getBucket();
}
public String uploadImageToS3(ImageFile imageFile) {
final String fileName = putImage(imageFile);
return getObjectUrl(fileName);
}
public List<String> uploadImagesToS3(List<ImageFile> imageFile) {
List<String> urls = new ArrayList<>();
for (ImageFile file : imageFile) {
final String fileName = putImage(file);
urls.add(getObjectUrl(fileName));
}
return urls;
}
private String putImage(ImageFile imageFile) {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(imageFile.getContentType());
final String fileName = UPLOADED_IMAGES_DIR + imageFile.getFileName();
amazonS3Client.putObject(bucket, fileName, imageFile.getImageInputStream(), metadata);
return fileName;
}
private String getObjectUrl(final String fileName) {
return URLDecoder.decode(amazonS3Client.getUrl(bucket, fileName).toString(), StandardCharsets.UTF_8);
}
}
자 이제 마지막입니다. 실제 S3에 이미지를 업로드하기 위한 클래스입니다. 위에서부터 차례대로 설명하겠습니다.
UPLOADED_IMAGES_DIR
: S3 bucket에 이미지를 보낼 경로입니다.AmazonS3Client
: 이미지를 S3에 저장하기 위해 사용되는 객체입니다. S3Config에서 Bean으로 등록해놓았습니다.bucket
: 버켓의 이름을 가지고 있습니다.uploadImageToS3
, uploadImagesToS3
: putImage
메서드를 사용해 S3에 업로드합니다.putImage
: 실제 파일을 S3에 업로드 해주고 S3 URL 주소를 반환 받습니다.getObjectUrl
: 반환받은 S3 URL주소를 decode해서 사람이 읽을 수 있는 이름으로 변환해줍니다.@Transactional
@SpringBootTest
class ImageServiceTest {
@InjectMocks
private ImageService imageService;
@Mock
private ImageUploader imageUploader;
@DisplayName("이미지 파일이 주어지면 업로드에 성공한다.")
@Test
void imageUpload() throws IOException {
// given
// (1)
MockMultipartFile mockMultipartFile = new MockMultipartFile(
"test-image", "test.png",
MediaType.IMAGE_PNG_VALUE, "imageBytes".getBytes(StandardCharsets.UTF_8));
// (2)
given(imageUploader.uploadImageToS3(any(ImageFile.class))).willReturn("url");
// when & then
// (3)
assertThatCode(() -> imageService.uploadImageToS3(mockMultipartFile))
.doesNotThrowAnyException();
}
}
긴 글 읽어주셔서 감사합니다 🦋 🩵
틀린 부분이 있다면 가감없이 알려주시면 감사하겠습니다 !