Presigned Url을 사용한 Multipart Upload with Node.js

Multipart upload with aws-sdk v3 s3 client in Node.js
Oct 27, 2023
Presigned Url을 사용한 Multipart Upload with Node.js

 
개발자가 이미지나 동영상 등 정적 객체를 다루는 일은 언제나 피곤한 일이다.
일반적으로 정적 객체를 사용하는 사이트라고 하면, S3에 해당 정적 객체를 업로드 하는 방법은 2가지다.
 
  1. 서버를 경유해 Upload
    1.  
      S3 같은 클라우드 저장소를 사용하다보면, aws의 accesskey나 secret key 등을 사용하게 되는데 이런 환경변수들을 React에서 관리하다보면 보안 취약점으로 외부에 노출되어 악의를 가진 익명의 사용자로부터 과금 폭탄을 맞을 수 있다. 자바스크립트 소스를 다운 받거나 다른 접근 방법이 많기 때문에 지양해야 한다.
       
      때문에 Client에서 직접 iam key를 사용하여 정적 객체를 업로드하는 것을 권장하지 않고 서버를 경유해 업로드 할 수 있다. Node.js에는 이런 서버를 거친 업로드를 Multer라는 라이브러리를 사용하여 업로드하게 된다.
       
      Pros
      • Multer를 사용하면 정적 객체에 대한 업로드가 매우 간단해진다는 장점이 있다. 서버와 클라이언트간 동작을 고민할 필요도 없고, 단순하게 formData로 보내면 서버에서 받아서 업로드하는 과정이라 방식 중에는 가장 간단하다.
      • 유연하게 사용할 수 있다. 서버에서 파일 원본을 제외한 업로드 방식을 모두 통제하고 있기 때문에 좀 더 자유롭게 사용 가능하다.
      Cons
      • 유저의 브라우저 즉, 유저의 컴퓨터의 성능을 사용해 업로드 하는 방식이 아니기 때문에 서버의 리소스를 사용하게 됩니다. 정적 객체 특히 동영상의 경우, 서버 자원을 많이 사용하게 됩니다. 만약 수 많은 요청이 동시 다발적으로 들어온다면 서버가 죽음으로 갈수 도 있다.
      • 만약 서버에서 동영상을 저장하고 관리하면서 병합 수정 등을 가해야 한다면, 악성 파일에 의한 서버 보안 취약점을 가질 수 있다.
       
  1. Client에서 바로 Upload
    1.  
      한편 서버를 거치지 않고 클라이언트에서 바로 S3로 이미지나 동영상 등을 업로드하는 방법이 있는데, 바로 Presigned Url이다.
       
      Presigned Url은 S3에 업로드할 수 있는 권한을 클라이언트에서 관리하는 것이 아닌, 서버에서 직접 S3로 이미지를 업로드 할 수 있는 권한을 인가받아 그 권한을 내려주는 방식이다.
       
      서버에서 어떤 HTTP 동작을 할지부터 만료기간까지 설정한 옵션을 AWS로 보내 AWS로부터 인가받은 그 주소값인 미리 서명된 Url(Presigned Url)을 통해, 클라이언트에서 AWS 보안 자격 증명이나, 권한이 없어도 업로드할 수 있다.
       
      Pros
      • Multer를 사용한 서버 경유 방식과 달리, 서버는 단순하게 보안 자격 증명이 허용된 미리 서명된 Url만을 클라이언트로 보내주고 서버에서 업로드 프로세스를 거치지 않기 때문에 보안적으로 더 우수하다.
      • 서버를 거쳐서 업로드 하는 것이 아니라, 클라이언트 즉 사용자의 자원을 사용하기 때문에 더 경제적이다.
      • 시간 제한을 통해 일시적으로만 유저가 접근하는 방식으로 사용자의 제한을 둘 수 있다.
      Cons
      • 복잡성이 올라갑니다.
      • 5GB까지밖에 올릴 수 없다.
      • 서명된 Url은 최대 7일 까지만 유효하다.
       
      사실 Multer를 사용하는 방식보다 좀 더 복잡성이 올라간다는 측면 빼고는 서버를 거치지 않고 S3로 바로 업로드할 수 있다는 압도적인 장점이 있는 방식이다.
       
      하지만, 이 방식에도 치명적인 단점이 있는데 바로 최대 5GB까지 밖에 못 올린다는 것이다.
      최대 7일의 제한은 일반적인 사용성에 있어서 큰 영향을 끼치지 않지만 한달동안만 유저에게 제공하고 그 이후로는 보이지 않는, 예를들면 이메일 템플릿을 전송하면서 같이 사진까지 보여줘야 하는 경우라면 Cloud Front의 signer를 이용하면 해결할 수 있지만 최대 5GB는 Presigned Url선에서는 방법이 없다.
       
      바로 이런 최대 5GB이상의 동영상을 업로드 해야하는 순간이 온다면 사용할 수 있는게 바로
      Multipart Upload이다.
 

Multipart Upload

