[Spring] Spring에서 S3에 파일을 업로드하는 세 가지 방법

Tak Jeon·2025년 4월 27일
0

Spring

목록 보기
1/8
post-thumbnail

이번 포스팅에서는 Spring을 사용한 프로젝트에서 S3에 업로드하는 세 가지 방법을 비교하고, 각 방법에 장 단점에 대하여 알아보고자 합니다.


S3에 파일을 업로드하는 세 가지 방법

파일을 S3에 업로드하는 방법은 크게 세 가지가 존재합니다.

  1. Stream을 이용한 업로드 방식
  2. MultipartFile을 이용한 업로드 방식
  3. PresignedURL을 이용한 업로드 방식

세 방식을 하나씩 알아보겠습니다.

Stream 업로드

Stream 업로드 방식

Stream Upload는 HttpServletRequest의 InputStream을 사용하여 AWS S3에 다이렉트로 파일은 전송하는 방식입니다.

작동 방식은 다음과 같습니다.

  1. 클라이언트가 서버로 파일 데이터를 전송합니다.
  2. 서버가 HttpServletRequest의 InputStream을 이용하여 파일 데이터를 InputStream으로 읽습니다.
  3. putObject를 호출하여 S3에 단일 요청으로 업로드합니다.

특징은 다음과 같습니다.

  • 적은 메모리 사용 및 디스크를 사용하지 않고 AWS에 바로 업로드 할 수 있습니다.
  • 파일을 청크로 나누지 않고 한 번에 업로드 합니다.
  • 스트리밍 전송이 가능합니다.

장단점은 다음과 같습니다.

장점단점
S3에 단일 요청으로 업로드하므로 코드 구현이 간단함파일 전체를 한 번에 업로드하므로 대용량 파일 처리에서는 비효율적
작은 파일의 경우 빠르게 업로드 가능AWS SDK 내부 버퍼링이 존재하므로 완전히 메모리 사용이 0은 아님
메모리 사용을 최소화하도록 설정하면 대용량 파일도 메모리를 효율적으로 사용하면서 처리가 가능업로드 중 중단 될 경우 전체 업로드가 실패함

따라서 Stream 업로드 방식은 단일 요청의 저용량 데이터 업로드에 효과적입니다.


MultipartFile 업로드

MultipartFile 업로드 방식 MultipartFile 업로드 방식

MultipartFile Upload는 MultipartFile을 사용하여 AWS S3에 파일을 업로드하는 방식입니다.

작동 방식은 다음과 같습니다.

  1. 클라이언트가 서버로 파일 데이터를 전송합니다.
  2. 서버가 MultipartFile로 파일 데이터를 받습니다.
  3. S3의 putObject를 호출하여 S3에 단일 요청으로 업로드합니다.
    • 이때, 파일의 크기에 따라 분할되어 업로드가 가능합니다.

특징은 다음과 같습니다.

  • 파일을 청크 단위로 나눠 업로드할 수 있습니다.
  • 각 청크는 독립적으로 업로드되며, 실패 시 해당 청크만 재시도가 가능합니다.
  • Spring에서 파일 데이터를 디스크에 임시 저장합니다.

장단점은 다음과 같습니다.

장점단점
파일을 청크 단위로 업로드하므로 메모리를 효율적으로 관리할 수 있음디스크 또는 메모리에 임시 파일을 저장하므로, 서버 부하가 발생
업로드가 중간에 실패한 경우, 실패한 청크만 재업로드가 가능청크 수만큼 HTTP 요청이 발생함 (15MB 파일을 3MB 기준으로 청크를 나눈다면, 5번의 요청이 발생)
대규모 파일 업로드에서 안정적으로 동작

따라서 MultipartFile 방식은 대용량 데이터 업로드에 효과적입니다.


PresignedURL 업로드

PresignedURL 업로드 방식

PresignedURL Upload는 AWS S3에서 제공하는 파일 업로드 방식입니다.

업로드할 파일을 작은 part로 나누어 각 부분을 개별적으로 업로드하고, 발급된 URL을 통하여 직접 S3에 업로드하기 때문에 Spring 서버를 거치지 않고 업로드가 가능합니다.

모든 part가 업로드되고 난 후, AWS에서 하나의 객체로 조립하여 저장됩니다.

작동 방식은 다음과 같습니다.

  1. 클라이언트가 서버에 파일 업로드 요청을 보냅니다.
  2. 서버가 S3으로부터 PresignedURL을 생성하고 클라이언트에게 응답합니다.
  3. 클라이언트가 PresignedURL을 사용해 S3에 직접 업로드합니다.
  4. S3에서는 ETag를 응답합니다.
  5. 클라이언트에서 ETag를 포함하여 서버에게 업로드 완료 요청을 보냅니다.

