AWS - S3 - Presigned URL

김기훈·2026년 3월 8일

AWS

목록 보기
4/5

AWS CORS

  • CORS(Cross-Origin Resource Sharing, 교차 출처 리소스 공유)
    • 다른 동네(도메인)에서 온 요청을 받아줄지 말지 결정하는 문지기
  • S3 방식

    • 프론트엔드 → Django 서버 → S3
      • 이때 S3에 파일을 넘겨주는 건 브라우저가 아니라 'Django 서버(파이썬)'
      • 서버끼리 통신할 때는 문지기(CORS)가 검사하지 않기 때문에 무사통과함
  • Presigned URL 방식

    • 프론트엔드(브라우저) → S3 (직접 전송)
      • 브라우저가 프론트엔드 주소(http://localhost:3000 등)를 달고
      • S3 문지기 입장에서는 "어? 너 우리 동네(S3) 출신 아니네? 해킹 아니야? 돌아가!" 하고 막아버림
        • 이게 바로 악명 높은 CORS 에러
      • 프론트엔드에서 직접 파일을 PUT(업로드)하러 오면, "내가 허락한 거니까 문 열어줘"
        • 라고 명부를 작성해 주는 작업이 바로 CORS 설정

AWS S3 CORS 설정

  • AWS S3 콘솔 접속

    • AWS에 로그인하고 S3 서비스로 이동
  • 내 버킷 클릭

    • 생성했던 버킷 이름을 클릭해 들어감
  • [권한] 탭 이동

    • 화면 상단의 여러 탭 중에서 [권한(Permissions)] 탭을 클릭
  • CORS 섹션 찾기

    • 마우스 스크롤을 맨 아래로 끝까지 내리면 [교차 출처 리소스 공유(CORS)] 라는 항목이 보임
  • [편집] 버튼 클릭

    • 우측 상단의 편집 버튼을 누름
  • JSON 코드 입력

    • 빈칸에 아래의 JSON 코드를 그대로 복사해서 붙여넣음
      • "AllowedOrigins": ["*"]
        • 어떤 도메인에서 오든 허락하겠다는 뜻(*는 전체를 의미)
        • 현재는 로컬 테스트, Swagger, 프론트엔드 등 어디서든 접근해야 하므로 *로 둠
        • 실무 배포 시에는
          • ["https://my-blog.com"] 처럼 실제 프론트엔드 주소만 넣는 것이 가장 안전한 최선의 방법
      • "AllowedMethods": ["PUT", "POST", ... ]
        • 파일을 업로드할 때는 PUT이나 POST 메서드를 사용하므로 이 권한들을 열어줌
      • "AllowedHeaders": ["*"]
        • 브라우저가 파일을 보낼 때 어떤 헤더를 달고 와도 통과시켜 줌
[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "GET",
            "PUT",
            "POST",
            "DELETE",
            "HEAD"
        ],
        "AllowedOrigins": [
            "*"
        ],
        "ExposeHeaders": []
    }
]

  • 변경 사항 저장
    • 우측 하단의 주황색 [변경 사항 저장] 버튼을 누름

CORS 설정 오류 해결했던 코드

  • ETag(Entity Tag)
    • '고유한 지문(Fingerprint)'이자 '업로드 성공 영수증' 같은 역할을 하는 HTTP 헤더
      • 역할
        • 파일의 무결성 검사 (제대로 올라갔는지 확인)
          • S3에 파일을 업로드하면
            • S3는 파일의 내용을 바탕으로 고유한 암호화 문자열(주로 MD5 해시값)을 만들어냄
            • 프론트엔드에서 파일을 보낼 때 "내가 보낸 파일의 암호화 값은 이거야"라고 알리고
              • S3가 응답으로 준 ETag 값과 비교해서 똑같다면
              • "파일이 1바이트도 깨지지 않고 완벽하게 S3에 저장되었구나!" 하고 확신할 수 있게 해줌
        • 브라우저 캐싱 (빠른 로딩)
          • 나중에 사용자가 블로그 글을 읽을 때 썸네일 이미지를 다운로드하게 되는데
            • 이때도 S3는 ETag를 같이 보내줌
          • 다음번에 똑같은 페이지를 열 때 브라우저가 S3에게 "나한테 ETag 'A123' 이미지 있는데
            • 혹시 파일이 바뀌었어?" 라고 물어봄
          • 만약 파일이 그대로라면 S3는 "안 바뀌었어!
            • 새로 다운받지 말고 네 컴퓨터에 있는 거 그대로 써!"(304 Not Modified) 라고 응답하여
            • 블로그 로딩 속도를 획기적으로 높여주고 데이터 요금도 아껴줌
        • 하필 CORS 설정(ExposeHeaders)에 넣은 이유
          • 웹 브라우저는 보안이 아주 엄격해서 프론트엔드 자바스크립트(fetch나 axios)가
            • 다른 도메인(S3)에서 온 응답의 헤더를 함부로 뜯어보지 못하게 막아둠
            • 하지만 무결성 검사나, 나중에 동영상 같은 대용량 파일을 여러 조각으로 쪼개서
            • 올리는 기능(Multipart Upload)을 구현하려면 프론트엔드 자바스크립트가 반드시 ETag 값을 읽어야만 함
  • 그래서 S3에게 "프론트엔드(내 웹사이트)가 네 응답을 받을 때
    • ETag라는 이름의 영수증(헤더)만큼은 자바스크립트가 읽을 수 있게 바깥으로 꺼내줘(Expose)" 라고 지시한 것
