민프

[AWS] Presigned URL을 이용하여 S3로 파일 업로드를 해보자, Presigned URL를 왜 쓸까? (feat. 다중 Presigned URL 요청) 본문

DevOps/[AWS]

[AWS] Presigned URL을 이용하여 S3로 파일 업로드를 해보자, Presigned URL를 왜 쓸까? (feat. 다중 Presigned URL 요청)

민프야 2025. 6. 20. 20:48

 

“Lambda에서 발생하는 6MB 초과 오류? Presigned URL로 해결하자!”

 

문제상황

최근 OCR 분석 기능을 Lambda + S3 + SQS 조합으로 구현하던 중 다음과 같은 에러를 마주하게 되었습니다:

Request must be smaller than 6291456 bytes for the InvokeFunction operation  
Lambda invocation failed with status: 413

이 문제는 이미지 파일의 크기가 Lambda가 허용하는 최대 요청 바이트 수(6MB) 를 초과한 경우 발생합니다.

그렇다면, Lambda를 거치지 않고 S3로 직접 업로드하면 해결됩니다.

 

이 글에서는 Presigned URL을 사용하여 클라이언트에서 직접 S3로 파일을 업로드하는 방법을 소개해보려 합니다.

 


1. Presigned URL이란?

 

Presigned URL (서명된 URL)

특정 S3 객체에 대해 일시적으로 접근 권한을 부여하는 서명된 URL입니다.

이 URL을 가진 사람은 일정 시간 동안

  • 파일을 업로드(PUT) 하거나
  • 파일을 다운로드(GET) 할 수 있습니다.
  • AWS 인증이 없어도 접근할 수 있습니다.

즉, AWS 자격 증명이 없는 외부 사용자도

해당 URL만 있으면 정해진 시간 동안 S3에 직접 접근할 수 있습니다. 


2. Lambda에 직접 업로드하면 왜 안 될까?

기존에는 다음과 같이 Lambda에 파일을 전송했었습니다.

[Client] → [API Gateway + Lambda] → [S3 업로드]

 

그런데 이 구조에서는 다음 문제가 발생합니다:

  • Lambda는 최대 6MB 이하의 요청 본문만 수용 가능
  • 이미지가 큰 경우 413 Request Entity Too Large 발생
  • 오버헤드가 크고, 비효율적

-> 그래서 Presigned URL을 사용한 구조로 변경했습니다.

[Client] → [S3 직접 PUT 요청] ← [Presigned URL 발급 서버]

 


3. Presigned URL 발급 API 구현 (Nestjs)

아래 패키지를 설치한다.

https://www.npmjs.com/package/@aws-sdk/s3-request-presigner

 

@aws-sdk/s3-request-presigner

