Home 이미지 파일 저장경로를 S3 로 변경
Post
Cancel

이미지 파일 저장경로를 S3 로 변경

기존에는 aws 가 아니라 PC 에서 프로그램을 실행했기 때문에 로컬 PC 에 사진을 저장했다.
하지만 aws 로 변경하면서 기존경로에 파일을 저장할 수 없게되었다.
단순하게 파일들을 EC2 내부에 저장해도 되지만 S3 에 저장하는 이유는 다음과 같다.
홈페이지에 나와있는 장점을 살펴보자.

  • 확장성 : S3를 사용하면 탁월한 성능으로 엑사바이트 단위까지 거의 모든 용량의 데이터를 저장할 수 있습니다. S3는 탄력적이며 데이터를 추가하고 제거함에 따라 자동으로 증가하고 줄어듭니다. 스토리지를 프로비저닝할 필요가 없고, 사용한 만큼만 비용을 지불하면 됩니다.
  • 내구성 및 가용성 : Amazon S3는 클라우드에서 가장 내구성이 뛰어난 스토리지와 업계 최고의 가용성을 제공합니다. 고유한 아키텍처를 기반으로 하는 S3는 기본적으로 99.999999999%의 데이터 내구성과 99.99%의 가용성을 제공하도록 설계되었으며, 클라우드에서 가장 강력한 SLA의 지원을 받습니다.
  • 보안 및 데이터 보호 : 탁월한 보안, 데이터 보호, 규정 준수 및 액세스 제어 기능으로 데이터를 보호합니다. S3는 기본적으로 안전하고 비공개적이며 암호화되어 있으며, S3 리소스에 대한 액세스 요청을 모니터링하는 다양한 감사 기능도 지원합니다.
  • 저렴한 가격과 최상의 성능 : S3는 모든 워크로드에 최고의 가격 대비 성능을 갖춘 다양한 스토리지 클래스와 자동화된 데이터 수명주기 관리를 제공하므로 자주 액세스하지 않거나 드물게 액세스하는 대량의 데이터를 비용 효율적인 방식으로 저장할 수 있습니다. S3는 복원력, 유연성, 레이턴시 및 처리량을 제공하여 스토리지가 성능을 제한하지 않도록 보장합니다.

이 중 내가 집중한 부분은 확장성이다. 사용자 입장에서 S3 는 무한한 용량을 가지고 있기 때문에 용량이 가득차서 디스크를 증설하거나 오래된 데이터를 백업 후 별도 보관할 필요가 없다는 장점이 있다.

파일 업로드 방식

파일을 업로드 하는 방식은 MultipartFile 업로드 방식을 사용하고, 파일을 다운로드 할 때는 Presigned URL 방식을 사용할 것이다.

MultipartFile 업로드 방식 채용이유
업로드 과정에서 resize 하는 기능을 넣을 수도 있고 기존 프로젝트에 파일을 저장하는 로직을 크게 변형하지 않을 수 있기 때문이다.

Presigned URL 채용이유
사용자가 profile image 를 읽거나 첨부파일을 다운받을 때, 동적인 URL 을 사용하도록 하고 싶었다.
고정 URL 을 사용하면 무작위 대입 공격에 다른 파일이 노출될 수 있는데, 이걸 방지하기 위해 Presigned URL 방식으로 채택했다.

Presigned URL
미리 서명된 URL 이란 의미로 URL 에 서명을 남겨 해당 URL 을 통해서 객체에 접근할 수 있게한다.
Presigned URL 에는 만료시간도 있기 때문에 URL 에 탈취되도 만료되면 객체에 접근할 수 없다.

S3 bucket 생성

이전 포스팅을 참고하여 S3 bucket 을 만든다.

  • ACL 비활성화
  • 모든 public access 차단

IAM Role 과 Presigned URL 만 사용하여 S3 에 접근할 것이기 때문에 ACL 비활성화 및 public access 를 차단해도 문제 없다.

IAM Role 생성

EC2 에서 S3 에 접근할 수 있게 해주는 Role 을 만든다.

사진1

좌측에서 역할 선택 - 역할 생성 버튼 클릭