[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "GET",
            "PUT",
            "POST",
            "DELETE",
            "HEAD"
        ],
        "AllowedOrigins": [
            "http://127.0.0.1:8000",
            "http://localhost:8000",
            "*"
        ],
        "ExposeHeaders": [
            "ETag"
        ]
    }
]

코드 작성

  • 프론트엔드에게 10분짜리 임시 업로드 허가증(Presigned URL)을 발급해 주는 API

    • 서버(Django)는 단지 가벼운 '문자열(URL)' 하나만 만들어주고
    • 실제 무거운 파일 전송은 S3와 브라우저가 알아서 하도록 책임을 떠넘김
    • 서버 부하가 0(Zero)에 가까워짐

Presigned URL 발급 API

import os
import uuid
import boto3
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from django.conf import settings
from datetime import datetime

class PresignedUrlAPIView(APIView):
    """
    S3에 직접 이미지를 업로드할 수 있는 임시 URL(Presigned URL)을 발급합니다.
    """
    # 이미지는 로그인한(인증된) 사용자만 올릴 수 있도록 제한합니다. (해킹/도배 방지)
    permission_classes = [IsAuthenticated]

    @extend_schema(
        tags=["이미지"],
        summary="S3 Presigned URL 발급",
        parameters=[
            OpenApiParameter(
                name="filename",
                description="업로드할 파일의 원본 이름 (예: my_photo.png)",
                type=OpenApiTypes.STR,
                location=OpenApiParameter.QUERY,
                required=True,
            )
        ]
    )
    def get(self, request):
        # 1. 프론트엔드가 보낸 원본 파일 이름을 가져옵니다.
        filename = request.query_params.get("filename")
        if not filename:
            return Response({"error": "filename은 필수입니다."}, status=status.HTTP_400_BAD_REQUEST)

        # 2. 파일 이름 충돌(덮어쓰기)을 막기 위해 고유한 파일명을 생성합니다.
        # 확장자(ext)를 분리한 뒤, 임의의 고유 문자열(uuid)을 붙여줍니다.
        ext = filename.split(".")[-1]
        unique_filename = f"{uuid.uuid4().hex}.{ext}"

        # 3. S3 버킷 내에 저장될 최종 경로를 생성합니다. (예: post/thumbnails/2026/03/08/고유문자열.png)
        today = datetime.now().strftime("%Y/%m/%d")
        object_name = f"post/thumbnails/{today}/{unique_filename}"

        # 4. boto3 S3 클라이언트를 생성합니다. (settings.py에 적어둔 환경변수를 가져옵니다)
        s3_client = boto3.client(
            's3',
            region_name=settings.AWS_S3_REGION_NAME,
            aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
            aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY
        )

        try:
            # 5. 대망의 Presigned URL 생성 부분입니다! (가장 핵심)
            # 'put_object'는 S3에 파일을 올리는 작업을 의미합니다.
            presigned_url = s3_client.generate_presigned_url(
                'put_object',
                Params={
                    'Bucket': settings.AWS_STORAGE_BUCKET_NAME,
                    'Key': object_name, # 저장될 경로
                    'ContentType': f'image/{ext}' # 파일 형식 지정 (필수)
                },
                ExpiresIn=600 # 이 URL의 유효기간을 600초(10분)로 설정합니다. 10분이 지나면 쓸 수 없는 휴지조각이 됩니다.
            )
            
            # 6. S3에 파일이 저장된 후, 프론트엔드가 사용할 이미지의 최종 접속 주소를 만들어 줍니다.
            # 이 주소를 나중에 게시글 저장 API로 보내게 됩니다.
            image_url = f"https://{settings.AWS_S3_CUSTOM_DOMAIN}/{object_name}"

            # 7. 프론트엔드에게 임시 업로드 URL과 최종 이미지 URL을 모두 넘겨줍니다.
            return Response({
                "presigned_url": presigned_url,
                "image_url": image_url
            }, status=status.HTTP_200_OK)

        except Exception as e:
            return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

스웨거에서 저장잘되는데 콘솔에서 안보임

  • Django가 버전업을 하면서 파일 저장소를 설정하는 방식이 변경되었기 때문
    • DEFAULT_FILE_STORAGE 방식은 과거 버전의 방식
    • Django 최신 버전에서는 이 설정어를 완전히 무시하고 기본값인 '로컬 저장소'로 폴백(Fallback) 시켜버림
    • 최신 장고 버전에 맞는 STORAGES 설정으로 바꿔주어 해결
# Django 미디어 파일 저장소 변경
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'

——————————————————————————————————————[비교]—————————————————————————————————————————
STORAGES = {
    # 1. 미디어 파일 (유저가 업로드하는 파일, 썸네일 등) -> S3로 보냄
    "default": {
        "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
    },
    # 2. 정적 파일 (CSS, JS 등) -> 일단 기존처럼 서버 로컬에서 처리
    "staticfiles": {
        "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
    },
}

profile
안녕하세요.

0개의 댓글