메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
@upload λ°μ½”λ ˆμ΄ν„°λŠ” 파일 μ—…λ‘œλ“œλ₯Ό μ²˜λ¦¬ν•˜λŠ” APIλ₯Ό μƒμ„±ν•©λ‹ˆλ‹€. Multipart form-data μš”μ²­μ„ μžλ™μœΌλ‘œ νŒŒμ‹±ν•˜κ³ , 파일 객체λ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€. @api λ°μ½”λ ˆμ΄ν„° 없이도 λ…λ¦½μ μœΌλ‘œ μ‚¬μš©ν•  수 있으며, μžλ™μœΌλ‘œ POST λ©”μ„œλ“œμ™€ multipart ν΄λΌμ΄μ–ΈνŠΈ(axios-multipart, tanstack-mutation-multipart)λ₯Ό μ„€μ •ν•©λ‹ˆλ‹€.

μ—…λ‘œλ“œ λͺ¨λ“œ

SonamuλŠ” 두 κ°€μ§€ 파일 μ—…λ‘œλ“œ λͺ¨λ“œλ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€:
λͺ¨λ“œμ˜΅μ…˜Context μ†μ„±νŒŒμΌ νƒ€μž…νŠΉμ§•
Buffer (κΈ°λ³Έ)consume: "buffer" λ˜λŠ” μƒλž΅bufferedFilesBufferedFile[]λ©”λͺ¨λ¦¬μ— λ‘œλ“œ, MD5 계산/이미지 처리 λ“± μœ μ—°ν•œ μž‘μ—… κ°€λŠ₯
Streamconsume: "stream"uploadedFilesUploadedFile[]μ¦‰μ‹œ μ €μž₯μ†Œλ‘œ 슀트리밍, λŒ€μš©λŸ‰ νŒŒμΌμ— 적합

Buffer λͺ¨λ“œ (κΈ°λ³Έ)

Buffer λͺ¨λ“œλŠ” νŒŒμΌμ„ λ©”λͺ¨λ¦¬μ— λ‘œλ“œν•œ ν›„ μ²˜λ¦¬ν•©λ‹ˆλ‹€. MD5 ν•΄μ‹œ 계산, 이미지 리사이징 λ“± 파일 λ‚΄μš©μ„ 직접 닀뀄야 ν•  λ•Œ μ‚¬μš©ν•©λ‹ˆλ‹€.
import { BaseModelClass, upload, Sonamu } from "sonamu";

class FileModelClass extends BaseModelClass {
  @upload() // κΈ°λ³Έκ°’: consume: "buffer"
  async uploadAvatar() {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];

    if (!file) {
      throw new Error("No file uploaded");
    }

    // MD5 ν•΄μ‹œλ‘œ 쀑볡 λ°©μ§€
    const md5 = await file.md5();
    const key = `avatars/${md5}.${file.extname}`;
    const url = await file.saveToDisk("fs", key);

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

BufferedFile 객체

class BufferedFile {
  // 원본 파일 이름
  get filename(): string;

  // MIME νƒ€μž…
  get mimetype(): string;

  // 파일 크기 (bytes)
  get size(): number;

  // ν™•μž₯자 (점 μ œμ™Έ, 예: "jpg", "png")
  get extname(): string | false;

  // 파일 Buffer (λ©”λͺ¨λ¦¬μ— λ‘œλ“œλœ 데이터)
  get buffer(): Buffer;

  // saveToDisk ν›„ μ €μž₯된 URL (Unsigned)
  get url(): string;

  // saveToDisk ν›„ μ €μž₯된 Signed URL
  get signedUrl(): string;

  // 원본 Fastify MultipartFile μ ‘κ·Ό
  get raw(): MultipartFile;

  // MD5 ν•΄μ‹œ 계산
  async md5(): Promise<string>;