[![NPM version](https://img.shields.io/npm/v/@aws-sdk/s3-request-presigner/latest.svg)](https://www.npmjs.com/package/@aws-sdk/s3-request-presigner) [![NPM downloads](https://img.shields.io/npm/dm/@aws-sdk/s3-request-presigner.svg)](https://www.npmjs.com/.

www.npmjs.com

https://www.npmjs.com/package/@aws-sdk/client-s3

 

https://www.npmjs.com/package/@aws-sdk/client-s3

AWS SDK for JavaScript S3 Client for Node.js, Browser and React Native. To install this package, simply type add or install @aws-sdk/client-s3 using your favorite package manager: npm install @aws-sdk/client-s3 yarn add @aws-sdk/client-s3 pnpm add @aws-sdk

www.npmjs.com

 

 

DTO

// 다중 이미지 업로드를 위한 DTO
export class GenerateMultiplePresignedUrlDto {
  @IsEnum(BucketFolder, {
    message: `유효한 폴더를 선택해주세요. 허용된 값: ${Object.values(BucketFolder).join(', ')}`,
  })
  folder: BucketFolder;

  @IsNumber({}, { message: '파일 개수는 숫자여야 합니다' })
  @Min(1, { message: '최소 1개 이상의 파일이 필요합니다' })
  @Max(10, { message: '최대 10개까지 업로드 가능합니다' })
  fileCount: number;

  @IsOptional()
  @IsNumber({}, { message: '만료 시간은 숫자여야 합니다' })
  @Min(60, { message: '만료 시간은 최소 60초 이상이어야 합니다' })
  @Max(3600, { message: '만료 시간은 최대 3600초(1시간)까지 가능합니다' })
  expiresIn?: number = 300;
}


------
Controller 

  @Post('multiple-presigned-url')
  async generateMultiplePresignedUrls(
    @Body()
    dto: GenerateMultiplePresignedUrlDto,
  ): Promise<any> {
    try {
      const { folder, fileCount, expiresIn } = dto;

      const results = await this.s3Service.generateMultiplePresignedUrls(
        folder,
        fileCount,
        expiresIn,
      );

      return createResData(results, CustomHttpStatus.OK, '다중 Presigned URL 생성 성공');
    } catch (error: unknown) {
      const errorMessage = String(error);
      return createResData(
        null,
        CustomHttpStatus.INTERNAL_ERROR,
        `다중 Presigned URL 생성 실패: ${errorMessage}`,
      );
    }
  }
  
  
 -----
 Service
 import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { extname } from 'path';
import { v4 as uuid } from 'uuid';
import { BucketFolder } from './enums/bucket-folder.enum';

@Injectable()
export class S3Service {
  private s3: S3Client;
  private readonly bucket: string;
  private readonly logger = new Logger(S3Service.name);

  constructor(private configService: ConfigService) {
    this.s3 = new S3Client({
      region: this.getEnv('AWS_REGION'),
      credentials: {
        accessKeyId: this.getEnv('AWS_ACCESS_KEY_ID'),
        secretAccessKey: this.getEnv('AWS_SECRET_ACCESS_KEY'),
      },
    });
    this.bucket = this.getEnv('AWS_S3_BUCKET_NAME');
  }

   /**
   * 다중 Presigned URL 생성 함수
   * @param folder S3 버킷 내 폴더
   * @param fileCount 생성할 파일 개수
   * @param expiresIn 만료 시간 (초, 기본값: 300초 = 5분)
   * @returns uploadUrl과 fileKey를 포함한 객체 배열
   */
  async generateMultiplePresignedUrls(
    folder: BucketFolder,
    fileCount: number,
    expiresIn: number = 300,
  ): Promise<ResData<any>> {
    try {
      // 폴더 유효성 검사
      const allowedFolders = Object.values(BucketFolder);
      if (!allowedFolders.includes(folder)) {
        return createResData(
          null,
          CustomHttpStatus.UNAUTHORIZED,
          `허용되지 않은 폴더입니다: ${folder}`,
        );
      }

      // 파일 개수 제한 검사
      if (fileCount > 10) {
        return createResData(
          null,
          CustomHttpStatus.UNAUTHORIZED,
          '최대 10개까지 업로드 가능합니다.',
        );
      }

      const results = await Promise.all(
        Array.from({ length: fileCount }, async () => {
          // 파일 키 생성 (png로 고정)
          const fileKey = `${folder}/${uuid()}.png`;

          const command = new PutObjectCommand({
            Bucket: this.bucket,
            Key: fileKey,
            ContentType: 'image/png',
          });

          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
          const uploadUrl = await getSignedUrl(this.s3, command, { expiresIn });

          return {
            // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
            uploadUrl,
            fileKey,
          };
        }),
      );

      this.logger.log(`✅ 다중 Presigned URL 생성 성공: ${fileCount}개 파일`);

      return createResData(results, CustomHttpStatus.OK, '다중 Presigned URL 생성 성공');
    } catch (error: unknown) {
      const errorMessage = String(error);
      this.logger.error(`❌ 다중 Presigned URL 생성 실패: ${errorMessage}`);
      return createResData(
        null,
        CustomHttpStatus.INTERNAL_ERROR,
        `다중 Presigned URL 생성에 실패했습니다: ${errorMessage}`,
      );
    }
  }

 


4. 권한 추가

S3에 접근하려는 IAM 사용자에 S3접근 권한을 추가해주어야합니다.

 


5. Postman으로 테스트 해보기

5-1. Presigned URL 발급 받기

  • POST http://localhost:8000/presigned-url
  • BODY
{
  "folder": "menu",
  "fileCount": 3,
  "expiresIn": 300
}

 

5-2. URL, filekey 응답 받기

 

5-3. 응답 받은 URL, filekey로 PUT하기

5-4. S3 결과 확인하기


서버에서 직접 이미지 받고 S3에 올리는 것 과 뭐가 다를까?

 

두 방식은 S3에 이미지를 업로드한다는 목적은 같지만, 동작 방식, 보안 관점, 그리고 어떤 쪽이 클라이언트/서버의 부담을 줄이느냐에서 차이가 큽니다.

 

차이점은 아래 표와 같습니다.

항목 uploadImages() (Multer 방식) presigned-url 방식
이미지 업로드 주체 백엔드(NestJS 서버)가 직접 S3에 업로드 클라이언트가 직접 S3에 업로드
트래픽 부담 서버 -> S3
: 서버에 이미지 전송 후 업로드하므로 서버 트래픽 증가
클라이언트 -> S3
:서버 트래픽 거의 없음
S3 인증 방식 NestJS 서버의 AWS SDK 자격증명 사용 사전에 발급된 presigned URL 사용 (제한된 시간 내 사용 가능)
보안 백엔드 서버만 S3 접근 권한 가짐 클라이언트는 presigned URL만 갖고 제한된 권한으로 업로드 가능
용량 제한 우회 Lambda 등에서 제한(6MB 등)에 걸릴 수 있음 S3는 직접 접근이므로 제한 회피 가능
사용성 간단한 서버-중심 API에 적합 (예: 관리자 대시보드) 모바일/웹 클라이언트에서 이미지 직접 업로드 시 유리

 

처리 흐름 차이는 아래와 같습니다.

 

1. Multer 방식 (uploadImages())

[클라이언트] → [NestJS 서버] → [S3 저장]
             ↑ 트래픽 부담

 

2. Presigned URL 방식

[클라이언트] → (Presigned URL 요청) → [서버]
[클라이언트] ← (uploadUrl + fileKey 반환) ← [서버]
[클라이언트] → [S3에 직접 PUT 요청 (파일 업로드)]

참고링크

https://velog.io/@mimi0905/Presigned-URL%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%98%EC%97%AC-S3%EB%A1%9C-%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C

 

Presigned URL을 이용하여 S3로 파일 업로드

이 글은 API gateway, lamdba를 이용한 RESTful API 생성에 대한 선수 지식이 있어야 원활한 이해가 가능합니다.

velog.io

 

Comments