본문 바로가기
Java & Kotlin/Spring

S3 파일업로드, 어떻게 하고 계세요?

by heekng 2025. 1. 23.
반응형

안녕하세요.
지난 2년이 넘는 기간동안 이미지, PDF 리소스를 중심으로 서비스를 제공하며 겪었던 문제를 AWS에서 제공하는 pre signed url로 해결한 경험을 공유하고자 합니다.

기술 스택

  • SpringBoot
  • S3
  • IDC, AWS EC2, AWS ECS문제먼저 기존의 파일 업로드 과정은 다음과 같습니다.

백엔드 서버에서 이미지를 업로드하는 경우

 

이러한 형태의 파일 업로드는 가장 일반적으로 사용하는 파일 업로드 과정이며 MultipartFile 등을 이용해 백엔드 서버에서 S3 스토리지로 직접 업로드를 수행합니다.

저희 서비스의 경우 이미지를 주로 다루며 고해상도 이미지 사용과 PDF 업로드를 제공하기 위해 파일 업로드 용량 제한을 크게 설정해 두었습니다.

그리고 다음과 같은 장애가 간헐적으로 발생했습니다.

  1. 대용량 파일을 연속적으로 업로드하는 경우 getByte() 사용으로 인한 서버의 메모리 부족 문제 발생
  2. 파일 업로드 도중 기타 장애 발생시 톰캣 서버에서 처리하는 임시 디렉토리에 파일이 남아있게 되는 문제
  3. 병목현상 발생 가능성
  4. 대용량 파일 업로드 시 해당 요청이 스레드를 너무 오래 붙잡고 있음(timeout 위험성)

현재 파일 업로드 과정에서 장애 발생시 서버 재실행을 자동으로 처리하거나, 작업자가 직접 처리하는 방식으로 순간의 문제를 해결해 나갔습니다.

추가로 어떻게 하면 어플리케이션의 외부 시스템 의존성을 줄일 수 있을지도 고민하고 있었습니다.

multipart로 해결되는 것 아닌가?

먼저 현재 백엔드 서버는 tomcat으로 구동되며 temp 디렉토리를 사용해 multipartFile로 파일을 업로드하고 있습니다.
이러한 경우 대규모 동시 업로드 처리 시 디스크 I/O 병목이 발생할 수 있고 예외 발생시 임시 파일이 임시폴더에 남는 문제가 발생할 수 있습니다.
또한, 단순히 multipartFile.getInputStream() 으로 스트림 업로드를 진행한다면 문제되지 않지만 기존의 특정 로직에서 multipartFile.getBytes()를 호출하는 경우 파일을 메모리에 로드해 메모리 부족 문제가 발생할 가능성이 있습니다.

마지막으로 현재 백엔드 팀에서는 어플리케이션 레이어의 프레임워크 의존성을 최소화하기 위해 multipartFile이 서비스 로직에 노출되는 것을 피할 방법을 찾게 되었습니다.

S3 파일 업로드의 책임을 서버에서 S3로 넘기자

AWS S3는 파일을 업로드(PUT)하거나 다운로드(GET)할 수 있도록 pre-signed URL(임시 서명 URL)을 제공하고 있습니다.

 


파일 업로드 시 클라이언트는 백엔드 서버에 PUT pre-signed URL을 요청하고 백엔드 서버는 S3에 pre-signed URL을 발급받고 클라이언트에 응답합니다.
클라이언트는 발급받은 pre-signed URL에 파일을 담은 PUT 요청을 전송합니다.
S3로의 파일 업로드가 성공했다면 클라이언트는 해당 파일 업로드를 완료했다는 것을 알리기 위해 백엔드 서버의 API 를 호출합니다.
위와 같은 과정으로 서버는 pre-signed URL 발급에 대한 역할만 담당하고, 파일을 직접적으로 다루지 않게 됩니다.

예시 코드

dependencies {
    ...
    implementation(platform("software.amazon.awssdk:bom:2.29.39"))
    implementation("software.amazon.awssdk:s3")

    implementation("org.testcontainers:localstack")
    ...
}
@Slf4j
@Service
@RequiredArgsConstructor
public class S3FilePresignedService {

    private final S3Presigner s3Presigner;

    public String getPreSignedUrl(String bucketName, String key, Map<String, String> metadata) {
        PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                .bucket(bucketName)
                .key(key)
                .metadata(metadata)
                .build();

        PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
                .signatureDuration(Duration.ofMinutes(10))
                .putObjectRequest(putObjectRequest)
                .build();

        PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest);
        log.info("presigned URL: {}", presignedRequest.url());
        log.info("Http Method: {}", presignedRequest.httpRequest().method());

