External 업로드 (S3/GCS)
파일을 Django 서버가 아니라 클라우드 스토리지로 직접 올린다. 서버 대역폭을 쓰지 않으므로 큰 파일에 유리하고, 청크 엔드포인트가 요구하는 공유 저장소 조건(chunked-uploads.md)에서도 자유롭다.
흐름#
- 클라이언트가 파일을 고른다
- 서버가 presigned URL을 만들어 준다
- 클라이언트가 스토리지로 직접 올린다
- 업로드가 끝나면 서버에 알린다
빠른 시작#
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까지 |
권장 사항#
- 큰 파일에 쓴다. 10MB를 넘으면 이점이 뚜렷하다
- 만료를 짧게 잡는다. 1시간 정도
- Content-Type을 반드시 넣는다. 스토리지에 제대로 저장되게 한다
- 기본을 private ACL로 둔다. 공개가 꼭 필요할 때만 연다
- 키를 고유하게 만든다.
entry.ref를 섞는다 - 실패를 사용자에게 알린다. 아래 오류 처리 참고
오류 처리#
콜백에서 예외를 던지면 그 업로드가 거절된다.
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(...)
보안#
- 파일 종류를 서버에서 검증한다. 클라이언트 검증만 믿지 않는다
- URL 수명을 짧게 한다.
- 속도 제한을 건다. 업로드 빈도를 제한해 남용을 막는다
- 업로드된 파일을 검사한다. 바이러스 스캔을 고려한다
- 사용자를 인증한다. 콜백 안에서 권한을 확인한다
관련#
- 튜토리얼: File Uploads 심화
- chunked-uploads.md — 서버를 지나는 청크 경로
- Phoenix LiveView External Uploads