메인 콘텐츠로 건너뛰기
Sonamu는 @upload 데코레이터를 통해 파일 업로드를 간편하게 처리할 수 있습니다.

파일 업로드 개요

@upload 데코레이터

자동 파일 파싱 단일/다중 파일 지원

UploadedFile 클래스

파일 정보 접근 saveToDisk() 메서드

Storage Manager

local, S3, GCS 통합 유연한 스토리지

URL 자동 생성

Public URL Signed URL

@upload 데코레이터

기본 사용법

import { BaseModelClass, api, upload } from "sonamu";
import type { UploadedFile } from "sonamu";
import type { FileSubsetKey, FileSubsetMapping } from "../sonamu.generated";
import { fileLoaderQueries, fileSubsetQueries } from "../sonamu.generated.sso";

class FileModelClass extends BaseModelClass<
  FileSubsetKey,
  FileSubsetMapping,
  typeof fileSubsetQueries,
  typeof fileLoaderQueries
> {
  constructor() {
    super("File", fileSubsetQueries, fileLoaderQueries);
  }

  // 단일 파일 업로드
  @upload()
  async uploadSingle(params: { file: UploadedFile; title?: string }): Promise<{
    fileId: number;
    url: string;
  }> {
    const { file, title } = params;

    if (!file) {
      throw new Error("File is required");
    }

    // 파일 정보
    console.log({
      filename: file.filename,
      mimetype: file.mimetype,
      size: file.size,
    });

    // 파일 저장
    const url = await file.saveToDisk(`uploads/${Date.now()}-${file.filename}`);

    // DB에 메타데이터 저장
    const wdb = this.getPuri("w");
    const [record] = await wdb
      .table("files")
      .insert({
        filename: file.filename,
        mime_type: file.mimetype,
        size: file.size,
        url,
        title,
      })
      .returning({ id: "id" });

    return {
      fileId: record.id,
      url,
    };
  }
}

다중 파일 업로드

class FileModelClass extends BaseModelClass<
  FileSubsetKey,
  FileSubsetMapping,
  typeof fileSubsetQueries,
  typeof fileLoaderQueries
> {
  constructor() {
    super("File", fileSubsetQueries, fileLoaderQueries);
  }

  @upload()
  async uploadMultiple(params: { files: UploadedFile[]; category?: string }): Promise<{
    uploadedFiles: Array<{
      fileId: number;
      filename: string;
      url: string;
    }>;
  }> {
    const { files, category } = params;

    if (!files || files.length === 0) {
      throw new Error("At least one file is required");
    }

    const uploadedFiles = [];
    const wdb = this.getPuri("w");

    for (const file of files) {
      // 파일 저장
      const key = `uploads/${Date.now()}-${file.filename}`;
      const url = await file.saveToDisk(key);

      // DB에 저장
      const [record] = await wdb
        .table("files")
        .insert({
          filename: file.filename,
          mime_type: file.mimetype,
          size: file.size,
          url,
          category,
        })
        .returning({ id: "id" });

      uploadedFiles.push({
        fileId: record.id,
        filename: file.filename,
        url,
      });
    }

    return { uploadedFiles };
  }
}

Storage 설정

Storage Manager

Sonamu는 여러 스토리지를 통합 관리하는 Storage Manager를 제공합니다.
// storage.config.ts
import type { StorageConfig } from "sonamu";

export const storageConfig: StorageConfig = {
  default: "local", // 기본 디스크

  disks: {
    // 로컬 스토리지
    local: {
      driver: "local",
      root: "uploads",
      url: process.env.APP_URL || "http://localhost:3000",
    },

    // AWS S3
    s3: {
      driver: "s3",
      bucket: process.env.S3_BUCKET!,
      region: process.env.S3_REGION || "us-east-1",
      credentials: {
        accessKeyId: process.env.S3_ACCESS_KEY_ID!,
        secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
      },
      endpoint: process.env.S3_ENDPOINT, // MinIO 등
    },

    // Google Cloud Storage
    gcs: {
      driver: "gcs",
      bucket: process.env.GCS_BUCKET!,
      projectId: process.env.GCS_PROJECT_ID!,
      keyFilename: process.env.GCS_KEY_FILENAME!,
    },

    // Public 파일용 별도 디스크
    public: {
      driver: "s3",
      bucket: process.env.PUBLIC_S3_BUCKET!,
      region: "us-east-1",
      credentials: {
        accessKeyId: process.env.S3_ACCESS_KEY_ID!,
        secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
      },
    },
  },
};

환경 변수 설정

# .env

# 앱 URL
APP_URL=http://localhost:3000

# AWS S3
S3_BUCKET=my-app-uploads
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
# S3_ENDPOINT=http://localhost:9000  # MinIO 등

