[👀 Owing] S3에 파일을 업로드하는 세 가지 방법 (MultipartFile, Stream, PresignedURL)
프로젝트에서 파일 업로드 기능을 구현하며 공부한 내용을 정리하려 한다.
Stream 업로드
Stream 업로드는 HttpServletRequest의 InputStream을 이용하여 AWS S3에 다이렉트로 파일을 전송하는 방식이다. 즉, 서버를 bypass하는 형태로 S3에 업로드하는 것이다.
이 방식의 가장 큰 특징은 업로드할 파일의 바이너리 전체를 Spring Boot 애플리케이션을 실행하고 있는 서버의 디스크나 힙 메모리에 저장하지 않는다는 점이다.
(물론 업로드를 위해 파일의 청크를 메모리에 로드하기 때문에 약간의 메모리를 사용하게 된다.)
그리고 애플리케이션 로직을 통해 모든 바이너리를 메모리에 로드하지 않는 이상 전처리(이미지 리사이징)가 불가능한 특징을 가지고 있다.
동작 흐름
1. 클라이언트가 서버에 파일을 스트림 형태로 전달한다.
2. 서버는 클라이언트에게 받은 스트림 데이터를 바로 S3에 저장한다.
예시 코드
컨트롤러에서 `HttpServletRequest`를 받는다. 스트림 형태로 S3에 보내기 위해 request에서 InputStream을 꺼낸다.
File 객체를 따로 받지 않기 때문에 헤더에서 `fileName`과 `contentLength`를 꺼내 S3에 업로드한다.
@PostMapping("/stream")
public String uploadFile(HttpServletRequest request) {
try (InputStream inputStream = request.getInputStream()) {
String fileName = request.getHeader("file-name");
long contentLength = request.getContentLengthLong();
s3Service.uploadFileToS3(fileName, inputStream, contentLength);
return "업로드 완료";
} catch (IOException ex) {
return "업로드 실패 : " + ex.getMessage();
}
}
MultipartFile 업로드
SpringMVC에서 제공하는 인터페이스를 활용하는 방식이다.
MultipartFile 인터페이스란?
MultipartFile은 SpringMVC에서 제공하는 인터페이스로, 파일 객체를 추상화한 개념이다.
MultipartFile을 사용하면, 파일이 서버에 전달되는 과정에서 이를 메모리나 임시 디스크에 저장해두었다가, 필요한 경우 이를 영구 저장소(ex. AWS S3)로 다시 업로드할 수 있다. 즉, 파일이 서버를 거쳐가기 때문에 서버에 큰 부하를 줄 수 있고, 서버의 디스크 용량에 따라 실패할 수 있다.
실제 MultiparFile 인터페이스를 살펴보면 아래와 같은 설명이 포함되어 있다.
The file contents are either stored in memory or temporarily on disk.
동작 흐름
1. 클라이언트가 파일을 서버로 전송
클라이언트는 서버로 파일을 전송할 때, 파일 데이터를 멀티파트 형식으로 인코딩하여 HTTP 요청의 바디에 포함한다. 이 멀티파트 요청은 스트림 형태로 서버에 전달되어, 서버에서 이를 메모리 또는 임시 디스크에 일시적으로 저장한다.
2. 서버가 스트림 데이터를 MultipartFile 객체로 변환
서버에 도착한 파일은 SpringMVC가 제공하는 `MultipartResolver`에 의해 자동으로 `MultipartFile` 객체로 변환된다. 이를 통해 컨트롤러에서는 파일을 MultipartFile 객체로 다룰 수 있게 된다. 이 `MultipartFile` 객체를 통해 파일의 이름, 크기, MIME 타입 등을 가져올 수 있다.
3. 파일의 임시 저장 및 처리
`MultipartFile`을 사용해 전달된 파일은 메모리나 임시 디스크에 잠시 저장된다. 그리고 필요에 따라 S3와 같은 외부 저장소로 스트림 형태로 재업로드 할 수 있다. 파일 업로드가 끝나면 서버는 이를 메모리나 디스크에서 제거한다.
위 구조를 보고 궁금했던 점 🤔
클라이언트에서 tomcat server로 전달할 때 스트림 데이터로 전달한다. 그리고 서버에서 S3에 업로드할 때도 스트림 데이터로 업로드한다. 그렇다면 MultipartResolver는 중간에 왜 굳이 스트림 데이터를 MultipartFile로 변환할까?
이 질문에 답하기 위해서는 MultipartFile을 왜 사용하는가? 에 대한 물음부터 답할 수 있어야 한다.
MultipartFile 인터페이스를 사용하는 이유는, SpringMVC에서 파일을 쉽게 처리하기 위함이다.
다시 말해, MultipartFile 인터페이스를 사용하면 Spring의 컨트롤러에서 파일 데이터를 쉽게 처리하고, 여러 파일 속성에 접근할 수 있다는 것이다. `getOriginalFilename()`, `getSize()`, `getContentType()` 등의 메서드로 파일 정보에 쉽게 접근할 수 있고, 이를 통해 파일의 유효성 검사를 수행하기도 용이하다. 예를 들어, 이미지 파일만 허용하거나 파일 크기를 제한하고 싶은 경우 유용하다.
따라서, 단순히 클라이언트에서 S3로 파일을 업로드하는 것만이 목적이라면, MultipartFile로 변환할 필요 없이 스트림 데이터를 직접 전달하는 방식으로도 구현할 수 있다는게 내가 내린 결론이다.
예시 코드
@PostMapping("/multipart-file")
public String uploadMultipleFile(@RequestPart("file") MultipartFile file) {
try (InputStream inputStream = file.getInputStream()) {
String fileName = file.getOriginalFilename();
long contentLength = file.getSize();
s3Service.uploadFileToS3(fileName, inputStream, contentLength);
return "업로드 완료";
} catch (IOException ex) {
return "업로드 실패 : " + ex.getMessage();
}
}
public void uploadFileToS3(String fileName, InputStream inputStream, long contentLength) {
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(BUCKET_NAME)
.key(DIRECTORY_PATH + UUID.randomUUID() + fileName)
.contentType(getContentType(fileName))
.build();
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, contentLength));
}
PresignedURL
결론부터 말하자면 presigned url은 클라이언트가 직접 S3에 업로드할 수 있는 url로, 서버의 자원을 절약할 수 있다는 장점이 있다.
일반적으로 사진과 같은 미디어는 S3 버킷으로 업로드되고, 해당 사진의 엔드포인트(url)만 RDB에 저장된다. 이때 사진 파일은 굉장히 많은 트래픽을 소모하므로, `프론트엔드→백엔드→ S3`로 전달하는 것보다 `프론트엔드→S3`로 전달하는 것이 더 효율적이다. 또한 이미지를 조회할 때도 `S3→백엔드→프론트엔드`보다는 `S3→프론트엔드`로 전달하는 게 서버의 과부화를 막을 수 있다.
여기서 문제는 AWS 리소스인 S3에 접근하기 위해서는 엑세스 키처럼 권한이 필요한데, 이 키를 프론트엔드가 직접 사용할 수는 없다는 것이다. 프론트엔드는 사용자가 접근 가능한 영역이므로, 프론트엔드에 키를 줄 경우 유출 문제가 생길 수 있다.
이 문제를 해결하기 위해서 사용하는 것이 바로 presigned url이다. presigned url은 서버에서 가지고 있는 권한을 사용해 클라이언트에게 임시적인 권한을 발급해주는 것을 의미한다. 서버는 presigned url을 사용해 특정 객체(이미지)를 조회하거나, 아니면 업로드할 권한을 클라이언트에게 임시적으로 부여할 수 있다.
PresignedURL 구조
https://bucket.s3.ap-northeast-2.amazonaws.com/testFile123.jpg
?X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Date=20231029T070655Z
&X-Amz-SignedHeaders=content-type%3Bhost
&X-Amz-Expires=299&X-Amz-Credential=AccessKey%2F20231029%2FRegion%2Fs3%2Faws4_request
&X-Amz-Signature=SignatureValue
기본 URL
- `https://***.s3.ap-northeast-2.amazonaws.com` : S3버킷의 URL
- `/testFile123.jpg` : 해당 Presigned URL로 접근할 수 있는 객체의 이름
쿼리 파라미터
- `X-Amz-Algorithm`: 서명 버전과 알고리즘을 식별하고 서명을 계산하는데 사용되는 값
- `X-Amz-Date`: ISO 8601 형식의 날짜로 URL 생성 시간
- `X-Amz-SignedHeaders`: 서명을 계산하기 위해 요구되는 헤더 목록 (기본적으로 host 헤더 요구)
- `X-Amz-Expires`: Presigned URL이 유효한 시간으로 단위는 초 (1~604800)
- `X-Amz-Credential`: Access Key, 요청 날짜, Region, 서비스명
- `X-Amz-Signature`: 요청을 인증하기 위한 서명 값
동작 흐름
1. 사용자가 파일을 선택한다.
2. 선택한 파일에서 파일 정보(파일명, 컨텐츠 타입, 컨텐츠 길이)를 획득한다.
3. 입력한 파일 정보는 서버에서 허용 가능한지 판단하며, 이와 다른 정보를 가진 파일을 업로드 하려 한다면 실패한다.
4. 파일 정보를 기반으로 API에 요청하여 `presignedUrl`과 `fileUrl`을 획득한다.
- `presignedUrl`: 파일을 업로드 할 URL
- `fileUrl`: 업로드하게 될 파일이 저장될 URL (클라이언트는 `fileUrl`을 통해 저장된 이미지를 확인할 수 있다.)
- `fileName`: 기존 파일명에 랜덤 스트링을 추가한 값 (파일명이 같을 때 덮어씌워지는 것 방지)
5. 클라이언트는 `presignedUrl`에 PUT 메서드로 파일을 업로드(전송)한다.
6. 클라이언트는 파일 업로드 후 서버에게 알린다. (저장 경로 포함하여 요청)
7. 서버는 저장 경로를 데이터베이스에 저장한다.
적용 과정이 궁금하다면 이 글을 참고하자.
결론
Stream 업로드 | MultipartFile 업로드 | PresignedURL | |
설명 | InputStream을 이용하여 AWS S3에 다이렉트로 파일을 전송하는 방식 | SpringMVC에서 제공하는 인터페이스를 활용하는 방식 | 서버가 업로드할 파일에 대한 URL을 미리 생성하고, 클라이언트가 이를 통해 S3에 파일을 직접 업로드하는 방식 |
장점 | 파일의 바이너리를 서버의 디스크나 메모리에 저장하지 않고 서버를 Bypass하기 때문에 서버 부담이 적다. | 파일의 유효성 검사나 파일 크기 제한 등이 가능하다. | 서버를 거치지 않고 S3에 직접 이루어지므로, 서버 리소스를 절약할 수 있다. |
단점 | 이미지 전처리(리사이징)가 불가능하다. | 파일의 바이너리를 서버의 디스크에 임시 저장하기 때문에 서버의 부담이 크다. | 보안상 유효 기간이 존재하기 때문에, 유효 기간 내에 업로드를 완료하지 못하면 다시 URL을 요청해야 하는 불편함이 있을 수 있다. |
참고
https://techblog.woowahan.com/11392/
https://www.youtube.com/watch?v=r2arbkCTVUk