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

파일 업로드 개요

@upload 데코레이터

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

UploadedFile 클래스

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

Storage Manager

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

URL 자동 생성

Public URLSigned URL

@upload 데코레이터

기본 사용법

import { BaseModelClass, api, upload } from "sonamu";
import type { UploadedFile } from "sonamu";

class FileModel extends BaseModelClass {
  modelName = "File";
  
  // 단일 파일 업로드
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  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 FileModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @upload({ mode: "multiple" })
  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 FileModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  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 };
  }
  
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  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 ImageModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  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 UserModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  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 PostModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @upload({ mode: "multiple" })
  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 설정 확인

다음 단계