# Public S3 (선택)
PUBLIC_S3_BUCKET=my-app-public

# Google Cloud Storage
GCS_BUCKET=my-app-uploads
GCS_PROJECT_ID=my-project
GCS_KEY_FILENAME=/path/to/service-account-key.json

디스크 선택

특정 디스크에 저장

class FileModelClass extends BaseModelClass<
  FileSubsetKey,
  FileSubsetMapping,
  typeof fileSubsetQueries,
  typeof fileLoaderQueries
> {
  constructor() {
    super("File", fileSubsetQueries, fileLoaderQueries);
  }

  @upload()
  async uploadToS3(params: { file: UploadedFile }): Promise<{ url: string }> {
    const { file } = params;

    // S3 디스크에 저장
    const url = await file.saveToDisk(
      `uploads/${Date.now()}-${file.filename}`,
      "s3", // 디스크 이름
    );

    return { url };
  }

  @upload()
  async uploadPublic(params: { file: UploadedFile }): Promise<{ url: string }> {
    const { file } = params;

    // Public 디스크에 저장
    const url = await file.saveToDisk(`public/${Date.now()}-${file.filename}`, "public");

    return { url };
  }
}

Fastify Multipart 설정 (필수)

Sonamu는 내부적으로 Fastify를 사용하므로, multipart 플러그인을 등록해야 합니다.

서버 설정

// server.ts
import fastify from "fastify";
import multipart from "@fastify/multipart";

const app = fastify({
  bodyLimit: 50 * 1024 * 1024, // 50MB
});

// Multipart 플러그인 등록
app.register(multipart, {
  limits: {
    fileSize: 50 * 1024 * 1024, // 파일 최대 크기 (50MB)
    files: 10, // 최대 파일 개수
    fields: 10, // 최대 필드 개수
  },
});

// Sonamu 라우트 등록
// ...
Sonamu의 @upload 데코레이터는 Fastify multipart를 자동으로 처리합니다.

파일 검증

크기 및 타입 검증

class ImageModelClass extends BaseModelClass<
  ImageSubsetKey,
  ImageSubsetMapping,
  typeof imageSubsetQueries,
  typeof imageLoaderQueries
> {
  constructor() {
    super("Image", imageSubsetQueries, imageLoaderQueries);
  }

  @upload()
  async uploadImage(params: { image: UploadedFile }): Promise<{ imageId: number; url: string }> {
    const { image } = params;

    // 파일 존재 확인
    if (!image) {
      throw new Error("Image is required");
    }

    // 크기 확인
    const maxSize = 5 * 1024 * 1024; // 5MB
    if (image.size > maxSize) {
      throw new Error(`Image too large (max ${maxSize / 1024 / 1024}MB)`);
    }

    // MIME 타입 확인
    const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
    if (!allowedTypes.includes(image.mimetype)) {
      throw new Error(`Invalid image type: ${image.mimetype}`);
    }

    // 확장자 확인
    const ext = image.extname;
    if (!ext || !["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
      throw new Error(`Invalid image extension: ${ext}`);
    }

    // 이미지 저장
    const url = await image.saveToDisk(`images/${Date.now()}.${ext}`);

    // DB에 저장
    const wdb = this.getPuri("w");
    const [record] = await wdb
      .table("images")
      .insert({
        filename: image.filename,
        mime_type: image.mimetype,
        size: image.size,
        url,
      })
      .returning({ id: "id" });

    return {
      imageId: record.id,
      url,
    };
  }
}

실전 예제

프로필 이미지 업로드

class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }

  @upload()
  async uploadProfileImage(params: { image: UploadedFile }): Promise<{
    imageId: number;
    url: string;
  }> {
    const context = Sonamu.getContext();

    if (!context.user) {
      throw new Error("Authentication required");
    }

    const { image } = params;

    // 검증
    if (!image) {
      throw new Error("Image is required");
    }

    if (image.size > 5 * 1024 * 1024) {
      throw new Error("Image too large (max 5MB)");
    }

    const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
    if (!allowedTypes.includes(image.mimetype)) {
      throw new Error("Invalid image type");
    }

    // 파일 저장
    const ext = image.extname;
    const key = `profiles/${context.user.id}/${Date.now()}.${ext}`;
    const url = await image.saveToDisk(key);

    // 기존 프로필 이미지 삭제
    const rdb = this.getPuri("r");
    const oldImage = await rdb.table("profile_images").where("user_id", context.user.id).first();

    if (oldImage) {
      // 기존 이미지 삭제 (Storage Manager 사용)
      const disk = Sonamu.storage.use();
      await disk.delete(oldImage.key);

      // DB 레코드 삭제
      const wdb = this.getPuri("w");
      await wdb.table("profile_images").where("id", oldImage.id).delete();
    }

    // 새 이미지 저장
    const wdb = this.getPuri("w");
    const [record] = await wdb
      .table("profile_images")
      .insert({
        user_id: context.user.id,
        key,
        filename: image.filename,
        mime_type: image.mimetype,
        size: image.size,
        url,
      })
      .returning({ id: "id" });

    return {
      imageId: record.id,
      url,
    };
  }
}

