๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
Sonamu์˜ Storage Manager๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋กœ์ปฌ, S3, GCS ๋“ฑ์— ํŒŒ์ผ์„ ์ €์žฅํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์•Œ์•„๋ด…๋‹ˆ๋‹ค.

Storage Manager ๊ฐœ์š”

ํ†ตํ•ฉ ์ธํ„ฐํŽ˜์ด์Šค

๋‹จ์ผ API์—ฌ๋Ÿฌ ์Šคํ† ๋ฆฌ์ง€ ์ง€์›

๋“œ๋ผ์ด๋ฒ„ ์‹œ์Šคํ…œ

local, s3, gcsํ™•์žฅ ๊ฐ€๋Šฅ

saveToDisk()

๊ฐ„ํŽธํ•œ ์ €์žฅURL ์ž๋™ ์ƒ์„ฑ

๋””์Šคํฌ ๊ด€๋ฆฌ

์—ฌ๋Ÿฌ ๋””์Šคํฌ ์„ค์ •๋ชฉ์ ๋ณ„ ๋ถ„๋ฆฌ

saveToDisk() ๋ฉ”์„œ๋“œ (๊ถŒ์žฅ)

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

UploadedFile์˜ saveToDisk() ๋ฉ”์„œ๋“œ๊ฐ€ ๊ฐ€์žฅ ๊ฐ„ํŽธํ•œ ํŒŒ์ผ ์ €์žฅ ๋ฐฉ๋ฒ•์ž…๋‹ˆ๋‹ค.
import type { UploadedFile } from "sonamu";

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

Storage Manager ์ง์ ‘ ์‚ฌ์šฉ

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

saveToDisk()๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋Š” ๊ฒฝ์šฐ Storage Manager๋ฅผ ์ง์ ‘ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
import { Sonamu } from "sonamu";

class FileModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async uploadBuffer(params: {
    buffer: Buffer;
    filename: string;
    mimetype: string;
  }): Promise<{ url: string }> {
    const { buffer, filename, mimetype } = params;
    
    // ๊ธฐ๋ณธ ๋””์Šคํฌ ๊ฐ€์ ธ์˜ค๊ธฐ
    const disk = Sonamu.storage.use();
    
    // ํŒŒ์ผ ์ €์žฅ
    const key = `uploads/${Date.now()}-${filename}`;
    await disk.put(key, new Uint8Array(buffer), {
      contentType: mimetype,
    });
    
    // URL ์ƒ์„ฑ
    const url = await disk.getUrl(key);
    
    return { url };
  }
}

ํŠน์ • ๋””์Šคํฌ ์‚ฌ์šฉ

class FileModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async uploadToS3(params: {
    buffer: Buffer;
    filename: string;
  }): Promise<{ url: string }> {
    const { buffer, filename } = params;
    
    // S3 ๋””์Šคํฌ ๊ฐ€์ ธ์˜ค๊ธฐ
    const s3Disk = Sonamu.storage.use("s3");
    
    // ํŒŒ์ผ ์ €์žฅ
    const key = `uploads/${Date.now()}-${filename}`;
    await s3Disk.put(key, new Uint8Array(buffer), {
      contentType: "application/octet-stream",
    });
    
    // URL ์ƒ์„ฑ
    const url = await s3Disk.getUrl(key);
    
    return { url };
  }
}

Storage ์„ค์ •

storage.config.ts

// 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!,
      },
    },
    
    // ํ”„๋ผ์ด๋น— ํŒŒ์ผ์šฉ
    private: {
      driver: "s3",
      bucket: process.env.PRIVATE_S3_BUCKET!,
      region: "us-east-1",
      credentials: {
        accessKeyId: process.env.S3_ACCESS_KEY_ID!,
        secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
      },
    },
  },
};

Storage Driver API

put() - ํŒŒ์ผ ์ €์žฅ

const disk = Sonamu.storage.use();

await disk.put(key, data, options);
ํŒŒ๋ผ๋ฏธํ„ฐ:
  • key: string - ์ €์žฅ ๊ฒฝ๋กœ
  • data: Uint8Array - ํŒŒ์ผ ๋ฐ์ดํ„ฐ
  • options: { contentType?: string } - ์˜ต์…˜

get() - ํŒŒ์ผ ์ฝ๊ธฐ

const buffer = await disk.get(key);

delete() - ํŒŒ์ผ ์‚ญ์ œ

await disk.delete(key);

exists() - ํŒŒ์ผ ์กด์žฌ ํ™•์ธ

const exists = await disk.exists(key);

getUrl() - Public URL ์ƒ์„ฑ

const url = await disk.getUrl(key);

getSignedUrl() - Signed URL ์ƒ์„ฑ

const signedUrl = await disk.getSignedUrl(key, expiresIn);

์‹ค์ „ ์˜ˆ์ œ

๋‚ ์งœ๋ณ„ ํด๋” ๊ตฌ์กฐ

class FileModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  async upload(params: {
    file: UploadedFile;
  }): Promise<{ url: string }> {
    const { file } = params;
    
    // ๋‚ ์งœ๋ณ„ ๊ฒฝ๋กœ ์ƒ์„ฑ
    const now = new Date();
    const year = now.getFullYear();
    const month = String(now.getMonth() + 1).padStart(2, "0");
    const day = String(now.getDate()).padStart(2, "0");
    
    const key = `uploads/${year}/${month}/${day}/${Date.now()}.${file.extname}`;
    
    const url = await file.saveToDisk(key);
    
    return { url };
  }
}

์‚ฌ์šฉ์ž๋ณ„ ํด๋” ๊ตฌ์กฐ

class FileModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  async upload(params: {
    file: UploadedFile;
  }): Promise<{ url: string }> {
    const context = Sonamu.getContext();
    
    if (!context.user) {
      throw new Error("Authentication required");
    }
    
    const { file } = params;
    
    // ์‚ฌ์šฉ์ž๋ณ„ ๊ฒฝ๋กœ
    const key = `users/${context.user.id}/${Date.now()}.${file.extname}`;
    
    const url = await file.saveToDisk(key);
    
    return { url };
  }
}

์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ๋””์Šคํฌ

class MediaModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  async uploadMedia(params: {
    file: UploadedFile;
    category: "public" | "private";
  }): Promise<{ url: string }> {
    const { file, category } = params;
    
    // ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ๋””์Šคํฌ ์„ ํƒ
    const diskName = category === "public" ? "public" : "private";
    
    const key = `media/${Date.now()}.${file.extname}`;
    const url = await file.saveToDisk(key, diskName);
    
    return { url };
  }
}

์ด๋ฏธ์ง€ ์ตœ์ ํ™” ํ›„ ์ €์žฅ

import sharp from "sharp";

class ImageModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  async uploadOptimized(params: {
    image: UploadedFile;
  }): Promise<{
    originalUrl: string;
    thumbnailUrl: string;
    mediumUrl: string;
  }> {
    const { image } = params;
    
    // ์›๋ณธ ์ด๋ฏธ์ง€ Buffer
    const buffer = await image.toBuffer();
    
    const timestamp = Date.now();
    const ext = image.extname;
    
    // ์›๋ณธ ์ €์žฅ
    const originalKey = `images/original/${timestamp}.${ext}`;
    const originalUrl = await image.saveToDisk(originalKey);
    
    // Storage Manager ๊ฐ€์ ธ์˜ค๊ธฐ
    const disk = Sonamu.storage.use();
    
    // ์ธ๋„ค์ผ ์ƒ์„ฑ ๋ฐ ์ €์žฅ (200x200)
    const thumbnailBuffer = await sharp(buffer)
      .resize(200, 200, { fit: "cover" })
      .jpeg({ quality: 80 })
      .toBuffer();
    
    const thumbnailKey = `images/thumbnail/${timestamp}.jpg`;
    await disk.put(thumbnailKey, new Uint8Array(thumbnailBuffer), {
      contentType: "image/jpeg",
    });
    const thumbnailUrl = await disk.getUrl(thumbnailKey);
    
    // ์ค‘๊ฐ„ ํฌ๊ธฐ ์ƒ์„ฑ ๋ฐ ์ €์žฅ (800x800)
    const mediumBuffer = await sharp(buffer)
      .resize(800, 800, { fit: "inside" })
      .jpeg({ quality: 85 })
      .toBuffer();
    
    const mediumKey = `images/medium/${timestamp}.jpg`;
    await disk.put(mediumKey, new Uint8Array(mediumBuffer), {
      contentType: "image/jpeg",
    });
    const mediumUrl = await disk.getUrl(mediumKey);
    
    return {
      originalUrl,
      thumbnailUrl,
      mediumUrl,
    };
  }
}

์—ฌ๋Ÿฌ ๋””์Šคํฌ์— ๋™์‹œ ์ €์žฅ

class FileModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  async uploadWithBackup(params: {
    file: UploadedFile;
  }): Promise<{
    primaryUrl: string;
    backupUrl: string;
  }> {
    const { file } = params;
    
    const key = `uploads/${Date.now()}.${file.extname}`;
    const buffer = await file.toBuffer();
    
    // Primary ๋””์Šคํฌ์— ์ €์žฅ
    const primaryUrl = await file.saveToDisk(key, "s3");
    
    // Backup ๋””์Šคํฌ์—๋„ ์ €์žฅ
    const backupDisk = Sonamu.storage.use("backup");
    await backupDisk.put(key, new Uint8Array(buffer), {
      contentType: file.mimetype,
    });
    const backupUrl = await backupDisk.getUrl(key);
    
    return {
      primaryUrl,
      backupUrl,
    };
  }
}

ํŒŒ์ผ ์‚ญ์ œ

ํŒŒ์ผ ์‚ญ์ œ