  // νŒŒμΌμ„ λ””μŠ€ν¬μ— μ €μž₯ (URL λ°˜ν™˜)
  async saveToDisk(diskName: DriverKey, key: string): Promise<string>;
}
saveToDisk의 νŒŒλΌλ―Έν„° μˆœμ„œλŠ” (diskName, key) μž…λ‹ˆλ‹€.

Stream λͺ¨λ“œ

Stream λͺ¨λ“œλŠ” νŒŒμΌμ„ λ©”λͺ¨λ¦¬μ— λ‘œλ“œν•˜μ§€ μ•Šκ³  μ¦‰μ‹œ μ €μž₯μ†Œλ‘œ μŠ€νŠΈλ¦¬λ°ν•©λ‹ˆλ‹€. λŒ€μš©λŸ‰ 파일 μ—…λ‘œλ“œμ— μ ν•©ν•©λ‹ˆλ‹€.
@upload({
  consume: "stream",
  destination: "s3",  // μ €μž₯ν•  λ””μŠ€ν¬ 이름
  keyGenerator: (file) => `uploads/${Date.now()}-${file.filename}`,  // ν‚€ 생성 ν•¨μˆ˜
  limits: { files: 5 }
})
async uploadLargeFiles() {
  const { uploadedFiles } = Sonamu.getContext();

  if (!uploadedFiles || uploadedFiles.length === 0) {
    throw new Error("No files uploaded");
  }

  // νŒŒμΌμ€ 이미 μ €μž₯μ†Œμ— μ—…λ‘œλ“œλœ μƒνƒœ
  return {
    files: uploadedFiles.map((file) => ({
      filename: file.filename,
      url: file.url,
      key: file.key,
      size: file.size,
    })),
  };
}

UploadedFile 객체

Stream λͺ¨λ“œμ—μ„œλŠ” 파일이 이미 μ €μž₯μ†Œμ— μ—…λ‘œλ“œλœ μƒνƒœλ‘œ λ©”νƒ€λ°μ΄ν„°λ§Œ μ ‘κ·Όν•  수 μžˆμŠ΅λ‹ˆλ‹€.
class UploadedFile {
  // 원본 파일 이름
  get filename(): string;

  // MIME νƒ€μž…
  get mimetype(): string;

  // 파일 크기 (bytes)
  get size(): number;

  // ν™•μž₯자 (점 μ œμ™Έ, 예: "jpg", "png")
  get extname(): string | false;

  // μ €μž₯된 URL (Unsigned)
  get url(): string;

  // μ €μž₯된 Signed URL
  get signedUrl(): string;

  // μ €μž₯μ†Œ λ‚΄ ν‚€
  get key(): string;

  // μ €μž₯된 λ””μŠ€ν¬ 이름
  get diskName(): DriverKey;

  // μ €μž₯μ†Œμ—μ„œ 파일 λ‹€μš΄λ‘œλ“œ (λ‚˜μ€‘μ— μ²˜λ¦¬ν•΄μ•Ό ν•  λ•Œ)
  async download(): Promise<Buffer>;
}

μ˜΅μ…˜

guards

μ—…λ‘œλ“œ API에 μ μš©ν•  κ°€λ“œλ₯Ό μ§€μ •ν•©λ‹ˆλ‹€. 인증이 ν•„μš”ν•œ μ—…λ‘œλ“œ APIμ—μ„œ μ‚¬μš©ν•©λ‹ˆλ‹€.
@upload({ guards: ["user"] })
async uploadAvatar() {
  const { user, bufferedFiles } = Sonamu.getContext();
  // user κ°€λ“œκ°€ μ μš©λ˜μ–΄ 인증된 μ‚¬μš©μžλ§Œ μ ‘κ·Ό κ°€λŠ₯
  // ...
}

description

API μ„€λͺ…을 μ§€μ •ν•©λ‹ˆλ‹€. μžλ™ μƒμ„±λ˜λŠ” API λ¬Έμ„œμ— ν‘œμ‹œλ©λ‹ˆλ‹€.
@upload({ description: "μ‚¬μš©μž ν”„λ‘œν•„ 이미지 μ—…λ‘œλ“œ" })
async uploadAvatar() {
  // ...
}

limits

파일 μ—…λ‘œλ“œ μ œν•œμ„ μ„€μ •ν•©λ‹ˆλ‹€. Fastify multipart의 limits μ˜΅μ…˜μ„ κ·ΈλŒ€λ‘œ λ°›μŠ΅λ‹ˆλ‹€.
type UploadDecoratorOptions = {
  // 곡톡 μ˜΅μ…˜
  guards?: GuardKey[]; // μ μš©ν•  κ°€λ“œ (예: ["user", "admin"])
  description?: string; // API μ„€λͺ…
  limits?: {
    fileSize?: number; // μ΅œλŒ€ 파일 크기 (bytes)
    files?: number; // μ΅œλŒ€ 파일 개수
    fields?: number; // μ΅œλŒ€ ν•„λ“œ 개수
    fieldSize?: number; // μ΅œλŒ€ ν•„λ“œ 크기
    parts?: number; // μ΅œλŒ€ 파트 개수
  };
} &
  // Buffer λͺ¨λ“œ
  (| { consume?: "buffer" }
    // Stream λͺ¨λ“œ
    | {
        consume: "stream";
        destination: DriverKey;
        keyGenerator?: (file: { filename: string; mimetype: string }) => string;
      }
  );
@upload({
  limits: {
    fileSize: 10 * 1024 * 1024, // 10MB
    files: 5,                    // μ΅œλŒ€ 5개 파일
  }
})
async uploadWithLimits() {
  const { bufferedFiles } = Sonamu.getContext();
  // ...
}
@upload()
async uploadAvatar() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const md5 = await file.md5();
  return {
    filename: file.filename,
    size: file.size,
    md5
  };
}

