File Uploads 심화

심화 (Deep Dive)예상 1-2시간

파일 업로드의 고급 기능과 보안 전략을 다룹니다.

학습 목표#

  • 업로드 설정 상세
  • 진행률 추적
  • 다중 파일 업로드
  • 보안 및 검증
  • 저장소 통합

업로드 설정#

allow_upload 옵션#

업로드 필드는 joined()에서 allow_upload()로 연다. 첫 인자가 필드 이름이고 나머지는 키워드다.

async def joined(self):
    self.allow_upload(
        "avatar",                    # 업로드 필드 식별자
        accept=[".jpg", ".png"],     # 허용 확장자
        max_entries=1,               # 동시에 진행할 수 있는 업로드 수
        max_file_size=5*1024*1024,   # 5MB
        chunk_size=64*1024,          # 64KB 청크
        auto_upload=True,            # 선택 즉시 업로드
    )

max_entries는 진행 중인 업로드의 수다. consume_uploads()로 소비한 업로드는 세지 않으므로, 한 개짜리 필드로도 파일을 하나씩 계속 올릴 수 있다.

다양한 설정 예#

async def joined(self):
    # 프로필 이미지 (단일, 작은 파일)
    self.allow_upload(
        "avatar",
        accept=[".jpg", ".jpeg", ".png", ".gif", ".webp"],
        max_file_size=2 * 1024 * 1024,  # 2MB
    )

    # 문서 업로드 (다중, 큰 파일)
    self.allow_upload(
        "documents",
        accept=[".pdf", ".doc", ".docx", ".xls", ".xlsx"],
        max_entries=10,
        max_file_size=50 * 1024 * 1024,  # 50MB
        chunk_size=256 * 1024,  # 256KB 청크
    )

    # 갤러리 이미지 (다중)
    self.allow_upload(
        "photos",
        accept=[".jpg", ".jpeg", ".png"],
        max_entries=20,
        max_file_size=10 * 1024 * 1024,  # 10MB
        auto_upload=False,  # 수동 업로드
    )

컴포넌트 구현#

기본 구조#

from wireview import Component


class XFileUploader(Component):
    class Meta:
        template_name = 'uploader/upload_form.html'

    async def joined(self):
        self.allow_upload(
            "files",
            accept=[".pdf", ".jpg", ".png"],
            max_entries=5,
            max_file_size=10 * 1024 * 1024,
        )

    async def upload_files(self):
        """업로드된 파일 처리"""
        async for upload in self.consume_uploads("files"):
            # upload는 ConsumedUpload 인스턴스
            path = await upload.save_to("uploads/")
            print(f"Saved: {path}")

    async def cancel_file(self, ref: str):
        """목록의 × 버튼. 자세한 것은 아래 "업로드 취소"에서 다룬다"""
        await self.cancel_upload("files", ref)

템플릿#

{% load wireview %}
<div {% tag_header %} class="uploader">
  <div class="upload-zone" {% upload_drop_zone "files" %}>
    {% upload_input "files" %}
    <p>Drop files here or click to browse</p>
  </div>

  <!-- 업로드 목록 -->
  <ul class="upload-list">
    {% for entry in this.uploads.files %}
      <li class="upload-entry {{ entry.status }}">
        <span class="name">{{ entry.client_name }}</span>
        <span class="size">{{ entry.client_size|filesizeformat }}</span>

        {% if entry.status == 'uploading' %}
          <div class="progress">
            <div class="bar" style="width: {{ entry.progress }}%"></div>
          </div>
        {% elif entry.status == 'completed' %}
          <span class="status">✓ Ready</span>
        {% elif entry.status == 'error' %}
          <span class="error">{{ entry.errors|join:", " }}</span>
        {% endif %}

        <button {% on "click" "cancel_file" ref=entry.ref %}>×</button>
      </li>
    {% endfor %}
  </ul>

  <button {% on "click" "upload_files" %}>Save Files</button>
</div>

진행률 추적#

UploadEntry 속성#

