이번 프로젝트에선, S3와의 통신을 모두 클라이언트단에서 처리하기로 했습니다.
업로드를 구현하는 과정에서 Multipart Upload라는걸 알게되었고 적용시켜보았습니다
⛳️ task
AWS SDK for JavaScript 사용하여 대용량 파일을 청크로 나누어 업로드(MultiPart Upload)한다.
✅ task flow
Multipart upload 구현 작업
- Multipart upload initiation: 고유한 upload id를 반환받는 단계
- Parts upload: 업로드 할 파일을 chunk로 쪼개서 각각 요청을 보내는 단계
- Multipart upload completion: chunk를 결합하고, src를 반환받는 단계
- Abort Upload: 예외처리
Multipart upload Progress bar 구현 작업
- Upload 클래스 설정
- 진행 상황 리스닝
- 업로드 완료 처리
- 전체 업로드 완료 후 처리
- XhrHttpHandler를 사용한 업로드 진행률 추적
🏃♀️ learning point
- multipart/form-data 란?
- 웹에서 파일이나 데이터를 서버로 전송할 때 사용되는 인코딩 타입
- application/x-www-form-urlencoded와 달리 바이너리 데이터나 텍스트 데이터 모두를 포함한 여러 부분으로 나누어 전송할 수 있어 파일 업로드 등에 적합
- https://lena-chamna.netlify.app/post/http_multipart_form-data/
- s3 분할 업로드 방식
- S3는 단일 개체에 대해 최대 5GB 까지만 업로드 가능
- MultipartUpload를 통해 하나의 파일을 여러 조각으로 나누어 전송 가능
- 100MB 이상의 파일부터 권장
- ComplatedPart를 Collection으로 관리하여 5TB까지 part를 저장하여 전송 권장
✨ Multipart upload 구현
1. Multipart upload initiation
- CreateMultipartUploadCommand: 업로드를 시작하고, 각 파트 업로드에 필요한 UploadId를 반환합니다. 2번, 3번의 요청을 할때에 UploadId를 사용해야 합니다.
const createCommand = new CreateMultipartUploadCommand({
Bucket: bucketName,
Key: key,
});
const createResult = await s3Client.send(createCommand);
/* 응답 데이터 예시
data = {
Bucket: "examplebucket",
Key: "largeobject",
UploadId: "ibZBv_75gd9r8lH_gqXatLdxMVpAlj6ZQjEs.OwyF3953YdwbcQnMA2BLGn8Lx12fQNICtMw5KyteFeHw.Sjng--"
}
*/
2. Parts upload
- UploadPartCommand: 파일의 개별 파트를 업로드합니다.
- uploadPartCommand 클래스는 ETag를 반환합니다. 3번의 completion에서 사용해야 하므로 배열에 담아 JsonArray 형태로 만듭니다.
const partSize = 5 * 1024 * 1024; // 5 MB per part
const parts = [];
for (let i = 0; i < file.size; i += partSize) {
const partNumber = Math.floor(i / partSize) + 1;
const part = file.slice(i, i + partSize);
const uploadPartCommand = new UploadPartCommand({
Bucket: bucketName,
Key: key,
PartNumber: partNumber, // 1 ~ 10,000
UploadId, // CreateMultipartUploadCommand 통해 얻은 id
Body: part, // chunk 사이즈로 자른 blob
});
const partResult = await s3Client.send(uploadPartCommand); // upload next part
parts.push({ ETag: partResult.ETag, PartNumber: partNumber });
}
/* 응답 데이터 예시
data = {
ETag: "\\"d8c2eafd90c266e19ab9dcacc479f8af\\""
}
*/
3. Multipart upload completion
- CompleteMultipartUploadCommand: 모든 파트가 업로드된 후, 이를 하나의 파일로 결합하여 업로드를 완료합니다.
const completeCommand = new CompleteMultipartUploadCommand({
Bucket: bucketName,
Key: key,
UploadId, // 1번에서 받은 upload id
MultipartUpload: {
Parts: parts, // 2번에서 담은 ETag Object의 배열
},
});
await s3Client.send(completeCommand);
/* eTagParts 예시
eTagParts = [
{
ETag: "\\"d8c2eafd90c266e19ab9dcacc479f8af\\"",
PartNumber: 1
},
{
ETag: "\\"d8c2eafd90c266e19ab9dcacc479f8af\\"",
PartNumber: 2
}
]
*/
/* 응답 데이터 예시
data = {
Bucket: "acexamplebucket",
ETag: "\\"4d9031c7644d8081c2829f4ea23c55f7-2\\"",
Key: "bigobject",
Location: "https://examplebucket.s3.<Region>.amazonaws.com/bigobject"
}
*/
4. Abort Upload
- 에러발생, 유저의 업로드 취소, 페이지 이탈 등의 이유로 중단되었을 때, 버킷에 해당 업로드가 남아있는 상태이므로 Abort를 해주어야 합니다.
- AbortMultipartUploadCommand: 업로드 중단을 처리합니다. 이는 실패 또는 사용자의 중단 요청 시 사용됩니다.
const abortCommand = new AbortMultipartUploadCommand({
Bucket: bucketName,
Key: key,
UploadId,
});
await s3Client.send(abortCommand);
전체코드
import {
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
S3Client,
UploadPartCommand
} from "@aws-sdk/client-s3";
import { useCallback, useMemo } from "react";
import { v1 as uuidv1 } from "uuid";
import { s3ClientConfig, bucketName } from "./config";
import type { MutateOptions, UploadToS3Function } from "./types";
interface UploadToS3Props {
accept?: string[] | string;
files: FileList | null;
path: string;
}
function useMultipartS3Upload<
TData = string[],
TError = Error,
TVariables extends UploadToS3Props = UploadToS3Props
>(): {
uploadToS3: UploadToS3Function<TData, TError, TVariables>;
} {
const s3Client = useMemo(() => new S3Client(s3ClientConfig), []);
const uploadToS3 = useCallback(
async (variables: TVariables, options?: MutateOptions<TData, TError, TVariables>) => {
const { files, path } = variables;
const uploads = Array.from(files).map(async (file) => {
const key = `${path}/${uuidv1().replace(/-/g, "")}.${file.type.split("/")[1]}`;
let UploadId = undefined;
try {
const createCommand = new CreateMultipartUploadCommand({
Bucket: bucketName,
Key: key,
ContentType: file.type
});
const createResult = await s3Client.send(createCommand);
UploadId = createResult.UploadId;
const partSize = 5 * 1024 * 1024; // 5 MB per part
const parts = [];
for (let i = 0; i < file.size; i += partSize) {
const partNumber = Math.floor(i / partSize) + 1;
const part = file.slice(i, i + partSize);
const uploadPartCommand = new UploadPartCommand({
Bucket: bucketName,
Key: key,
PartNumber: partNumber,
UploadId,
Body: part
});
const partResult = await s3Client.send(uploadPartCommand);
parts.push({ ETag: partResult.ETag, PartNumber: partNumber });
}
const completeCommand = new CompleteMultipartUploadCommand({
Bucket: bucketName,
Key: key,
UploadId,
MultipartUpload: { Parts: parts }
});
await s3Client.send(completeCommand);
return `https://${bucketName}.s3.amazonaws.com/${key}`;
} catch (error) {
if (UploadId) {
const abortCommand = new AbortMultipartUploadCommand({
Bucket: bucketName,
Key: key,
UploadId
});
await s3Client.send(abortCommand);
}
throw error; // Rethrow to handle in the upper catch block
}
});
try {
const urls = await Promise.all(uploads);
// onSuccess 콜백
} catch (error) {
// onError 콜백
}
},
[s3Client]
);
return { uploadToS3 };
}
export { useMultipartS3Upload };
✨ Multipart 업로드 진행 상황 추적
AWS SDK의 Upload 클래스를 활용하여 각 파일 파트의 업로드 시 진행 상태를 리스너를 통해 실시간으로 업데이트합니다. 이를 통해 사용자에게 각 파일의 업로드 진행률을 표시하고, 전체 업로드 과정에서 현재 어느 위치에 있는지 알려줍니다.
1. Upload 클래스 설정
각 파일의 업로드를 개별적으로 처리하고, AWS SDK의 Upload 클래스를 사용하여 업로드 과정을 관리합니다.
import { Upload } from "@aws-sdk/lib-storage";
const uploader = new Upload({
client: s3Client,
params: {
Bucket: bucketName,
Key: key,
Body: file
},
partSize: 5 * 1024 * 1024, // 5 MB per part
leavePartsOnError: false // 실패 시 파트를 버킷에 남기지 않음
});
2. 진행 상황 리스닝
Upload 인스턴스는 httpUploadProgress 이벤트를 통해 업로드 진행률을 제공합니다.
- `httpUploadProgress` 이벤트는 chunk가 업로드 될 때 발생합니다.
- https://github.com/aws/aws-sdk-js/issues/4363
uploader.on("httpUploadProgress", ({ loaded, total }) => {
const currentProgress = Math.floor((loaded / total) * 100);
setProgress(currentProgress);
console.log(
progress.loaded, // 현재 업로드 된 바이트 수
progress.total // 총 바이트 수
);
});
3. 업로드 완료 처리:
각 파일 업로드가 완료되면, 완료 처리 로직을 실행합니다. 성공적으로 업로드된 파일의 URL을 배열에 추가하고, 업로드된 파일 수를 업데이트합니다.
try {
await uploader.done(); // 업로드 성공 했을 때 promise 반환
urls.push(`https://${bucketName}.s3.amazonaws.com/${key}`);
setUploaded((prev) => prev + 1);
} catch (error) {
throw error;
}
4. 전체 업로드 완료 후 처리
모든 파일의 업로드가 완료되면, 사용자에게 성공 메시지를 표시하고, 필요한 후속 조치를 취합니다.
if (urls.length > 0) {
console.log("All files uploaded successfully.");
}
5. XhrHttpHandler를 사용한 업로드 진행률 추적
: `XhrHttpHandler`를 사용하면 `XMLHttpRequest`를 기반으로 HTTP 요청을 처리하므로, 업로드 중인 데이터 스트림에 대한 저수준 접근이 가능합니다. 업로드 진행률 이벤트를 더 자주 활용할 수 있게 해주어, 파일 업로드의 실시간 진행 상황을 세밀하게 추적할 수 있습니다.
🏃♀️ 학습 포인트
- XhrHttpHandler는 AWS SDK for JavaScript가 제공하는 커스텀 HTTP 핸들러입니다.
- 기본적인 FetchHttpHandler 대신 XMLHttpRequest를 사용하여 HTTP 요청을 수행하며, 이를 통해 업로드 중인 파일의 데이터 전송 상황을 더 세밀하게 관찰하고, 진행률을 실시간으로 업데이트할 수 있습니다.
- 기본적으로 Upload의 httpUploadProgress 이벤트는 FetchHttpHandler를 사용할 때 각 청크(기본적으로 5MB)의 업로드 완료 후에만 발생합니다.
- S3Client 생성 시 XhrHttpHandler를 설정하면, XMLHttpRequest를 통해 httpUploadProgress 이벤트가 더 자주 발생하며, 이는 특히 파일 크기가 작거나 단일 파트 업로드를 사용할 때 유용합니다.
import { XhrHttpHandler } from "@aws-sdk/xhr-http-handler";
import { S3Client } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
const client = new S3Client({
requestHandler: new XhrHttpHandler(), // 기본 FetchHttpHandler 대신 XHR 핸들러 설정
});
const upload = new Upload({
client,
params: {
// 필요한 업로드 파라미터 설정
},
});
upload.on("httpUploadProgress", (progress) => {
// XhrHttpHandler를 사용할 때 이 이벤트는 훨씬 빈번하게 발생합니다.
// 컴퓨팅 리소스가 많이 필요한 이벤트 리스너인 경우, 이를 적절히 조절할 필요가 있습니다.
console.log(progress);
console.log(
progress.loaded, // 현재까지 업로드된 바이트 수
progress.total // 전체 바이트 수. 이 두 값으로 진행률을 계산할 수 있습니다.
);
});
const completeMultiPartUpload = await upload.done();
결과
느린 네트워크 환경에서 progress를 추적했을 때 화면입니다!
Multipart upload의 경우, 객체의 크기가 100MB이상일 때부터 사용하면 유용하다고 합니다. 그래서 100MB 이하일 때는 단일 객체업로드를 사용하려고 했는데, 단일 객체업로드(`PutObjectCommand`)의 경우에는 `httpUploadProgress`를 지원하고 있지 않는 듯해서 진행률을 추적할 땐 다른 방식을 사용해야할 것 같습니다 🤨
혹시나 제 글에 잘못된 부분이 있다면 편하게 알려주세요! 😄
Reference
https://kimjingo.tistory.com/189https://velog.io/@sangwoo-sean/Spring-AWS-S3-Multipart-Upload-JavaScript-SDKhttps://blog.logrocket.com/multipart-uploads-s3-node-js-react/https://di-story.tistory.com/entry/개발일기211101AWSmultipart-upload
https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-xhr-http-handler/
https://blog.filestack.com/amazon-s3-multipart-uploads-javascript/