[Spring Boot] AWS S3에 파일을 업로드, 다운, 삭제

박철현·2024년 10월 29일

Java

목록 보기
10/13

알아두면 좋은 것들

S3 주요 용어

  • 버킷(Bucket) : S3에서 데이터를 저장하는 기본 컨테이너
    • 고유한 글로벌 이름을 가지며 하나 이상의 객체 저장 가능
  • 객체(Object) : S3에 저장되는 기본 단위
    • 파일과 메타데이터를 포함
    • 버킷 안에 저장
  • 키(Key) : 객체를 고유하게 식별하는 식별자

AWS 전체 네트워크 구성

  • 여러개의 리전을 가짐
    • 리전 : 전 세계 여러 지역에 데이터 센터 명칭
    • 고유한 이름을 가지며 일반적으로 지역 코드와 번호로 구성
      • ap-northeast-2 : 아시아 태평양 지역의 서울 리전
  • 각 리전은 여러개의 가용 영역을 가짐
    • 가용 영역 : 물리적으로 분리된 데이터 센터
      • 하나의 리전 내에서 독립적인 전력 공급, 냉각 및 네트워크 연결 제공
      • 장애 발생 시 다른 가용 영역에서 서비스를 지속할 수 있는 이점 제공

AWS 계정, 정책 관련

  • root 계정 자체
    • 계정 이메일, ID 유일한 값을 가짐
    • 계정 별칭으로 로그인 가능
  • 추가 별칭으로 권한 줄 수 있음
    • IAM
    • 사용자 이름 유니크하지 않아도 됨
  • AWS 정책 : 권한들의 모음
    • S3 파일 업로드 관련 권한 모음 : AmazonS3FullAccess 필요

S3 파일 업로드의 큰 그림

  • yml 설정 파일에 AccessToken 정보 추가
  • API 호출 시 yml 정보를 같이 넘겨 권한 있는 사용자임을 인증하고 업로드
  • 이후 업로드 된 경로를 응답받아 DB에 저장
    • 이미지의 경우 img 태그에 src 부분에 사용
    • 파일 다운, 삭제의 경우 s3 내 저장된 파일 경로 필요

ContentDisposition

  • HTTP 응답 헤더 중 하나로 브라우저에게 파일을 어떻게 처리할지 지시하는 역할
    • 주로 다운로드 할 파일의 이름 지정하는데 사용
      • attachment : 파일이 다운로드 형식으로 제공됨을 나타냄
        • 브라우저가 파일 직접 열지X 다운
        • inline 으로 사용하면 브라우저는 파일을 직접 열려고 시도
      • filename : 다운로드 할 파일의 이름 설정
        • 사용자가 파일 다운로드 시 이 이름으로 저장

시작하기 1 - s3 버킷 생성

버킷 생성하기

  • 리전 : 서울 지정
  • 버킷 이름 : 고유한 이름 지정
  • 소유권 : ACL 비활성화
  • 퍼블릭 액세스 차단 : 풀기
  • 나머지 default

사용자 생성 및 권한 부여

  • 하단 블로그 참조

버킷 정책 수정




{
    "Version": "2012-10-17", // AWS 정책 언어 버전
    "Statement": [    // 정책의 각 규칙을 정의하는 배열
        {
            "Sid": "Statement1",  // 정책의 고유 식별자(선택적)
            "Principal": "*",  // 정책이 적용될 주체(모든 사용자 의미)
            "Effect": "Allow", // 허용 또는 거부의 효과(허용)
            "Action": "s3:*", // 허용할 작업(s3 모든 작업 허용)
            "Resource": "arn:aws:s3:::<버킷 이름>/*" 
            //정책이 적용될 리소스(특정 s3 버킷 내 모든 객체)
        }
    ]
}
  • s3와 관련된 모든 행위를 허용
    • principal -> 대상 : 사용자 설정
    • action -> 행위 : 업로드, 다운, 삭제 등 모든 행위 포함
  • 정책 적용 후 저장

시작하기 2 - Spring Boot 프로젝트와 연동

라이브러리 추가

implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

or 

