๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
ํŒŒ์ผ ์ €์žฅ ํ›„ ์ ‘๊ทผ์„ ์œ„ํ•œ 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 {
  @api({ httpMethod: "POST" })
  @upload({ mode: "single" })
  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 ์‚ฌ์šฉ

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