S3 Pre-signed URL로 파일 업로드/다운로드 최적화하기

S3 Pre-signed URL을 활용한 파일 업로드/다운로드 구현으로 백엔드 부담을 줄이고 성능과 보안을 동시에 향상시키는 실무 전략을 알아보자.

들어가며

웹 애플리케이션에서 파일 업로드/다운로드는 빼놓을 수 없는 핵심 기능이다. 하지만 기존의 백엔드 서버를 경유하는 방식은 여러 문제점을 안고 있다. 대용량 파일을 처리할 때마다 서버 리소스가 집중적으로 사용되고, 네트워크 대역폭이 부족해지며, 전체 시스템의 성능이 저하되는 문제가 발생한다.

이런 문제를 해결하기 위해 AWS S3의 Pre-signed URL을 활용한 직접 업로드/다운로드 방식이 주목받고 있다. 이 방식은 백엔드 서버의 부담을 대폭 줄이면서도 보안성과 성능을 동시에 확보할 수 있는 효과적인 해결책이다.

S3 Pre-signed URL의 개념과 동작 원리

Pre-signed URL이란?

Pre-signed URL은 AWS S3에서 제공하는 기능으로, 임시적으로 S3 객체에 대한 접근 권한을 부여하는 URL이다. 이 URL을 통해 AWS 자격 증명 없이도 특정 시간 동안 S3 객체에 대한 GET, PUT, DELETE 등의 작업을 수행할 수 있다.

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

const s3Client = new S3Client({
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});

// 업로드용 Pre-signed URL 생성
export async function generateUploadPresignedUrl(
  bucket: string,
  key: string,
  expiresIn: number = 3600 // 1시간
): Promise<string> {
  const command = new PutObjectCommand({
    Bucket: bucket,
    Key: key,
    ContentType: "application/octet-stream",
  });

  return await getSignedUrl(s3Client, command, { expiresIn });
}

동작 메커니즘

Pre-signed URL의 동작 과정은 다음과 같다. 클라이언트가 파일 업로드를 요청하면, 백엔드 서버는 파일 정보를 바탕으로 S3에 Pre-signed URL을 요청한다. 서버는 생성된 URL을 클라이언트에게 반환하고, 클라이언트는 이 URL을 사용해 직접 S3에 파일을 업로드한다.

이 과정에서 실제 파일 데이터는 백엔드 서버를 거치지 않고 클라이언트에서 S3로 직접 전송된다. 이는 서버의 CPU, 메모리, 네트워크 대역폭을 모두 절약하는 효과를 가져온다.

보안 특징

Pre-signed URL은 생성 시점에 지정된 권한과 만료 시간을 가진다. URL에는 AWS Signature Version 4 알고리즘을 통해 생성된 서명이 포함되어 있어, 위조나 변조를 방지한다. 또한 특정 작업(GET, PUT 등)에 대해서만 권한을 부여할 수 있어 최소 권한 원칙을 준수한다.

파일 업로드 구현 전략

백엔드 API 설계

파일 업로드를 위한 백엔드 API는 클라이언트로부터 파일 메타데이터를 받아 Pre-signed URL을 생성하는 역할을 한다.

import { Request, Response } from "express";

interface UploadRequestBody {
  filename: string;
  contentType: string;
  fileSize: number;
}

export async function getUploadUrl(req: Request, res: Response) {
  try {
    const { filename, contentType, fileSize }: UploadRequestBody = req.body;
    
    // 파일 크기 검증
    if (fileSize > 100 * 1024 * 1024) { // 100MB 제한
      return res.status(400).json({ error: "파일 크기가 너무 큽니다." });
    }

    // 안전한 파일 키 생성
    const fileKey = `uploads/${Date.now()}-${filename}`;
    
    // Pre-signed URL 생성
    const uploadUrl = await generateUploadPresignedUrl(
      process.env.S3_BUCKET_NAME!,
      fileKey,
      3600 // 1시간 후 만료
    );

    res.json({
      uploadUrl,
      key: fileKey,
      expiresIn: 3600
    });
  } catch (error) {
    res.status(500).json({ error: "URL 생성 중 오류가 발생했습니다." });
  }
}

클라이언트 구현

클라이언트 측에서는 Pre-signed URL을 받아 직접 S3에 파일을 업로드한다. 이때 진행률 추적과 오류 처리를 포함한 완전한 업로드 로직을 구현해야 한다.