implementation("com.amazonaws:aws-java-sdk-s3:1.12.174")
  • 우아한테크 블로그와 참조한 블로그의 gradle이 다름
    • spring-cloud-starter-aws
      • 다양한 AWS 서비스 지원
      • S3 뿐만 아닌 다른 AWS 서비스 통합 여러 기능 포함
    • aws-java-sdk-s3
      • AWS SDK for Java의 S3 전용 클라이언트
      • S3와 관련된 작업을 수행하기 위한 가장 기본적인 라이브러리
      • S3에 특화된 기능 제공, 다른 AWS 서비스 통합 기능 포함x
    • s3만 필요함으로 저는 aws-java-sdk-s3 사용 합니다!

application.yml에 아래 내용 추가

spring:
  profiles:
    active: dev
    include: secret
  servlet:
    multipart:
      enabled: true # 멀티파트 업로드 지원여부 (default: true)
      file-size-threshold: 0B # 파일을 디스크에 저장하지 않고 메모리에 저장하는 최소 크기 (default: 0B)
      # 임시 디렉터리에 저장된 파일은 힙 메모리가 아닌 Servlet Container Disk에 저장됨
      # 즉, file-size-threshold 초과한 크기의 파일 모두 아래 경로에 임시 저장
      # 업로드 중간에 실패하는 등 장애 발생 시 명시적으로 임시 파일 삭제를 위해 경로 지정 필요
      # 가끔 임시 파일 삭제되지 않고 남아있는 경우 별도 삭제 작업 필요한데 이를 용이하게 하기 위해 지정
      location: /users/parkcheorhyeon/temp # 업로드된 파일이 임시로 저장되는 디스크 위치 (default: WAS가 결정)
      max-file-size: 100MB # 한개 파일의 최대 사이즈 (default: 1MB)
      max-request-size: 100MB # 한개 요청의 최대 사이즈 (default: 10MB)
  • yml 설정과 관련해서는 아래 출처에 우아한테크 블로그 참조하시면 더 좋을 것 같습니다!
  • 기본적으로 secret 프로필을 추가하게끔하여 secret 설정 파일도 필요하여 아래 작성하였습니다.
aws:
  s3:
    accessKey: 액세스키
    secretKey: 시크릿키
    bucket : 버킷명
  • application-secret.yml 별도 파일을 만들어 위의 내용을 작성하고 .gitIgnore에 업로드 제거를 설정했습니다.
  • application-secret.yml.template 파일은 업로드에 포함시켜 pull 받을 시 위 내용을 작성할 템플릿을 제공하도록은 설정 해두었습니다!
    • pull 받고 나서 .template 부분을 지운 뒤 토큰 부분과 버킷명을 작성해주시면 됩니다.

S3Config.java 생성

@Configuration
public class S3Config {

	@Value("${aws.s3.accessKey}")
	private String accessKey;

	@Value("${aws.s3.secretKey}")
	private String secretKey;

	@Bean
	public AmazonS3 amazonS3Client() {
		return AmazonS3ClientBuilder.standard()
			.withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKey, secretKey)))
			.withRegion(Regions.AP_NORTHEAST_2)
			.build();
	}
}
  • AmazonS3ClientBuilder : 기본 설정을 가진 S3 Client 생성
    • withCredentials : AWS에 대한 인증 정보 설정
      • accessKey와 secretKey를 사용하여 AWS에 접근할 수 있도록 함
    • withRegion : 리전 설정
  • S3와의 상호작용 하기 위한 AmazonS3 클라이언트를 Spring Bean으로 생성
    • S3에 접근하고 작업을 수행하는데 사용
    • 객체 업로드, 다운로드, 버킷 생성등의 작업 수행 가능

파일 업로드 구현 - 단일 파일

@RestController
@RequiredArgsConstructor
public class FileUploadController {

    private final AmazonS3 amazonS3Client;

    @Value("${aws.s3.bucket}")
    private String bucket;

