๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
ํŒŒ์ผ ์ €์žฅ ํ›„ ์ ‘๊ทผ์„ ์œ„ํ•œ URL์„ ์ƒ์„ฑํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์•Œ์•„๋ด…๋‹ˆ๋‹ค.

URL ์ƒ์„ฑ ๊ฐœ์š”

Public URL

๊ณต๊ฐœ ์ ‘๊ทผ ์˜๊ตฌ์  ๋งํฌ

Signed URL

์‹œ๊ฐ„ ์ œํ•œ ์ ‘๊ทผ ๋ณด์•ˆ ๊ฐ•ํ™”

์ž๋™ ์ƒ์„ฑ

saveToDisk ํ›„ ์ž๋™ url, signedUrl ์†์„ฑ

Storage Manager

getUrl() getSignedUrl()

UploadedFile URL ์†์„ฑ

url ์†์„ฑ (Public URL)

saveToDisk() ํ˜ธ์ถœ ํ›„ ์ž๋™์œผ๋กœ Public URL์ด ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.
class FileModel extends BaseModelClass {
  @upload()
  async upload(params: { file: UploadedFile }): Promise<{
    url: string;
    signedUrl: string;
  }> {
    const { file } = params;

    // ํŒŒ์ผ ์ €์žฅ
    await file.saveToDisk(`uploads/${Date.now()}.${file.extname}`);

    // URL ์ž๋™ ์ƒ์„ฑ๋จ
    const publicUrl = file.url!; // Public URL
    const signedUrl = file.signedUrl!; // Signed URL

    return {
      url: publicUrl,
      signedUrl,
    };
  }
}
url๊ณผ signedUrl์€ saveToDisk() ํ˜ธ์ถœ ํ›„์—๋งŒ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

Storage Manager๋กœ URL ์ƒ์„ฑ

getUrl() - Public URL

import { Sonamu } from "sonamu";

class FileModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async getFileUrl(fileId: number): Promise<{ url: string }> {
    const rdb = this.getPuri("r");

    const file = await rdb.table("files").where("id", fileId).first();

    if (!file) {
      throw new Error("File not found");
    }

    // Storage Manager๋กœ URL ์ƒ์„ฑ
    const disk = Sonamu.storage.use(file.disk_name);
    const url = await disk.getUrl(file.key);

    return { url };
  }
}

getSignedUrl() - Signed URL

class FileModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async getDownloadUrl(
    fileId: number,
    expiresIn: number = 3600, // 1์‹œ๊ฐ„
  ): Promise<{
    url: string;
    expiresAt: Date;
  }> {
    const rdb = this.getPuri("r");

    const file = await rdb.table("files").where("id", fileId).first();

    if (!file) {
      throw new Error("File not found");
    }

    // Signed URL ์ƒ์„ฑ
    const disk = Sonamu.storage.use(file.disk_name);
    const url = await disk.getSignedUrl(file.key, expiresIn);

    const expiresAt = new Date(Date.now() + expiresIn * 1000);

    return {
      url,
      expiresAt,
    };
  }
}

์Šคํ† ๋ฆฌ์ง€๋ณ„ URL ํ˜•์‹

Local Storage

// Local ์Šคํ† ๋ฆฌ์ง€ URL
// http://localhost:3000/uploads/1736234567.jpg
๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€๋Š” ์•ฑ URL์„ ๊ธฐ๋ฐ˜์œผ๋กœ Public URL์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

AWS S3

// S3 Public URL
// https://my-bucket.s3.us-east-1.amazonaws.com/uploads/1736234567.jpg

// S3 Signed URL
// https://my-bucket.s3.us-east-1.amazonaws.com/uploads/1736234567.jpg?
// X-Amz-Algorithm=AWS4-HMAC-SHA256&
// X-Amz-Credential=...&
// X-Amz-Date=...&
// X-Amz-Expires=3600&
// X-Amz-Signature=...&
// X-Amz-SignedHeaders=host

Google Cloud Storage

// GCS Public URL
// https://storage.googleapis.com/my-bucket/uploads/1736234567.jpg

// GCS Signed URL
// https://storage.googleapis.com/my-bucket/uploads/1736234567.jpg?
// GoogleAccessId=...&
// Expires=...&
// Signature=...

์‹ค์ „ ์˜ˆ์ œ

ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ URL

class FileModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async getDownloadUrl(fileId: number): Promise<{
    url: string;
    filename: string;
    expiresAt: Date;
  }> {
    const context = Sonamu.getContext();
    const rdb = this.getPuri("r");

    const file = await rdb.table("files").where("id", fileId).first();

    if (!file) {
      throw new Error("File not found");
    }

    // ๋‹ค์šด๋กœ๋“œ ๋กœ๊ทธ
    const wdb = this.getPuri("w");
    await wdb.table("download_logs").insert({
      file_id: fileId,
      user_id: context.user?.id,
      ip_address: context.request.ip,
      user_agent: context.request.headers["user-agent"],
      downloaded_at: new Date(),
    });

    // Signed URL ์ƒ์„ฑ (1์‹œ๊ฐ„)
    const disk = Sonamu.storage.use(file.disk_name);
    const url = await disk.getSignedUrl(file.key, 3600);
    const expiresAt = new Date(Date.now() + 3600 * 1000);

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

์ด๋ฏธ์ง€ ํฌ๊ธฐ๋ณ„ URL

class ImageModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async getImageUrls(imageId: number): Promise<{
    original: string;
    thumbnail: string;
    medium: string;
  }> {
    const rdb = this.getPuri("r");

    const image = await rdb.table("images").where("id", imageId).first();

    if (!image) {
      throw new Error("Image not found");
    }

    const disk = Sonamu.storage.use(image.disk_name);

    return {
      original: await disk.getUrl(image.original_key),
      thumbnail: await disk.getUrl(image.thumbnail_key),
      medium: await disk.getUrl(image.medium_key),
    };
  }
}

๊ถŒํ•œ ๊ธฐ๋ฐ˜ URL ์ƒ์„ฑ

class DocumentModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async getDocumentUrl(documentId: number): Promise<{
    url: string;
    type: "public" | "signed";
  }> {
    const context = Sonamu.getContext();
    const rdb = this.getPuri("r");

    const document = await rdb.table("documents").where("id", documentId).first();

    if (!document) {
      throw new Error("Document not found");
    }

    const disk = Sonamu.storage.use(document.disk_name);

    // Public ๋ฌธ์„œ๋Š” Public URL
    if (document.is_public) {
      const url = await disk.getUrl(document.key);
      return {
        url,
        type: "public",
      };
    }

    // Private ๋ฌธ์„œ๋Š” Signed URL
    if (!context.user) {
      throw new Error("Authentication required");
    }

    // ๊ถŒํ•œ ํ™•์ธ
    if (document.user_id !== context.user.id && context.user.role !== "admin") {
      throw new Error("Access denied");
    }

    const url = await disk.getSignedUrl(document.key, 3600);

    return {
      url,
      type: "signed",
    };
  }
}

์ผ๊ด„ URL ์ƒ์„ฑ

class FileModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async getBatchUrls(params: { fileIds: number[] }): Promise<{
    urls: Record<
      number,
      {
        url: string;
        signedUrl: string;
      }
    >;
  }> {
    const { fileIds } = params;

    if (fileIds.length > 100) {
      throw new Error("Maximum 100 files at once");
    }

    const rdb = this.getPuri("r");

    const files = await rdb.table("files").whereIn("id", fileIds).select("*");

    const urls: Record<
      number,
      {
        url: string;
        signedUrl: string;
      }
    > = {};

    for (const file of files) {
      const disk = Sonamu.storage.use(file.disk_name);

      urls[file.id] = {
        url: await disk.getUrl(file.key),
        signedUrl: await disk.getSignedUrl(file.key, 3600),
      };
    }

    return { urls };
  }
}

URL ์บ์‹ฑ

Redis ์บ์‹ฑ

import Redis from "ioredis";

class FileModel extends BaseModelClass {
  private redis = new Redis(process.env.REDIS_URL);

  @api({ httpMethod: "GET" })
  async getSignedUrlCached(fileId: number): Promise<{
    url: string;
    expiresAt: Date;
  }> {
    // ์บ์‹œ ํ™•์ธ
    const cacheKey = `signed-url:${fileId}`;
    const cached = await this.redis.get(cacheKey);

    if (cached) {
      const data = JSON.parse(cached);
      return data;
    }

    // ํŒŒ์ผ ์ •๋ณด ์กฐํšŒ
    const rdb = this.getPuri("r");
    const file = await rdb.table("files").where("id", fileId).first();

    if (!file) {
      throw new Error("File not found");
    }

    // Signed URL ์ƒ์„ฑ
    const disk = Sonamu.storage.use(file.disk_name);
    const expiresIn = 3600; // 1์‹œ๊ฐ„
    const url = await disk.getSignedUrl(file.key, expiresIn);
    const expiresAt = new Date(Date.now() + expiresIn * 1000);

    const result = { url, expiresAt };

    // ์บ์‹œ ์ €์žฅ (๋งŒ๋ฃŒ ์‹œ๊ฐ„๋ณด๋‹ค ์งง๊ฒŒ)
    await this.redis.setex(
      cacheKey,
      expiresIn - 60, // 1๋ถ„ ์—ฌ์œ 
      JSON.stringify(result),
    );

    return result;
  }
}

CDN URL

CloudFront (AWS)

class FileModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async getCDNUrl(fileId: number): Promise<{ url: string }> {
    const rdb = this.getPuri("r");

    const file = await rdb.table("files").where("id", fileId).first();

    if (!file) {
      throw new Error("File not found");
    }

    // CloudFront URL ์ƒ์„ฑ
    const cloudFrontDomain = process.env.CLOUDFRONT_DOMAIN;

    if (cloudFrontDomain) {
      const url = `https://${cloudFrontDomain}/${file.key}`;
      return { url };
    }

    // CloudFront ์—†์œผ๋ฉด ์ผ๋ฐ˜ URL
    const disk = Sonamu.storage.use(file.disk_name);
    const url = await disk.getUrl(file.key);

    return { url };
  }
}

Cloud CDN (GCP)

class FileModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async getCDNUrl(fileId: number): Promise<{ url: string }> {
    const rdb = this.getPuri("r");

    const file = await rdb.table("files").where("id", fileId).first();

    if (!file) {
      throw new Error("File not found");
    }

    // Cloud CDN URL ์ƒ์„ฑ
    const cdnDomain = process.env.CLOUD_CDN_DOMAIN;

    if (cdnDomain) {
      const url = `https://${cdnDomain}/${file.key}`;
      return { url };
    }

    // CDN ์—†์œผ๋ฉด ์ผ๋ฐ˜ URL
    const disk = Sonamu.storage.use(file.disk_name);
    const url = await disk.getUrl(file.key);

    return { url };
  }
}

์ž„์‹œ ์—…๋กœ๋“œ URL (Presigned Upload)

ํด๋ผ์ด์–ธํŠธ ์ง์ ‘ ์—…๋กœ๋“œ

class FileModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async getUploadUrl(params: { filename: string; contentType: string }): Promise<{
    uploadUrl: string;
    key: string;
  }> {
    const { filename, contentType } = params;

    // ์•ˆ์ „ํ•œ ํ‚ค ์ƒ์„ฑ
    const ext = filename.split(".").pop();
    const key = `uploads/${Date.now()}.${ext}`;

    // Presigned Upload URL ์ƒ์„ฑ (5๋ถ„)
    const disk = Sonamu.storage.use();
    const uploadUrl = await disk.getSignedUrl(key, 300);

    return {
      uploadUrl,
      key,
    };
  }

  @api({ httpMethod: "POST" })
  async confirmUpload(params: {
    key: string;
    filename: string;
    contentType: string;
    size: number;
  }): Promise<{
    fileId: number;
    url: string;
  }> {
    const { key, filename, contentType, size } = params;

    // ํŒŒ์ผ ์กด์žฌ ํ™•์ธ
    const disk = Sonamu.storage.use();
    const exists = await disk.exists(key);

    if (!exists) {
      throw new Error("File not uploaded");
    }

    // DB์— ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ €์žฅ
    const wdb = this.getPuri("w");
    const [record] = await wdb
      .table("files")
      .insert({
        key,
        filename,
        mime_type: contentType,
        size,
        url: await disk.getUrl(key),
      })
      .returning({ id: "id" });

    return {
      fileId: record.id,
      url: await disk.getUrl(key),
    };
  }
}

URL ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ

URL ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ํ™•์ธ

class FileModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async checkUrlValidity(url: string): Promise<{
    valid: boolean;
    expiresAt?: Date;
  }> {
    // Signed URL์—์„œ ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์ถ”์ถœ
    const urlObj = new URL(url);
    const expires = urlObj.searchParams.get("Expires") || urlObj.searchParams.get("X-Amz-Date");

    if (!expires) {
      // Public URL (๋งŒ๋ฃŒ ์—†์Œ)
      return { valid: true };
    }

    const expiresAt = new Date(parseInt(expires) * 1000);
    const valid = expiresAt > new Date();

    return {
      valid,
      expiresAt,
    };
  }
}

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

URL ์ƒ์„ฑ ์‹œ ์ฃผ์˜์‚ฌํ•ญ: 1. Signed URL์€ ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์„ค์ • 2. Public URL์€ ์˜๊ตฌ์ ์ด๋ฏ€๋กœ ์‹ ์ค‘ํžˆ ์‚ฌ์šฉ 3. CDN ์‚ฌ์šฉ ์‹œ ์บ์‹œ ๋ฌดํšจํ™” ๊ณ ๋ ค 4. URL ๋กœ๊น…์œผ๋กœ ์‚ฌ์šฉ ์ถ”์  ๊ถŒ์žฅ 5. ๋ฏผ๊ฐํ•œ ํŒŒ์ผ์€ Signed URL ์‚ฌ์šฉ

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

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

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

UploadedFile ํด๋ž˜์Šค

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

ํŒŒ์ผ ์ €์žฅ

Storage Manager

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

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