[AWS] 이미지 AWS S3에 업로드하기
💡 코드가 보이지 않으시다면 드래그 혹은 오른쪽 아래 🌜 아이콘을 눌러 테마 색을 변경해주세요.
안녕하세요!
키크니 개발자 입니다. 🦒
이미지를 AWS S3에 업로드하는 기능을 맡아서 구현한 김에 기록삼아 작성하였습니다!
DB 저장 로직을 제외한 S3에 이미지 업로드하는 예시입니다.
(DB 저장을 할 경우에는 이미지 업로드 후 데이터를 저장해주면 됩니다.)
아무나 S3에 파일을 업로드 하지 못하게 막으려면 IAM에서 accessKey와 secretKey를 발급 받아야 합니다.만약 누구나 업로드가 가능하게 하려면 S3 권한 > 버킷 정책에서 해당 정책을 생성한 후 적용해 주어야 합니다.
application.properties
# S3 Bucket
cloud.aws.credentials.accessKey=accessKey 입력
cloud.aws.credentials.secretKey=secretKey 입력
cloud.aws.stack.auto=false
# AWS S3 Service bucket
cloud.aws.s3.bucket=bucket name 입력
cloud.aws.region.static=region 입력
# AWS S3 Bucket URL
cloud.aws.s3.bucket.url=bucket 주소 입력
# multipart 사이즈 설정
spring.http.multipart.max.file.size=1024MB
spring.http.multipart.max.request.size=1024MB
💡 cloud.aws.stack.auto=false 안할 경우 아래와 같은 에러가 발생합니다.
발생 원인
AWS S3를 연동한 스프링부트 프로젝트를 aws 서버에 배포할 때 에러 발생합니다.
(로컬에서는 문제 없음) 프로젝트 배포시 기본으로 CloudFormation 구성을 시작하기 때문에 설정한 CloudFormation이 없으면 프로젝트 실행이 되지 않습니다.
해당 기능을 False로 설정해야합니다.
org.springframework.beans.factory.BeanCreationException:
Error creating bean with name 'org.springframework.cloud.aws.core.env.ResourceIdResolver.BEAN_NAME':
Invocation of init method failed; nested exception is org.springframework.beans.factory.BeanCreationException:
Error creating bean with name 'stackResourceRegistryFactoryBean' defined in class path resource
[org/springframework/cloud/aws/autoconfigure/context/ContextStackAutoConfiguration$StackAutoDetectConfiguration.class]:
Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.cloud.aws.core.env.stack.config.StackResourceRegistryFactoryBean]:
Factory method 'stackResourceRegistryFactoryBean' threw exception; nested exception is com.amazonaws.services.cloudformation.model.AmazonCloudFormationException: User: arn:aws:iam::xxxxxxxxx: is not authorized to perform:
cloudformation:DescribeStackResources (Service: AmazonCloudFormation; Status Code: 403; Error Code: AccessDenied; Request ID: )
build.gradle
# S3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
implementation platform('com.amazonaws:aws-java-sdk-bom:1.12.239')
# FilenameUtils Class 사용
implementation 'commons-io:commons-io:2.6'
implementation platform('com.amazonaws:aws-java-sdk-bom:1.12.239') 대신
implementation 'com.amazonaws:aws-java-sdk:1.11.404'을 사용하는 방법도 있습니다.
AwsConfig에 사용할 AmazonS3Client는 현재 deprecated가 되었습니다.
AmazonS3Client를 대신할 AmazonS3를 사용하겠습니다.
/**
* Constructs a new Amazon S3 client using the specified AWS credentials to
* access Amazon S3.
*
* @param awsCredentials
* The AWS credentials to use when making requests to Amazon S3
* with this client.
*
* @see AmazonS3Client#AmazonS3Client()
* @see AmazonS3Client#AmazonS3Client(AWSCredentials, ClientConfiguration)
* @deprecated use {@link AmazonS3ClientBuilder#withCredentials(AWSCredentialsProvider)}
*/
@Deprecated
public AmazonS3Client(AWSCredentials awsCredentials) {
this(awsCredentials, configFactory.getConfig());
}
AwsConfig
@Configuration
public class AWSConfig {
@Value("${cloud.aws.credentials.accessKey}")
private String amazonAWSAccessKey;
@Value("${cloud.aws.credentials.secretKey}")
private String amazonAWSSecretKey;
@Value("${cloud.aws.region.static}")
private String region;
@Bean
public AWSCredentials amazonAWSCredentials() {
return new BasicAWSCredentials(amazonAWSAccessKey, amazonAWSSecretKey);
}
@Bean
public AmazonS3 amazonS3() {
return AmazonS3ClientBuilder.standard()
.withCredentials(new AWSStaticCredentialsProvider(amazonAWSCredentials()))
.withRegion(this.region)
.build();
}
}
Contorller
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/user")
@RestController
public class UserController {
private final UserService userService;
private final String BUCKET_NAME = "user_profile/";
@PostMapping("/image/upload/{userId}")
public ResponseEntity<List<UserImageDTO>> uploadUserImage(@PathVariable String userId,
@RequestPart(value = "image") List<MultipartFile> images) throws IOException {
log.info("user image upload to aws s3. userId={}, images={}", userId, images);
return ResponseEntity.ok(
userService.uploadUserImage(BUCKET_NAME + userId, images)
);
}
}
@RequestPart : MultipartFile 타입을 바인딩 해줍니다. (파일 업로드 시 주로 씁니다.)
저는 필수값이기 때문에 다른 옵션을 주지 않았지만, 혹시나 필수값이 아닐 경우에는 @RequestPart(required= false) 설정을 해주면 됩니다. 여러 이미지를 받기 위해서는 List<MultipartFile> 사용해야 합니다.
Service
@Slf4j
@RequiredArgsConstructor
@Service
public class UserProfileService {
private final AmazonS3 amazonS3;
@Value("${cloud.aws.s3.bucket.user}")
private String S3_BUCKET_USER;
// bucket 안에 폴더에 이미지를 업로드 합니다.
public List<UserImageDTO> uploadUserImage(String userId, List<MultipartFile> images) throws IOException {
return uploadS3Images(makeS3FolderName(userId, BucketFolderType.PICTURE), images);
}
// bucket 내부에서 /를 하면 folder로 분리되어집니다. (기존에 해당 folder가 없을 경우에는 생성이 되고, 있을 경우에는 해당 folder로 이미지가 업로드 됩니다.)
private static String makeS3FolderName(String userId, BucketFolderType folderType) {
return delegateId + "/" + folderType;
}
private List<UserImageDTO> uploadS3Images(String folderName, String childDelegateId, List<MultipartFile> images) {
List<UserImageDTO> results = new ArrayList<>();
images.forEach(image -> {
ObjectMetadata om = new ObjectMetadata();
om.setContentLength(image.getSize());
om.setContentType(image.getContentType());
String extension = FilenameUtils.getExtension(image.getOriginalFilename()); // (1)
String now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); // (2)
String imageUrl = folderName + String.format("/%s-%s.%s", now, RandomStringUtils.randomAlphabetic(4), extension); // (3)
try {
amazonS3.putObject(
new PutObjectRequest(S3_BUCKET_USER, imageUrl, image.getInputStream(), om)); // (4)
BufferedImage imageBuffer = ImageIO.read(image.getInputStream());
int width = imageBuffer.getWidth();
int height = imageBuffer.getHeight();
results.add(UserImageDTO.from(userId, imageUrl, width, height));
} catch (IOException e) {
// custom Exception, 어떤 이미지가 에러를 발생시켰는지 기존 이름 로그
log.info("Fail S3 Upload image. folderName={}, originImageName={}", folderName, image.getOriginalFilename());
throw new FailFileUploadException();
}
});
return results;
}
}
S3에 저장할 객체 메타데이터를 지정해줍니다.
(객체 메타데이터는 두가지 종류가 있습니다. 이중 시스템 정의 객체 메타데이터를 지정해줍니다.)
더 자세한 S3 객체 메타데이터는 참고 해주세요
contentType을 지정하지 않을 경우 Fild upload시 octet-stream으로 동작하여 특정 이미지 포멧의 경우 에러가 발생할 수 있습니다.
(1) : 이미지(파일) 확장자를 추출합니다.
(2) : 오늘날짜를 yyyyMMdd로 format 합니다. (ex : 220916)
(3) : 파일명을 현재날짜 + 랜덤 4글자 + 확장자를 붙여 해당 bucket folder url을 붙여줍니다.
(4) : S3 buckt에 이미지를 업로드 합니다.
BucketType
@Getter
@RequiredArgsConstructor
public enum BucketFolderType {
PROFILE("프로필사진"),
PICTURE("사진");
private final String value;
}
Response
{
"status": 200,
"data": [
{
"userId": "QBSCZr6HK2J",
"url": "/user_profile/QBSCZr6HK2J/PICTURE/20220905-rauJ.png",
"width": 210,
"height": 210
},
{
"userId": "QBSCZr6HK2J",
"url": "/user_profile/QBSCZr6HK2J/PICTURE/20220905-JZLB.jpeg",
"width": 225,
"height": 225
}
]
}
포스트맨으로 확인해볼때에는 아래와 같이 form-data로 설정한 뒤 controller에 설정해둔 image value에 이미지를 등록시키면 됩니다.
References
https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/UsingMetadata.html#object-key-guidelines
https://icthuman.tistory.com/entry/AWS-Java-SDK-S3-File-upload-2
https://www.notion.so/S3-456e8d942e0c423fabee6a6c12cb51ac#89772ab3493941e88bfdc5da73059968