๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
UploadedFile ํด๋ž˜์Šค๋Š” ์—…๋กœ๋“œ๋œ ํŒŒ์ผ์˜ ์ •๋ณด๋ฅผ ์ œ๊ณตํ•˜๊ณ , saveToDisk() ๋ฉ”์„œ๋“œ๋กœ ํŒŒ์ผ์„ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.

UploadedFile ๊ฐœ์š”

ํŒŒ์ผ ์ •๋ณด

filename, mimetype, size extname, md5

saveToDisk()

์Šคํ† ๋ฆฌ์ง€์— ์ €์žฅ URL ์ž๋™ ์ƒ์„ฑ

toBuffer()

Buffer ๋ณ€ํ™˜ ์บ์‹ฑ ์ง€์›

URL ์†์„ฑ

url, signedUrl ์ €์žฅ ํ›„ ์ž๋™ ์„ค์ •

๊ธฐ๋ณธ ์†์„ฑ

ํŒŒ์ผ ์ •๋ณด

import type { UploadedFile } from "sonamu";

class FileModel extends BaseModelClass {
  @upload()
  async upload(params: { file: UploadedFile }): Promise<any> {
    const { file } = params;

    // ํŒŒ์ผ ์ •๋ณด ์ ‘๊ทผ
    console.log({
      filename: file.filename, // ์›๋ณธ ํŒŒ์ผ๋ช…: "photo.jpg"
      mimetype: file.mimetype, // MIME ํƒ€์ž…: "image/jpeg"
      size: file.size, // ํŒŒ์ผ ํฌ๊ธฐ (bytes): 524288
      extname: file.extname, // ํ™•์žฅ์ž: "jpg" (์  ์ œ์™ธ)
    });

    return {
      filename: file.filename,
      mimetype: file.mimetype,
      size: file.size,
      extname: file.extname,
    };
  }
}
์†์„ฑ ๋ชฉ๋ก:
  • filename: string - ์›๋ณธ ํŒŒ์ผ๋ช…
  • mimetype: string - MIME ํƒ€์ž…
  • size: number - ํŒŒ์ผ ํฌ๊ธฐ (๋ฐ”์ดํŠธ)
  • extname: string | false - ํ™•์žฅ์ž (์  ์ œ์™ธ)
  • url: string | undefined - ์ €์žฅ ํ›„ Public URL
  • signedUrl: string | undefined - ์ €์žฅ ํ›„ Signed URL

saveToDisk() ๋ฉ”์„œ๋“œ

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

class FileModel extends BaseModelClass {
  @upload()
  async upload(params: { file: UploadedFile }): Promise<{ url: string }> {
    const { file } = params;

    // ํŒŒ์ผ ์ €์žฅ (๊ธฐ๋ณธ ๋””์Šคํฌ ์‚ฌ์šฉ)
    const url = await file.saveToDisk(`uploads/${Date.now()}-${file.filename}`);

    // url ์†์„ฑ์— ์ž๋™์œผ๋กœ ์ €์žฅ๋จ
    console.log(file.url); // Public URL
    console.log(file.signedUrl); // Signed URL

    return { url };
  }
}

ํŠน์ • ๋””์Šคํฌ์— ์ €์žฅ

class FileModel extends BaseModelClass {
  @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 };
  }
}

Buffer ๋ณ€ํ™˜

toBuffer() ๋ฉ”์„œ๋“œ