💡
Web 브라우저에서 사용할 수 있는 방식입니다. Mobile에서는 해당 방식의 수정이 필요합니다.
 
https://medium.com/analytics-vidhya/aws-s3-multipart-upload-download-using-boto3-python-sdk-2dedb0945f11
Multipart Upload는 큰 파일을 여러 부분으로 나누어 병렬 업로드하고, 업로드 후 조립하여 하나의 완전판 파일로 만드는 AWS의 기술이다. 기술의 장단점은 아래와 같다.
 
  • Pros
    • 대용량 파일 처리
      • ⇒ 기존 업로드 방식의 최대 업로드 가능 용량이 5GB까지 밖에 안되는 것에비해 커다란 파일을 작은 단위로 병렬 업로드하기 때문에, Multipart Upload는 기존 방식보다 더 큰 동영상을 업로드할 수 있다.
    • 네트워크 재해 복구
      • ⇒ 파일 전송 중에 네트워크 문제 또는 다른 장애가 발생하면, 각 부분 별도 업로드하고 실패한 부분 재전송함으로써 전체 업로드를 안정적으로 복구 할 수 있다.
    • 다중 스레딩 및 병렬 업로드
      • ⇒ 병렬적으로 업로드하기 때문에 업로드 속도가 기존보다 더 향상된 속도를 보장한다.
    • 진행상태 모니터링
      • ⇒ 개별 업로드되기 때문에, 업로드 진행 상태를 모니터링하고 중단된 경우 재개할 수 있도록 기능을 지원한다.
  • Cons
    • 관리 비용 증대
      • ⇒ 기술을 효율적으로 사용하기 위해 관리 비용이 더 증가할 수 있습니다.
    • 작은 파일에 부적합
      • ⇒ 작은 파일에는 적합하지 않습니다.
    • 객체 복원에 어려움
      • ⇒ 위에서 설명한 장점인 재해복구를 잘 구현하기 위함이 어렵습니다.
 
일반적으로 기존 방법보다, 대용량 객체를 처리하는데 명확한 장점이 있고 이를 잘 사용하기 어렵다는 단점이 있다. 단순하게 사용하기 어렵다는 단점을 제외하고는 장점이 더 많다.
 
