๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
@upload ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋Š” ํŒŒ์ผ ์—…๋กœ๋“œ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” API๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. Multipart form-data ์š”์ฒญ์„ ์ž๋™์œผ๋กœ ํŒŒ์‹ฑํ•˜๊ณ , ํŒŒ์ผ ๊ฐ์ฒด๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•

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

class FileModelClass extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  async uploadAvatar() {
    const { file } = Sonamu.getUploadContext();
    
    if (!file) {
      throw new Error("No file uploaded");
    }

    // ํŒŒ์ผ ์ €์žฅ
    const key = `avatars/${Date.now()}_${file.filename}`;
    const url = await file.saveToDisk(key);
    
    return { url, filename: file.filename, size: file.size };
  }
}

์˜ต์…˜

mode

ํŒŒ์ผ ์—…๋กœ๋“œ ๋ชจ๋“œ๋ฅผ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.
type UploadMode = "single" | "multiple";
๊ธฐ๋ณธ๊ฐ’: "single"
@api({ httpMethod: "POST" })
@upload({ mode: "single" })
async uploadAvatar() {
  const { file } = Sonamu.getUploadContext();
  
  // file: UploadedFile | undefined
  if (!file) {
    throw new Error("No file");
  }

  return {
    filename: file.filename,
    size: file.size
  };
}

UploadContext ์‚ฌ์šฉ๋ฒ•

Sonamu.getUploadContext()๋กœ ์—…๋กœ๋“œ๋œ ํŒŒ์ผ์— ์ ‘๊ทผํ•ฉ๋‹ˆ๋‹ค.
interface UploadContext {
  file: UploadedFile | undefined;    // single ๋ชจ๋“œ
  files: UploadedFile[];             // multiple ๋ชจ๋“œ
}

UploadedFile ๊ฐ์ฒด

class UploadedFile {
  // ์›๋ณธ ํŒŒ์ผ ์ด๋ฆ„
  get filename(): string;
  
  // MIME ํƒ€์ž…
  get mimetype(): string;
  
  // ํŒŒ์ผ ํฌ๊ธฐ (bytes)
  get size(): number;
  
  // ํ™•์žฅ์ž (์  ์ œ์™ธ, ์˜ˆ: "jpg", "png")
  get extname(): string | false;
  
  // saveToDisk ํ›„ ์ €์žฅ๋œ URL (Unsigned)
  get url(): string | undefined;
  
  // saveToDisk ํ›„ ์ €์žฅ๋œ Signed URL
  get signedUrl(): string | undefined;
  
  // ์›๋ณธ Fastify MultipartFile ์ ‘๊ทผ
  get raw(): MultipartFile;
  
  // ํŒŒ์ผ์„ Buffer๋กœ ์ฝ๊ธฐ
  async toBuffer(): Promise<Buffer>;
  
  // MD5 ํ•ด์‹œ ๊ณ„์‚ฐ
  async md5(): Promise<string>;
  
  // ํŒŒ์ผ์„ ๋””์Šคํฌ์— ์ €์žฅ (URL ๋ฐ˜ํ™˜)
  async saveToDisk(key: string, diskName?: DriverKey): Promise<string>;
}

ํŒŒ์ผ ์ •๋ณด ํ™•์ธ

@upload({ mode: "single" })
async uploadFile() {
  const { file } = Sonamu.getUploadContext();
  
  if (!file) {
    throw new Error("No file");
  }

  console.log("Filename:", file.filename);
  console.log("MIME type:", file.mimetype);
  console.log("Size:", file.size);
  console.log("Extension:", file.extname);
  
  return { uploaded: true };
}

ํŒŒ์ผ ์ฝ๊ธฐ

@upload({ mode: "single" })
async processImage() {
  const { file } = Sonamu.getUploadContext();
  
  // Buffer๋กœ ์ฝ๊ธฐ
  const buffer = await file.toBuffer();
  
  // ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ
  const processed = await sharp(buffer)
    .resize(300, 300)
    .toBuffer();
  
  return { size: processed.length };
}