    @PostMapping("/multipart-files/single")
    public String uploadMultipleFile(@RequestPart MultipartFile file) throws IOException {
        // 객체의 메타 data 설정 - 해두면 클라이언트에서 다운 받을 때 처리 용이
        ObjectMetadata objectMetadata = new ObjectMetadata();
        // 파일의 형식에 맞는 MIME 타입을 설정, size 설정 하는것이 좋음
        objectMetadata.setContentType(file.getContentType());
        objectMetadata.setContentLength(file.getSize());
        String folderName = "yourFolderName/";
        String transFolder = folderName + UUID.randomUUID() + file.getOriginalFilename();

        PutObjectRequest putObjectRequest = new PutObjectRequest(
                bucket, // 버킷
                transFolder, // 파일명, 폴더 구분할 수 있다.
                file.getInputStream(),
                objectMetadata // 객체의 메타data 설정 클래스
        );

// 파일을 저장 하는 메섣드
  amazonS3Client.putObject(putObjectRequest);
  // 파일이 저장된 URI를 return, 해당 경로 이동 시 파일 open
  // 버킷 정책을 변경하지 않았으면 파일은 업로드 되지만
  // 해당 URL로 이동 시 Access Denied 됨
  return amazonS3Client.getUrl(bucket, transFolder).toString();
    }
  • transFolder : s3 버킷 내 파일명 지정 가능

    • /로 구분되어 폴더로 생성됨
    • yourFolderName/file명 => yourFolderName이라는 폴더 밑에 생김
    • 맨 처음에 /yourFolderName 이런식으로 사용하면 / 이라는 폴더도 생김
  • 파일명 지정 : UUID를 같이 포함하여 같은 파일을 업로드 하더라도 여러번 업로드 되도록 하기 위함

    • 파일명이 key로써 작용함
    • 아래 생성자 참조
  • putObject() 메서드가 파일을 저장해주는 메서드

    • PutObjectRequest 객체 생성자
      • 맨 위에 그림에 나타난 것 처럼 Object 안에 file, meta data 구성
      • 어느 bucket에 들어갈건지도 지정

파일 업로드 구현 - 여러 파일 리스트

@PostMapping("/multipart-files")
    public LinkedHashMap<String, String> uploadMultipleFiles(@RequestPart("uploadFiles") List<MultipartFile> multipartFiles) throws IOException {
        LinkedHashMap<String, String> map = new LinkedHashMap<>();
        for (MultipartFile file : multipartFiles) {
            // 객체의 메타 data 설정 - 해두면 클라이언트에서 다운 받을 때 처리 용이
            ObjectMetadata objectMetadata = new ObjectMetadata();
            // 파일의 형식에 맞는 MIME 타입을 설정, size 설정 하는것이 좋음
            objectMetadata.setContentType(file.getContentType());
            objectMetadata.setContentLength(file.getSize());
            String folderName = "yourFolderName/";
            String transFolder = folderName + UUID.randomUUID() + file.getOriginalFilename();

            PutObjectRequest putObjectRequest = new PutObjectRequest(
                    bucket, // 버킷
                    transFolder, // 파일명, 폴더 구분할 수 있다.
                    file.getInputStream(),
                    objectMetadata // 객체의 메타data 설정 클래스
            );

            amazonS3Client.putObject(putObjectRequest);
            String url = amazonS3Client.getUrl(bucket, transFolder).toString();
            map.put(file.getOriginalFilename(), url);
        }
        return map;
    }
  • 각 파일 하나씩마다 적용

파일 다운로드 구현

@GetMapping("/multipart-files")
    public ResponseEntity<UrlResource> downloadImage(@RequestParam String originalFilename) throws
		UnsupportedEncodingException {
        UrlResource urlResource = new UrlResource(amazonS3Client.getUrl(bucket, originalFilename));

        // prefix로 사용되는 "yourFolderName-" 제외한 다운로드 자동 되도록 설정
        // attachment : 브라우저가 파일 다운로드 하도록 지시 / inline : 브라우저는 파일 직접 열려고 시도
        // folderName과 UUID 길이를 제외한 파일 이름 추출
        int folderNameLength = "yourFolderName/".length();
        int uuidLength = 36; // UUID 길이는 항상 36자
        String extractedFilename = originalFilename.substring(uuidLength + folderNameLength);

        // URL 인코딩된 파일 이름 생성
        String encodedFilename = URLEncoder.encode(extractedFilename, StandardCharsets.UTF_8);

        // Content-Disposition 헤더에 인코딩된 파일 이름 사용
        String contentDisposition = "attachment; filename=\"" + encodedFilename + "\"";

        // header에 CONTENT_DISPOSITION 설정을 통해 클릭 시 다운로드 진행
        return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
            .body(urlResource);
    }
  • S3내 저장된 파일 경로를 넣어주면 파일 다운로드 진행
    • 파라미터로 받은 이유 : yourFolderName/a.pdf 라 했을때 SpringBoot에서 requestPath로 취급해버려서 에러남
      • a.pdf를 별도로 pathVariable로 인식해버림
  • 위에서 언급한 contentDisposition 설명 참조
    • 파일 다운하라고 지시
    • 파일 다운로드명 지시

포스트맨 파일 다운 시 문자 깨짐 문제 발생

