External 업로드 (S3/GCS)

파일을 Django 서버가 아니라 클라우드 스토리지로 직접 올린다. 서버 대역폭을 쓰지 않으므로 큰 파일에 유리하고, 청크 엔드포인트가 요구하는 공유 저장소 조건(chunked-uploads.md)에서도 자유롭다.

흐름#

  1. 클라이언트가 파일을 고른다
  2. 서버가 presigned URL을 만들어 준다
  3. 클라이언트가 스토리지로 직접 올린다
  4. 업로드가 끝나면 서버에 알린다

빠른 시작#

S3#

import boto3
from wireview import Component, ExternalUploadMeta


class FileUploader(Component):
    class Meta:
        template_name = "uploads/file_uploader.html"

    files: list[dict] = []

    async def joined(self):
        self.allow_upload(
            "documents",
            accept=[".pdf", ".doc", ".docx"],
            max_entries=5,
            max_file_size=50 * 1024 * 1024,  # 50MB
            # 이름이 _로 시작해야 한다: 아니면 클라이언트가 부를 수 있는 이벤트 핸들러가 된다
            external=self._presign_s3_upload,
        )

    def _presign_s3_upload(self, entry, component):
        """Generate a presigned URL for the S3 upload."""
        s3 = boto3.client(
            "s3",
            aws_access_key_id="YOUR_ACCESS_KEY",
            aws_secret_access_key="YOUR_SECRET_KEY",
            region_name="us-east-1",
        )

        key = f"uploads/{entry.ref}/{entry.client_name}"

        url = s3.generate_presigned_url(
            "put_object",
            Params={
                "Bucket": "your-bucket",
                "Key": key,
                "ContentType": entry.client_type,
            },
            ExpiresIn=3600,  # 1시간
        )

        return ExternalUploadMeta(
            uploader="S3",
            url=url,
            method="PUT",
            headers={"Content-Type": entry.client_type},
        )

    async def on_upload_complete(self, name, entry):
        """Called for each upload the client reports as finished."""
        self.files.append(
            {
                "name": entry.client_name,
                "ref": entry.ref,
                # 나중에 꺼내 쓸 S3 키를 기억해 둔다
                "key": f"uploads/{entry.ref}/{entry.client_name}",
            }
        )

on_upload_complete(name, entry)가 완료 훅이다. external 업로드에는 서버에 파일이 없으므로 consume_uploads()로 바이트를 읽을 수 없다. 스토리지의 키를 기억해 두는 것이 이 훅의 일이다.

이 이름은 Component가 가진 콜백이다. joined처럼 오버라이드해도 클라이언트가 이벤트로 부를 수 없다 (노출 규칙).

Google Cloud Storage#

from google.cloud import storage
from wireview import Component, ExternalUploadMeta


class GCSUploader(Component):
    class Meta:
        template_name = "uploads/gcs_uploader.html"

    async def joined(self):
        self.allow_upload(
            "images",
            accept=[".jpg", ".png", ".gif"],
            external=self._presign_gcs_upload,
        )

    def _presign_gcs_upload(self, entry, component):
        """Generate a signed URL for the GCS upload."""
        client = storage.Client()
        bucket = client.bucket("your-bucket")
        blob = bucket.blob(f"uploads/{entry.ref}/{entry.client_name}")

        url = blob.generate_signed_url(
            version="v4",
            expiration=3600,
            method="PUT",
            content_type=entry.client_type,
        )

        return ExternalUploadMeta(
            uploader="GCS",
            url=url,
            method="PUT",
            headers={"Content-Type": entry.client_type},
        )

ExternalUploadMeta#

콜백이 돌려주는 값이다.

from wireview import ExternalUploadMeta

meta = ExternalUploadMeta(
    uploader="S3",           # 디버깅·로그용 이름
    url="https://...",       # presigned 업로드 URL
    method="PUT",            # HTTP 메서드 (기본 "PUT")
    headers={                # 추가 헤더 (선택)
        "Content-Type": "application/pdf",
        "x-amz-acl": "private",
    },
)
인자 타입 필수 뜻
uploader str ✅ 업로드 서비스 식별자 ("S3", "GCS" 등)
url str ✅ 직접 업로드용 presigned URL
method str HTTP 메서드 (기본 "PUT")
headers dict 함께 보낼 헤더

템플릿#

{% load wireview %}

