Skip to main content

왜 Presigned URL인가?

S3 Presigned URL 파일 업로드

파일 업로드를 서버를 통해 처리하면 문제가 생깁니다. 대용량 파일이 서버 메모리를 점유하고, 네트워크 대역폭을 두 배로 사용하며, 업로드 시간이 길어질수록 서버 커넥션이 묶입니다. AWS S3 Presigned URL은 이 문제를 근본적으로 해결합니다. 클라이언트가 서버를 거치지 않고 S3에 직접 파일을 올리는 방식이죠.

Presigned URL은 일정 시간(보통 5~15분) 동안만 유효한 서명된 URL입니다. 서버는 이 URL을 생성해서 클라이언트에게 전달하고, 클라이언트는 해당 URL로 파일을 S3에 직접 PUT 요청합니다. 서버 부하가 거의 없고, 업로드 속도도 훨씬 빠릅니다.

AWS S3 버킷 설정

먼저 S3 버킷을 생성하고 CORS 정책을 설정해야 합니다. 브라우저에서 직접 PUT 요청을 보내므로 CORS 허용이 필수입니다.

[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
    "AllowedOrigins": ["https://yourdomain.com"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3000
  }
]

버킷 정책에서 퍼블릭 액세스를 차단하고, IAM 사용자 또는 역할에만 접근 권한을 부여하는 것이 보안상 올바른 방식입니다. 업로드 버킷과 서빙 버킷을 분리하는 것도 좋은 패턴입니다.

FastAPI 백엔드 구현

FastAPI에서 Presigned URL을 생성하는 엔드포인트를 만들어봅니다. boto3 라이브러리를 사용합니다.

import boto3
import uuid
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional

router = APIRouter()

s3_client = boto3.client(
    "s3",
    region_name="ap-northeast-2",
    aws_access_key_id="YOUR_ACCESS_KEY",
    aws_secret_access_key="YOUR_SECRET_KEY",
)

BUCKET_NAME = "your-upload-bucket"
ALLOWED_CONTENT_TYPES = ["image/jpeg", "image/png", "image/webp", "application/pdf"]
MAX_FILE_SIZE = 10 * 1024 * 1024  # 10MB

class PresignedUrlRequest(BaseModel):
    filename: str
    content_type: str
    file_size: int

class PresignedUrlResponse(BaseModel):
    upload_url: str
    file_key: str
    expires_in: int

@router.post("/upload/presigned-url", response_model=PresignedUrlResponse)
async def get_presigned_url(
    request: PresignedUrlRequest,
    current_user=Depends(get_current_user)
):
    if request.content_type not in ALLOWED_CONTENT_TYPES:
        raise HTTPException(status_code=400, detail="허용되지 않는 파일 형식입니다.")
    
    if request.file_size > MAX_FILE_SIZE:
        raise HTTPException(status_code=400, detail="파일 크기가 10MB를 초과합니다.")
    
    ext = request.filename.rsplit(".", 1)[-1].lower()
    file_key = f"uploads/{current_user.id}/{uuid.uuid4()}.{ext}"
    
    presigned_url = s3_client.generate_presigned_url(
        "put_object",
        Params={
            "Bucket": BUCKET_NAME,
            "Key": file_key,
            "ContentType": request.content_type,
            "ContentLength": request.file_size,
        },
        ExpiresIn=600,  # 10분
    )
    
    return PresignedUrlResponse(
        upload_url=presigned_url,
        file_key=file_key,
        expires_in=600
    )

ContentLength를 Presigned URL 생성 시 지정하면, 클라이언트가 해당 크기와 다른 파일을 올릴 수 없게 강제할 수 있습니다. 이렇게 하면 서버 검증 없이도 파일 크기 제한을 S3 레벨에서 강제할 수 있습니다.

SvelteKit 프론트엔드 구현

클라이언트 측에서는 먼저 서버에 Presigned URL을 요청하고, 받은 URL로 파일을 직접 S3에 업로드합니다.