  • 물론 다운 받는 사람이 파일명을 변경해도 되지만 이게 불편..
  • 정말 별 짓을 다했지만.. HTML에서도 이럴까? 하는 의문

HTML에서 다운로드 버튼 눌러보기

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>파일 다운로드</title>
    <script>
        function downloadFile() {
            const filename = 'yourFolderName/12de6241-965a-4a13-93cb-21706375c0f42024년 재개발임대주택 모집공고문 (1).pdf';
            const url = `http://localhost:8080/multipart-files?originalFilename=${filename}`;
            window.location.href = url; // 새 창에서 다운로드 요청
        }
    </script>
</head>
<body>
<h1>파일 다운로드 버튼</h1>
<button onclick="downloadFile()">다운로드</button>
</body>
</html>

  • 포스트맨으로 파일 다운.. 하지마십셔..
    • 저 모집 공고는 다운 받았던거 그냥 올려본거라 신경 안쓰셔도 됩니다..!

추가 1 - Spring View Resolver 비활성화로 인한 HTML 인식 불가

@Controller
public class HomeController {
	@GetMapping("/")
	public String home() {
		return "test";
	}
}
  • 저 위에 HTML이 매핑되서 당연 보이겠지 싶었는데 바로 NoResourceFoundException 발생

추가 2 - Thymleaf 템플릿 엔진 추가하여 View Resolver 활성화 시키기

  • 엥 왜지? 싶어서 뤼튼에게 물어보니
    • 뷰 리졸버의 경우 템플릿 엔진 사용하지 않으면 기본적으로 비활성화
    • gradle 의존성에 타임리프 추가하여 해결하였습니다..!

 implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' // Thymeleaf 의존성 추가
  • 나오는 화면은 위에 다운받을 때 HTML 파일로 잘 매핑되서 나옵니다!

파일 삭제 구현

    @DeleteMapping("/multipart-files")
    public String uploadMultipleFiles(@RequestParam String deleteFileName){
        try {
            amazonS3Client.deleteObject(bucket, deleteFileName);
            return "삭제 성공";
        } catch (AmazonServiceException e) {
            return "삭제 실패: " + e.getMessage();
        }
    }
  • 파일 삭제는 그냥 s3 업로드된 경로 넣어주면 삭제됩니다.

코드 리팩토링

  • 공통 부분 : 메타데이터 생성
  • 메서드화 : 특정 s3 폴더명 넣어주면 업로드 하도록
    • 구체적 구현 부분 몰라도 가능하도록
    • 사실 서비스단으로 분리하는 것이 좋겠으나 그냥 하나의 파일로
@RestController
@RequiredArgsConstructor
public class FileUploadController {

    private final AmazonS3 amazonS3Client;
    private static final String FOLDER_NAME = "yourFolderName/";
    private static final int UUID_LENGTH = 36;

    @Value("${aws.s3.bucket}")
    private String bucket;

    @PostMapping("/multipart-files/single")
    public String uploadMultipleFile(@RequestPart MultipartFile file) throws IOException {
        return uploadFile(FOLDER_NAME, file);
    }