속성 타입 설명
ref str 고유 참조 ID
client_name str 원본 파일명
client_size int 파일 크기 (bytes)
client_type str MIME 타입
status UploadStatus pending/uploading/completed/error/cancelled/consumed. 문자열 열거형(StrEnum)이라 "uploading"과 같고, {{ entry.status }}는 uploading을 찍는다
progress int 0-100
errors list 에러 메시지 목록

상태별 처리#

{% if entry.status == 'pending' %}
  <span>Waiting...</span>

{% elif entry.status == 'uploading' %}
  <div class="progress">{{ entry.progress }}%</div>

{% elif entry.status == 'completed' %}
  <span class="success">Upload complete</span>

{% elif entry.status == 'error' %}
  <span class="error">
    {% for error in entry.errors %}
      {{ error }}<br>
    {% endfor %}
  </span>

{% elif entry.status == 'cancelled' %}
  <span>Cancelled</span>
{% endif %}

파일 처리#

ConsumedUpload API#

async def process_upload(self):
    async for upload in self.consume_uploads("files"):
        # 파일 정보
        print(upload.name)         # 원본 파일명
        print(upload.size)         # 크기 (bytes)
        print(upload.content_type) # MIME 타입
        print(upload.ref)          # 참조 ID

        # 내용 읽기. 임시 파일을 읽을 뿐이라 몇 번이든 된다
        content = upload.read()    # bytes로 읽기
        with upload.open("rb") as f:
            header = f.read(16)

        # 저장. 한 업로드에 한 번만 — save_to가 임시 파일을 지우고 업로드를 소비한다
        path = await upload.save_to("uploads/")

원래 이름 대신 다른 이름으로 저장하려면 filename=을 넘긴다. 같은 업로드를 두 번 save_to()하면 두 번째는 RuntimeError("Upload already consumed")다 — 두 곳에 저장하려면 read()한 바이트를 직접 쓴다.

path = await upload.save_to("avatars/", filename=f"user_{self.user.pk}.jpg")

save_to()를 부르지 않아도 루프가 다음 업로드로 넘어가면 그 업로드는 소비되고 임시 파일은 지워진다.

Django Storage 통합#

save_to()는 Django의 default storage를 사용합니다:

# settings.py
STORAGES = {
    "default": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"},
    "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
}

# 컴포넌트
async def save_to_s3(self):
    async for upload in self.consume_uploads("files"):
        # S3에 저장됨
        path = await upload.save_to("media/uploads/")

직접 처리#

from PIL import Image

async def process_image(self):
    async for upload in self.consume_uploads("avatar"):
        # Pillow로 이미지 처리
        with upload.open("rb") as f:
            img = Image.open(f)
            img = img.resize((200, 200))

            # 처리된 이미지 저장
            from io import BytesIO
            buffer = BytesIO()
            img.save(buffer, format='JPEG')
            # ...

보안#

확장자 검증#

self.allow_upload("images", accept=[".jpg", ".png"])  # 허용된 확장자만

Magic Bytes 검증#

Wireview는 파일 시그니처를 자동 검증합니다:

# wireview/features/uploads.py
MAGIC_BYTES = {
    ".jpg": [b"\xff\xd8\xff"],
    ".png": [b"\x89PNG\r\n\x1a\n"],
    ".pdf": [b"%PDF"],
    # ...
}

.jpg 확장자지만 실제로 PNG인 경우 → 에러

크기 제한#

self.allow_upload("images", max_file_size=5 * 1024 * 1024)  # 5MB

수동 검증#

errors: list[str] = []

async def save_files(self):
    async for upload in self.consume_uploads("files"):
        # 추가 검증. 건너뛴 업로드도 소비된 것이라 임시 파일은 지워진다
        if upload.size > 1024 * 1024:
            self.errors = [*self.errors, f"{upload.name}: too large"]
            continue

        if not self._is_safe_filename(upload.name):
            self.errors = [*self.errors, f"{upload.name}: invalid filename"]
            continue

        await upload.save_to("uploads/")