사진2
신뢰할 수 있는 엔터티 유형 - aws 서비스
서비스는 ec2 선택

사진3

권한 정책은 나중에 만들거라 선택하지 않고 넘어가도 됨(미리 만들고 여기서 선택해도 OK)

사진4

이름과 설명은 마음대로 정하고 역할 생성버튼 클릭

사진5

역할 목록에 방금 만든 역할이 있음. 클릭

사진6

아까 권한정책 선택안했던거 이제 만들거임. 권한 추가 선택
인라인 정책 생성 클릭

사진7

정책 편집기가 나올거임. 정책 만드는 규칙을 알면 JSON 으로 직접 만들어도 됨.
여기서는 클릭으로 만들것임.
S3 에 접근할 것이므로 S3 선택

사진8

작업에는 DeleteObject, PutObject, GetObject 를 선택하고 체크까지 해준다.
각 작업에 대한 설명은 정보를 클릭하면 볼 수 있다.
Object 는 bucket 에 저장되는 파일을 의미한다.

사진9

정책을 적용당할 리소스를 선택한다. 위에서 만든 버킷의 이름을 작성하면 된다.

리소스를 선택할 수 있음. ‘모두’를 선택하면 모든 S3 bucket 에 접근할 수 있음.
여기서는 특정을 선택하고 ARN 추가를 클릭
Resource bucket name 에는 대상이 될 bucket 이름을 작성. 위에서 만든 버킷 이름을 넣어줌.
Resource Object name 은 bucket 에서 대상이 될 경로를 작성하면됨. 모든 파일을 대상으로 삼을것이기 때문에 * 입력
그럼 리소스 arn 이 자동으로 작성됨. 내가 리소스 arn 을 직접 작성할 줄 안다면 시각적 대신 텍스트를 선택해서 작성하면됨.

사진10

ARN 추가 버튼을 누르고 다음 버튼을 눌러서 다음 단계로 넘어간다.

사진11

정책이름을 지어주고 정책 생성 버튼 클릭

ec2 인스턴스에 등록

ec2 인스턴트를 클릭하고 상단의 작업버튼 - 보안 - IAM 역할 수정 - 방금 만든 Role 을 선택
앗 이런 IAM Role 은 하나만 적용 가능하다고하네!
이러면 두 기능을 합친 Role 을 새로 만들어야한다.
나는 위에서 작성했던 권한을 기존에 적용된 Role 에 추가할것이다.
인스턴스 선택 - 하단의 보안 탭 - IAM 역할 에 적용된 역할이 보인다. 바로가기 클릭.
권한 추가 - 인라인 정책 생성후 아까 했던거 다시 하면된다.

build.gradle

1
implementation("software.amazon.awssdk:s3:2.25.32")

build.gradle 의 dependencies 에 amazon s3 을 사용할 수 있게 해주는 라이브러리를 추가한다.

application.propertise

1
2
3
4
5
6
7
8
9
cloud.aws.region.s3=파일을 업로드 할 bucket 명 작성
cloud.aws.region.static=ap-northeast-2
cloud.aws.region.stack.auto=false

spring.servlet.multipart.enabled=true # multipart file 업로드 지원여부. default: true
spring.servlet.multipart.file-size-threshold=0B # 디스크에 저장하지 않고 메모리에 올리는 최소 크기
spring.servlet.multipart.location=/opt/unichat/temp # 파일 업로드 과정에 파일이 임시로 저장될 위치 지정
spring.servlet.multipart.max-file-size=10MB # 파일의 최대 사이즈. default: 1MB
spring.servlet.multipart.max-request-size=10MB # 파일 요청의 최대 사이즈. default: 10MB

cloud.aws.region 변수들은 하드 코딩을 피하기 위해 변수로 작성해주고,
아래 spring.servlet.multipart 변수들은 default 값이 있으므로 작성하지 않아도 된다.

config 파일 작성

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
@Configuration
public class AwsS3Config {
    @Value("${cloud.aws.region.static}")
    private String region;


    // 데이터 전송 테스트 후 정상이면 key 는 제거한다.
    private String accessKey = "나의 access key";
    private String secretKey = "나의 secret key";