    @PostMapping("/multipart-files")
    public LinkedHashMap<String, String> uploadMultipleFiles(@RequestPart("uploadFiles") List<MultipartFile> multipartFiles) throws IOException {
        LinkedHashMap<String, String> map = new LinkedHashMap<>();
        for (MultipartFile file : multipartFiles) {
            String url = uploadFile(FOLDER_NAME, file);
            map.put(file.getOriginalFilename(), url);
        }
        return map;
    }

    @DeleteMapping("/multipart-files")
    public String uploadMultipleFiles(@RequestParam String deleteFileName) {
        try {
            amazonS3Client.deleteObject(bucket, deleteFileName);
            return "삭제 성공";
        } catch (AmazonServiceException e) {
            return "삭제 실패: " + e.getMessage();
        }
    }

    @GetMapping("/multipart-files")
    public ResponseEntity<UrlResource> downloadImage(@RequestParam String originalFilename) {
        UrlResource urlResource = new UrlResource(amazonS3Client.getUrl(bucket, originalFilename));

        // prefix로 사용되는 "yourFolderName-" 제외한 다운로드 자동 되도록 설정
        // attachment : 브라우저가 파일 다운로드 하도록 지시 / inline : 브라우저는 파일 직접 열려고 시도
        // folderName과 UUID 길이를 제외한 파일 이름 추출
        String extractedFilename = originalFilename.substring(FOLDER_NAME.length() + UUID_LENGTH);

        // URL 인코딩된 파일 이름 생성
        String encodedFilename = URLEncoder.encode(extractedFilename, StandardCharsets.UTF_8);

        // Content-Disposition 헤더에 인코딩된 파일 이름 사용
        String contentDisposition = "attachment; filename=\"" + encodedFilename + "\"";

        // header에 CONTENT_DISPOSITION 설정을 통해 클릭 시 다운로드 진행
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
                .body(urlResource);
    }

    /*
      - @param folderName : s3에 업로드할 폴더명
      - @param file : s3에 업로드할 파일
      - desc : 업로드할 폴더명과 파일을 받아서 해당 경로에 파일 업로드 메서드
     */
    public String uploadFile(String folderName, MultipartFile file) throws IOException {
        ObjectMetadata objectMetadata = getObjectMetadata(file);
        String transFolder = folderName + UUID.randomUUID() + file.getOriginalFilename();

        PutObjectRequest putObjectRequest = new PutObjectRequest(
                bucket, // 버킷
                transFolder, // 파일명, 폴더 구분할 수 있다.
                file.getInputStream(),
                objectMetadata // 객체의 메타data 설정 클래스
        );

        amazonS3Client.putObject(putObjectRequest);
        return amazonS3Client.getUrl(bucket, transFolder).toString();
    }

    /*
      - @param file : s3에 업로드할 파일
      - desc : 업로드할 파일을 받아서 ObjectMetaData 반환 메서드
     */
    private ObjectMetadata getObjectMetadata(MultipartFile file) {
        // 객체의 메타 data 설정 - 해두면 클라이언트에서 다운 받을 때 처리 용이
        ObjectMetadata objectMetadata = new ObjectMetadata();
        // 파일의 형식에 맞는 MIME 타입을 설정, size 설정 하는것이 좋음
        objectMetadata.setContentType(file.getContentType());
        objectMetadata.setContentLength(file.getSize());
        return objectMetadata;
    }
}

출처

깃허브

회고

  • MultiPartFile을 필드로 가지는 interFace를 생성하고 이를 구현한 구현체를 이용하여 좀 더 추상화 하여 사용하는 코드를 본 적이 있다..
  • 인터페이스를 구현하고, 추상 메서드를 구현함으로써 어떤 파일의 규격화(?)를 하는 경우를 봤었는데.. 고수의 느낌이..
    • 이정도로만 해도 좋다..!
  • 추가로 다루진 않았지만 파일 업로드되는 경로 url로 image 태그에 src에 넣어서 이미지를 불러오기도 한다..!

  • 잘가라 버킷..! 괜히 버킷명이 노출되어 찜찜하니 깔끔히 삭제하도록 하겠슴다!
profile
비슷한 어려움을 겪는 누군가에게 도움이 되길

0개의 댓글