스프링부트/Code Tip

Presigned URL을 통한 S3 업로드

삼록이 2026. 2. 2. 15:23

보통, AWS S3에 최종적으로 파일을 저장시킬 때 사용자(클라이언트)->서버->S3 이 구조를 통해 업로드가 된다.

예를 들어, 채용플랫폼이 있고 구직자가 자신의 이력서와 함께 구직 신청을 하는 API가 있다고 해보자.

그러면 아래처럼 resume를 MultipartFile로 받아서 s3Client.putObject를 통해 S3에 업로드 되는 로직이다.

 

 public void createJobApplication(Long id, CreateReqDto dto, MultipartFile resume){
      
            String objectKey = UUID.randomUUID();
        
                PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                        .key(objectKey)
                        .bucket(bucket)
                        .contentType(resume.getContentType())
                        .acl(ObjectCannedACL.PRIVATE) /acl-Private!
                        .build();
                
                //s3 업로드!        
                s3Client.putObject(putObjectRequest, RequestBody.fromBytes(resume.getBytes()));
                }
    }

 

*이때 acl(Access Control List)는 이 객체는 원칙적으로 누구에게 열려있나를 설정하는 것이다.

이력서 같은 경우는 함부로 유출되거나 공개되어서는 안되는 파일이니 PRIVATE설정을 해주었다.

  • ObjectCannedACL.PRIVATE : 이 객체는 소유자(객체를 업로드한 AWS계정) 말고는 접근 불가
  • ObjectCannedACL.PUBLIC_READ : 이 객체는 URL만 알면 인터넷에 있는 누구나 접근 가능

PUBLIC_READ 같은 경우는  게시글 이미지와 같은 경우에 보통 설정된다.

*그런데 PRIVATE으로 설정함에도 URL을 그대로 브라우저에 입력하면 파일이 잘 조회되는 경우가 있다.

이때는 객체ACL보다 더 상위 설정인 S3 버킷 정책(Bucket Policy)에서 아예 공개인 경우가 있으니 확인이 필요하다.

 

무튼, 다시 돌아와서 이렇게 서버를 경유해서 S3에 업로드 시키는 구조는 사용자가 업로드하는 파일용량이 크면 클수록,

그리고 동시다발적으로 업로드하는 트래픽이 많을 수록 서버에 부하가 심해지는 문제를 가지고 있다.

왜냐하면 업로드가 끝날때 까지 서버는 클라이언트와 연결을 유지하여야하며, 트래픽,메모리,스레드 등 자원을 감당해야하기 때문이다.

그런데 어차피 최종적으로 S3에 업로드 될 건데 굳이 서버를 거쳐야할까?

서버를 거치지 않고 사용자가 S3에 곧장 업로드 시키면, 서버에 부하는 전혀 주지않으면서 목적을 달성 시킬 수 있다.

이때 사용할 수 있는 방법이 바로 PresingedURL을 통한 업로드 방식이다.

 


 

PresingedURL이란?

특정 S3 요청(GET,PUT 등)에 대해 일정 시간 동안만 유효한 서명을 URL 형태로 포함시켜 임시로 접근을 허용하도록 하는 URL이다.

즉, 이 URL을 받은 클라이언트는 AWS 자격 증명 없이도, 정해진 시간 동안, 정해진 동작(조회,업로드 등)에 한해서만 S3 접근할 수 있게 된다.

 

따라서 우리는 사용자가 구직 신청 할 때, 서버에서 사용자에게 S3에 3분안에 업로드할 수 있는 URL을 주고, 그 URL을 통해 사용자가 본인의 resume를 S3에 바로 업로드 할 수 있도록 해야한다.

이걸 전체적인 흐름으로 보면,

  • 사용자가 지원하기 버튼을 클릭하면
  • 서버에 presigned URL을 요청하는 API를 호출 시켜 url값을 리턴받는다.
  • 그리고 사용자가 필요한 정보값(이름,경력,나이 등)과 이력서 파일을 첨부하여 최종적으로 지원 완료 버튼을 누르면
  • 해당 url에 먼저 이력서 파일을 업로드 시키고, 지원완료 API를 순차적으로 처리한다.

 

백엔드 - Presigned URL 발급

그러면 우리가 먼저 만들어야할게 사용자에게 Presigned URL을 발급하도록 하는 API다.