<script lang="ts">
  let fileInput: HTMLInputElement;
  let uploading = false;
  let uploadProgress = 0;
  let uploadedKey = '';

  async function handleFileUpload(event: Event) {
    const file = (event.target as HTMLInputElement).files?.[0];
    if (!file) return;

    uploading = true;
    uploadProgress = 0;

    try {
      // 1. 서버에서 Presigned URL 받기
      const res = await fetch('/api/upload/presigned-url', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          filename: file.name,
          content_type: file.type,
          file_size: file.size
        })
      });

      if (!res.ok) throw new Error('Presigned URL 발급 실패');
      const { upload_url, file_key } = await res.json();

      // 2. XMLHttpRequest로 진행률 추적하며 S3에 직접 업로드
      await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        
        xhr.upload.addEventListener('progress', (e) => {
          if (e.lengthComputable) {
            uploadProgress = Math.round((e.loaded / e.total) * 100);
          }
        });
        
        xhr.addEventListener('load', () => {
          if (xhr.status === 200) resolve();
          else reject(new Error(`업로드 실패: ${xhr.status}`));
        });
        
        xhr.addEventListener('error', () => reject(new Error('네트워크 오류')));
        
        xhr.open('PUT', upload_url);
        xhr.setRequestHeader('Content-Type', file.type);
        xhr.send(file);
      });

      uploadedKey = file_key;
      console.log('업로드 완료:', file_key);
      
    } catch (error) {
      console.error('업로드 오류:', error);
      alert('파일 업로드에 실패했습니다.');
    } finally {
      uploading = false;
    }
  }
</script>

<div class="upload-area">
  {#if uploading}
    <div class="progress-bar">
      <div class="progress" style="width: {uploadProgress}%"></div>
    </div>
    <p>업로드 중... {uploadProgress}%</p>
  {:else}
    <input type="file" bind:this={fileInput} on:change={handleFileUpload} accept="image/*,application/pdf" />
    <label for="file">파일 선택</label>
  {/if}
</div>

업로드 완료 후 처리 — S3 Key 저장과 URL 생성

업로드가 완료된 후에는 S3 파일 키를 DB에 저장하고, 조회 시 Presigned GET URL을 생성해서 내려주는 패턴을 사용합니다. 파일 자체의 URL을 저장하면 버킷 정책 변경 시 문제가 생기지만, 키를 저장하고 매번 Presigned URL을 생성하면 유연하게 관리할 수 있습니다.

# 업로드 완료 후 DB에 key 저장
@router.post("/upload/confirm")
async def confirm_upload(
    file_key: str,
    current_user=Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
):
    # S3에 실제로 파일이 있는지 확인
    try:
        s3_client.head_object(Bucket=BUCKET_NAME, Key=file_key)
    except s3_client.exceptions.ClientError:
        raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다.")
    
    # DB에 저장
    attachment = Attachment(
        user_id=current_user.id,
        s3_key=file_key,
        uploaded_at=datetime.utcnow()
    )
    db.add(attachment)
    await db.commit()
    
    return {"message": "저장 완료", "attachment_id": attachment.id}


# 파일 조회 시 Presigned GET URL 생성
def get_file_url(s3_key: str, expires_in: int = 3600) -> str:
    return s3_client.generate_presigned_url(
        "get_object",
        Params={"Bucket": BUCKET_NAME, "Key": s3_key},
        ExpiresIn=expires_in
    )


@router.get("/files/{attachment_id}")
async def get_file(
    attachment_id: int,
    current_user=Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
):
    attachment = await db.get(Attachment, attachment_id)
    if not attachment or attachment.user_id != current_user.id:
        raise HTTPException(status_code=404)
    
    return {
        "url": get_file_url(attachment.s3_key),
        "expires_in": 3600
    }

보안 고려사항

Presigned URL 방식을 사용할 때 몇 가지 보안 요소를 반드시 챙겨야 합니다. 첫째, 파일 키에 사용자 ID를 포함해 타인의 파일 키를 추측하지 못하도록 합니다. 둘째, 업로드 완료 후 반드시 서버 측에서 S3에 파일 존재 여부를 검증(head_object)합니다. 셋째, 이미지 파일이라면 업로드 후 Lambda 등을 통해 바이러스 스캔이나 이미지 리사이징을 비동기 처리하는 것을 권장합니다. 넷째, 업로드 버킷과 서빙 버킷을 분리하고, 검증된 파일만 서빙 버킷으로 복사하는 아키텍처가 가장 안전합니다.

코드벤터는 글로벌 협력 네트워크를 기반으로 다양한 SaaS 및 플랫폼 서비스를 구축하면서 축적한 실전 경험을 기술 블로그를 통해 공유합니다. S3 Presigned URL 패턴은 파일 업로드가 필요한 모든 서비스에서 서버 부하를 줄이고 사용자 경험을 개선하는 핵심 기술입니다. 더 구체적인 구현 사례나 아키텍처 상담이 필요하시면 코드벤터 팀에 문의해주세요.

코드픽 - 외주 전문 AI 바이브 코딩 글로벌 진출

댓글 남기기