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
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 ์์ฑ ์ ์ฃผ์์ฌํญ:
- Signed URL์ ๋ง๋ฃ ์๊ฐ ์ค์
- Public URL์ ์๊ตฌ์ ์ด๋ฏ๋ก ์ ์คํ ์ฌ์ฉ
- CDN ์ฌ์ฉ ์ ์บ์ ๋ฌดํจํ ๊ณ ๋ ค
- URL ๋ก๊น ์ผ๋ก ์ฌ์ฉ ์ถ์ ๊ถ์ฅ
- ๋ฏผ๊ฐํ ํ์ผ์ Signed URL ์ฌ์ฉ