ํŒŒ์ผ ์ €์žฅ

@upload({ mode: "single" })
async uploadDocument() {
  const { file } = Sonamu.getUploadContext();
  
  // ๋””์Šคํฌ์— ์ €์žฅ
  const key = `uploads/${Date.now()}_${file.filename}`;
  const url = await file.saveToDisk(key);
  
  // ์ €์žฅ๋œ URL ์ ‘๊ทผ
  console.log("URL:", file.url);
  console.log("Signed URL:", file.signedUrl);
  
  return { url, filename: file.filename, size: file.size };
}

MD5 ํ•ด์‹œ ๊ณ„์‚ฐ

@upload({ mode: "single" })
async uploadWithHash() {
  const { file } = Sonamu.getUploadContext();
  
  // MD5 ํ•ด์‹œ ๊ณ„์‚ฐ
  const hash = await file.md5();
  
  // ํ•ด์‹œ๋ฅผ ํŒŒ์ผ๋ช…์— ํฌํ•จ
  const key = `uploads/${hash}${file.extname ? `.${file.extname}` : ''}`;
  const url = await file.saveToDisk(key);
  
  return { url, hash };
}

์Šคํ† ๋ฆฌ์ง€ ๋“œ๋ผ์ด๋ฒ„ ์‚ฌ์šฉ

Sonamu๋Š” ์—ฌ๋Ÿฌ ์Šคํ† ๋ฆฌ์ง€ ๋“œ๋ผ์ด๋ฒ„๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.
@upload({ mode: "single" })
async uploadToS3() {
  const { file } = Sonamu.getUploadContext();
  
  // S3 ๋””์Šคํฌ์— ์ €์žฅ (sonamu.config.ts์—์„œ ์„ค์ •)
  const key = `avatars/${Date.now()}_${file.filename}`;
  const url = await file.saveToDisk(key, "s3");
  
  return { url };
}
์Šคํ† ๋ฆฌ์ง€ ๋“œ๋ผ์ด๋ฒ„๋Š” sonamu.config.ts์—์„œ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

๋‹ค๋ฅธ ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ์™€ ํ•จ๊ป˜ ์‚ฌ์šฉ

@api์™€ ํ•จ๊ป˜

@api({
  httpMethod: "POST",
  clients: ["axios-multipart"]
})
@upload({ mode: "single" })
async uploadFile() {
  // ํŒŒ์ผ ์—…๋กœ๋“œ API
}
@upload์„ ์‚ฌ์šฉํ•˜๋ฉด clients ์˜ต์…˜์ด ์ž๋™์œผ๋กœ ์„ค์ •๋ฉ๋‹ˆ๋‹ค.

@transactional๊ณผ ํ•จ๊ป˜

@api({ httpMethod: "POST" })
@upload({ mode: "single" })
@transactional()
async uploadAndSave() {
  const { file } = Sonamu.getUploadContext();
  const wdb = this.getDB("w");
  
  // ํŒŒ์ผ ์ €์žฅ + DB ์—…๋ฐ์ดํŠธ๋ฅผ ํŠธ๋žœ์žญ์…˜์œผ๋กœ
  const key = `documents/${Date.now()}_${file.filename}`;
  const url = await file.saveToDisk(key);
  
  await wdb.table("documents").insert({
    filename: file.filename,
    url,
    size: file.size,
    created_at: new Date()
  });
  
  return { url };
}

ํด๋ผ์ด์–ธํŠธ ์‚ฌ์šฉ (Web)

Sonamu๋Š” ์ž๋™์œผ๋กœ ํŒŒ์ผ ์—…๋กœ๋“œ ํด๋ผ์ด์–ธํŠธ ์ฝ”๋“œ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