class FileUploader {
  async uploadFile(file: File): Promise<string> {
    try {
      // 1. 백엔드에서 Pre-signed URL 획득
      const urlResponse = await fetch('/api/upload-url', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          filename: file.name,
          contentType: file.type,
          fileSize: file.size
        })
      });

      if (!urlResponse.ok) {
        throw new Error('Upload URL 생성 실패');
      }

      const { uploadUrl, key } = await urlResponse.json();

      // 2. S3에 직접 업로드
      const uploadResponse = await fetch(uploadUrl, {
        method: 'PUT',
        body: file,
        headers: {
          'Content-Type': file.type,
        }
      });

      if (!uploadResponse.ok) {
        throw new Error('파일 업로드 실패');
      }

      return key;
    } catch (error) {
      console.error('업로드 에러:', error);
      throw error;
    }
  }
}

업로드 진행률 추적

대용량 파일의 경우 업로드 진행률을 표시하는 것이 사용자 경험에 중요하다. XMLHttpRequest나 fetch의 ReadableStream을 활용해 진행률을 추적할 수 있다.

async uploadWithProgress(
  file: File, 
  onProgress: (percent: number) => void
): Promise<string> {
  // Pre-signed URL 획득 과정 생략...
  
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    
    xhr.upload.addEventListener('progress', (event) => {
      if (event.lengthComputable) {
        const percent = (event.loaded / event.total) * 100;
        onProgress(percent);
      }
    });

    xhr.addEventListener('load', () => {
      if (xhr.status === 200) {
        resolve(key);
      } else {
        reject(new Error(`업로드 실패: ${xhr.status}`));
      }
    });

    xhr.addEventListener('error', () => {
      reject(new Error('네트워크 오류'));
    });

    xhr.open('PUT', uploadUrl);
    xhr.setRequestHeader('Content-Type', file.type);
    xhr.send(file);
  });
}

파일 다운로드 구현 전략

다운로드 URL 생성

파일 다운로드를 위한 Pre-signed URL 생성은 업로드와 유사하지만, GetObjectCommand를 사용한다는 점이 다르다.

import { GetObjectCommand } from "@aws-sdk/client-s3";

export async function generateDownloadPresignedUrl(
  bucket: string,
  key: string,
  expiresIn: number = 3600
): Promise<string> {
  const command = new GetObjectCommand({
    Bucket: bucket,
    Key: key,
  });

  return await getSignedUrl(s3Client, command, { expiresIn });
}

// 다운로드 API 엔드포인트
export async function getDownloadUrl(req: Request, res: Response) {
  try {
    const { fileKey } = req.params;
    
    // 사용자 권한 검증
    const hasPermission = await checkUserPermission(req.user.id, fileKey);
    if (!hasPermission) {
      return res.status(403).json({ error: "접근 권한이 없습니다." });
    }

    const downloadUrl = await generateDownloadPresignedUrl(
      process.env.S3_BUCKET_NAME!,
      fileKey,
      900 // 15분 후 만료
    );

    res.json({ downloadUrl });
  } catch (error) {
    res.status(500).json({ error: "다운로드 URL 생성 실패" });
  }
}

클라이언트 다운로드 처리

클라이언트에서는 다운로드 URL을 받아 파일을 다운로드하거나, 브라우저에서 직접 열 수 있도록 처리한다.

class FileDownloader {
  async downloadFile(fileKey: string, filename: string): Promise<void> {
    try {
      // 다운로드 URL 획득
      const response = await fetch(`/api/download/${fileKey}`);
      if (!response.ok) {
        throw new Error('다운로드 URL 생성 실패');
      }

      const { downloadUrl } = await response.json();

      // 파일 다운로드 실행
      const link = document.createElement('a');
      link.href = downloadUrl;
      link.download = filename;
      link.click();
    } catch (error) {
      console.error('다운로드 에러:', error);
      throw error;
    }
  }

  async previewFile(fileKey: string): Promise<string> {
    const response = await fetch(`/api/download/${fileKey}`);
    const { downloadUrl } = await response.json();
    return downloadUrl;
  }
}

백엔드 서버 부담 완화 효과

리소스 사용량 비교

전통적인 방식에서는 1GB 파일을 업로드할 때 백엔드 서버에서 최소 1GB의 메모리와 네트워크 대역폭을 사용해야 한다. 반면 Pre-signed URL을 사용하면 서버는 단순히 URL 생성을 위한 몇 KB의 연산만 수행하면 된다.

