Next.js에서의 대용량 파일 s3 업로드

@Ryan· February 14, 2024 · 10 min read

현재 프로젝트에서 사용자가 챗봇 생성에 필요한 데이터를 업로드 하는 기능이 있다. 업로드를 구현하기 위해, 프론트엔드에서 AWS S3 SDK를 사용하여 업로드하기로 했다.

작은 파일의 경우는 단일 객체 업로드를 사용하여 업로드하면 간단하게 구현할 수 있다.

단일 객체 업로드의 경우 AWS 측에서 5GB 자료까지만 지원하며, 100MB 이상의 파일일 경우 멀티파트 업로드를 사용하도록 권하고 있다.

멀티파트 업로드는 AWS S3에서 제공하는 파일 업로드 방식이다. 업로드할 파일을 작은 part로 나누어 각 부분을 개별적으로 업로드한다. 파일의 바이너리가 서버를 거치지 않고 S3에 다이렉트로 업로드되기 때문에 서버의 부하를 고려하지 않아도 된다는 장점이 있다.

만약 모든 part가 업로드 되었을 경우 AWS에서 하나의 객체로 조립하여 저장한다. 이 때, part별로 ETag라는 MD5 Checksum을 활용하여 파일의 정합성을 확인한다. 또한, 파트가 업로드 되면 확인하고 사용자에게 업로드 진행상황을 제공할 수 있다.

멀티파트 업로드는 4단계 프로세스로 구성된다.

  1. 멀티파트 업로드 시작
  2. PresignedURL 발급
  3. PresignedURL part 업로드 -> 클라이언트 사이드에서 업로드
  4. 멀티파트 업로드 완료

백엔드 구현

백엔드 파트에는 3번을 제외한 3개의 엔드포인트가 있다.

  • POST api/s3-upload/get-id 이 엔드포인트의 목적은 서버가 멀티파트 업로드에 대한 고유 식별자인 Upload Id를 생성하는데 있다. 부분 업로드, 업로드 완료 또는 업로드 중단 요청 시 항상 해당 식별자를 포함해야 하기 때문에 값을 잘 저장해 두어야 한다.
import { S3Client, CreateMultipartUploadCommand } from '@aws-sdk/client-s3';
import { NextResponse } from 'next/server';

const Bucket = process.env.NEXT_PUBLIC_AWS_BUCKET_NAME as string;
const s3 = new S3Client({
  region: process.env.NEXT_PUBLIC_AWS_REGION,
  credentials: {
    accessKeyId: process.env.NEXT_PUBLIC_AWS_ACCESS_KEY as string,
    secretAccessKey: process.env.NEXT_PUBLIC_AWS_SECRET_KEY as string,
  },
});

export async function POST(request: Request) {
  const body = await request.json();

  try {
    const command = new CreateMultipartUploadCommand({
      Bucket,
      Key: body.data.fileName,
      ContentType: body.data.fileType,
    });
    const result = await s3.send(command);
    return NextResponse.json(result);
  } catch (error) {
    console.error(error);
  }
}
  • POST api/s3-upload/get-url 업로드를 위한 AWS의 서명된 URL을 발급받는 요청이다. 사전 서명된 URL을 사용하여 필요한 사람에게 세분화된 액세스 제어 및 강화된 보안을 갖춘 임시 액세스를 제공한다. Upload IDPartNumber값을 함께 요청해야 한다. AWS에서는 Part Number를 활용하여 업로드하는 객체의 각 부분과 그 위치를 고유하게 식별한다. 만약 이전에 업로드한 부분과 동일한 부분 번호로 새 부분을 업로드할 경우 이전에 업로드한 부분을 덮어쓰게 된다.
export async function POST(request: Request) {
  const body = await request.json();

  try {
    const command = new UploadPartCommand({
      Bucket,
      Key: body.data.fileName,
      PartNumber: body.data.partNumber,
      UploadId: body.data.uploadId,
    });
    const url = await getSignedUrl(s3, command, { expiresIn: 3600 });
    return NextResponse.json(url);
  } catch (error) {
    console.error(error);
  }
}
  • POST api/s3-upload/get-complete 업로드가 완료되었음을 S3에 알리는 요청이다. Upload Id, 각 PartNumber와 매칭되는 ETag값이 배열로 포함되어야 한다. 업로드 완료가 수행되어야 S3에서는 PartNumberETag를 기준으로 객체를 재조립한다.
export async function POST(request: Request) {
  const body = await request.json();

  try {
    const command = new CompleteMultipartUploadCommand({
      Bucket,
      Key: body.data.fileName,
      UploadId: body.data.uploadId,
      MultipartUpload: {
        Parts: body.data.parts,
      },
    });
    const result = await s3.send(command);
    return NextResponse.json(result);
  } catch (error) {
    console.error(error);
  }
}

프론트엔드 구현

프론트엔드에서 처리해야할 단계는 5단계가 있다.

  1. 파일을 파트(청크)로 나누기: createChunkedArray()
  2. 멀티파트 업로드 ID 가져오기: getIdForMultipartUpload()
  3. 각 파트에 대한 PresignedUrl 발급: getUploadUrlForChunk()
  4. PresignedUrl로 업로드 요청 * 청크 수 만큼
  5. 모든 청크가 업로드 되었음을 서버에 알림: closeMultipartUpload()

