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

Storage Manager ๊ฐœ์š”

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

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

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

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

saveToDisk()

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

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

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

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

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

@upload() ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋กœ ์—…๋กœ๋“œ๋œ ํŒŒ์ผ์€ Sonamu.getContext().bufferedFiles๋ฅผ ํ†ตํ•ด ์ ‘๊ทผํ•ฉ๋‹ˆ๋‹ค. BufferedFile์˜ saveToDisk() ๋ฉ”์„œ๋“œ๊ฐ€ ๊ฐ€์žฅ ๊ฐ„ํŽธํ•œ ํŒŒ์ผ ์ €์žฅ ๋ฐฉ๋ฒ•์ž…๋‹ˆ๋‹ค.
class FileModel extends BaseModelClass {
  @upload()
  async upload(): Promise<{ url: string }> {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("ํŒŒ์ผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");

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

    return { url };
  }
}

๋””์Šคํฌ ์„ ํƒ

class FileModel extends BaseModelClass {
  @upload()
  async uploadToS3(): Promise<{ url: string }> {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("ํŒŒ์ผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");

    // S3 ๋””์Šคํฌ์— ์ €์žฅ
    const url = await file.saveToDisk("s3", `uploads/${Date.now()}-${file.filename}`);

    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 {
  @upload()
  async upload(): Promise<{ url: string }> {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("ํŒŒ์ผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");

    // ๋‚ ์งœ๋ณ„ ๊ฒฝ๋กœ ์ƒ์„ฑ
    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("fs", key);

    return { url };
  }
}

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

class FileModel extends BaseModelClass {
  @upload()
  async upload(): Promise<{ url: string }> {
    const context = Sonamu.getContext();

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

    const { bufferedFiles } = context;
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("ํŒŒ์ผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");

    // ์‚ฌ์šฉ์ž๋ณ„ ๊ฒฝ๋กœ
    const key = `users/${context.user.id}/${Date.now()}.${file.extname}`;

    const url = await file.saveToDisk("fs", key);

    return { url };
  }
}

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

class MediaModel extends BaseModelClass {
  @upload()
  async uploadMedia(params: { category: "public" | "private" }): Promise<{ url: string }> {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("ํŒŒ์ผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");
    const { category } = params;

    // ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ๋””์Šคํฌ ์„ ํƒ
    const diskName = category === "public" ? "public" : "private";

    const key = `media/${Date.now()}.${file.extname}`;
    const url = await file.saveToDisk(diskName, key);

    return { url };
  }
}

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

import sharp from "sharp";

class ImageModel extends BaseModelClass {
  @upload()
  async uploadOptimized(): Promise<{
    originalUrl: string;
    thumbnailUrl: string;
    mediumUrl: string;
  }> {
    const { bufferedFiles } = Sonamu.getContext();
    const image = bufferedFiles?.[0];
    if (!image) throw new Error("์ด๋ฏธ์ง€๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");

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

    const timestamp = Date.now();
    const ext = image.extname;

    // ์›๋ณธ ์ €์žฅ
    const originalKey = `images/original/${timestamp}.${ext}`;
    const originalUrl = await image.saveToDisk("fs", 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 {
  @upload()
  async uploadWithBackup(): Promise<{
    primaryUrl: string;
    backupUrl: string;
  }> {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("ํŒŒ์ผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");

    const key = `uploads/${Date.now()}.${file.extname}`;
    const buffer = file.buffer;

    // Primary ๋””์Šคํฌ์— ์ €์žฅ
    const primaryUrl = await file.saveToDisk("s3", key);

    // 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();
  }
}

๊ณ ๊ธ‰ ์‚ฌ์šฉ

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

๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ์˜ ๊ฒฝ์šฐ @upload({ consume: "stream" }) ์˜ต์…˜์„ ์‚ฌ์šฉํ•˜๋ฉด ํŒŒ์ผ์ด ์ง€์ •๋œ ์Šคํ† ๋ฆฌ์ง€์— ์ง์ ‘ ์ŠคํŠธ๋ฆฌ๋ฐ ์ €์žฅ๋ฉ๋‹ˆ๋‹ค. ์ด ๊ฒฝ์šฐ context.uploadedFiles๋ฅผ ํ†ตํ•ด ์ด๋ฏธ ์—…๋กœ๋“œ๋œ ํŒŒ์ผ ์ •๋ณด์— ์ ‘๊ทผํ•ฉ๋‹ˆ๋‹ค.
class FileModel extends BaseModelClass {
  @upload({ consume: "stream", destination: "s3" })
  async uploadLarge(): Promise<{ url: string }> {
    const { uploadedFiles } = Sonamu.getContext();
    const file = uploadedFiles?.[0];
    if (!file) throw new Error("ํŒŒ์ผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");

    // stream ๋ชจ๋“œ์—์„œ๋Š” ํŒŒ์ผ์ด ์ด๋ฏธ destination์— ์ €์žฅ๋˜์–ด ์žˆ์Œ
    return { url: file.url };
  }
}

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

class FileModel extends BaseModelClass {
  @upload()
  async uploadWithMetadata(params: {
    title?: string;
    description?: string;
    tags?: string[];
  }): Promise<{ fileId: number; url: string }> {
    const context = Sonamu.getContext();

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

    const { bufferedFiles } = context;
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("ํŒŒ์ผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");
    const { title, description, tags } = params;

    // ํŒŒ์ผ ์ €์žฅ
    const key = `uploads/${Date.now()}-${file.filename}`;
    const url = await file.saveToDisk("fs", 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,
        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. ๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ์€ ์ŠคํŠธ๋ฆฌ๋ฐ ๊ณ ๋ ค

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

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

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

UploadedFile ํด๋ž˜์Šค

ํŒŒ์ผ ์ •๋ณด ์ ‘๊ทผ

URL ์ƒ์„ฑ

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

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

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