특징은 다음과 같습니다.

  • 서버가 파일 데이터를 전혀 처리하지 않습니다.
    • 따라서 메모리와 디스크를 전혀 사용하지 않습니다.
  • 클라이언트가 S3와 직접 통신해 업로드합니다.

장단점은 다음과 같습니다.

장점단점
서버에서 파일 데이터를 처리하지 않으므로, 메모리 / 디스크 사용량이 없음PresignedURL이 유출되면 누구나 업로드 가능
동시 업로드가 많아도 서버 부하가 존재하지 않음클라이언트가 S3와 직접 통신하므로 네트워크 환경에 따라 성능 차이가 발생
서버는 PresignedURL을 생성하는 로직만 구현서버를 거치지 않기 때문에, 어떤 파일이 올라가는지 정확히 알 수 없음

정리

항목StreamMultipartFilePreSigned URL
메모리 사용최소로 설정 가능설정한 청크 크기 만큼 사용없음
디스크 사용없음있음없음
대용량 파일 처리비효율적효율적클라이언트의 네트워크 환경 의존
재시도 가능불가가능클라이언트가 재시도
권장하는 파일 크기5MB 이하5MB 이상 ~ 5GB 이하제한 없음
보안서버에서 처리하므로 안전서버에서 처리하므로 안전URL 유출 시 위험

Stream 업로드 방식의 경우 대용량 파일 처리 측면에서는 비효율적입니다. 따라서 단순 이미지 파일, 텍스트 파일과 같은 크기가 작은 파일을 업로드 시 용이합니다.

MultipartFile 업로드 방식의 경우 대용량 파일 처리 측면에서 효율적입니다. 하지만 디스크를 사용하기 때문에 서버에 부하가 발생할 수 있다는 단점이 존재합니다. 따라서 해당 문제를 해결한다면, 대용량 파일을 업로드 시 용이합니다.

PresignedURL 업로드 방식의 경우 서버 부하가 발생하지 않고, 대용량 파일 처리 측면에서 효율적이라는 장점이 존재합니다. 하지만 클라이언트의 네트워크 환경에 의존해야 하고, 비교적 많은 HTTP 요청이 발생한다는 단점이 존재합니다. 대용량 파일 업로드 시 용이합니다.


테스트

위 내용을 바탕으로, 실제 Spring 프로젝트를 구현하여 실제로 어떻게 동작하는지 테스트를 진행해보겠습니다.

테스트에서는 Stream 업로드 방식, MulitpartFile 방식을 비교하는 테스트를 하겠습니다.

PresignedURL의 경우, Client에서 직접 S3에 업로드하는 방식이기 때문에, 테스트에서 제외하였습니다.

기본 설정

프로젝트에 Dependency를 추가합니다.

dependencies {

		...
		
    // AWS
    implementation 'io.awspring.cloud:spring-cloud-aws-starter:3.1.1'

    // S3
    implementation 'software.amazon.awssdk:s3:2.31.0'
}

AWS S3와 연결하기 위하여 환경변수를 설정합니다.

cloud:
  aws:
    credentials:
      access-key: ${AWS_S3_ACCESS_KEY}
      secret-key: ${AWS_S3_SECRET_KEY}
    s3:
      bucket: ${AWS_S3_IMAGE_BUCKET}
    region:
      static: ${AWS_REGION}

AWS S3에 업로드하기 위하여 Config 파일을 생성하고, 업로드에 필요한 S3Client을 Bean으로 등록합니다.

@Configuration
public class S3Config {

    @Value("${cloud.aws.credentials.access-key}")
    private String accessKey;
    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;
    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    public S3Client s3Client() {
        AwsCredentials awsCredentials = AwsBasicCredentials.create(accessKey, secretKey);
        return S3Client.builder()
                .region(Region.of(region))
                .credentialsProvider(StaticCredentialsProvider.create(awsCredentials))
                .build();
    }
}

테스트 내용

테스트는 다음과 같이 진행됩니다.

  • 약 10MB 파일 업로드시 메모리 사용량 및 처리 속도를 비교합니다.
  • 약 200MB 파일 업로드시 메모리 사용량 및 처리 속도를 비교합니다.
  • 약1.5GB 파일 업로드시 메모리 사용량 및 처리 속도를 비교합니다.

처리 속도는 각 메서드의 처리 시간을 사용했습니다.

메모리 사용량은 Grafana와 Prometheus를 사용하여 확인해보겠습니다.

테스트 결과

테스트는 Postman을 통하여 API를 호출해보았습니다.

Stream 업로드 방식

Stream 업로드 방식을 구현한 예제 코드는 다음과 같습니다.

public Double uploadViaStream(HttpServletRequest request, String fileName) throws IOException {
        double startTime = System.currentTimeMillis();
        PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                .bucket(bucketName)
                .key(fileName)
                .contentType(request.getContentType())
                .contentLength(request.getContentLengthLong())
                .build();

        s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(request.getInputStream(), request.getInputStream().available()));
        double endTime = System.currentTimeMillis();
        return (endTime - startTime) / 1000;
    }