class FileModel extends BaseModelClass {
  @upload()
  async processImage(params: { image: UploadedFile }): Promise<{ url: string }> {
    const { image } = params;

    // Buffer๋กœ ๋ณ€ํ™˜ (์บ์‹ฑ๋จ)
    const buffer = await image.toBuffer();

    console.log("Buffer size:", buffer.length);

    // Buffer๋ฅผ ์ด์šฉํ•œ ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ
    const sharp = require("sharp");
    const resized = await sharp(buffer).resize(800, 600).toBuffer();

    // ์ฒ˜๋ฆฌ๋œ ์ด๋ฏธ์ง€ ์ €์žฅ
    // (saveToDisk๋Š” ์›๋ณธ ํŒŒ์ผ์„ ์ €์žฅํ•˜๋ฏ€๋กœ,
    // ์ฒ˜๋ฆฌ๋œ Buffer๋Š” Storage Manager๋ฅผ ์ง์ ‘ ์‚ฌ์šฉ)
    const disk = Sonamu.storage.use();
    const key = `images/${Date.now()}.jpg`;

    await disk.put(key, new Uint8Array(resized), {
      contentType: "image/jpeg",
    });

    const url = await disk.getUrl(key);

    return { url };
  }
}
toBuffer()๋Š” ๊ฒฐ๊ณผ๋ฅผ ์บ์‹ฑํ•˜๋ฏ€๋กœ, ์—ฌ๋Ÿฌ ๋ฒˆ ํ˜ธ์ถœํ•ด๋„ ํ•œ ๋ฒˆ๋งŒ ์ฝ์Šต๋‹ˆ๋‹ค.

MD5 ํ•ด์‹œ

md5() ๋ฉ”์„œ๋“œ

class FileModel extends BaseModelClass {
  @upload()
  async uploadWithHash(params: { file: UploadedFile }): Promise<{
    url: string;
    md5: string;
  }> {
    const { file } = params;

    // MD5 ํ•ด์‹œ ๊ณ„์‚ฐ
    const md5Hash = await file.md5();
    console.log("MD5:", md5Hash); // "abc123def456..."

    // ์ค‘๋ณต ํŒŒ์ผ ์ฒดํฌ
    const rdb = this.getPuri("r");
    const existing = await rdb.table("files").where("md5_hash", md5Hash).first();

    if (existing) {
      // ์ด๋ฏธ ๋™์ผํ•œ ํŒŒ์ผ์ด ์กด์žฌ
      return {
        url: existing.url,
        md5: md5Hash,
      };
    }

    // ์ƒˆ ํŒŒ์ผ ์ €์žฅ
    const url = await file.saveToDisk(`uploads/${md5Hash}.${file.extname}`);

    // DB์— ์ €์žฅ
    const wdb = this.getPuri("w");
    await wdb.table("files").insert({
      filename: file.filename,
      mime_type: file.mimetype,
      size: file.size,
      md5_hash: md5Hash,
      url,
    });

    return { url, md5: md5Hash };
  }
}

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

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

class FileValidator {
  static validateSize(file: UploadedFile, maxSize: number): void {
    if (file.size > maxSize) {
      throw new Error(`File too large: ${file.size} bytes (max ${maxSize} bytes)`);
    }
  }
}

// ์‚ฌ์šฉ
class FileModel extends BaseModelClass {
  @upload()
  async upload(params: { file: UploadedFile }): Promise<{ url: string }> {
    const { file } = params;

    // ํฌ๊ธฐ ๊ฒ€์ฆ (10MB)
    FileValidator.validateSize(file, 10 * 1024 * 1024);

    const url = await file.saveToDisk(`uploads/${Date.now()}.${file.extname}`);

    return { url };
  }
}

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

class FileValidator {
  static validateMimeType(file: UploadedFile, allowedTypes: string[]): void {
    if (!allowedTypes.includes(file.mimetype)) {
      throw new Error(
        `Invalid file type: ${file.mimetype}. ` + `Allowed: ${allowedTypes.join(", ")}`,
      );
    }
  }
}

// ์‚ฌ์šฉ
class ImageModel extends BaseModelClass {
  @upload()
  async uploadImage(params: { image: UploadedFile }): Promise<{ url: string }> {
    const { image } = params;

    // MIME ํƒ€์ž… ๊ฒ€์ฆ
    FileValidator.validateMimeType(image, ["image/jpeg", "image/png", "image/gif", "image/webp"]);

    const url = await image.saveToDisk(`images/${Date.now()}.${image.extname}`);

    return { url };
  }
}

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