    // IAM Role 을 이용해 접근하는 방법을 사용
    // access key 와 secret key 가 필요 없다.
    // @Bean
    // public S3Client s3Client() {
    //     return S3Client.builder()
    //             .region(Region.of(region))
    //             .credentialsProvider(DefaultCredentialsProvider.create())
    //             .build();
    // }
    
    // @Bean
    // public S3Presigner s3Presigner() {
    //     return S3Presigner.builder()
    //             .region(Region.of(region))
    //             .credentialsProvider(DefaultCredentialsProvider.create())
    //             .build();
    // }


    // 아래 Bean 두 개도 local 에서 테스트가 끝나면 삭제한다.
    @Bean
    public S3Client s3Client() {
        AwsBasicCredentials awsCreds = AwsBasicCredentials.create(accessKey, secretKey);
        return S3Client.builder()
                .region(Region.of(region))
                .credentialsProvider(StaticCredentialsProvider.create(awsCreds))
                .build();
    }

    @Bean
    public S3Presigner s3Presigner() {
        AwsBasicCredentials awsCreds = AwsBasicCredentials.create(accessKey, secretKey);
        return S3Presigner.builder()
                .credentialsProvider(StaticCredentialsProvider.create(awsCreds))
                .region(Region.of(region))
                .build();
    }

}

AWS 리소스에 접근할 수 있도록 설정하는 코드이다.
원래는 IAM Role 을 이용해 접근할 것이기 때문에 access key 와 secret key 는 필요없다.
하지만 local 에서 S3 bucket 으로 데이터 전송을 먼저 테스트 하기위해 access key 를 사용해 접근할 수 있게 했다.
local 환경에는 IAM Role 이 없기 때문이다.
테스트가 끝나면 아래 코드는 전부 삭제 후 주석처리한 코드는 주석을 해제한다.
access key 와 secret key 는 local 에서 테스트를 끝나면 삭제할 것이므로 하드코딩 했다.

AwsS3Service

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
@Service
public class AwsS3Service {
    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    private final S3Client s3Client;
    private final S3Presigner s3Presigner;

    public AwsS3Service(S3Client s3Client, S3Presigner s3Presigner) {
        this.s3Client = s3Client;
        this.s3Presigner = s3Presigner;
    }

    public String uploadFile(MultipartFile multipartFile){

        // null 검사
        // 만약 file 이 null 이면 isEmpty 에서 예외가 발생하여 500 응답을 보내고 종료.
        // null 이 아니면 isEmpty(), getSize() 정상 수행되고 넘어감.
        try {
            System.out.println("multipartFile == null : " + (multipartFile == null));
            System.out.println("multipartFile.isEmpty() : " + multipartFile.isEmpty());
            System.out.println("multipartFile.getSize() :" + multipartFile.getSize());
        } catch (Exception e) {
            System.out.println("예외: " + e.getMessage());
            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "예외: " + e.getMessage());
        }

        String fileName = createFileName(multipartFile.getOriginalFilename());
        try(InputStream inputStream = multipartFile.getInputStream()){
            PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                    .bucket(bucket)
                    .key(fileName)
                    .contentLength(multipartFile.getSize())
                    .contentType(multipartFile.getContentType())
                    .build();
            s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, multipartFile.getSize()));
        } catch (IOException e){
            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다.");
        } catch (Exception e) {
            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "예외: " + e.getMessage());
        }

        return fileName;
    }

    public String createFileName(String fileName){
        return UUID.randomUUID().toString().concat(getFileExtension(fileName));
    }

    private String getFileExtension(String fileName){
        try{
            return fileName.substring(fileName.lastIndexOf("."));
        } catch (StringIndexOutOfBoundsException e){
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잘못된 형식의 파일" + fileName + ") 입니다.");
        }
    }

    public void deleteFile(String fileName){
        DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
                .bucket(bucket)
                .key(fileName)
                .build();
        s3Client.deleteObject(deleteObjectRequest);
        System.out.println(bucket);
    }

    public String getPrisignedUrl(String filename) {
        if(filename == null || filename.equals(""))
            return null;

        GetObjectRequest getObjectRequest = GetObjectRequest.builder()
                .bucket(bucket)
                .key(filename)
                .build();

        GetObjectPresignRequest getObjectPresignRequest = GetObjectPresignRequest.builder()
                .signatureDuration(Duration.ofMinutes(5)) // presignedURL 5분간 접근 허용
                .getObjectRequest(getObjectRequest)
                .build();

        PresignedGetObjectRequest presignedGetObjectRequest = s3Presigner
                .presignGetObject(getObjectPresignRequest);

        String url = presignedGetObjectRequest.url().toString();

        s3Presigner.close(); // presigner를 닫고 획득한 모든 리소스를 해제
        return url;
    }
}
1
2
3
4
5
6
7
8
9
10
// uploadFile 메서드 중 일부
try(InputStream inputStream = multipartFile.getInputStream()){
            PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                    .bucket(bucket)
                    .key(fileName)
                    .contentLength(multipartFile.getSize())
                    .contentType(multipartFile.getContentType())
                    .build();
            s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, multipartFile.getSize()));
        } catch (IOException e){ }
  • uploadFile 메서드