// 기존 방식의 문제점을 보여주는 예시
export async function traditionalUpload(req: Request, res: Response) {
  try {
    // 전체 파일이 서버 메모리에 로드됨
    const file = req.file; // multer 등을 통해 받은 파일
    
    // 서버에서 S3로 다시 업로드 (이중 네트워크 사용)
    const uploadResult = await s3Client.send(new PutObjectCommand({
      Bucket: process.env.S3_BUCKET_NAME!,
      Key: `uploads/${file.originalname}`,
      Body: file.buffer, // 메모리에 저장된 파일 데이터
    }));
    
    res.json({ success: true, key: uploadResult.Key });
  } catch (error) {
    res.status(500).json({ error: "업로드 실패" });
  }
}

동시 처리 성능 향상

Pre-signed URL 방식을 사용하면 서버가 파일 전송 작업에서 해방되어 더 많은 동시 요청을 처리할 수 있다. 이는 특히 대용량 파일이나 많은 사용자가 동시에 파일을 업로드하는 상황에서 큰 차이를 만든다.

확장성 개선

백엔드 서버의 부담이 줄어들면 자연스럽게 시스템의 확장성이 향상된다. 서버 인스턴스를 추가하지 않고도 더 많은 파일 업로드 요청을 처리할 수 있으며, 오토 스케일링 정책도 더 효율적으로 작동한다.

캐싱 전략

Pre-signed URL 캐싱

Pre-signed URL은 생성 비용이 상대적으로 높기 때문에, 적절한 캐싱 전략을 사용하면 성능을 더욱 향상시킬 수 있다.

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

export async function getCachedUploadUrl(
  fileKey: string,
  contentType: string
): Promise<string> {
  const cacheKey = `presigned_url:upload:${fileKey}`;
  
  // 캐시에서 확인
  const cachedUrl = await redis.get(cacheKey);
  if (cachedUrl) {
    return cachedUrl;
  }

  // 새로운 URL 생성
  const uploadUrl = await generateUploadPresignedUrl(
    process.env.S3_BUCKET_NAME!,
    fileKey,
    3600
  );

  // 캐시에 저장 (만료 시간의 90%만 캐시)
  await redis.setex(cacheKey, 3240, uploadUrl); // 54분
  
  return uploadUrl;
}

메타데이터 캐싱

파일 정보나 권한 체크 결과를 캐시하여 Pre-signed URL 생성 과정을 최적화할 수 있다.

interface FileMetadata {
  filename: string;
  contentType: string;
  size: number;
  ownerId: string;
  uploadedAt: Date;
}

export async function getCachedFileMetadata(
  fileKey: string
): Promise<FileMetadata | null> {
  const cacheKey = `file_metadata:${fileKey}`;
  
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }

  // 데이터베이스에서 조회
  const metadata = await database.files.findOne({ key: fileKey });
  if (metadata) {
    await redis.setex(cacheKey, 3600, JSON.stringify(metadata));
  }

  return metadata;
}

CDN 활용

정적 파일이나 공개 파일의 경우 CloudFront 같은 CDN을 통해 캐싱하여 다운로드 성능을 크게 향상시킬 수 있다.

export async function getOptimizedDownloadUrl(
  fileKey: string,
  useCache: boolean = true
): Promise<string> {
  if (useCache && isPublicFile(fileKey)) {
    // CDN URL 반환
    return `https://cdn.example.com/${fileKey}`;
  }

  // Private 파일은 Pre-signed URL 사용
  return await generateDownloadPresignedUrl(
    process.env.S3_BUCKET_NAME!,
    fileKey,
    900
  );
}

보안 전략

접근 권한 검증

Pre-signed URL을 생성하기 전에 사용자의 접근 권한을 철저히 검증해야 한다.

interface UserPermission {
  userId: string;
  fileKey: string;
  permissions: string[];
}

export async function checkUserPermission(
  userId: string,
  fileKey: string,
  action: 'read' | 'write' | 'delete'
): Promise<boolean> {
  // 파일 소유자 확인
  const file = await database.files.findOne({ key: fileKey });
  if (!file) {
    return false;
  }

  if (file.ownerId === userId) {
    return true;
  }

  // 공유 권한 확인
  const permission = await database.permissions.findOne({
    userId,
    fileKey,
    permissions: { $in: [action] }
  });

  return !!permission;
}

URL 만료 시간 관리

용도에 따라 적절한 만료 시간을 설정하여 보안을 강화한다.