파일 정보 확인

@upload()
async uploadFile() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  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 };
}

파일 처리 (Buffer λͺ¨λ“œ)

Buffer μ ‘κ·Ό

@upload()
async processImage() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  // Buffer 직접 μ ‘κ·Ό
  const buffer = file.buffer;

  // 이미지 처리
  const processed = await sharp(buffer)
    .resize(300, 300)
    .toBuffer();

  return { size: processed.length };
}

파일 μ €μž₯

@upload()
async uploadDocument() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  // λ””μŠ€ν¬μ— μ €μž₯ (diskName, key μˆœμ„œ)
  const md5 = await file.md5();
  const key = `uploads/${md5}.${file.extname}`;
  const url = await file.saveToDisk("fs", key);

  // μ €μž₯된 URL μ ‘κ·Ό
  console.log("URL:", file.url);
  console.log("Signed URL:", file.signedUrl);

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

MD5 ν•΄μ‹œ 계산

@upload()
async uploadWithHash() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  // MD5 ν•΄μ‹œ 계산 (쀑볡 파일 방지에 유용)
  const hash = await file.md5();

  // ν•΄μ‹œλ₯Ό 파일λͺ…μœΌλ‘œ μ‚¬μš©
  const key = `uploads/${hash}${file.extname ? `.${file.extname}` : ''}`;
  const url = await file.saveToDisk("fs", key);

  return { url, hash };
}

μŠ€ν† λ¦¬μ§€ λ“œλΌμ΄λ²„ μ‚¬μš©

SonamuλŠ” μ—¬λŸ¬ μŠ€ν† λ¦¬μ§€ λ“œλΌμ΄λ²„λ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€.
@upload()
async uploadToS3() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  // S3 λ””μŠ€ν¬μ— μ €μž₯ (sonamu.config.tsμ—μ„œ μ„€μ •)
  const md5 = await file.md5();
  const key = `avatars/${md5}.${file.extname}`;
  const url = await file.saveToDisk("s3", key);

  return { url };
}
μŠ€ν† λ¦¬μ§€ λ“œλΌμ΄λ²„λŠ” sonamu.config.tsμ—μ„œ μ„€μ •ν•©λ‹ˆλ‹€. 첫 번째 νŒŒλΌλ―Έν„°λ‘œ λ””μŠ€ν¬ 이름을 μ „λ‹¬ν•©λ‹ˆλ‹€.

λ‹€λ₯Έ λ°μ½”λ ˆμ΄ν„°μ™€ ν•¨κ»˜ μ‚¬μš©

@transactionalκ³Ό ν•¨κ»˜

@upload()
@transactional()
async uploadAndSave() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

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

  // 파일 μ €μž₯ + DB μ—…λ°μ΄νŠΈλ₯Ό νŠΈλžœμž­μ…˜μœΌλ‘œ
  const md5 = await file.md5();
  const key = `documents/${md5}.${file.extname}`;
  const url = await file.saveToDisk("fs", 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()
async uploadImage() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  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()
async uploadDocument() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const maxSize = 10 * 1024 * 1024; // 10MB
  if (file.size > maxSize) {
    throw new Error("File too large");
  }

  return await this.saveDocument(file);
}