@GetMapping("/job-post/{id}/resume/presigned-url")
public PresignedUrlResponse createResumeUploadUrl(Long id) {

    String objectKey = "jobResume/" + UUID.randomUUID();

     //S3 업로드 요청 스펙
    PutObjectRequest putObjectRequest = PutObjectRequest.builder()
            .bucket(bucket)
            .key(objectKey)
            .acl(ObjectCannedACL.PRIVATE)
            .build();
	//요청에 대한 서명
    PresignedPutObjectRequest presignedRequest =
            s3Presigner.presignPutObject(r -> r
                    .signatureDuration(Duration.ofMinutes(5))
                    .putObjectRequest(putObjectRequest)
            );

    return new PresignedUrlResponse(
            presignedRequest.url().toString(),
            objectKey
    );
}

 PutObjectRequest :  S3에 객체를 업로드할 때 어떤 조건으로 업로드 할 것인지 요청을 담는 클래스

s3Presigner :  그 요청에 대해서 서명해주는 클래스.

s3Presigner.presignPutObject()  : PUT Object 요청(=S3 업로드 요청)만 허용하는 Presigned URL이 생성된다.

.signatureDuration(Duration.ofMinutes(5)): 5분 후 만료

 

즉. 어떤 S3 bucket에 어떤 key로 그리고 객체에 대해서는 PRIVATE설정으로 업로드 요청하겠다 라고 명시하고,

해당 요청에 대해 5분의 유효 시간이 있는 서명을 한 것이다.

그렇게 만들어진 url(presignedRequest)과 objectKey를 프론트에 리턴 시켜준다.

 

* 여기서 말하는 '서명'이란?

AWS 계정이 가진 secret key로 요청조건(bucket,key,method,만료시간 등)을 위조할 수 없도록 계산한 값이다.

이 URL에 이 서명이 담겨있기 때문에 사용자가 해당 URL로 업로드하면 S3는 이 서명을 검증함으로써 이 요청이 위조되지 않은지를 판단하는 것이다.

 

프론트

그러면 프론트는 이 url과 objectKey를 가지고 있다가

사용자가 올리는 이력서 파일을 받아 곧장 해당 url로 요청을 보내면 된다.

import axios from "axios";

await axios.put(uploadUrl, file, {
  headers: {
    "Content-Type": file.type
  }
});

 

S3는 업로드가 성공하면 200 OK를 반환하므로, 프론트엔드는 이 응답을 기준으로 업로드 성공여부를 판단할 수 있다.

 

그리고 이름이나,경력, 등 추가적인 정보값을 입력하여 서버에 지원완료 API를 호출할때 그 때 objectKey를 같이 보낸다.

서버는 이 objectKey를 DB에 저장시켜 

나중에 이 파일을 다운로드 해야할 때 DB에서 objectKey를 통해 aws에서 파일을 찾아와 리턴시켜야하기 때문이다.

 

+파일 검증

그런데 서버를 경유하지 않고 곧장 S3에 업로드가 되니 이 파일이 정말 안전하고 유효한 파일인지에 대한 검증작업이 언제 이루어져야하는지 고민이 될 것이다.

이때는 마지막에 사용자가 호출한 지원완료 API 로직 속에서 해당 파일에 대해 MIME 타입 등을 검증해야한다.

그러면 다시 s3에서 파일을 서버로 가지고 와 검증을 하면 애시당초 처음에 파일을 업로드 할 때 서버를 건너띄는 게 의미가 없지않냐는 의문이 들 수 있다.

그럼에도 불구하고 업로드가 끝나고 나서 사후에 서버에서 검증하는 것은 업로드 중 내내 서버와 클라이언트가 연결이 유지되는 것보다 짧은 시간만이 걸리고 전체 파일이 아니라 앞부분 몇 바이트만 가지고 와서 MIME 타입,magic number 등을 검증할 수 있기에 부담이 훨씬 덜하다.

 

혹은 S3에 올라간 업로드한 파일 검증을 서버에서 수행하지 않고 AWS Lambda로도 처리할 수 있는데 이 방식을 취하는 것도 좋은 방법이다.

 

*magic number :  magic number는 파일의 진짜 정체를 알려주는 시작 바이트 패턴이다.

파일 확장자(.pdf, .jpg) 이런 것들은 충분히 속이기 쉽다. 헤더의 Content-type 도 쉽게 조작할 수 있다.

그런데 magic number까지는 조작하기가 어려우므로 이 magic number라는 것을 검증하면 pdf나 이미지 파일을 가장한 다른 실행파일이 아닌지 검증이 될 수 있는 것이다

 

 


 

이처럼 Presigned URL을 통한 업로드 방식은 서버의 트래픽과 자원 부담을 줄일 수 있다는 장점을 가지면서도

서명으로 인해 S3 접근에 대한 통제는 유지할 수 있는 구조다.

특히 용량이 크고 민감한 파일을 다루는 경우 서버를 경유하는 업로드 방식보다 이 Presigned URL방식을 사용하면 훨씬 안정적이고 확장성을 높일 수 있다.