여러 사이트들을 찾아봤지만, Multipart Upload를 Presigned Url로 수행하는 aws-sdk v3 솔루션이 없어서 직접 시행착오 끝에 다음과 같은 방법을 생각했다.
 
 
notion image
  • 먼저 클라이언트에서 서로 동영상을 몇 분할로 자를지(Part)와 동영상의 Prefix(Key)를 지정하여 Request를 전달
  • 서버는 클라이언트로부터 전달 받은 Part와 Key를 통해 Part 수만큼의 PresignedUrl을 발급
    • UploadPartCommand를 getSignedUrl메서드의 Command로 지정
    • Upload시 Body를 비운채로 URL 생성
    • async getMultipartPresignedUrl(key: any, part: any) { try { const { bucket, awsAccessKeyId, awsSecretAccessKey, awsRegion } = this.env; const client = new S3Client({}); // 1. S3Client 생성 const promises = []; const result = await Promise.all(key.map(async (keyList: { id: number, key: string, fileSize: any }, i: number) => { const multipartUpload = await client.send( new CreateMultipartUploadCommand({ Bucket: bucket, Key: keyList.key })); // 2. 개별 업로드하려는 영상마다 CreateMultipartUploadCommand로 UploadId를 생성합니다. const { UploadId } = multipartUpload; for (let i = 0; i < part; i++) { // 3. 분할하려는 크기(part)만큼 UploadPartCommand를 사용해 PresignedUrl을 분할합니다. // 이때에, UploadPartCommand의 인자인 Body를 비워서 생성합니다. const command = new UploadPartCommand({ Bucket: bucket, Key: keyList.key, UploadId, PartNumber: i + 1 }) promises.push( await getSignedUrl(client, command, { expiresIn: 3600 * 12 }) ); } return { UploadId, uploadPromises: await Promise.all(promises) } // 4. FE로 UploadId와 PresignedUrl이 배열형태로 들어가있는 // uploadPromises를 Response로 전달합니다. })); return result; } catch (e) { console.log(e); throw e; } };
 
 
notion image
  • FE에서는 PresignedUrl로 업로드하려는 동영상을 Part 크기만큼 분할하여 업로드 번호를 지정하고 순서대로 S3로 업로드를 진행
    • // 1. 서버로 동영상 업로드 요청 const response = await axios.post(`/upload`, dto); const { get } = response.data; // 2. ETag를 담을 etagList 생성 const etagList: Array<{ ETag: string; PartNumber: number; }> = []; // 3. 비동기 문제를 해결하기 위해, 코드를 함수 단위로 분리 const uploads = async () => { // 3-1. 업로드하려는 파일 지정하고, 자를 부분별 사이즈를 정함 const file = uploadList[0].file as string; const totalSize = Math.ceil(+uploadList[0].fileSize); const partSize = Math.ceil(totalSize / 20); // 3-2. 부분별 사이즈만큼 파일의 길이를 자르고 s3에 업로드한 후 // 3-3. upload 후 나오는 헤더의 Etag를 받아, etagList에 push await Promise.all( get.map(async (v) => { await Promise.all( v.uploadPromises.map(async (v2, idx) => { const start = idx * partSize; const end = start + partSize; const size = file.slice(start, end); const upload = await uploadS3(v2, size); etagList.push({ ETag: upload, PartNumber: idx + 1, }); }) ); }) ); }; // 4. 흐름 제어를 위해 분리된 함수 uploads가 실행되고 나서, // axios 요청으로 BE로 complete command 요청을 전달 // 이때, uploadId와 etagList 그리고 key를 같이 보내 데이터의 정합성이 일치하는지 확인 await uploads(); await customAxios.post(`/complete`, { uploadId: get[0].UploadId, etagList, key: image[0].key, });
  • 업로드가 모두 끝난후, Multipart Upload의 성공 Response Header의 ETag를 받고, 각 업로드의 번호를 BE로 전달
 
 
notion image
  • 최종적으로 BE에서 S3로 모든 동영상의 병합을 요청하는 Complete Command를 보내면 업로드가 완료
    • async completeMultipartUploadCommand(uploadResults: { uploadId: string; key: string; etagList : Array<{ ETag: string, PartNumber: number; key: string | File | ArrayBuffer | null | undefined }> }) { try { const { bucket, awsAccessKeyId, awsSecretAccessKey, awsRegion } = this.env; const client = new S3Client({}); // 1. S3Client 생성 const send = await client.send( new CompleteMultipartUploadCommand({ // 2. 최종 업로드는 CompleteMultipartUploadCommand로 진행합니다. // UploadPartCommand가 개별적으로 성공했다고 해서 업로드가 되는 것이 아닌, // 최종적으로 Complete 요청을 보내야만 병합이되고 S3에 객체가 생성됩니다. Bucket: bucket, Key: uploadResults.key, UploadId: uploadResults.uploadId, MultipartUpload: { // 3. CompleteMultipartUploadCommand의 Property에는 개별 병렬적으로 업로드한, // MultiPart Upload의 PartNumber(동영상 번호)와 업로드 성공 후 전달받는 ETag를 // 전달받는 값인 MultipartUpload라는 값이 존재합니다. Parts: uploadResults.etagList.sort((a,b) => { return a.PartNumber - b.PartNumber }).map(( v, i) => ({ ETag : v.ETag, PartNumber: v.PartNumber, })), }, }) ); // 4. 요청이 성공적으로 완료되면 S3에 객체가 생성됩니다. } catch (e) { console.log(e); throw e; } }
 
위와 같은 흐름으로 S3에 업로드를 진행한 결과 성공적으로 S3에 Part가 분할되어 업로드 됐다.
 
하지만 위 과정에서 몇 가지 에러를 겪었는데 하나씩 소개하면,
 
  1. EntityTooSmall
    1.  
      제안된 업로드가 허용되는 최소 개체 크기보다 작습니다. 마지막 부분을 제외하고 각 부분의 크기는 5MB 이상이어야 합니다.
      ⇒ 각 파트의 크기가 5MB 미만이여서 발생했다.
 
  1. InvalidPart
    1.  
      지정된 부품 중 하나 이상을 찾을 수 없습니다. 부품이 업로드되지 않았거나 지정된 엔터티 태그가 부품의 엔터티 태그와 일치하지 않았을 수 있습니다.
      ⇒ 지정된 Part의 일부분이 누락되어 발생했다.
 
  1. InvalidPartOrder
    1.  
      부품 목록이 오름차순이 아닙니다. 부품 목록은 부품 번호 순서대로 지정되어야 합니다.
      ⇒ Complete 요청시, 오름차순으로 PartNumber를 정렬되지 않아서 발생했다.
 
  1. NoSuchUpload
    1.  
      지정된 멀티파트 업로드가 존재하지 않습니다. 업로드 ID가 잘못되었거나 멀티파트 업로드가 중단 또는 완료되었을 수 있습니다.
      ⇒ 지정된 Multipart UploadId가 아닐 때 발생했다.
 
위와 같은 에러를 겪고 일부 수정하면서 최종적으로 Web에서는 정상적으로 업로드 된걸 확인 했으나, 한 가지 문제가 발생했는데 안드로이드/IOS app에서 업로드할 때는 Web과 달리 동작하지 않았다는 것이다.
 
동시 다발적인 요청발송을 핸드폰의 메모리가 버티지 못하여 터진 케이스도 있고, 비트레이트 문제로 Web에서 파트를 잘라 보냈던 것 처럼 동작하지도 않았다.
 
app 관련해서 결론적으로 해결하긴 했으나, 브라우저와 핸드폰의 동작에 관하여 고민하게 된 시간이었다.
Share article

vlogue