티스토리 뷰

개요

저번에도 언급했지만, 구글 드라이브와 같은 파일 저장소 서버를 계속 개발하고 있다.

 

올 4월 전시회 출품하는 단계까지는 개발을 완료했지만, 우려되는 부분이 많아서 고민들 중 하나를 정리해보려고한다.

 

가장 걱정되는 부분 중 하나가 업로드다.  현재 구현 방식에는 문제가 있을 수밖에 없다. 이전 글도 사실 파일 업로드를 어떻게하면 효과적일지에 대한 고민을 하면서 작성한 글이었다. MultipartFile에서 부터 찾아들어간 Multipart/form-data 전송 방식. 그리고 chunked 업로드

 

그리고 결론부터 말하면, 내가 고민한 것에 대한 정답은 이미 나와있었고 의외의 곳에서 힌트를 얻었다.

 

문제점

현재 서비스의 업로드의 가장 큰 문제는 서버가 파일 업로드/다운로드를 중개해준다는 것이다.

사용자           브라우저(JS)      Spring Boot API                S3
 |                  |                     |                       |
 |  [업로드 클릭]    |                     |                       |
 |----------------->|                     |                       |
 |                  | POST /api/files/upload (MultipartFile)      |
 |                  |-------------------->|                       |
 |                  |                     | S3에 파일 업로드 요청  |
 |                  |                     |---------------------->|
 |                  |                     |                       |
 |                  |                     |<----------------------| 200 OK
 |                  |                     |                       |
 |                  |<--------------------| 200 OK (업로드 성공)   |
 |                  |                     |                       |

이렇게 구성해두면 생기는 문제는 네트워크 환경이 좋지 않을 때,

 

업로드 요청이 과도하게 들어올 경우 서버 리소스(네트워크 커넥션, 스레드)가 한계에 도달했을 때 모든 사용자의 요청이 지연되는 현상이 발생할 것이다.

 

그렇다면 왜 이런식으로 구성해뒀을까? 방식이 이것밖에 없다고 생각했던 거였다. 그래서 개선 방식을 생각해봤다.

 

1. 업로드/다운로드 중개서버를 따로 구축

2. 클라이언트에서 S3로 바로 업로드하게 하기

 

고민의 여지가 없이 2번을 선택하게 됐다. 어차피 파일 업로드/다운로드 방식을 개선하면 API 파이프라인은 변하게 되는데, 1번은 비용적으로나 구성하는 방법론적으로나 고민할 여지가 많았다.

 

2번 방식은 S3 미리서명된 URL(Presigned URL)을 통해 구현 가능하다.

 

S3 Presigned URL 업로드 방식으로 개선하기

이전에 Presigned URL에 대해 다룬 글이 있었다(벌써 2년전이다)

AWS S3 파일 주기적으로 삭제하기 : SpringBoot에서 미리 서명된 URL(pre-signed URL) 써보기

 

이때는 다운로드만 가능한 줄 알았는데, 찾아보니 업로드도 가능하다. 이를 이용하면 파이프라인 아래와 같이 변경될 것이다.

사용자           브라우저(JS)          Spring Boot API           S3
 |                  |                     |                       |
 | [업로드 시작 클릭] |                     |                       |
 |----------------->|                     |                       |
 |                  | POST /api/files/presigned-url               |
 |                  |-------------------->|                       |
 |                  |                     | Presigned URL 생성    |
 |                  |                     |---------------------->|
 |                  |                     |                       |
 |                  |<--------------------| Presigned URL 반환    |
 |                  |                     |                       |
 |                  | PUT Presigned URL (파일 업로드)              |
 |                  |--------------------------------------------->|
 |                  |                     |                       |
 |                  |<---------------------------------------------| 200 OK
 |                  |                     |                       |
 |                  | POST /api/files/upload/success               |
 |                  |-------------------->|                       |
 |                  |                     | 메타데이터 저장        |
 |                  |                     |                       |
 |                  |<--------------------| 200 OK                 |
 |                  |                     |                       |

다만 파일에 대한 메타데이터(파일 이름, 파일 크기, 확장자) 등은 따로 보내는 조치가 필요하다.

 

성공했을 때 응답을 받고 메타데이터를 보내는 형식이 아닌, 업로드와 요청과 동시에 보내게끔도 할 수 있겠지만 이럴 경우 업로드가 실패했을 때, 메타데이터를 삭제하는 API를 별도로 받도록 처리해줘야한다.

 

이제 구현으로 가보자

S3 Presigned URL 업로드 구현

credential와 api key 관리는 따로 하고있다는 가정. S3 Presigned URL을 발급 받기 위해 별도의 클라이언트가 필요하다.

@Configuration
class S3PresignerConfig {

    @Bean
    fun s3PresignerClient(): S3Presigner {
        return S3Presigner.create()
    }
}

구현은 간단하다. AWS SDK v2의 S3Presigner를 이용해 지정 버킷/키/Content-Type으로 URL을 생성한다

fun generatePresignedPutUrl(bucket: String, key: String, contentType: String = "application/octet-stream"): URL {
    val putObjectRequest = PutObjectRequest.builder()
        .bucket(bucket)
        .key(key)
        .contentType(contentType)
        .build()

    val presignRequest = PutObjectPresignRequest.builder()
        .signatureDuration(Duration.ofMinutes(15))
        .putObjectRequest(putObjectRequest)
        .build()

    return s3Presigner.presignPutObject(presignRequest).url()
}

// 다운로드만 사용했으나 업로드 방식도 추가
fun generatePresignedGetUrl(bucket: String, key: String): URL {
    val getObjectRequest = GetObjectRequest.builder()
        .bucket(bucket)
        .key(key)
        .build()

    val presignRequest = GetObjectPresignRequest.builder()
        .signatureDuration(Duration.ofMinutes(15))
        .getObjectRequest(getObjectRequest)
        .build()

    val response = s3Presigner.presignGetObject(presignRequest)

    return response.url()
}

여기서는 15분의 제한시간을 걸었고, 이 요청들을 보내면 아래와 같은 url이 전달된다.

https://[bucket].s3.[region].amazonaws.com/***/-***.pptx?X-Amz-Security-Token=***&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=***&X-Amz-SignedHeaders=***&X-Amz-Credential=***&X-Amz-Expires=***&X-Amz-Signature=***

길고 못생기긴 했지만, 15분 동안 이 URL에 파일을 실어서 Put요청 보내면 파일이 업로드 된다.

 

주의할점은 Presigned URL을 발급 요청할 때 Content-Type과 업로드할 때가 서로 다를 경우 오류가 발생한다. 잘 확인하고 업로드를 진행하자. 

 

마무리

잘 생각해보니 게시판 서비스에서 파일 업로드를 구현할 때도, Presigned URL을 검토하긴 했었다.

 

다만 그때는 지금처럼 업로드 프로세스를 엎어버릴 FE 분들의 리소스가 없었던 거로 기억한다.

 

그리고 무슨 이유에선지 요청 한번에 모두 보낼 수 있는게 아니면 사용할 수 없지 않을까? 라는 생각을 했었다.

 

이 방식에 대한 실마리를 어떤 면접을 진행하면서 얻게 됐고 이번에 더 좋은 방식으로 개선하게 됐다.

 

이번 개선으로 업로드 경로의 병목을 제거하고, 서버 리소스를 절감할 수 있었다

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/10   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함