        return presignedRequest.url().toExternalForm();
    }
}

PutObjectPresignRequestsignatureDuration를 이용해 서명된 URL을 사용할 수 있는 유효 시간을 설정할 수 있으며
PutObjectRequestmetadata를 이용해 오브젝트의 메타데이터를 사전 정의할 수 있습니다.
pre-signed url를 생성할 때 입력한 메타데이터 값은 클라이언트가 업로드를 시도할 때, 동일한 키값 쌍의 헤더를 입력해야 업로드를 수행할 수 있습니다.

이러한 방식의 파일 업로드로 1. 대용량 파일 업로드에 대한 서버 부하를 AWS로 넘겨 백엔드 서버의 부담이 사라지고 2. 서버 부하를 분산하기 위해 스케일아웃 또는 스케일업 한 서버 비용을 감소할 수 있게 됩니다.

여러 사례에 대한 해결방법

버킷이 private 인데 권한문제는 없나요?
이미 백엔드 서버에서 인증된 IAM 으로 pre-signed url을 발급받습니다.
발급받은 pre-signed url에 S3가 식별할 수 있는 인증정보가 담겨있기 때문에 권한문제가 발생하지 않습니다.

업로드 성공 API 호출에 실패해 식별 불가능한 파일이 생기면 어떡하나요?
클라이언트에서 S3로 업로드를 성공한 후 업로드 성공 API를 호출할 때 백엔드 서버 장애 발생시 파일 정보에 대한 후처리가 불가할 수 있습니다.
클라이언트가 업로드 완료 API 재시도처리를 해도 좋지만, 해당 파일 업로드를 아예 실패한 경우로 처리하기 위해 S3 라이프사이클 정책을 활용하여 유효한 파일만 남겨놓도록 처리할 수 있습니다.

{
  "Rules": [
    {
      "ID": "ExpireAfter5Days",
      "Status": "Enabled",
      "Filter": {
        "Tag": {
          "Key": "expiry",
          "Value": "5days"
        }
      },
      "Expiration": {
        "Days": 5
      }
    }
  ]
}

위 정책은 S3 버킷에 적용할 수 있으며 expiry=5days 라는 태그가 포함된 파일의 경우 5일 후 삭제되도록 설정합니다.

PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                .bucket(bucketName)
                .key(key)
                .metadata(metadata)
                .tagging(Tagging.builder().tagSet(
                        Tag.builder().key("expiry").value("5days").build()
                ).build())
                .build();

위와 같이 pre-signed url 발급시 태그를 지정해 두고
PUT 요청의 헤더에 Key: x-amz-tagging Value: expiry=5days 형태로 태그를 포함해 파일을 업로드할 수 있습니다.

이후 업로드 완료 처리 API 로직 내에 expiry 태그를 삭제하는 로직을 포함해 정상 업로드된 파일은 삭제되지 않게 처리할 수 있습니다.

 

업로드 하고자 하는 파일이 아닌 다른 파일을 악의적으로 업로드하게 되면 어떡하나요?
한번의 요청으로 업로드하던 파일을 3단계로 나누어 업로드하며 pre-signed url 발급시 업로드하려 한 파일과 다르게 다른 파일을 업로드하는 경우가 생길 수 있습니다.
예를 들어 signatureDuration을 10분으로 설정하고 업로드가 완료되었을 때 시간 내에 다른 파일로 업로드를 시도시 정상 업로드가 수행됩니다.
이러한 문제를 해결하기 위해 pre-signed url 발급 요청과 함께 contentType, contentLength 과 같이 파일에 대한 정보를 함께 등록하고, 동일한 정보를 가진 파일만 업로드하도록 제약을 걸 수 있습니다.
또한 메타데이터를 지정하고 클라이언트가 동일한 메타데이터를 전송하도록 하는 방법도 의도하지 않은 업로드를 제어할 수 있는 방법입니다.

마치며

이미지와 PDF 파일을 자주 다루는 도메인을 개발하면서 메인 비즈니스 외에도 어드민 시스템 등에서 엑셀 파일 등 파일 업로드는 필수 요소가 되었습니다.
사내에서 비용 관련한 이야기가 계속되고, 서버개발자로서 항상 서버의 스펙을 고민하게 되는데 이를 통해서 조금이나마 비용을 줄임과 동시에 기능의 역할과 책임을 분리하는 효과까지 가져갈 수 있으면 좋겠습니다.
읽어주셔서 감사합니다.

반응형