MulitpartFile 업로드 방식

MultipartFile 업로드 방식을 구현한 예제 코드는 다음과 같습니다.

메모리 사용량의 극적인 효과를 반영하기 위하여, fileByte를 받아와 메모리에 업로드한다고 가정하겠습니다.

public Double uploadViaMultipart(MultipartFile file) throws IOException {
        double startTime = System.currentTimeMillis();
        byte[] fileBytes = file.getBytes();

        PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                .bucket(bucketName)
                .key(file.getOriginalFilename())
                .contentType(file.getContentType())
                .contentLength(file.getSize())
                .build();

        s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(
                new ByteArrayInputStream(fileBytes), fileBytes.length));

        double endTime = System.currentTimeMillis();
        return (endTime - startTime) / 1000;
    }

테스트 결과

테스트는 Postman을 통하여 API를 호출해보았습니다.

처리속도

약 10MB 파일 업로드

Stream 업로드 방식

MultipartFile 업로드 방식

약 200MB 파일 업로드

Stream 업로드 방식

MultipartFile 업로드 방식

약 1.5GB 파일 업로드

Stream 업로드 방식

MultipartFile 업로드 방식

파일 업로드 결과 비교

단위(초)Stream 방식MultipartFile 방식
10MB1.3761.531
200MB38.11833.168
1.5GB200.981189.869

다음 결과와 같이, 10MB 정도의 파일의 경우 속도가 비슷하지만, 200MB, 1.5GB 파일 업로드의 경우 MultipartFile 방식이 비교적 빠른 모습을 확인할 수 있습니다.

메모리 사용량

메모리 사용은 1.5GB 파일을 업로드 하였을 때 Stream 방식 과 MultipartFile 방식의 사용량을 확인했습니다.

Stream 업로드 방식

MulitpartFile 업로드 방식

결과

다음과 같이, Stream 방식의 경우 메모리 사용량이 비교적 일정한 모습을 확인할 수 있습니다.

하지만 MultipartFile 방식의 경우, 메모리 사용량이 파일 크기만큼 올라간 모습을 확인할 수 있습니다.

추가 내용

MultipartFile 방식에서도 메모리 사용량을 비교적 일정하게 유지할 수 있는 방법이 있습니다.

public Double uploadViaMultipart(MultipartFile file) throws IOException {
        double startTime = System.currentTimeMillis();
        PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                .bucket(bucketName)
                .key(file.getOriginalFilename())
                .contentType(file.getContentType())
                .contentLength(file.getSize())
                .build();

        s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
        
        double endTime = System.currentTimeMillis();
        return (endTime - startTime) / 1000;
    }

위 코드와 같이 file을 getByte로 받아오지 않고, file.getInputStream() 을 사용한다면, 메모리 사용량이 Stream 방식과 동일하게 일정하게 유지가 가능합니다.

하지만 메모리를 사용하는 대신, 디스크에 임시 파일을 저장하고 이후 업로드가 성공하면 해당 임시파일을 삭제하게 됩니다.

따라서 디스크를 사용한다는 측면에서는 결국 서버에 부하를 준다는 점이 존재하게 됩니다.


결론

이번 포스팅에서는 Spring에서 S3에 파일을 업로드하는 세 가지 방법을 알아보았습니다.

Stream 방식의 경우, 저용량 파일을 업로드할 시 유리합니다. 하지만 대용량 파일을 업로드하는데에는 비효율적인 단점이 있습니다.

MultipartFile 방식의 경우, 대용량 파일을 업로드할 시 유리합니다. 하지만 서버에 부하를 준다는 단점이 있습니다.

PresignedURL 방식의 경우, 서버의 부하 없이 S3에 클라이언트가 파일을 직접 올릴 수 있다는 장점이 있습니다. 하지만 그에 따른 보안 문제와 고아 객체 관리를 해결해야 한다는 단점이 존재합니다.

각 프로젝트의 환경에 따라 3가지의 방식 중 가장 맞는 방식을 선택하는 것이 좋습니다. 만약 저용량 파일 업로드가 대부분인 프로젝트에서 MultipartFile 또는 PresignedURL을 사용할 경우 오버 엔지니어링일 수 있기 때문입니다.

따라서 현재 진행하고 있는 프로젝트에서 맞는 방식을 사용하여 오버 엔지니어링을 막고 효율적으로 S3에 파일을 업로드할 수 있도록 하는 것이 중요합니다.


예제 코드

https://github.com/JEONTAK/spring-docs
제 Github에 해당 구현을 위한 프로젝트를 구성해놓았으니, 참고하셔도 좋을 것 같습니다.
해당 Repository의 S3Upload 입니다.

profile
문제 해결을 좋아하는 개발자 입니다 :)

0개의 댓글