PutObjectRequest 객체에 값을 저장할 file 의 정보와 목적지(bucket name) 을 작성한다.
s3Client.putObject(정보, file) 메서드를 사용해 file 을 bucket 으로 전달한다.

  • deleteFile 메서드

uploadFile 메서드의 putObject 와 방법이 동일하다.

  • getpresignedUrl 메서드

upload 할 때와 다르게 다운로드할 때는 presigned URL 을 사용할 것이므로 단계가 조금 다르다.
다운로드 하길 원하는 file 의 이름을 가지고 GetObjectRequest 를 만드는 것 까지는 동일하다. 그리고 그 객체를 GetObjectPresignRequest 객체를 생성하며 매개변수로 넣어준다.
최종적으로 s3Presigner.presignGetObject(getObjectPresignRequest) 를 호출해주면 S3 에서 해당 file 의 presigned URL 을 건네준다.
그 URL 을 주소창에 넣으면 S3 에 있는 file 에 접근할 수 있게 된다.

AmazonS3Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@RequiredArgsConstructor
@RequestMapping("/file")
public class AmazonS3Controller {
 
    private final AwsS3Service awsS3Service;
 
    @PostMapping
    public ResponseEntity<List<String>> uploadFile(List<MultipartFile> multipartFiles){
         return ResponseEntity.ok(awsS3Service.uploadFile(multipartFiles));
    }
 
    @DeleteMapping
    public ResponseEntity<String> deleteFile(@RequestParam String fileName){
        awsS3Service.deleteFile(fileName);
        return ResponseEntity.ok(fileName);
    }
}

local 환경에서 s3 로 file 이 업로드 되는지 확인하기 위해 사용할 수 있는 controller 코드이다.
나는 기존에 file 을 업로드 하는 로직이 있어서 사용하지 않았다.

테스트

사진12

기존의 file 업로드 코드에 awsS3Service.uploadFile(file) 를 삽입 후 테스트.

S3 에 파일 업로드 된 것을 확인하였다.

이미지 파일 경로 수정

1
2
3
4
5
<!-- before -->
profilePicture.setAttribute('src', "/uploaded_images/" + img_link);

<!-- after -->
profilePicture.setAttribute('src', img_link);

기존 경로는 프로그램이 실행중인 환경의 경로였다.
하지만 이제 img_link 에는 presigned URL 이 있으므로 바로 src 속성에 넣어주었다.

사진13

경로 수정 후 테스트시 웹페이지에 정상 출력되었다.
최종적으로 Config 파일의 access key, secret key 를 삭제 후 AWS 에 배포하여 정상 동작하는지 테스트 하였고 정상임을 확인했다.



Reference

우아한 기술 블로그 - Spring Boot에서 S3에 파일을 업로드하는 세 가지 방법
Inpa Dev - S3 개념 & 버킷 · 권한 설정 방법
개발과 고양이발 - Spring Boot로 S3 이미지 업로드 기능 구현하기

This post is licensed under CC BY 4.0 by the author.

[Spring Security JWT] 사용자 정보와 CORS 설정

Web Application 에 대한 이해