def _is_safe_filename(self, name: str) -> bool:
    import re
    # 안전한 문자만 허용
    return bool(re.match(r'^[\w\-. ]+$', name))

소유권과 수명#

업로드는 연결(connection)의 것이지 컴포넌트 id의 것이 아니다. 컴포넌트 id는 페이지 안에서만 고유하고, 템플릿이 {% component 'X' id="bookmarks" %}처럼 id를 고정하면 같은 페이지를 연 두 연결이 같은 id를 쓴다. 그래서 서버가 업로드에 쓰는 모든 것에 연결 id가 붙는다 (#77).

연결 id는 WebSocket 연결이 열려 세션이 시작될 때 secrets.token_urlsafe(16)으로 발급된다. 채널 레이어 주소인 channel_name과 달리 URL에 넣어도 되는 값이다.

무엇 모양
HTTP 엔드포인트 /__wireview_upload__/<connection_id>/<component_id>/<upload_name>/
업로드 토큰 connection_id·component_id·upload_name·ref에 더해 허용 크기와 확장자를 서명한 객체 (salt wireview.upload, 유효기간 UPLOAD_TOKEN_MAX_AGE)
진행률 통지 그룹 wireview_upload_<connection_id> — 연결마다 하나, 업로드가 있는 첫 컴포넌트가 join할 때 가입
청크 파일 <UPLOAD_TEMP_DIR 또는 시스템 temp>/wireview-uploads/<connection_id>/<digest>.part

엔드포인트는 서버가 config 업로드 op으로 클라이언트에 보내 준다. 경로를 직접 적어 두지 않았다면 앱에서 고칠 것은 없다.

정리 시점#

시점 일어나는 일
join 컴포넌트에 업로드가 있으면 이 연결이 진행 통지 그룹에 가입한다(연결당 한 번)
LiveComponent 자식의 joined 부모 렌더 중에 자식의 업로드도 같은 방식으로 준비된다
leave / 재join으로 교체 그 컴포넌트의 엔트리마다 취소 마커(.cancelled)를 남기고 청크 파일을 지운다
disconnect 연결 디렉터리에 .gone을 남기고 그 안의 청크 파일을 전부 지운 뒤, 가입했다면 통지 그룹에서 탈퇴한다
취소 뒤 도착한 청크 410. 청크를 쓰는 도중에 취소가 나도 파일을 남기지 않는다
leave·disconnect 뒤 도착한 청크 410. 마커는 그 청크를 쓰는 워커가 다른 프로세스여도 보인다

브라우저도 같은 때 그 컴포넌트의 업로드 상태(기다리는 파일, 진행 중인 요청, 미리보기)를 버린다. 상세는 chunked-uploads.md의 「브라우저 쪽 수명」.

경로와 토큰 양쪽에 소유자가 들어가므로 한 연결의 leave가 다른 연결의 파일을 건드릴 수 없다. A에서 받은 토큰을 B의 URL로 보내면 서명은 검증되지만 소유자가 URL과 달라 403이다.

워커가 여럿일 때#

청크 HTTP 요청은 아무 워커에나 닿아도 된다(#83). 엔드포인트는 업로드 상태를 하나도 들고 있지 않고, 서명된 토큰만으로 판단해 토큰에서 계산한 경로에 쓴다. 대신 워커들이 청크 저장소(WIREVIEW["UPLOAD_TEMP_DIR"], None이면 시스템 임시 디렉터리 — 한 호스트라면 이미 공유다)와 서명 키를 공유해야 한다.

  • 설정된 경로는 없으면 만들어지고, 쓸 수 없으면 ImproperlyConfigured로 실패한다 — 공유 볼륨을 지정했는데 말없이 로컬 디스크를 쓰는 일은 없다. manage.py check의 wireview.W008이 미리 잡는다.
  • 정상 disconnect 없이 프로세스가 죽으면 청크 파일이 남는다. 남는 양은 UPLOAD_TOKEN_MAX_AGE로 묶이고, 쓰기 경로가 프로세스당 10분에 한 번 청소한다. cron으로 돌리려면 manage.py wireview_upload_gc.
  • 서버 쪽 동작 전체는 docs/features/chunked-uploads.md, 배포 조건은 docs/DEPLOYMENT.md.

다중 업로드 필드#

여러 업로드 설정#

async def joined(self):
    self.allow_upload("avatar", max_entries=1)
    self.allow_upload("documents", max_entries=10)
    self.allow_upload("gallery", max_entries=20)

필드별 처리#

async def save_all(self):
    # 아바타
    async for upload in self.consume_uploads("avatar"):
        await upload.save_to("avatars/")

    # 문서
    async for upload in self.consume_uploads("documents"):
        await upload.save_to("documents/")

    # 갤러리
    async for upload in self.consume_uploads("gallery"):
        await upload.save_to("gallery/")

업로드 취소#

사용자 취소#

async def cancel_file(self, ref: str):
    """특정 파일 업로드 취소"""
    await self.cancel_upload("files", ref)

템플릿#

<button {% on "click" "cancel_file" ref=entry.ref %}>Cancel</button>

드래그 앤 드롭#

업로드 태그는 넷이다. upload_input과 upload_preview는 요소를 그리고, upload_drop_zone과 upload_button은 사용자가 쓴 요소에 붙일 속성을 낸다.

태그 내는 것
{% upload_input "필드" class="…" %} <input type="file">. accept와 multiple은 allow_upload() 설정에서 온다
<div {% upload_drop_zone "필드" %}> 파일을 떨어뜨릴 영역
<button type="button" {% upload_button "필드" %}> 누르면 파일 선택 창이 열리는 요소. 페이지가 막 live가 되어 업로드 설정이 아직 없을 때는 accept 필터 없이 한 파일만 고르는 창이 열린다(chunked-uploads.md)
{% upload_preview entry class="…" %} 고른 이미지의 미리보기 <img>

{% upload_drop_zone "필드" %}를 단 요소에 파일을 떨어뜨리면 그 필드로 업로드된다. 파일을 끌고 들어오는 동안 요소에 wireview-drag-over 클래스가 붙는다.

<div class="upload-zone" {% upload_drop_zone "files" %}>
  여기에 파일을 놓으세요
  {% upload_input "files" %}
</div>
.upload-zone {
  border: 2px dashed #ccc;
  padding: 2rem;
  text-align: center;
}

.upload-zone.wireview-drag-over {
  border-color: #007bff;
  background: #f0f7ff;
}

이미지 미리보기#

{% upload_preview %} 태그로 업로드 전 이미지 미리보기를 표시합니다.

기본 사용법#

{% load wireview %}

<div {% tag_header %}>
  <h3>프로필 사진 업로드</h3>

  {% upload_input "avatar" %}

  <!-- 업로드된 이미지 미리보기 -->
  {% for entry in this.uploads.avatar %}
    <div class="preview-container">
      {% upload_preview entry class="w-32 h-32 rounded-full object-cover" %}
      <p>{{ entry.client_name }}</p>
    </div>
  {% endfor %}
</div>

갤러리 미리보기#

<div class="gallery-upload">
  {% upload_input "photos" %}  {# max_entries가 1보다 크면 multiple이 자동으로 붙는다 #}

  <div class="grid grid-cols-4 gap-4">
    {% for entry in this.uploads.photos %}
      <div class="relative">
        {% upload_preview entry class="w-full h-32 object-cover rounded" %}

        <!-- 업로드 진행률 오버레이 -->
        {% if entry.status == 'uploading' %}
          <div class="absolute inset-0 bg-black/50 flex items-center justify-center">
            <span class="text-white">{{ entry.progress }}%</span>
          </div>
        {% endif %}

        <!-- 취소 버튼 -->
        <button
          {% on "click" "cancel_file" ref=entry.ref %}
          class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6"
        >×</button>
      </div>
    {% endfor %}
  </div>
</div>

미리보기가 표시되지 않는 경우#

  • 비이미지 파일: PDF, DOC 등은 미리보기가 표시되지 않습니다
  • 파일 미선택: 아직 파일을 선택하지 않은 경우
  • 브라우저 호환성: 구형 브라우저에서는 Blob URL을 지원하지 않을 수 있습니다

외부 스토리지 업로드 (S3/GCS)#

대용량 파일은 Django 서버를 거치지 않고 직접 클라우드 스토리지로 업로드할 수 있습니다.

S3 직접 업로드#

콜백 이름은 _로 시작한다. _가 없는 메서드는 클라이언트가 부를 수 있는 이벤트 핸들러로 노출된다.

import boto3
from wireview import Component, ExternalUploadMeta


class XDocumentUploader(Component):
    class Meta:
        template_name = "documents/uploader.html"

    async def joined(self):
        self.allow_upload(
            "documents",
            accept=[".pdf", ".doc", ".docx"],
            max_file_size=100 * 1024 * 1024,  # 100MB
            external=self._presign_s3_upload,  # 외부 업로드 콜백
        )

    def _presign_s3_upload(self, entry, component):
        """S3 presigned URL 생성"""
        s3 = boto3.client("s3")

        key = f"documents/{entry.ref}/{entry.client_name}"
        url = s3.generate_presigned_url(
            "put_object",
            Params={
                "Bucket": "my-bucket",
                "Key": key,
                "ContentType": entry.client_type,
            },
            ExpiresIn=3600,
        )

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

    async def save_document(self):
        # 업로드 완료 후 DB에 기록
        async for upload in self.consume_uploads("documents"):
            key = f"documents/{upload.ref}/{upload.name}"
            await Document.objects.acreate(
                name=upload.name,
                s3_key=key,
                size=upload.size,
            )

GCS 직접 업로드#

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


class XImageUploader(Component):
    class Meta:
        template_name = "images/uploader.html"

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

    def _presign_gcs_upload(self, entry, component):
        """GCS signed URL 생성"""
        client = storage.Client()
        bucket = client.bucket("my-bucket")
        blob = bucket.blob(f"images/{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,
            headers={"Content-Type": entry.client_type},
        )

CORS 설정#

외부 업로드를 위해 스토리지 버킷에 CORS 설정이 필요합니다:

S3 CORS:

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

일반 업로드 vs 외부 업로드#

항목 일반 업로드 외부 업로드 (S3/GCS)
서버 대역폭 파일이 서버를 통과 직접 스토리지로 전송
속도 소용량 파일에 적합 대용량 파일에 최적
설정 즉시 사용 가능 CORS 설정 필요
파일 처리 서버에서 가공 가능 업로드 후 별도 처리

에러 처리#

공통 에러#

에러 원인 해결
"Invalid file type" 허용되지 않은 확장자 accept 설정 확인
"File too large" max_file_size 초과 제한 늘리거나 파일 압축
"Maximum entries reached" max_entries 초과 기존 파일 제거
"File content doesn't match" Magic bytes 불일치 올바른 파일 사용

에러 표시#

{% if entry.errors %}
  <div class="errors">
    {% for error in entry.errors %}
      <p class="error">{{ error }}</p>
    {% endfor %}
  </div>
{% endif %}

완성된 예제#

class XProfileEditor(Component):
    class Meta:
        template_name = 'profile/editor.html'

    user_id: int
    avatar_url: str = ""

    async def joined(self):
        self.allow_upload(
            "avatar",
            accept=[".jpg", ".jpeg", ".png", ".gif"],
            max_file_size=2 * 1024 * 1024,
        )

    async def save_avatar(self):
        async for upload in self.consume_uploads("avatar"):
            try:
                path = await upload.save_to(
                    "avatars/",
                    filename=f"user_{self.user_id}.jpg"
                )
                self.avatar_url = str(path)

                # DB 업데이트
                await User.objects.filter(id=self.user_id).aupdate(
                    avatar=path
                )

            except ValueError as e:
                await self.put_flash("error", str(e))

다음 단계#