S3 이미지를 업로드 프로세스
- 클라이언트에서 이미지를 업로드하면 서버가 이를 수신해 MultipartFile 객체로 처리한다.
- MultipartFile을 File 객체로 변환하고 S3 클라이언트를 사용하여 변환된 파일을 S3 버킷에 업로드한다. 여기서 AWS S3 버킷의 이름과 업로드할 파일의 이름을 지정한다.
- 업로드된 파일은 S3 버킷에 저장되며, 해당 파일에 접근할 수 있는 URL을 생성하여 클라이언트에게 반환한다. 이 URL은 클라이언트가 파일을 접근하거나 다운로드할 수 있도록 한다. 이 URL을 db에 저장해서 필요할 때 이 URL로 이미지 데이터를 사용할 수 있다.
Spring Boot + Gradle 프로젝트 - S3 파일 업로드
✅ build.gradle
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
✅ application.yml
accessKey와 secretKey를 application.yml에 등록해야 하는데 이 Key는 외부에 노출되서는 안된다!!!!
이 Key를 이용하여 누군가 악용하면 AWS 계정에 비용이 청구될 수 있다.
따라서 application.yml 파일에 설정을 추가해 주고, application-private.yml 파일을 생성하고 . gitignore 파일에 등록하여 github에 올라가지 않도록 설정해야 한다.
✅ S3Config.java
ackage com.example.fitwithme.common;
import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@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;
@Bean
public AmazonS3 amazonS3() {
AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return AmazonS3ClientBuilder
.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(region)
.build();
}
}
✅ S3ImageService.java
package com.example.fitwithme.application.service;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.DeleteObjectRequest;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.amazonaws.util.IOUtils;
import com.example.fitwithme.common.exception.S3ErrorStatus;
import com.example.fitwithme.common.exception.S3Exception;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLDecoder;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
@RequiredArgsConstructor
@Component
public class S3ImageService {
private final AmazonS3 amazonS3;
@Value("${cloud.aws.s3.bucketName}")
private String bucketName;
public String upload(MultipartFile image) {
if(image.isEmpty() || Objects.isNull(image.getOriginalFilename())){
throw new S3Exception(S3ErrorStatus.EMPTY_FILE_EXCEPTION);
}
return this.uploadImage(image);
}
private String uploadImage(MultipartFile image) {
this.validateImageFileExtension(image.getOriginalFilename());
try {
return this.uploadImageToS3(image);
} catch (IOException e) {
throw new S3Exception(S3ErrorStatus.IO_EXCEPTION_ON_IMAGE_UPLOAD);
}
}
private void validateImageFileExtension(String filename) {
int lastDotIndex = filename.lastIndexOf(".");
if (lastDotIndex == -1) {
throw new S3Exception(S3ErrorStatus.NO_FILE_EXTENSION);
}
String extension = filename.substring(lastDotIndex + 1).toLowerCase();
List<String> allowedExtensionList = Arrays.asList("jpg", "jpeg", "png", "gif");
if (!allowedExtensionList.contains(extension)) {
throw new S3Exception(S3ErrorStatus.INVALID_FILE_EXTENSION);
}
}
private String uploadImageToS3(MultipartFile image) throws IOException {
String originalFilename = image.getOriginalFilename(); //원본 파일 명
String extention = originalFilename.substring(originalFilename.lastIndexOf("."));
String s3FileName = UUID.randomUUID().toString().substring(0, 10) + originalFilename;
InputStream is = image.getInputStream();
byte[] bytes = IOUtils.toByteArray(is);
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType("image/" + extention);
metadata.setContentLength(bytes.length);
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
try{
PutObjectRequest putObjectRequest =
new PutObjectRequest(bucketName, s3FileName, byteArrayInputStream, metadata)
.withCannedAcl(CannedAccessControlList.PublicRead);
amazonS3.putObject(putObjectRequest);
}catch (Exception e){
throw new S3Exception(S3ErrorStatus.PUT_OBJECT_EXCEPTION);
}finally {
byteArrayInputStream.close();
is.close();
}
return amazonS3.getUrl(bucketName, s3FileName).toString();
}
public void deleteImageFromS3(String imageAddress){
String key = getKeyFromImageAddress(imageAddress);
try{
amazonS3.deleteObject(new DeleteObjectRequest(bucketName, key));
}catch (Exception e){
throw new S3Exception(S3ErrorStatus.IO_EXCEPTION_ON_IMAGE_DELETE);
}
}
private String getKeyFromImageAddress(String imageAddress){
try{
URL url = new URL(imageAddress);
String decodingKey = URLDecoder.decode(url.getPath(), "UTF-8");
return decodingKey.substring(1);
}catch (MalformedURLException | UnsupportedEncodingException e){
throw new S3Exception(S3ErrorStatus.IO_EXCEPTION_ON_IMAGE_DELETE);
}
}
}
🔹upload(MultipartFile image)
넘겨받은 이미지 파일이 비어있는 파일인지 검증한다.
uploadImage를 호출하여 S3에 저장된 이미지 객체의 public url을 반환한다.
🔹uploadImage(MultipartFile image)
validateImageFileExtension을 호출하여 확장자 명이 올바른지 확인한다.
uploadImageToS3를 호출하여 이미지를 S3에 업로드하고 업로드된 이미지의 public url을 받아서 서비스 로직에 반환한다.
🔹validateImageFileExtension(String filename)
filename을 받아서 파일 확장자가 jpg, jpeg, png, gif 중에 속하는지 검증한다.
🔹uploadImageToS3(MultipartFile image)
S3에 이미지를 업로드한다.
private String uploadImageToS3(MultipartFile image) throws IOException {
String originalFilename = image.getOriginalFilename(); //원본 파일 명
String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); //확장자 명
String s3FileName = UUID.randomUUID().toString().substring(0, 10) + originalFilename; //변경된 파일 명
InputStream is = image.getInputStream();
byte[] bytes = IOUtils.toByteArray(is); //image를 byte[]로 변환
ObjectMetadata metadata = new ObjectMetadata(); //metadata 생성
metadata.setContentType("image/" + extension);
metadata.setContentLength(bytes.length);
//S3에 요청할 때 사용할 byteInputStream 생성
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
try{
//S3로 putObject 할 때 사용할 요청 객체
//생성자 : bucket 이름, 파일 명, byteInputStream, metadata
PutObjectRequest putObjectRequest =
new PutObjectRequest(bucketName, s3FileName, byteArrayInputStream, metadata)
.withCannedAcl(CannedAccessControlList.PublicRead);
//실제로 S3에 이미지 데이터를 넣는 부분이다.
amazonS3.putObject(putObjectRequest); // put image to S3
}catch (Exception e){
throw new S3Exception(ErrorCode.PUT_OBJECT_EXCEPTION);
}finally {
byteArrayInputStream.close();
is.close();
}
return amazonS3.getUrl(bucketName, s3FileName).toString();
}
🔹deleteImageFromS3(String imageAddress)
이미지의 public url을 이용해서 S3에서 해당 이미지를 삭제한다.
getKeyFromImageAddress를 호출해서 삭제에 필요한 key를 가져온다.
🔹getKeyFromImageAddress(String imageAddress)
이미지의 url을 받아와 디코딩한 url을 맨 앞의 '/'를 제거한 key를 반환한다.
✅ S3Exception.java
package com.example.fitwithme.common.exception;
public class S3Exception extends RuntimeException{
private final S3ErrorStatus status;
public S3Exception(S3ErrorStatus status) {
super(status.getMessage());
this.status = status;
}
public S3ErrorStatus getStatus() {
return this.status;
}
}
✅ S3ErrorStatus.java
package com.example.fitwithme.common.exception;
public enum S3ErrorStatus {
OK("Success"),
EMPTY_FILE_EXCEPTION("첨부된 이미지가 없습니다."),
IO_EXCEPTION_ON_IMAGE_UPLOAD("IOException이 발생했습니다."),
NO_FILE_EXTENSION("잘못된 파일 형식입니다."),
INVALID_FILE_EXTENSION("이미지 파일 형식이 아닙니다."),
PUT_OBJECT_EXCEPTION("이미지 업로드에 실패했습니다."),
IO_EXCEPTION_ON_IMAGE_DELETE("이미지 삭제에 실패했습니다.");
private final String message;
S3ErrorStatus(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
}
✅ UserController.java
@PostMapping("/Upload-profile")
public ResponseEntity<String> profileUpload(@RequestHeader("ACCESS_TOKEN") String accessToken, @RequestPart(value = "image", required = false) MultipartFile image){
String userId = jwtUtil.getUserIdFromToken(accessToken);
String profileImage = userService.upload(userId, image);
return ResponseEntity.ok(profileImage);
}
@PutMapping("/update-profile")
public ResponseEntity<String> updateProfile(@RequestHeader("ACCESS_TOKEN") String accessToken, @RequestPart(value = "image", required = false) MultipartFile image){
String userId = jwtUtil.getUserIdFromToken(accessToken);
String profileImage = userService.updateProfile(userId, image);
return ResponseEntity.ok(profileImage);
}
✅ UserService.java
@Transactional
public String upload(String userId, MultipartFile image) {
String profileImage = s3ImageService.upload(image);
userDao.uploadProfile(userId, profileImage);
return userId;
}
@Transactional
public String updateProfile(String userId, MultipartFile image) {
String imageUrl = userDao.findById(userId).imageUrl();
s3ImageService.deleteImageFromS3(imageUrl);
String profileImage = s3ImageService.upload(image);
userDao.uploadProfile(userId, profileImage);
return userId;
}
이미지 업로드 시 s3 ImageService를 이용하여 이미지를 저장한 뒤, 반환받은 public url을 DB에 저장하는 로직으로 처리했다.
☝ S3를 사용해 이미지를 업로드할 경우의 장점
- 내구성과 안전성 : AWS의 S3는 데이터의 내구성과 안정성을 보장합니다. 데이터가 여러 복제본에 걸쳐 저장되므로 데이터 손실 우려가 줄어듭니다.
- 확장성 : S3는 거의 무한한 확장성을 제공하므로 대용량 데이터를 저장하고 처리할 수 있습니다.
- 다양한 데이터 관리 기능 : 버전 관리, 암호화, 액세스 제어 등 다양한 데이터 관리 기능을 제공하여 데이터를 보다 효율적으로 관리할 수 있습니다.
- 저렴한 비용 : S3는 사용한 만큼만 비용을 지불하므로 비용을 절감할 수 있습니다.
'공부 > 프로젝트' 카테고리의 다른 글
[ jwt / Redis ] Redis를 사용한 JWT 토큰 관리 (0) | 2024.08.22 |
---|---|
[AWS S3 / CloudFront] CloudFront로 이미지 캐싱하기 (0) | 2024.08.21 |
[AWS S3] Spring Boot 프로젝트 이미지 업로드를 위한 S3 버킷 만들기 (0) | 2024.07.09 |
Docker에 MySQL 설치하기 (0) | 2024.05.13 |