파일 ν™•μž₯자 검증

@upload()
async uploadFile() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  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()
async uploadAndResizeImage() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const buffer = file.buffer;

  // 이미지 리사이징
  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 λ°μ½”λ ˆμ΄ν„° 없이 λ…λ¦½μ μœΌλ‘œ μ‚¬μš©ν•©λ‹ˆλ‹€:
// μ˜¬λ°”λ₯Έ μ‚¬μš©λ²•
@upload()
async uploadFile() {}

// λΆˆν•„μš” - @uploadκ°€ μžλ™μœΌλ‘œ API μ„€μ •
@api({ httpMethod: "POST" })
@upload()
async uploadFile() {}

2. httpMethodλŠ” POST

@uploadλ₯Ό μ‚¬μš©ν•˜λ©΄ μžλ™μœΌλ‘œ httpMethod: "POST"κ°€ μ„€μ •λ©λ‹ˆλ‹€.

3. clients μžλ™ μ„€μ •

@uploadλ₯Ό μ‚¬μš©ν•˜λ©΄ clients μ˜΅μ…˜μ΄ μžλ™μœΌλ‘œ ["axios-multipart", "tanstack-mutation-multipart"]둜 μ„€μ •λ©λ‹ˆλ‹€:
@upload()
async uploadFile() {
  // clients: ["axios-multipart", "tanstack-mutation-multipart"]
}

μ˜ˆμ‹œ λͺ¨μŒ

class UserModelClass extends BaseModelClass {
  @upload()
  @transactional()
  async uploadAvatar() {
    const { user, bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];

    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 = file.buffer;
    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 };
  }
}

μ°Έκ³  사항

@api λ°μ½”λ ˆμ΄ν„°μ™€μ˜ 관계

@upload λ°μ½”λ ˆμ΄ν„°λŠ” λ‚΄λΆ€μ μœΌλ‘œ μžλ™μœΌλ‘œ API μ—”λ“œν¬μΈνŠΈλ₯Ό μƒμ„±ν•©λ‹ˆλ‹€. λ”°λΌμ„œ @api λ°μ½”λ ˆμ΄ν„°λ₯Ό λ³„λ„λ‘œ μ‚¬μš©ν•  ν•„μš”κ°€ μ—†μŠ΅λ‹ˆλ‹€. μžλ™ μ„€μ •λ˜λŠ” κ°’:
  • httpMethod: "POST" (κ³ μ •)
  • clients: ["axios-multipart", "tanstack-mutation-multipart"] (multipart μ „μš© ν΄λΌμ΄μ–ΈνŠΈ)
  • guards: @upload μ˜΅μ…˜μ˜ guards 값이 API에 전달됨
  • description: @upload μ˜΅μ…˜μ˜ description 값이 API에 전달됨
// @upload만 μ‚¬μš© (ꢌμž₯)
@upload()
async uploadFile() {
  // ...
}

// λΆˆν•„μš” - @apiλ₯Ό ν•¨κ»˜ μ‚¬μš©ν•  ν•„μš” μ—†μŒ
@api({ httpMethod: "POST" })  // λΆˆν•„μš”
@upload()
async uploadFile() {
  // ...
}
@uploadλŠ” 파일 μ—…λ‘œλ“œμ— μ΅œμ ν™”λœ 섀정을 μžλ™μœΌλ‘œ μ μš©ν•˜λ―€λ‘œ, @api λ°μ½”λ ˆμ΄ν„°λ₯Ό μΆ”κ°€λ‘œ μ‚¬μš©ν•  ν•„μš”κ°€ μ—†μŠ΅λ‹ˆλ‹€.

λ‹€μŒ 단계

@api

API μ—”λ“œν¬μΈνŠΈ λ§Œλ“€κΈ°

@transactional

νŠΈλžœμž­μ…˜μœΌλ‘œ μ•ˆμ „ν•˜κ²Œ μ €μž₯ν•˜κΈ°

Storage λ“œλΌμ΄λ²„

S3, Local λ“± μŠ€ν† λ¦¬μ§€ μ‚¬μš©ν•˜κΈ°

파일 처리

이미지/λ¬Έμ„œ 처리 κ°€μ΄λ“œ