createChunkedArray() 사용자가 선택한 파일의 청크 배열을 생성한다. 분할된 파트는 5MB ~ 5GB의 크기만 가능하지만, 마지막 파트는 5MB이하여도 괜찮다. 이와 같은 로직에서, 5MB이하의 단일 자료를 업로드 할 경우 첫 번째와 파트와 마지막 파트가 동일한 파트이기 때문에 멀티파트 업로드를 활용하는데 문제가 없다. 파트는 최대 5GB까지 10,000개를 업로드 할 수 있으니 (PartNumber값은 1부터 10,000까지 가능) 이론상 5TB 크기의 파일까지 업로드할 수 있다.

export const createChunkedArray = async (
  file: File,
  chunkSize: number,
): 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;
};

getIdForMultipartUpload() 파일 청크가 생성된 이후 파일에 대한 업로드를 알린다.

export const getIdForMultipartUpload = async (
  fileName: string,
  fileType: string,
): Promise<string> => {
  try {
    const response = await axios.post('/api/s3-upload/get-id', {
      data: { fileName, fileType },
      action: 'get-id',
    });
    return response.data.UploadId;
  } catch (error) {
    console.error('Error obtaining upload ID:', error);
    throw error;
  }
};

getUploadUrlForChunk() 각 청크에 대한 Presigned Url 을 발급받는다. 이 로직은 이후 createChunkedArray()에서 생성된 청크 배열을 순회하며 각 청크에 대한 url을 발급받고, 각 url에 대한 업로드 요청을 수행한다.

export const getUploadUrlForChunk = async (
  fileName: string,
  partNumber: number,
  uploadId: string,
): Promise<string> => {
  try {
    const response = await axios.post('/api/s3-upload/get-url', {
      data: {
        fileName,
        partNumber,
        uploadId,
      },
      action: 'get-url',
    });
    return response.data;
  } catch (error) {
    console.error('Error obtaining upload URL:', error);
    throw error;
  }
};

closeMultipartUpload() completedPartschunkedFile의 배열의 길이가 동일하면 모든 청크가 전송된 것으로 간주하고 S3에 업로드 완료 요청을 발송한다.

export const closeMultipartUpload = async (
  fileName: string,
  uploadId: string,
  completedParts: CompletedPartType[],
): Promise<object | void> => {
  try {
    await axios.post('/api/s3-upload/complete', {
      data: { fileName, uploadId, parts: completedParts },
      action: 'complete-upload',
    });
  } catch (error) {
    console.error('Error completing upload:', error);
    throw error;
  }
};

handleUpload() 위에서 구현한 함수들을 사용하여 업로드를 수행하는 함수이다. 해당 코드가 컴포넌트에서 요청된다.

export const handleUpload = async (
  file: File,
  setUploadProgress: React.Dispatch<React.SetStateAction<number>>,
  setIsUploading: React.Dispatch<React.SetStateAction<boolean>>,
  setIsUploaded: React.Dispatch<React.SetStateAction<boolean>>,
): Promise<void> => {
  setIsUploading(true);
  const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
  let uploadedChunk = 0; // progress를 추적하기 위한 변수
  try {
    const chunkedFile = await createChunkedArray(file, CHUNK_SIZE);
    const uploadId = await getIdForMultipartUpload(file.name, file.type);
    const uploadPromises = chunkedFile.map(async (chunk, index) => {
      const uploadUrl = await getUploadUrlForChunk(
        file.name,
        index + 1,
        uploadId,
      );
      const response = await fetch(uploadUrl, {
        method: 'PUT',
        body: chunk,
        headers: {
          'Content-Type': file.type,
        },
      });
      uploadedChunk++;
      const progress = Math.ceil((uploadedChunk / chunkedFile.length) * 100);
      setUploadProgress(progress);
      if (!response.ok) throw new Error('Upload failed');
      return {
        ETag: response.headers.get('ETag') ?? '',
        PartNumber: index + 1,
      };
    });

    const completedParts: CompletedPartType[] =
      await Promise.all(uploadPromises);
    await closeMultipartUpload(file.name, uploadId, completedParts);
    setIsUploaded(true);
  } catch (error) {
    console.error('Upload error:', error);
    setIsUploaded(false);
  } finally {
    setIsUploading(false);
    setUploadProgress(0);
  }
};

컴포넌트에서의 사용 예시코드는 다음과 같다.

import React, { useState } from 'react';
import { handleUpload } from '@/app/_lib/uploader';

export default function UploadFile() {
  const [uploadProgress, setUploadProgress] = useState(0);
  const [isUploading, setIsUploading] = useState(false);
  const [isUploaded, setIsUploaded] = useState(false);

  const onFileUpload = async (file: File) => {
    await handleUpload(file, setUploadProgress, setIsUploading, setIsUploaded);
  };
  return (
        {isUploading ? (
          <>
            <div>업로드 중입니다...</div>
            <div>{uploadProgress}%</div>
          </>
        ) : isUploaded ? (
          <div>업로드 완료!</div>
        ) : (
          <>
                <UploadButton onFileSelected={(file) => onFileUpload(file)} />

          </>
        )}

}

만약 최대 20MB정도의 이미지 업로드 기능을 개발하면서 멀티파트 업로드 방식으로 구현한다면 오버엔지니어링이라고 볼 수 있을 것이다. 본인의 프로젝트 요구사항에 따라서 타협적인 방식도 필요하다....

참고: https://techblog.woowahan.com/11392/ https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/Welcome.html

@Ryan
일단 해보자
© ryanbae.dev, Built with Gatsby