Presigned Url을 사용한 Multipart Upload with Node.js
Multipart upload with aws-sdk v3 s3 client in Node.js
Oct 27, 2023
개발자가 이미지나 동영상 등 정적 객체를 다루는 일은 언제나 피곤한 일이다.
일반적으로 정적 객체를 사용하는 사이트라고 하면, S3에 해당 정적 객체를 업로드 하는 방법은 2가지다.
- 서버를 경유해 Upload
- Multer를 사용하면 정적 객체에 대한 업로드가 매우 간단해진다는 장점이 있다. 서버와 클라이언트간 동작을 고민할 필요도 없고, 단순하게 formData로 보내면 서버에서 받아서 업로드하는 과정이라 방식 중에는 가장 간단하다.
- 유연하게 사용할 수 있다. 서버에서 파일 원본을 제외한 업로드 방식을 모두 통제하고 있기 때문에 좀 더 자유롭게 사용 가능하다.
- 유저의 브라우저 즉, 유저의 컴퓨터의 성능을 사용해 업로드 하는 방식이 아니기 때문에 서버의 리소스를 사용하게 됩니다. 정적 객체 특히 동영상의 경우, 서버 자원을 많이 사용하게 됩니다. 만약 수 많은 요청이 동시 다발적으로 들어온다면 서버가 죽음으로 갈수 도 있다.
- 만약 서버에서 동영상을 저장하고 관리하면서 병합 수정 등을 가해야 한다면, 악성 파일에 의한 서버 보안 취약점을 가질 수 있다.
S3 같은 클라우드 저장소를 사용하다보면, aws의 accesskey나 secret key 등을 사용하게 되는데 이런 환경변수들을 React에서 관리하다보면 보안 취약점으로 외부에 노출되어 악의를 가진 익명의 사용자로부터 과금 폭탄을 맞을 수 있다. 자바스크립트 소스를 다운 받거나 다른 접근 방법이 많기 때문에 지양해야 한다.
때문에 Client에서 직접 iam key를 사용하여 정적 객체를 업로드하는 것을 권장하지 않고 서버를 경유해 업로드 할 수 있다. Node.js에는 이런 서버를 거친 업로드를 Multer라는 라이브러리를 사용하여 업로드하게 된다.
Pros
Cons
- Client에서 바로 Upload
- Multer를 사용한 서버 경유 방식과 달리, 서버는 단순하게 보안 자격 증명이 허용된 미리 서명된 Url만을 클라이언트로 보내주고 서버에서 업로드 프로세스를 거치지 않기 때문에 보안적으로 더 우수하다.
- 서버를 거쳐서 업로드 하는 것이 아니라, 클라이언트 즉 사용자의 자원을 사용하기 때문에 더 경제적이다.
- 시간 제한을 통해 일시적으로만 유저가 접근하는 방식으로 사용자의 제한을 둘 수 있다.
- 복잡성이 올라갑니다.
- 5GB까지밖에 올릴 수 없다.
- 서명된 Url은 최대 7일 까지만 유효하다.
한편 서버를 거치지 않고 클라이언트에서 바로 S3로 이미지나 동영상 등을 업로드하는 방법이 있는데, 바로 Presigned Url이다.
Presigned Url은 S3에 업로드할 수 있는 권한을 클라이언트에서 관리하는 것이 아닌, 서버에서 직접 S3로 이미지를 업로드 할 수 있는 권한을 인가받아 그 권한을 내려주는 방식이다.
서버에서 어떤 HTTP 동작을 할지부터 만료기간까지 설정한 옵션을 AWS로 보내 AWS로부터 인가받은 그 주소값인 미리 서명된 Url(Presigned Url)을 통해, 클라이언트에서 AWS 보안 자격 증명이나, 권한이 없어도 업로드할 수 있다.
Pros
Cons
사실 Multer를 사용하는 방식보다 좀 더 복잡성이 올라간다는 측면 빼고는 서버를 거치지 않고 S3로 바로 업로드할 수 있다는 압도적인 장점이 있는 방식이다.
하지만, 이 방식에도 치명적인 단점이 있는데 바로 최대 5GB까지 밖에 못 올린다는 것이다.
최대 7일의 제한은 일반적인 사용성에 있어서 큰 영향을 끼치지 않지만 한달동안만 유저에게 제공하고 그 이후로는 보이지 않는, 예를들면 이메일 템플릿을 전송하면서 같이 사진까지 보여줘야 하는 경우라면 Cloud Front의 signer를 이용하면 해결할 수 있지만 최대 5GB는 Presigned Url선에서는 방법이 없다.
바로 이런 최대 5GB이상의 동영상을 업로드 해야하는 순간이 온다면 사용할 수 있는게 바로
Multipart Upload이다.
Multipart Upload
Web 브라우저에서 사용할 수 있는 방식입니다. Mobile에서는 해당 방식의 수정이 필요합니다.
Multipart Upload는 큰 파일을 여러 부분으로 나누어 병렬 업로드하고, 업로드 후 조립하여 하나의 완전판 파일로 만드는 AWS의 기술이다. 기술의 장단점은 아래와 같다.
- Pros
- 대용량 파일 처리
- 네트워크 재해 복구
- 다중 스레딩 및 병렬 업로드
- 진행상태 모니터링
⇒ 기존 업로드 방식의 최대 업로드 가능 용량이 5GB까지 밖에 안되는 것에비해 커다란 파일을 작은 단위로 병렬 업로드하기 때문에, Multipart Upload는 기존 방식보다 더 큰 동영상을 업로드할 수 있다.
⇒ 파일 전송 중에 네트워크 문제 또는 다른 장애가 발생하면, 각 부분 별도 업로드하고 실패한 부분 재전송함으로써 전체 업로드를 안정적으로 복구 할 수 있다.
⇒ 병렬적으로 업로드하기 때문에 업로드 속도가 기존보다 더 향상된 속도를 보장한다.
⇒ 개별 업로드되기 때문에, 업로드 진행 상태를 모니터링하고 중단된 경우 재개할 수 있도록 기능을 지원한다.
- Cons
- 관리 비용 증대
- 작은 파일에 부적합
- 객체 복원에 어려움
⇒ 기술을 효율적으로 사용하기 위해 관리 비용이 더 증가할 수 있습니다.
⇒ 작은 파일에는 적합하지 않습니다.
⇒ 위에서 설명한 장점인 재해복구를 잘 구현하기 위함이 어렵습니다.
일반적으로 기존 방법보다, 대용량 객체를 처리하는데 명확한 장점이 있고 이를 잘 사용하기 어렵다는 단점이 있다. 단순하게 사용하기 어렵다는 단점을 제외하고는 장점이 더 많다.
여러 사이트들을 찾아봤지만, Multipart Upload를 Presigned Url로 수행하는 aws-sdk v3 솔루션이 없어서 직접 시행착오 끝에 다음과 같은 방법을 생각했다.
- 먼저 클라이언트에서 서로 동영상을 몇 분할로 자를지(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; } };
- 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로 전달
- 최종적으로 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가 분할되어 업로드 됐다.
하지만 위 과정에서 몇 가지 에러를 겪었는데 하나씩 소개하면,
- EntityTooSmall
제안된 업로드가 허용되는 최소 개체 크기보다 작습니다. 마지막 부분을 제외하고 각 부분의 크기는 5MB 이상이어야 합니다.
⇒ 각 파트의 크기가 5MB 미만이여서 발생했다.
- InvalidPart
지정된 부품 중 하나 이상을 찾을 수 없습니다. 부품이 업로드되지 않았거나 지정된 엔터티 태그가 부품의 엔터티 태그와 일치하지 않았을 수 있습니다.
⇒ 지정된 Part의 일부분이 누락되어 발생했다.
- InvalidPartOrder
부품 목록이 오름차순이 아닙니다. 부품 목록은 부품 번호 순서대로 지정되어야 합니다.
⇒ Complete 요청시, 오름차순으로 PartNumber를 정렬되지 않아서 발생했다.
- NoSuchUpload
지정된 멀티파트 업로드가 존재하지 않습니다. 업로드 ID가 잘못되었거나 멀티파트 업로드가 중단 또는 완료되었을 수 있습니다.
⇒ 지정된 Multipart UploadId가 아닐 때 발생했다.
위와 같은 에러를 겪고 일부 수정하면서 최종적으로 Web에서는 정상적으로 업로드 된걸 확인 했으나, 한 가지 문제가 발생했는데 안드로이드/IOS app에서 업로드할 때는 Web과 달리 동작하지 않았다는 것이다.
동시 다발적인 요청발송을 핸드폰의 메모리가 버티지 못하여 터진 케이스도 있고, 비트레이트 문제로 Web에서 파트를 잘라 보냈던 것 처럼 동작하지도 않았다.
app 관련해서 결론적으로 해결하긴 했으나, 브라우저와 핸드폰의 동작에 관하여 고민하게 된 시간이었다.
Share article