class FileModel extends BaseModelClass {
  @api({ httpMethod: "DELETE" })
  async remove(fileId: number): Promise<void> {
    const rdb = this.getPuri("r");
    
    // ํŒŒ์ผ ์ •๋ณด ์กฐํšŒ
    const file = await rdb
      .table("files")
      .where("id", fileId)
      .first();
    
    if (!file) {
      throw new Error("File not found");
    }
    
    // Storage์—์„œ ์‚ญ์ œ
    const disk = Sonamu.storage.use(file.disk_name);
    await disk.delete(file.key);
    
    // DB์—์„œ ์‚ญ์ œ
    const wdb = this.getPuri("w");
    await wdb.table("files").where("id", fileId).delete();
  }
}

ํŒŒ์ผ ์กด์žฌ ํ™•์ธ ํ›„ ์‚ญ์ œ

class FileModel extends BaseModelClass {
  @api({ httpMethod: "DELETE" })
  async removeSafe(fileId: number): Promise<void> {
    const rdb = this.getPuri("r");
    
    const file = await rdb
      .table("files")
      .where("id", fileId)
      .first();
    
    if (!file) {
      throw new Error("File not found");
    }
    
    const disk = Sonamu.storage.use(file.disk_name);
    
    // ํŒŒ์ผ ์กด์žฌ ํ™•์ธ
    const exists = await disk.exists(file.key);
    
    if (exists) {
      await disk.delete(file.key);
    } else {
      console.warn(`File not found in storage: ${file.key}`);
    }
    
    // DB์—์„œ ์‚ญ์ œ
    const wdb = this.getPuri("w");
    await wdb.table("files").where("id", fileId).delete();
  }
}

๊ณ ๊ธ‰ ์‚ฌ์šฉ

์ŠคํŠธ๋ฆฌ๋ฐ ์—…๋กœ๋“œ (๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ)

๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ์˜ ๊ฒฝ์šฐ Buffer ๋Œ€์‹  ์ŠคํŠธ๋ฆฌ๋ฐ์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
import { pipeline } from "stream/promises";
import fs from "fs";

class FileModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  async uploadLarge(params: {
    file: UploadedFile;
  }): Promise<{ url: string }> {
    const { file } = params;
    
    // ์ž„์‹œ ํŒŒ์ผ๋กœ ์ €์žฅ
    const tempPath = `/tmp/${Date.now()}.${file.extname}`;
    const writeStream = fs.createWriteStream(tempPath);
    
    await pipeline(file.raw.file, writeStream);
    
    // ์ž„์‹œ ํŒŒ์ผ์„ Storage์— ์—…๋กœ๋“œ
    const disk = Sonamu.storage.use();
    const key = `uploads/${Date.now()}.${file.extname}`;
    
    const buffer = await fs.promises.readFile(tempPath);
    await disk.put(key, new Uint8Array(buffer), {
      contentType: file.mimetype,
    });
    
    // ์ž„์‹œ ํŒŒ์ผ ์‚ญ์ œ
    await fs.promises.unlink(tempPath);
    
    const url = await disk.getUrl(key);
    
    return { url };
  }
}

ํŒŒ์ผ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ €์žฅ

class FileModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  async uploadWithMetadata(params: {
    file: UploadedFile;
    title?: string;
    description?: string;
    tags?: string[];
  }): Promise<{ fileId: number; url: string }> {
    const context = Sonamu.getContext();
    
    if (!context.user) {
      throw new Error("Authentication required");
    }
    
    const { file, title, description, tags } = params;
    
    // MD5 ํ•ด์‹œ ๊ณ„์‚ฐ
    const md5Hash = await file.md5();
    
    // ํŒŒ์ผ ์ €์žฅ
    const key = `uploads/${Date.now()}.${file.extname}`;
    const url = await file.saveToDisk(key);
    
    // DB์— ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ €์žฅ
    const wdb = this.getPuri("w");
    const [record] = await wdb
      .table("files")
      .insert({
        user_id: context.user.id,
        key,
        filename: file.filename,
        mime_type: file.mimetype,
        size: file.size,
        md5_hash: md5Hash,
        url,
        title,
        description,
        tags: tags ? JSON.stringify(tags) : null,
        uploaded_at: new Date(),
      })
      .returning({ id: "id" });
    
    return {
      fileId: record.id,
      url,
    };
  }
}

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

ํŒŒ์ผ ์ €์žฅ ์‹œ ์ฃผ์˜์‚ฌํ•ญ:
  1. saveToDisk()๊ฐ€ ๊ฐ€์žฅ ๊ฐ„ํŽธํ•œ ๋ฐฉ๋ฒ•
  2. ๋””์Šคํฌ ์„ค์ • ํ™•์ธ
  3. ํŒŒ์ผ ํฌ๊ธฐ ์ œํ•œ ์„ค์ •
  4. ์—๋Ÿฌ ์ฒ˜๋ฆฌ ํ•„์ˆ˜
  5. ๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ์€ ์ŠคํŠธ๋ฆฌ๋ฐ ๊ณ ๋ ค

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