Axios (๋‹จ์ผ ํŒŒ์ผ)

import { FileService } from "@/services/FileService";

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

const result = await FileService.uploadAvatar(formData);

Axios (์—ฌ๋Ÿฌ ํŒŒ์ผ)

const formData = new FormData();
files.forEach(file => {
  formData.append("files", file);
});

const result = await FileService.uploadDocuments(formData);

React ์˜ˆ์‹œ

import { useState } from "react";
import { FileService } from "@/services/FileService";

function FileUploader() {
  const [uploading, setUploading] = useState(false);

  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    setUploading(true);
    try {
      const formData = new FormData();
      formData.append("file", file);
      
      const result = await FileService.uploadAvatar(formData);
      console.log("Uploaded:", result.url);
    } catch (error) {
      console.error("Upload failed:", error);
    } finally {
      setUploading(false);
    }
  };

  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={uploading}
      />
      {uploading && <p>Uploading...</p>}
    </div>
  );
}

TanStack Query ์˜ˆ์‹œ

import { useMutation } from "@tanstack/react-query";
import { FileService } from "@/services/FileService";

function useUploadFile() {
  return useMutation({
    mutationFn: (file: File) => {
      const formData = new FormData();
      formData.append("file", file);
      return FileService.uploadAvatar(formData);
    },
    onSuccess: (data) => {
      console.log("Upload success:", data);
    },
    onError: (error) => {
      console.error("Upload failed:", error);
    }
  });
}

function FileUploader() {
  const upload = useUploadFile();

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      upload.mutate(file);
    }
  };

  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={upload.isPending}
      />
      {upload.isPending && <p>Uploading...</p>}
      {upload.isSuccess && <p>Success: {upload.data.url}</p>}
      {upload.isError && <p>Error: {upload.error.message}</p>}
    </div>
  );
}

ํŒŒ์ผ ๊ฒ€์ฆ

MIME ํƒ€์ž… ๊ฒ€์ฆ

@upload({ mode: "single" })
async uploadImage() {
  const { file } = Sonamu.getUploadContext();
  
  const allowedTypes = ["image/jpeg", "image/png", "image/gif"];
  if (!allowedTypes.includes(file.mimetype)) {
    throw new Error(`Invalid file type: ${file.mimetype}`);
  }

  return await this.processImage(file);
}

ํŒŒ์ผ ํฌ๊ธฐ ๊ฒ€์ฆ

@upload({ mode: "single" })
async uploadDocument() {
  const { file } = Sonamu.getUploadContext();
  
  const maxSize = 10 * 1024 * 1024; // 10MB
  if (file.size > maxSize) {
    throw new Error("File too large");
  }

  return await this.saveDocument(file);
}

ํŒŒ์ผ ํ™•์žฅ์ž ๊ฒ€์ฆ

@upload({ mode: "single" })
async uploadFile() {
  const { file } = Sonamu.getUploadContext();
  
  const allowedExtensions = ["jpg", "png", "pdf"];
  if (!file.extname || !allowedExtensions.includes(file.extname)) {
    throw new Error(`Invalid file extension: ${file.extname}`);
  }

  return await this.saveFile(file);
}

์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ

Sharp ์‚ฌ์šฉ

import sharp from "sharp";

@upload({ mode: "single" })
async uploadAndResizeImage() {
  const { file } = Sonamu.getUploadContext();
  const buffer = await file.toBuffer();

  // ์ด๋ฏธ์ง€ ๋ฆฌ์‚ฌ์ด์ง•
  const resized = await sharp(buffer)
    .resize(800, 600, { fit: "inside" })
    .jpeg({ quality: 80 })
    .toBuffer();

  // ์ธ๋„ค์ผ ์ƒ์„ฑ
  const thumbnail = await sharp(buffer)
    .resize(200, 200, { fit: "cover" })
    .jpeg({ quality: 70 })
    .toBuffer();

  // S3์— ์—…๋กœ๋“œ (Buffer๋ฅผ ์ง์ ‘ ์ €์žฅ)
  const imageKey = `images/${Date.now()}.jpg`;
  await Sonamu.storage.use("s3").put(imageKey, resized);
  const imageUrl = await Sonamu.storage.use("s3").getUrl(imageKey);

  const thumbKey = `thumbnails/${Date.now()}.jpg`;
  await Sonamu.storage.use("s3").put(thumbKey, thumbnail);
  const thumbUrl = await Sonamu.storage.use("s3").getUrl(thumbKey);

  return { imageUrl, thumbUrl };
}