class FileValidator {
  static validateExtension(file: UploadedFile, allowedExtensions: string[]): void {
    const ext = file.extname;

    if (!ext || !allowedExtensions.includes(ext.toLowerCase())) {
      throw new Error(
        `Invalid file extension: ${ext}. ` + `Allowed: ${allowedExtensions.join(", ")}`,
      );
    }
  }
}

// ์‚ฌ์šฉ
class DocumentModel extends BaseModelClass {
  @upload()
  async uploadDocument(params: { document: UploadedFile }): Promise<{ url: string }> {
    const { document } = params;

    // ํ™•์žฅ์ž ๊ฒ€์ฆ
    FileValidator.validateExtension(document, ["pdf", "doc", "docx", "txt"]);

    const url = await document.saveToDisk(`documents/${Date.now()}.${document.extname}`);

    return { url };
  }
}

ํ†ตํ•ฉ ๊ฒ€์ฆ ํด๋ž˜์Šค

class FileValidator {
  static validateImage(file: UploadedFile): void {
    // ํฌ๊ธฐ ํ™•์ธ (5MB)
    if (file.size > 5 * 1024 * 1024) {
      throw new Error("Image too large (max 5MB)");
    }

    // MIME ํƒ€์ž… ํ™•์ธ
    const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
    if (!allowedTypes.includes(file.mimetype)) {
      throw new Error(`Invalid image type: ${file.mimetype}`);
    }

    // ํ™•์žฅ์ž ํ™•์ธ
    const allowedExtensions = ["jpg", "jpeg", "png", "gif", "webp"];
    if (!file.extname || !allowedExtensions.includes(file.extname.toLowerCase())) {
      throw new Error(`Invalid image extension: ${file.extname}`);
    }
  }

  static validateDocument(file: UploadedFile): void {
    // ํฌ๊ธฐ ํ™•์ธ (20MB)
    if (file.size > 20 * 1024 * 1024) {
      throw new Error("Document too large (max 20MB)");
    }

    // MIME ํƒ€์ž… ํ™•์ธ
    const allowedTypes = [
      "application/pdf",
      "application/msword",
      "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
      "text/plain",
    ];

    if (!allowedTypes.includes(file.mimetype)) {
      throw new Error(`Invalid document type: ${file.mimetype}`);
    }
  }

  static validateVideo(file: UploadedFile): void {
    // ํฌ๊ธฐ ํ™•์ธ (100MB)
    if (file.size > 100 * 1024 * 1024) {
      throw new Error("Video too large (max 100MB)");
    }

    // MIME ํƒ€์ž… ํ™•์ธ
    const allowedTypes = ["video/mp4", "video/mpeg", "video/quicktime", "video/x-msvideo"];

    if (!allowedTypes.includes(file.mimetype)) {
      throw new Error(`Invalid video type: ${file.mimetype}`);
    }
  }
}

// ์‚ฌ์šฉ
class MediaModel extends BaseModelClass {
  @upload()
  async uploadImage(params: { image: UploadedFile }): Promise<{ url: string }> {
    const { image } = params;

    FileValidator.validateImage(image);

    const url = await image.saveToDisk(`images/${Date.now()}.${image.extname}`);

    return { url };
  }
}

์‹ค์ „ ์˜ˆ์ œ

ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ

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

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

    const { image } = params;

    // ๊ฒ€์ฆ
    FileValidator.validateImage(image);

    // ์›๋ณธ ์ด๋ฏธ์ง€ Buffer
    const buffer = await image.toBuffer();

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

    // ์›๋ณธ ์ €์žฅ
    const originalKey = `profiles/${context.user.id}/original-${Date.now()}.${image.extname}`;
    const originalUrl = await image.saveToDisk(originalKey);

    // ์ธ๋„ค์ผ ์ €์žฅ (Storage Manager ์ง์ ‘ ์‚ฌ์šฉ)
    const disk = Sonamu.storage.use();
    const thumbnailKey = `profiles/${context.user.id}/thumb-${Date.now()}.jpg`;
    await disk.put(thumbnailKey, new Uint8Array(thumbnailBuffer), {
      contentType: "image/jpeg",
    });
    const thumbnailUrl = await disk.getUrl(thumbnailKey);

    // DB์— ์ €์žฅ
    const wdb = this.getPuri("w");
    const [record] = await wdb
      .table("profile_images")
      .insert({
        user_id: context.user.id,
        original_key: originalKey,
        thumbnail_key: thumbnailKey,
        original_url: originalUrl,
        thumbnail_url: thumbnailUrl,
        mime_type: image.mimetype,
        size: image.size,
      })
      .returning({ id: "id" });

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

