✅ AWS S3
Simple Storeage Servce, 주로 파일 서버로 사용
⭐️ Scalability: S3는 트래픽이 증가함에 따라 서버 인프라, 용량 변경을 대신 처리해 줌
⭐️ Durability: 여러 영역에 데이터 복사본을 저장해 한 영역이 다운되어도 데이터 복구 가능
- Bucket: 다수의 객체를 관리하는 컨테이너, 파일시스템
- entities stored in Amazon S3
- data + metadata
- identified by unique key
- Object: 파일과 파일 정보로 구성된 저장단위
- Containers for storing objects
⭐️ S3 권한 설정 IAM
IAM
Identity and Access Management
✅ S3 버킷 생성
AWS console ▶️ S3 ▶️ 버킷 ▶️ 버킷 만들기 ⭐️ EC2랑 리전 동일하게 설정할 것!
✅ IAM 사용자 생성
AWS console ▶️ IAM ▶️ 액세스 관리 ▶️ 사용자 ▶️ 사용자 추가
✅ 액세스 키, 시크릿 키 생성
AWS Console ▶️ IAM ▶️ 엑세스 관리자 ▶️ 사용자 ▶️ 생성한 사용자 이름 클릭 ▶️ 보안 자격 증명 ▶️ 엑세스 키 만들기
이 때 .csv파일로 저장해 둘 것
💡참고
AWS 설정 https://gaeggu.tistory.com/33
✅ build.gradle
1
2
| // amazon s3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
|
✅ application.yaml
cloud.aws.stack.auto=false
EC2에서 Spring Cloud 프로젝트를 실행시키면 기본적으로 CloudFormation 구성을 시작
그러나 우리는 CloudFormation이 없으므로 해당 기능을 사용하지 않도록 false
region.static
우리는 한국에 있으므로 ap-northeast-2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| server: port:8080
spring:
mvc:
pathmatch:
matching-strategy: ant_path_matcher
datasource:
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
driver-class-name: org.mariadb.jdbc.Driver
url:
jpa:
show-sql: true
cloud:
aws:
s3:
credentials:
access-key: ${ACCESS_KEY}
secret-key: ${SECRET_KEY}
bucket-name: ${BUCKET_NAME}
region.static: ap-northeast-2
stack.auto: false
jwtpassword:
source: ${JWT_SECRET_KEY}
logging:
level: debug
|
✅ S3Config
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| package com.github.drug_store_be.config.security;
import com.amazonaws.auth.AWSCredentialsProvider;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.s3.AmazonS3Client;
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.s3.region.static}")
private String region;
@Value("${cloud.aws.s3.credentials.access-key}")
private String accessKey;
@Value("${cloud.aws.s3.credentials.secret-key}")
private String secretKey;
@Bean
public AmazonS3Client amazonS3Client(){
return (AmazonS3Client) AmazonS3ClientBuilder.standard()
.withCredentials(
new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKey, secretKey))
)
.withRegion(region)
.build();
}
}
|
✅ 사진 업로드만 간단ver.
☑️ FileUploadController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| package com.github.drug_store_be.web.controller;
import com.amazonaws.services.s3.AmazonS3Client;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.github.drug_store_be.web.DTO.ResponseDto;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@RestController
@RequestMapping("/upload")
@RequiredArgsConstructor
public class FileUploadController {
private final AmazonS3Client amazonS3Client;
@Value("${cloud.aws.s3.bucket-name}")
private String bucket;
@PostMapping("/pics")
public ResponseDto uploadFile(@RequestParam("file") MultipartFile file){
try{
String fileName= file.getOriginalFilename();
String fileUrl= "https://"+ bucket+ "/test"+ fileName;
ObjectMetadata metadata= new ObjectMetadata();
metadata.setContentType(file.getContentType());
metadata.setContentLength(file.getSize());
amazonS3Client.putObject(bucket, fileName, file.getInputStream(), metadata); //s3에 저장
return new ResponseDto(HttpStatus.OK.value(), "file upload success", fileUrl);
} catch (IOException e) {
e.printStackTrace();
return new ResponseDto(HttpStatus.INTERNAL_SERVER_ERROR.value(), "upload fail");
}
}
}
|
☑️ 포스트맨 성공
multipart 데이터를 전송해야 하므로 Body 유형을 form-data으로 선택하고, KEY의 속성을 File을 선택 정상적으로 보내지면 200응답이 올 것이고, AWS S3 콘솔에서 파일 들어온 것 확인 가능
✅ 여러개 사진 업로드, 삭제, 수정
1️⃣ 여러개 사진 업로드
☑️ StorageController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| package com.github.drug_store_be.web.controller;
import com.github.drug_store_be.service.service.StorageService;
import com.github.drug_store_be.web.DTO.ResponseDto;
import com.github.drug_store_be.web.DTO.awsS3.FileDto;
import com.github.drug_store_be.web.DTO.awsS3.SaveFileType;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Optional;
@RestController
@RequestMapping("/storage")
@RequiredArgsConstructor
public class StorageController {
private final StorageService storageService;
//여러개 업로드
@PostMapping("/multipart-files")
public ResponseDto uploadMultipleFiles(@RequestPart("uploadFiles")List<MultipartFile> multipartFiles,
@RequestParam(required= false) Optional<SaveFileType> type
){
List<FileDto> response= storageService.fileUploadAndGetUrl(multipartFiles, type.orElseGet(()-> SaveFileType.small));
return new ResponseDto(response);
}
}
|
☑️ SaveFileType Enum
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| package com.github.drug_store_be.web.DTO.awsS3;
public enum SaveFileType {
small("일반파일"),
large("대용량파일");
private final String kor;
SaveFileType(String kor) {
this.kor = kor;
}
public String getTypeKor(){
return this.kor;
}
}
|
☑️ FileDto
1
2
3
4
5
6
7
8
9
10
11
12
| package com.github.drug_store_be.web.DTO.awsS3;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class FileDto {
private String fileName;
private String fileUrl;
}
|
☑️ ResponseDto
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| package com.github.drug_store_be.web.DTO;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Getter;
import lombok.Setter;
import org.springframework.http.HttpStatus;
@Setter
@Getter
public class ResponseDto {
private int code;
private String message;
@JsonInclude(JsonInclude.Include.NON_NULL)
private Object data;
public ResponseDto() {
this.code = HttpStatus.OK.value();
this.message = HttpStatus.OK.name();
}
public ResponseDto(int code, String message) {
this.code =code;
this.message = message;
}
public ResponseDto(Object data) {
this.code = HttpStatus.OK.value();
this.message = HttpStatus.OK.name();
this.data = data;
}
public ResponseDto(int code, String message, Object data) {
this.code =code;
this.message = message;
this.data= data;
}
}
|
☑️ StorageService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
| package com.github.drug_store_be.service.service;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.AmazonS3Exception;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.github.drug_store_be.service.exceptions.StorageUpdateFailedException;
import com.github.drug_store_be.web.DTO.awsS3.FileDto;
import com.github.drug_store_be.web.DTO.awsS3.SaveFileType;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class StorageService {
@Value("${cloud.aws.s3.bucket-name}")
private String bucketName;
private final AmazonS3 amazonS3Client;
public List<FileDto> fileUploadAndGetUrl(List<MultipartFile> multipartFiles, SaveFileType type) {
List<FileDto> response= new ArrayList<>();
switch(type){
case small:
for(MultipartFile file: multipartFiles){
PutObjectRequest putObjectRequest= makePutObjectRequest(file);
amazonS3Client.putObject(putObjectRequest);
String url= amazonS3Client.getUrl(bucketName, putObjectRequest.getKey()).toString();
response.add(new FileDto(file.getOriginalFilename(), url));
}
break;
case large:
break;
}
return response;
}
private PutObjectRequest makePutObjectRequest(MultipartFile file) {
String storageFileName= makeStorageFileName(Objects.requireNonNull(file.getOriginalFilename()));
ObjectMetadata objectMetadata= new ObjectMetadata();
objectMetadata.setContentType(file.getContentType());
objectMetadata.setContentLength(file.getSize());
try{
return new PutObjectRequest(bucketName, storageFileName, file.getInputStream(), objectMetadata);
} catch (IOException e) {
throw new StorageUpdateFailedException("File Upload Failed", file.getOriginalFilename());
}
}
private String makeStorageFileName(String orignialFileName) {
String extension= orignialFileName.substring(orignialFileName.lastIndexOf(".")+1);
return UUID.randomUUID() + "." + extension; //이미지 저장할 때 고유번호
}
}
|
☑️ StorageUpdateFailedException
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| package com.github.drug_store_be.service.exceptions;
import lombok.Getter;
@Getter
public class StorageUpdateFailedException extends RuntimeException{
private final String detailMessage;
private final String request;
public StorageUpdateFailedException(String detailMessage, String request) {
this.detailMessage = detailMessage;
this.request = request;
}
}
|
☑️ ExceptionControllerAdvice
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| @RestControllerAdvice
@Slf4j
public class ExceptionControllerAdvice {
@ResponseStatus(HttpStatus.NOT_FOUND)
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ResponseDto> handleNotFoundException(NotFoundException nfe){
log.error("Client 요청이후 DB 검색 중 에러로 다음처럼 출력합니다. " + nfe.getMessage());
ResponseDto responseDto = new ResponseDto(HttpStatus.NOT_FOUND.value(), nfe.getMessage());
return new ResponseEntity<>(responseDto, HttpStatus.NOT_FOUND);
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(StorageUpdateFailedException.class)
public ResponseEntity<ResponseDto> handleFileUploadFailedException(StorageUpdateFailedException sufe){
ResponseDto responseDto = new ResponseDto(HttpStatus.INTERNAL_SERVER_ERROR.value(), sufe.getMessage());
return new ResponseEntity<>(responseDto, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
|
2️⃣ 사진 삭제
☑️ StorageController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| @RestController
@RequestMapping("/storage")
@RequiredArgsConstructor
public class StorageController {
private final StorageService storageService;
//여러개 업로드
@PostMapping("/multipart-files")
public ResponseDto uploadMultipleFiles(@RequestPart("uploadFiles")List<MultipartFile> multipartFiles,
@RequestParam(required= false) Optional<SaveFileType> type
){
List<FileDto> response= storageService.fileUploadAndGetUrl(multipartFiles, type.orElseGet(()-> SaveFileType.small));
return new ResponseDto(response);
}
//업로드 취소(삭제)
@DeleteMapping("/multipart-files")
public ResponseDto deleteMultipleFiles(@RequestParam(value= "file-url") List<String> fileUrls){
storageService.uploadCancel(fileUrls);
return new ResponseDto();
}
}
|
☑️ StorageService
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
| @Service
@RequiredArgsConstructor
public class StorageService {
@Value("${cloud.aws.s3.bucket-name}")
private String bucketName;
private final AmazonS3 amazonS3Client;
public List<FileDto> fileUploadAndGetUrl(List<MultipartFile> multipartFiles, SaveFileType type) {
List<FileDto> response= new ArrayList<>();
switch(type){
case small:
for(MultipartFile file: multipartFiles){
PutObjectRequest putObjectRequest= makePutObjectRequest(file);
amazonS3Client.putObject(putObjectRequest);
String url= amazonS3Client.getUrl(bucketName, putObjectRequest.getKey()).toString();
response.add(new FileDto(file.getOriginalFilename(), url));
}
break;
case large:
break;
}
return response;
}
private PutObjectRequest makePutObjectRequest(MultipartFile file) {
String storageFileName= makeStorageFileName(Objects.requireNonNull(file.getOriginalFilename()));
ObjectMetadata objectMetadata= new ObjectMetadata();
objectMetadata.setContentType(file.getContentType());
objectMetadata.setContentLength(file.getSize());
try{
return new PutObjectRequest(bucketName, storageFileName, file.getInputStream(), objectMetadata);
} catch (IOException e) {
throw new StorageUpdateFailedException("File Upload Failed", file.getOriginalFilename());
}
}
private String makeStorageFileName(String orignialFileName) {
String extension= orignialFileName.substring(orignialFileName.lastIndexOf(".")+1);
return UUID.randomUUID() + "." + extension;
}
public void uploadCancel(List<String> fileUrls) {
try{
for(String url: fileUrls){
String[] parts= url.split("/");
String key= parts[parts.length-1];
amazonS3Client.deleteObject(bucketName, key);
}
}catch(AmazonS3Exception e){
e.printStackTrace();
throw new StorageUpdateFailedException("File Delete Failed "+ e.getMessage(), fileUrls.toString());
}
}
}
|
3️⃣ 사진 수정
☑️ StorageController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| @RestController
@RequestMapping("/storage")
@RequiredArgsConstructor
public class StorageController {
private final StorageService storageService;
//여러개 업로드
@PostMapping("/multipart-files")
public ResponseDto uploadMultipleFiles(@RequestPart("uploadFiles")List<MultipartFile> multipartFiles,
@RequestParam(required= false) Optional<SaveFileType> type
){
List<FileDto> response= storageService.fileUploadAndGetUrl(multipartFiles, type.orElseGet(()-> SaveFileType.small));
return new ResponseDto(response);
}
//업로드 취소(삭제)
@DeleteMapping("/multipart-files")
public ResponseDto deleteMultipleFiles(@RequestParam(value= "file-url") List<String> fileUrls){
storageService.uploadCancel(fileUrls);
return new ResponseDto();
}
@PutMapping("/multipart-files")
public ResponseDto modifyMultipleFiles(@RequestParam(value="file-url") List<String> deleteFileUrls,
@RequestPart("uploadFiles") List<MultipartFile> multipartFiles,
@RequestParam(required = false) Optional<SaveFileType> type){
List<FileDto> response= storageService.fileUploadAndGetUrl(multipartFiles, type.orElseGet(()-> SaveFileType.small));
storageService.uploadCancel(deleteFileUrls);
return new ResponseDto(response);
}
}
|
✅ AWS S3 CORS에러 해결
AWS S3 bucket에 CORS(Cross-origin 리소스 공유) 규칙을 추가한다.
물론 자신의 SecurityConfig 파일에 적합한 코드를 넣어야 할 것이다.
1
2
3
4
5
6
7
8
9
| [
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
"AllowedOrigins": ["*"],
"ExposeHeaders": ["Authorization", "Authorization-refresh", "Token"],
"MaxAgeSeconds": 3600
}
]
|
🔴 Trouble Shooting
🔴 URL을 붙여넣었을 때 사진이 뜨지 않고 이런 에러가 뜨는 문제
🔵 문제 원인
객체가 퍼블릭으로 설정되지 않아서 생기는 문제이다. 객체가 퍼블릭이 아니기 때문에 외부에서 접근할 수가 없다.
🟢 Solution
외부에서 해당 버킷에 접근 가능하도록 버킷 정책을 수정한다. https://inpa.tistory.com/entry/AWS-%F0%9F%93%9A-S3-%EB%B2%84%ED%82%B7-%EC%83%9D%EC%84%B1-%EC%82%AC%EC%9A%A9%EB%B2%95-%EC%8B%A4%EC%A0%84-%EA%B5%AC%EC%B6%95
https://awspolicygen.s3.amazonaws.com/policygen.html
💡 참고
https://gaeggu.tistory.com/33
https://github.com/JBA-jeju-basketball-association/JBA-BE/blob/feat-S3bucket/src/main/java/github/com/jbabe/web/controller/StorageController.java
https://inpa.tistory.com/entry/AWS-%F0%9F%93%9A-S3-%EB%B2%84%ED%82%B7-%EC%83%9D%EC%84%B1-%EC%82%AC%EC%9A%A9%EB%B2%95-%EC%8B%A4%EC%A0%84-%EA%B5%AC%EC%B6%95