์ œ์•ฝ์‚ฌํ•ญ

1. @api์™€ ํ•จ๊ป˜ ์‚ฌ์šฉ ํ•„์š”

@upload๋Š” @api ์—†์ด๋„ ๋™์ž‘ํ•˜์ง€๋งŒ, ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค:
// โœ… ๊ถŒ์žฅ
@api({ httpMethod: "POST" })
@upload({ mode: "single" })
async uploadFile() {}

// โš ๏ธ ๋™์ž‘ํ•˜์ง€๋งŒ ๊ถŒ์žฅํ•˜์ง€ ์•Š์Œ
@upload({ mode: "single" })
async uploadFile() {}

2. httpMethod๋Š” POST

@upload๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์ž๋™์œผ๋กœ httpMethod: "POST"๊ฐ€ ์„ค์ •๋ฉ๋‹ˆ๋‹ค.
@api({ httpMethod: "GET" })  // ๋ฌด์‹œ๋จ
@upload()
async uploadFile() {
  // POST๋กœ ๋™์ž‘ํ•จ
}

3. clients ์ž๋™ ์„ค์ •

@upload๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด clients ์˜ต์…˜์ด ์ž๋™์œผ๋กœ ์„ค์ •๋ฉ๋‹ˆ๋‹ค:
// mode: "single"
@upload({ mode: "single" })
async uploadFile() {
  // clients: ["axios-multipart", "tanstack-mutation-multipart"]
}

// mode: "multiple"
@upload({ mode: "multiple" })
async uploadFiles() {
  // clients: ["axios-multipart", "tanstack-mutation-multipart"]
}

๋กœ๊น…

@upload ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋Š” ์ž๋™์œผ๋กœ ๋กœ๊ทธ๋ฅผ ๋‚จ๊น๋‹ˆ๋‹ค:
@upload({ mode: "single" })
async uploadFile() {
  // ์ž๋™ ๋กœ๊ทธ:
  // [DEBUG] upload: FileModel.uploadFile
}

์˜ˆ์‹œ ๋ชจ์Œ

class UserModelClass extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  @transactional()
  async uploadAvatar() {
    const { user } = Sonamu.getContext();
    const { file } = Sonamu.getUploadContext();
    
    if (!file) {
      throw new Error("No file uploaded");
    }

    // ์ด๋ฏธ์ง€ ํƒ€์ž… ๊ฒ€์ฆ
    const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
    if (!allowedTypes.includes(file.mimetype)) {
      throw new Error("Invalid image type");
    }

    // ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ
    const buffer = await file.toBuffer();
    const processed = await sharp(buffer)
      .resize(300, 300, { fit: "cover" })
      .jpeg({ quality: 85 })
      .toBuffer();

    // S3 ์—…๋กœ๋“œ
    const key = `avatars/${user.id}/${Date.now()}.jpg`;
    await Sonamu.storage.use("s3").put(key, processed);
    const url = await Sonamu.storage.use("s3").getUrl(key);

    // DB ์—…๋ฐ์ดํŠธ
    const wdb = this.getDB("w");
    await wdb.table("users")
      .where("id", user.id)
      .update({ avatar_url: url });

    return { url };
  }
}

๋‹ค์Œ ๋‹จ๊ณ„