게시글 첨부파일

class PostModelClass extends BaseModelClass<
  PostSubsetKey,
  PostSubsetMapping,
  typeof postSubsetQueries,
  typeof postLoaderQueries
> {
  constructor() {
    super("Post", postSubsetQueries, postLoaderQueries);
  }

  @upload()
  async createWithAttachments(params: {
    title: string;
    content: string;
    attachments?: UploadedFile[];
  }): Promise<{
    postId: number;
    attachmentUrls: string[];
  }> {
    const context = Sonamu.getContext();

    if (!context.user) {
      throw new Error("Authentication required");
    }

    const { title, content, attachments } = params;

    const wdb = this.getPuri("w");

    // 게시글 생성
    const [post] = await wdb
      .table("posts")
      .insert({
        user_id: context.user.id,
        title,
        content,
      })
      .returning({ id: "id" });

    const attachmentUrls: string[] = [];

    // 첨부파일 처리
    if (attachments && attachments.length > 0) {
      for (const file of attachments) {
        // 검증
        if (file.size > 20 * 1024 * 1024) {
          throw new Error(`File ${file.filename} too large (max 20MB)`);
        }

        // 저장
        const ext = file.extname;
        const key = `posts/${post.id}/${Date.now()}.${ext}`;
        const url = await file.saveToDisk(key);

        // DB에 첨부파일 정보 저장
        await wdb.table("post_attachments").insert({
          post_id: post.id,
          key,
          filename: file.filename,
          mime_type: file.mimetype,
          size: file.size,
          url,
        });

        attachmentUrls.push(url);
      }
    }

    return {
      postId: post.id,
      attachmentUrls,
    };
  }
}

클라이언트 예제

HTML Form

<!DOCTYPE html>
<html>
  <head>
    <title>File Upload</title>
  </head>
  <body>
    <h1>Upload File</h1>

    <form id="uploadForm">
      <input type="file" name="file" required />
      <input type="text" name="title" placeholder="Title" />
      <button type="submit">Upload</button>
    </form>

    <div id="result"></div>

    <script>
      document.getElementById("uploadForm").addEventListener("submit", async (e) => {
        e.preventDefault();

        const formData = new FormData(e.target);

        try {
          const response = await fetch("http://localhost:3000/api/file/uploadSingle", {
            method: "POST",
            body: formData,
          });

          const result = await response.json();

          document.getElementById("result").innerHTML = `
          <p>Uploaded!</p>
          <p>File ID: ${result.fileId}</p>
          <p>URL: <a href="${result.url}" target="_blank">${result.url}</a></p>
        `;
        } catch (error) {
          console.error("Upload failed:", error);
        }
      });
    </script>
  </body>
</html>

React 예제

import React, { useState } from "react";

export function FileUpload() {
  const [file, setFile] = useState<File | null>(null);
  const [uploading, setUploading] = useState(false);
  const [result, setResult] = useState<any>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!file) return;

    setUploading(true);

    const formData = new FormData();
    formData.append("file", file);
    formData.append("title", "My Upload");

    try {
      const response = await fetch("http://localhost:3000/api/file/uploadSingle", {
        method: "POST",
        body: formData,
      });

      const data = await response.json();
      setResult(data);
    } catch (error) {
      console.error("Upload failed:", error);
    } finally {
      setUploading(false);
    }
  };

  return (
    <div>
      <h1>Upload File</h1>

      <form onSubmit={handleSubmit}>
        <input
          type="file"
          onChange={(e) => setFile(e.target.files?.[0] || null)}
        />

        <button type="submit" disabled={!file || uploading}>
          {uploading ? "Uploading..." : "Upload"}
        </button>
      </form>

      {result && (
        <div>
          <p>Uploaded!</p>
          <p>File ID: {result.fileId}</p>
          <p>URL: <a href={result.url}>{result.url}</a></p>
        </div>
      )}
    </div>
  );
}

주의사항

파일 업로드 설정 시 주의사항: 1. Fastify multipart 플러그인 필수 등록 2. @upload 데코레이터는 @api 없이 독립적으로 사용 3. 파일 크기 제한 설정 4. MIME 타입 검증 5. Storage 설정 확인

다음 단계

UploadedFile 클래스

파일 정보와 메서드

파일 저장

saveToDisk 상세 설명

URL 생성

URL 생성하기

@api 데코레이터

API 기본 사용법