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 사용
