Multipart Upload를 적용한 이유
현재 제공 중인 서비스에서 채팅으로 파일과 메시지를 전송 시,
20MB 제한을 걸어둬 20MB를 초과하는 파일의 경우에는
업로드 제한을 알리는 모달을 활성화시키는 것으로 처리했는데요.
몇몇 유저가 대용량 파일도 채팅으로 보낼 수 있게 해 달라는 요청을 하며
기획 변경으로 Multipart Upload feature를 구현해야 되었습니다.
AI 관련 파일의 특성상 500mb 이상의 무거운 JSON 파일들과
문서의 사이즈가 크기 때문에 대용량 파일 업로드에 대한 처리는
언젠가 필요하겠다 싶었는데 그 시점이 지금이었네요.
먼저 멀티 파트 업로드에 대해 알아보자
AWS에서 S3 멀티파트 업로드를 구현하는 방법으로 SDK, REST API, CLI 총 3가지를 제공합니다.
저는 그중 REST API를 사용한 방법을 선택했는데요.
SDK를 사용한 feature 개발 시, 브라우저에서 파일 업로드 요청을 받으면
프런트-> 백엔드에 파일 전달 -> 백엔드에서 S3로 파일 업로드라는
불필요한 과정이 발생한다고 판단했고,
AWS CLI는 개인적으로 익숙하지 않았으며 코드 작성 또한 번거로워 보였습니다.
반면에 REST API를 사용한 개발 진행은 레퍼런스를 찾기 수월하기도 했고,
가장 익숙한 방법이었기에 REST API를 통한 개발을 선택했습니다.
TMI) 단일 파일 업로드와 멀티 파일 업로드의 사용 타이밍
단일 객체 업로드의 경우 AWS 측에서 5GB 자료까지만 지원하며,
100MB 이상의 파일일 경우 멀티파트 업로드를 사용하도록 권하고 있습니다.
멀티파트 적용 전
기존에는 S3에 856MB의 파일을 업로드하는데 36.04s가 소요되었습니다.
파일의 사이즈가 증가할수록 업로드 타임도 비례해 증가하는데요.
조금이라도 빠르게 파일을 전달하고 싶은 것이 유저의 입장일 테니
멀티파트 업로드를 적용해 업로드 시간을 줄여보겠습니다.
🧩 개발 과정
(프로젝트의 환경은 Next.js 13v, react, TS, Tanstack-Query입니다.)
클라이언트에서 멀티파트를 위한 단계는 크게 5단계로 진행됩니다.
아래에서 예시 코드를 보며 각 단계별로 설명드리겠습니다.
1️⃣ 대용량 파일을 나누는 것(= chunk).
chunk의 사이즈는 5MB ~ 5GB의 크기만 가능한데요.
예외로 마지막 파트는 5MB 이하인 경우에도 괜찮다고 합니다.
나머지 데이터가 남아있지 않기 때문에 마지막 chunk 사이즈에 대한 유연성을 허용한다고 하네요!
export const makeChunk = async (
file: File,
chunkSize: number, // 개인적으론 10-20mb가 적절하다고 생각
): Promise<Blob[]> => {
const chunkedArray: Blob[] = [];
let offset = 0;
while (offset < file.size) {
const chunk = file.slice(offset, offset + chunkSize);
chunkedArray.push(chunk);
offset += chunkSize;
}
return chunkedArray;
};
2️⃣ chunk들이 같은 업로드 그룹에 포함된다는 것을 나타내는 id값을 가져오기 위한 통신.
파일의 업로드를 알리고, uploadId를 가져옵니다.
const getMultipartUploadId = async (fileName: string) => {
const response = await instance.post('presigned url의 uploadId를 가져오는 API', {
name: fileName,
});
return response.data;
};
export const bringMultipartUploadId = useMutation({
mutationFn: getMultipartUploadId,
onSuccess: () => {
// 성공 시 메시지를 초기화한다던지의 로직을 작성해주면 좋겠죠?
},
});
3️⃣ 각 chunk에 대한 presigned URL(= S3 업로드 URL) 발급을 위한 통신.
각 chunk에 대한 url을 발급받고, 각 url에 대한 업로드 요청을 진행합니다.
chunk 하나당 3, 4번 통신이 매번 이뤄져야 하기 때문에
제 코드에서는 makeChunk를 통해 생성된 chunk 배열의 반복문을 순회하며
3,4번 통신을 진행했어요.
const getS3UploadUrl = async ({
objectKey,
partNumber,
uploadId,
}: IUploadUrlRequest) => {
const response = await instance.post('presigned_url을 가져오는 API', {
objectKey,
partNumber,
uploadId,
});
return response.data.url;
};
export const bringS3UploadUrl = useMutation({
mutationFn: getS3UploadUrl,
});
4️⃣ presigned URL에 업로드를 요청하는 통신.
const putS3UploadChunk = async ({
uploadUrl,
chunk,
fileType,
}: IUploadChunk) => {
const response = await axios.put(uploadUrl, chunk, {
headers: {
'Content-Type': fileType,
},
});
return {
ETag: response.headers.etag ?? '',
};
};
export const sendS3UploadChunk = useMutation({
mutationFn: putS3UploadChunk,
});
5️⃣ 업로드가 마무리되었음을 서버에 알리는 통신.
presigned URL에 업로드가 완료되었지만 chunk가 하나의 파일로 합쳐지지는 않고
S3에 일시적으로 저장되어 있는데요.
S3에 업로드된 chunk를 하나의 파일로 결합을 지시하는 역할을 업로드 마무리 요청이 수행합니다.
업로드 마무리 통신을 하지 않는다면 업로드가 미완료되었다고 판단,
chunk들은 24시간 이내로 S3에 의해 자동으로 삭제됩니다.
const postCompleteMultipartUpload = async ({
objectKey,
uploadId,
parts,
}: ICloseMultipart) => {
const response = await instance.post('complete 알림 API', {
objectKey,
uploadId,
parts,
});
return 설정한 응답값;
};
export const completeMultipart = useMutation({
mutationFn: postCompleteMultipartUpload,
onError: () => {
},
});
🚀 트러블 슈팅
1️⃣ 통신마다 동일한 uploadId와 objectKey를 보낼 것.
만약 각 요청마다 다른 uploadId와 objectKey를 보낸다면
아래와 같은 에러를 마주할 수 있는데요.
동일한 uploadId를 사용해야만 chunk들이 같은 업로드 그룹에 포함되고
서버에서는 어떤 chunk가 하나의 파일로 결합되어야 하는지를 알 수 있습니다.
또한 특정 uploadId와 연결된 objectKey가 다르면 서버는
해당 업로드 요청을 인식하지 못하고 위의 사진처럼 통신이 실패할 수 있거든요.
위의 이유를 토대로 uploadId와 objectKey는 항상 전달받은 값을 유지해야 합니다.
2️⃣ partNumber는 number type으로, 동일한 숫자로 보내지 말 것.
동일한 partNumber에 대해 다른 데이터를 업로드하는 경우
이전에 업로드된 chunk가 새로운 chunk로 대체될 수 있는데요.
chunk의 덮어쓰기로 인해 파일의 일부가 손실될 수 있기 때문에
partNumber는 매 요청마다 다르게 전달을 해주셔야 합니다.
3️⃣ 멀티 파트를 적용했음에도 기존과 비슷한 업로드 속도였던 이유
기존에 for.. of로 브라우저에 업로드 요청이 들어온 파일을 순회하던 것이 문제였습니다.
for.. of 순회문은 각 반복에서 작업이 완료될 때까지 기다렸다가 다음 반복으로 넘어가는데요.
작업이 동기적으로 흘러가기에 병렬 처리가 이루어지지 않습니다.
저는 map을 사용해 병렬 처리를 진행했습니다.
map을 사용하면 각 요소에 대해 비동기 함수를 호출하고, 해당 함수들이 동시에 실행되며
병렬 처리가 가능합니다.
presigned_url을 가져오는 mutation 함수와 presigned_url로 chunk를 전달하는 mutation 함수를
병렬 처리하고 Promise.all을 사용해 chunk와 관련된 모든 비동기 작업이 완료되면
후속 작업인 업로드 마무리 mutation 함수 호출 시점을 앞당길 수 있게 되고
결과적으로 멀티파트 업로드를 이른 시간에 끝낼 수 있게 됩니다.
4️⃣ 멀티 파트의 병렬 처리로 인한 변수의 초기화, 그에 따른 대처
하지만 map을 사용한 병렬 처리 시 문제가 발생했는데요.
채팅 메시지 전송 시, 멀티파트 업로드 완료 mutation 함수의 응답값을 함께 실어 전달했는데
멀티파트 업로드 완료 mutation 함수의 응답값이 빈 값으로 초기화되어 있었습니다.
map 메서드 내부의 비동기성으로 이전 파일 업로드가 전부 완료되기 전에
다음 파일에 대한 업로드가 진행되는 것이 문제였는데요.
멀티파트 업로드 이벤트 핸들러의 가장 외부 스코프에 배열을 할당한 변수를 선언해 처리하였습니다.
멀티파트 업로드 완료 통신이 이뤄지고 chunk 관련 정보를 배열에 추가해
map 순회문 내부에 존재하는 chunk 관련 정보가 초기화되더라도
외부 스코프의 배열에서 정보를 가져와 최종 채팅 메시지를 전달시켜
기능을 동작시켰습니다.
5️⃣ develop 환경과 production 환경에서의 ETag 포함 여부
production 환경의 S3 설정을 하지 않아 발생한 문제였습니다.
통신의 header를 통해 ETag를 가져오는데, header에 노출시킬 정보 설정에서 ETag를 빠뜨렸습니다.
S3에서 권한 설정의 ExposeHeaders 배열 안에 "ETag"를 추가해 주면 해결됩니다.
6️⃣ 5GB 이상의 파일 업로드를 시도하면 서버에 S3업로드 완료 통신에서 Timeout 발생
chunk가 포함된 배열이 너무 커져 발생한 문제였습니다.
이 부분은 기획팀과 논의를 통해 2GB 이상의 파일부터는 업로드 제한을 걸기로 협의했어요.
멀티파트 업로드 구현 최종 코드는요
interface IFileUploadInfo {
name: string;
size: number;
path: string;
}
...
const multipartUpload = async (
uploadedFiles: File[],
chatId: number,
message: string,
) => {
const mediaSets: IFileUploadInfo[] = [];
const fileUploadPromises = uploadedFiles.map(async (singleFile) => {
const chunkedFile = await makeChunk(singleFile, CHUNK_SIZE);
const uploadStartInfo = await bringMultipartUploadId.mutateAsync(singleFile.name);
const { objectKey, uploadId } = uploadStartInfo;
const s3UploadInfo = chunkedFile.map(async (chunk, index) => {
const s3UploadUrl = await bringS3UploadUrl.mutateAsync({
objectKey,
partNumber: index + 1,
uploadId,
});
const { ETag } = await sendS3UploadChunk.mutateAsync({
s3UploadUrl,
chunk,
fileType: singleFile.type,
});
return {
ETag,
PartNumber: index + 1,
};
});
try {
const completedParts = await Promise.all(s3UploadInfo);
const completeRes = await completeMultipart.mutateAsync({
objectKey,
uploadId,
parts: completedParts,
});
mediaSets.push({
name: file.name,
size: file.size,
path: completeRes,
});
} catch (error) {
// 추가적인 에러 핸들링 로직
}
});
await Promise.all(fileUploadPromises);
// 최종적으로 메시지를 전송.
await createChat({
data: {
chatId,
text: message ? message : null,
mediaSet: mediaSets,
},
});
};
멀티파트 적용 후
멀티파트 업로드 적용 후, 23.50s가 소요되었습니다.
적용 전과 비교했을 때 약 업로드 속도가 36-37% 빨라진 것을 볼 수 있습니다.
아직 multipart upload 시, 스피너만 활성화되도록 처리했는데요.
UX 개선을 위해 기획팀에 Progress bar 적용도 요청해 두었습니다.
🤷🏻♂️ 개발 후 짧은 돌아봄
멀티파트 업로드 feature를 개발하며 유저의 니즈를 100% 만족시키는 서비스를 개발하기란
참 어렵다는 생각을 했습니다.
채팅으로 2GB 이상의 파일도 클라우드에 업로드하는 것처럼 빠르게 업로드할 수 있는 방법은 없을까란
생각도 해보고, 아예 500MB로 제한해 클라우드 업로드를 제안하는 모달을 활성화시켜야 할지
고민이 많았는데.. 아직 결론을 내리지 못했네요.
Reference
S3 pre_signed URL을 활용한 대용량 파일 업로드
멀티파트 업로드 시 응답 헤더에 ETag가 안 넘어올 때
'TIL' 카테고리의 다른 글
실패한 2년차 경력직 인터뷰 경험 (1) | 2024.11.12 |
---|---|
서비스에 GA를 도입해보자 (0) | 2024.10.11 |
기차 티켓 대기열은 어떤 구조일까(feat.Queue) (7) | 2024.09.21 |
dev-dependencies를 두고 구내 식당에서 펼쳐진 토론 (1) | 2024.09.10 |
FEConf 2024 회고 (1) | 2024.09.03 |