export function getExpirationTime(fileType: string, action: string): number {
  const expirationMap = {
    'image': { 'read': 3600, 'write': 1800 },      // 이미지: 읽기 1시간, 쓰기 30분
    'document': { 'read': 1800, 'write': 900 },    // 문서: 읽기 30분, 쓰기 15분
    'video': { 'read': 7200, 'write': 3600 },      // 비디오: 읽기 2시간, 쓰기 1시간
    'default': { 'read': 900, 'write': 600 }       // 기본: 읽기 15분, 쓰기 10분
  };

  return expirationMap[fileType]?.[action] || expirationMap['default'][action];
}

업로드 정책 적용

S3 버킷 정책과 Pre-signed URL 정책을 조합하여 다층 보안을 구현한다.

export async function generateSecureUploadUrl(
  fileKey: string,
  contentType: string,
  maxSize: number
): Promise<string> {
  const command = new PutObjectCommand({
    Bucket: process.env.S3_BUCKET_NAME!,
    Key: fileKey,
    ContentType: contentType,
    // 조건부 업로드 설정
    Conditions: [
      ['content-length-range', 0, maxSize],
      ['starts-with', '$Content-Type', contentType.split('/')[0]],
    ],
  });

  return await getSignedUrl(s3Client, command, { 
    expiresIn: getExpirationTime(contentType, 'write') 
  });
}

파일 스캔 및 검증

업로드된 파일의 안전성을 검증하기 위한 추가 보안 절차를 구현한다.

export async function scanUploadedFile(fileKey: string): Promise<boolean> {
  try {
    // 파일 타입 검증
    const headResponse = await s3Client.send(new HeadObjectCommand({
      Bucket: process.env.S3_BUCKET_NAME!,
      Key: fileKey,
    }));

    // 실제 파일 타입과 선언된 타입 비교
    const actualContentType = headResponse.ContentType;
    const expectedContentType = getExpectedContentType(fileKey);
    
    if (actualContentType !== expectedContentType) {
      // 위험한 파일로 판단하여 삭제
      await s3Client.send(new DeleteObjectCommand({
        Bucket: process.env.S3_BUCKET_NAME!,
        Key: fileKey,
      }));
      return false;
    }

    return true;
  } catch (error) {
    console.error('파일 스캔 실패:', error);
    return false;
  }
}

실제 운영 시 고려사항

오류 처리 및 재시도

네트워크 불안정이나 일시적인 오류에 대비한 재시도 로직을 구현해야 한다.

export async function uploadWithRetry(
  file: File,
  maxRetries: number = 3
): Promise<string> {
  let lastError: Error;

  for (let i = 0; i < maxRetries; i++) {
    try {
      return await uploadFile(file);
    } catch (error) {
      lastError = error as Error;
      
      // 재시도 가능한 오류인지 확인
      if (isRetryableError(error)) {
        await delay(Math.pow(2, i) * 1000); // 지수적 백오프
        continue;
      }
      
      throw error;
    }
  }

  throw lastError;
}

function isRetryableError(error: any): boolean {
  return error.code === 'NETWORK_ERROR' || 
         error.status >= 500 || 
         error.name === 'TimeoutError';
}

모니터링 및 알림

시스템의 안정성을 위해 각종 지표를 모니터링하고 이상 상황 시 알림을 받을 수 있도록 구성한다.

export async function logUploadMetrics(
  fileKey: string,
  fileSize: number,
  uploadTime: number,
  success: boolean
): Promise<void> {
  const metrics = {
    timestamp: new Date(),
    fileKey,
    fileSize,
    uploadTime,
    success,
    throughput: success ? fileSize / uploadTime : 0,
  };

  // 메트릭스 수집 시스템으로 전송
  await sendMetrics('file_upload', metrics);
  
  // 임계값 초과 시 알림
  if (uploadTime > 30000) { // 30초 초과
    await sendAlert(`느린 업로드 감지: ${fileKey} (${uploadTime}ms)`);
  }
}

마무리

S3 Pre-signed URL을 활용한 파일 업로드/다운로드 시스템은 백엔드 서버의 부담을 크게 줄이면서도 보안성과 성능을 동시에 확보할 수 있는 효과적인 해결책이다.

핵심은 적절한 권한 검증, 캐싱 전략, 그리고 보안 정책을 조합하여 안정적이고 확장 가능한 시스템을 구축하는 것이다. 특히 대용량 파일을 다루는 애플리케이션이나 많은 사용자가 동시에 파일을 업로드하는 환경에서는 이러한 방식의 이점이 더욱 두드러진다.

실제 운영 환경에서는 모니터링과 오류 처리, 사용자 경험 최적화 등을 종합적으로 고려하여 시스템을 설계해야 한다. 이를 통해 안정적이고 효율적인 파일 관리 시스템을 구축할 수 있을 것이다.

참고