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

UploadedFile ๊ฐœ์š”

ํŒŒ์ผ ์ •๋ณด

filename, mimetype, sizeextname, md5

saveToDisk()

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

toBuffer()

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

URL ์†์„ฑ

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

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

ํŒŒ์ผ ์ •๋ณด

import type { UploadedFile } from "sonamu";

class FileModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  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 {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  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 {
  @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 };
  }
}

Buffer ๋ณ€ํ™˜

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

class FileModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  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 {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  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 {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  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 {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  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 {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  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 {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  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 {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  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 {
  @api({ httpMethod: "POST" })
  @upload({ mode: "multiple" })
  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 {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  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 ์„ค์ • ํ™•์ธ

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