대용량 파일 업로드, 서버를 태우지 마라: S3 Presigned URL 및 Multipart Upload 실전 가이드

개발자가 흔히 저지르는 실수 중 하나는 기가바이트(GB) 단위의 대용량 파일을 백엔드 서버가 직접 받아서 S3로 넘겨주는 구조를 짜는 것이다. 이 방식은 EC2의 CPU와 메모리를 불필요하게 점유할 뿐만 아니라, 불필요한 대역폭 비용(Data Transfer Out)을 이중으로 발생시킨다. 브라우저에서 S3로 직접 쏘는 것이 정답이다.

AWS S3 Presigned URL(미리 서명된 URL)이란 AWS 보안 자격 증명 없이도 특정 객체에 대해 제한된 시간 동안 업로드나 다운로드를 할 수 있도록 발급된 임시 URL이다. 이를 Multipart Upload와 결합하면 대용량 파일을 안정적으로, 그리고 서버 부하 없이 처리할 수 있다.

발렛 파킹 vs 지정 주차권: 개념의 이해

비유(Analogy): 전통적인 서버 경유 업로드는 발렛 파킹과 같다. 손님(Client)이 차(File)를 직원(Server)에게 맡기면, 직원이 대신 주차장(S3)에 넣는다. 직원이 많아지면 인건비(서버 비용)가 폭발한다.

반면, Presigned URL지정 주차권을 발급해주는 것과 같다. 직원은 종이 티켓(URL)만 건네주고, 손님이 직접 지정된 구역에 주차한다. 직원은 다른 중요한 업무에 집중할 수 있다.

이 패턴의 핵심은 제어권(Control)데이터(Data)의 흐름을 분리하는 것이다. 인증과 권한 제어는 서버가 담당하고, 무거운 데이터 전송은 클라이언트와 S3가 직접 수행한다.

구현: AWS SDK v3와 Node.js

단일 파일 업로드는 간단하지만, 100MB가 넘어가면 Multipart Upload가 필수적이다. 네트워크가 끊겨도 전체를 다시 올릴 필요 없이 실패한 조각(Part)만 재전송하면 되기 때문이다. 아래 코드는 최신 AWS SDK v3를 기준으로 작성되었다.

1. S3 클라이언트 설정 및 패키지 설치


// 필요한 패키지 설치
// npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

import { 
  S3Client, 
  CreateMultipartUploadCommand, 
  UploadPartCommand, 
  CompleteMultipartUploadCommand 
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3Client = new S3Client({ region: "ap-northeast-2" });

2. 업로드 프로세스 (3단계)

Multipart Upload는 시작(Initiate) -> 서명된 URL 발급(Presign) -> 완료(Complete)의 3단계로 이루어진다.


// Step 1: 멀티파트 업로드 시작 (UploadId 발급)
// 서버가 클라이언트에게 호출받는 API 핸들러라고 가정
export const initiateUpload = async (bucket, key) => {
  const command = new CreateMultipartUploadCommand({
    Bucket: bucket,
    Key: key,
    // 필요 시 ContentType 등 메타데이터 추가
  });
  
  const { UploadId } = await s3Client.send(command);
  return UploadId;
};

// Step 2: 각 파트별 Presigned URL 생성
// partNumber: 1부터 시작하는 정수
export const getPresignedUrls = async (bucket, key, uploadId, parts) => {
  const promises = parts.map(async (partNumber) => {
    const command = new UploadPartCommand({
      Bucket: bucket,
      Key: key,
      UploadId: uploadId,
      PartNumber: partNumber,
    });
    
    // URL 유효기간: 3600초 (1시간)
    const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
    return { partNumber, url };
  });

  return Promise.all(promises);
};

// Step 3: 업로드 완료 처리 (클라이언트가 모든 파트 업로드 후 호출)
// parts: [{ ETag: "...", PartNumber: 1 }, ...]
export const completeUpload = async (bucket, key, uploadId, parts) => {
  // PartNumber 순으로 정렬 필수
  const sortedParts = parts.sort((a, b) => a.PartNumber - b.PartNumber);
  
  const command = new CompleteMultipartUploadCommand({
    Bucket: bucket,
    Key: key,
    UploadId: uploadId,
    MultipartUpload: {
      Parts: sortedParts,
    },
  });

  return await s3Client.send(command);
};

주의할 점 (Common Pitfalls):

  • ETag 노출: 클라이언트가 업로드 성공 후 ETag 헤더를 읽으려면, S3 버킷 CORS 설정에서 ExposeHeaders: ["ETag"]를 반드시 추가해야 한다.
  • Part 크기: 마지막 파트를 제외한 모든 파트는 최소 5MB 이상이어야 한다. 이 규칙을 어기면 EntityTooSmall 에러가 발생한다.

비용 폭탄 방지: 생명주기(Lifecycle) 정책

Multipart Upload를 구현할 때 가장 중요한 운영 팁이다. 사용자가 업로드를 하다가 브라우저를 닫거나 네트워크가 끊겨 업로드가 중단되면 어떻게 될까? '완료되지 않은 조각(Incomplete Parts)'들이 S3에 영구히 남는다. 이 데이터는 보이지 않지만 스토리지 비용을 야기한다.

해결책: S3 버킷 설정 -> Management -> Lifecycle Rules에서 아래 규칙을 반드시 추가한다.

Rule: "Abort incomplete multipart uploads" (불완전한 멀티파트 업로드 중단)
Days after initiation: 7일 (추천)

이렇게 설정하면 7일 동안 완료되지 않은 쓰레기 데이터들은 AWS가 알아서 삭제해준다. 비용 최적화의 핵심이다.

자주 묻는 질문 (FAQ)

Q. 클라이언트에서 파일을 몇 조각으로 나누는 게 좋은가?

A. 파일 크기와 네트워크 환경에 따라 다르지만, 일반적으로 파트 당 5MB ~ 20MB 사이를 권장한다. 파트 수가 너무 많으면(수천 개) URL 생성 및 병합 요청의 오버헤드가 커지고, 파트가 너무 크면 재전송 시 효율이 떨어진다.

Q. Presigned URL의 보안 위험은 없는가?

A. URL 자체가 인증 토큰 역할을 하므로, 해당 URL이 탈취되면 누구나 업로드가 가능하다. 따라서 유효 기간(expiresIn)을 최소한(예: 15분~1시간)으로 설정하고, 업로드 가능한 파일의 크기나 타입을 제한하는 로직을 서버단에 포함시켜야 한다.

Q. CORS 에러가 계속 발생한다면?

A. S3 버킷 권한 탭의 CORS 설정 JSON을 확인해야 한다. AllowedMethodsPUT이 있어야 하며, AllowedOrigins에 클라이언트 도메인(예: https://my-app.com)이 정확히 명시되어야 한다. 개발 중에는 *를 쓰기도 하지만 배포 시엔 구체적인 도메인을 적는 것이 좋다.

OlderNewest

Post a Comment