์—ฌ๋Ÿฌ ํŒŒ์ผ ์ผ๊ด„ ์ฒ˜๋ฆฌ

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

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

    if (files.length > 10) {
      throw new Error("Maximum 10 files allowed");
    }

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

    for (const file of files) {
      // ๊ฒ€์ฆ
      if (file.size > 20 * 1024 * 1024) {
        throw new Error(`File ${file.filename} too large (max 20MB)`);
      }

      // MD5 ํ•ด์‹œ
      const md5Hash = await file.md5();

      // ์ค‘๋ณต ์ฒดํฌ
      const rdb = this.getPuri("r");
      const existing = await rdb.table("files").where("md5_hash", md5Hash).first();

      if (existing) {
        // ์ด๋ฏธ ์กด์žฌํ•˜๋Š” ํŒŒ์ผ
        uploadedFiles.push({
          fileId: existing.id,
          filename: existing.filename,
          url: existing.url,
          md5: md5Hash,
        });
        continue;
      }

      // ์ƒˆ ํŒŒ์ผ ์ €์žฅ
      const key = `uploads/${category}/${Date.now()}.${file.extname}`;
      const url = await file.saveToDisk(key);

      // DB์— ์ €์žฅ
      const [record] = await wdb
        .table("files")
        .insert({
          key,
          filename: file.filename,
          mime_type: file.mimetype,
          size: file.size,
          md5_hash: md5Hash,
          url,
          category,
        })
        .returning({ id: "id" });

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

    return { uploadedFiles };
  }
}

์›๋ณธ MultipartFile ์ ‘๊ทผ

raw ์†์„ฑ

class FileModel extends BaseModelClass {
  @upload()
  async uploadAdvanced(params: { file: UploadedFile }): Promise<any> {
    const { file } = params;

    // ์›๋ณธ Fastify MultipartFile ์ ‘๊ทผ
    const rawFile = file.raw;

    console.log({
      encoding: rawFile.encoding,
      fieldname: rawFile.fieldname,
      // ...
    });

    const url = await file.saveToDisk(`uploads/${Date.now()}.${file.extname}`);

    return { url };
  }
}

์ฃผ์˜์‚ฌํ•ญ

UploadedFile ์‚ฌ์šฉ ์‹œ ์ฃผ์˜์‚ฌํ•ญ: 1. saveToDisk() ํ˜ธ์ถœ ์ „ ๊ฒ€์ฆ ํ•„์ˆ˜ 2. toBuffer()๋Š” ์บ์‹ฑ๋˜๋ฏ€๋กœ ์—ฌ๋Ÿฌ ๋ฒˆ ํ˜ธ์ถœ ๊ฐ€๋Šฅ 3. url, signedUrl์€ ์ €์žฅ ํ›„์—๋งŒ ์‚ฌ์šฉ ๊ฐ€๋Šฅ 4. ๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ์€ ์ŠคํŠธ๋ฆฌ๋ฐ ๊ณ ๋ ค 5. Storage ์„ค์ • ํ™•์ธ

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

ํŒŒ์ผ ์—…๋กœ๋“œ ์„ค์ •

@upload ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ ์„ค์ •

ํŒŒ์ผ ์ €์žฅ

Storage Manager ์‚ฌ์šฉ

URL ์ƒ์„ฑ

URL ์ƒ์„ฑํ•˜๊ธฐ

@api ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ

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