<div {% tag_header %}>
    <h2>파일 올리기</h2>

    <!-- 파일 선택 input -->
    {% upload_input "documents" %}

    <!-- 드래그 앤 드롭 영역. 속성 태그이므로 엘리먼트에 붙인다 -->
    <div {% upload_drop_zone "documents" %} class="drop-area">
        <p>여기에 파일을 놓거나 클릭해서 고르세요</p>
    </div>

    <!-- 진행 상황 -->
    <ul>
    {% for entry in this.uploads.documents %}
        <li>
            {{ entry.client_name }}
            {% if entry.status == "completed" %}
                ✓ 완료
            {% elif entry.status == "error" %}
                ✗ {{ entry.errors|join:", " }}
            {% endif %}
        </li>
    {% endfor %}
    </ul>
</div>

드래그 중인 동안 드롭 영역에는 wireview-drag-over 클래스가 붙는다.

진행률은 서버에 오지 않는다. 바이트가 서버를 거치지 않으므로 서버의 entry.progress는 완료될 때까지 0이고, 템플릿으로는 진행률을 그릴 수 없다. 브라우저에서는 컴포넌트 요소에서 올라오는 wireview:upload-progress 이벤트로 받는다.

document.addEventListener("wireview:upload-progress", (e) => {
  const { upload, ref, progress } = e.detail;  // progress는 0-100
  document.querySelector(`[data-ref="${ref}"] progress`)?.setAttribute("value", progress);
});

업로드 이벤트는 모두 컴포넌트 요소에서 버블링되고, detail에 upload(이름)와 componentId가 있다. external이 아닌 업로드도 같은 이벤트를 낸다.

이벤트 언제 detail에 더 있는 것
wireview:upload-added 파일을 골랐거나 떨어뜨렸다
wireview:upload-progress 진행률이 바뀌었다 ref, progress(0–100)
wireview:upload-complete 한 항목이 끝났다 ref
wireview:upload-error 한 항목이 실패했다 ref, errors
wireview:upload-cancel 한 항목이 취소됐다 ref

0.x의 이름(upload:progress 등)도 2.0까지 함께 나간다.

CORS 설정#

external 업로드는 브라우저가 스토리지로 직접 요청하므로 버킷에 CORS가 필요하다.

S3#

{
    "CORSRules": [
        {
            "AllowedOrigins": ["https://your-domain.com"],
            "AllowedMethods": ["PUT"],
            "AllowedHeaders": ["*"],
            "ExposeHeaders": ["ETag"],
            "MaxAgeSeconds": 3600
        }
    ]
}

GCS#

[
    {
        "origin": ["https://your-domain.com"],
        "method": ["PUT"],
        "responseHeader": ["Content-Type"],
        "maxAgeSeconds": 3600
    }
]

external vs 청크 업로드#

항목 external 청크
서버 대역폭 안 쓴다 (스토리지로 직행) 파일 전량이 서버를 지난다
큰 파일 유리하다 작은 파일에 적합
이어받기 스토리지에 달렸다 없다. 클라이언트가 순차·무재시도로 보낸다
설정 CORS와 자격증명이 필요하다 워커들이 청크 저장소와 서명 키를 공유해야 한다
서버에서 파일 읽기 못 읽는다. 스토리지 키만 남는다 consume_uploads()로 읽는다
파일 크기 스토리지 한도까지 max_file_size까지

권장 사항#

  1. 큰 파일에 쓴다. 10MB를 넘으면 이점이 뚜렷하다
  2. 만료를 짧게 잡는다. 1시간 정도
  3. Content-Type을 반드시 넣는다. 스토리지에 제대로 저장되게 한다
  4. 기본을 private ACL로 둔다. 공개가 꼭 필요할 때만 연다
  5. 키를 고유하게 만든다. entry.ref를 섞는다
  6. 실패를 사용자에게 알린다. 아래 오류 처리 참고

오류 처리#

콜백에서 예외를 던지면 그 업로드가 거절된다.

def _presign_upload(self, entry, component):
    # 로그인하지 않은 사용자의 업로드를 막는다
    if not component.user.is_authenticated:
        raise ValueError("Authentication required for uploads")

    # 요금제 한도를 넘는 파일을 막는다
    if entry.client_size > 100 * 1024 * 1024:
        raise ValueError("Files over 100MB are not allowed")

    # presigned URL 생성...
    return ExternalUploadMeta(...)

보안#

  1. 파일 종류를 서버에서 검증한다. 클라이언트 검증만 믿지 않는다
  2. URL 수명을 짧게 한다.
  3. 속도 제한을 건다. 업로드 빈도를 제한해 남용을 막는다
  4. 업로드된 파일을 검사한다. 바이러스 스캔을 고려한다
  5. 사용자를 인증한다. 콜백 안에서 권한을 확인한다

관련#