@upload 데코레이터를 통해 파일 업로드를 간편하게 처리할 수 있습니다.
파일 업로드 개요
@upload 데코레이터
자동 파일 파싱단일/다중 파일 지원
UploadedFile 클래스
파일 정보 접근saveToDisk() 메서드
Storage Manager
local, S3, GCS 통합유연한 스토리지
URL 자동 생성
Public URLSigned URL
@upload 데코레이터
기본 사용법
복사
import { BaseModelClass, api, upload } from "sonamu";
import type { UploadedFile } from "sonamu";
class FileModel extends BaseModelClass {
modelName = "File";
// 단일 파일 업로드
@api({ httpMethod: "POST" })
@upload({ mode: "single" })
async uploadSingle(params: {
file: UploadedFile;
title?: string;
}): Promise<{
fileId: number;
url: string;
}> {
const { file, title } = params;
if (!file) {
throw new Error("File is required");
}
// 파일 정보
console.log({
filename: file.filename,
mimetype: file.mimetype,
size: file.size,
});
// 파일 저장
const url = await file.saveToDisk(`uploads/${Date.now()}-${file.filename}`);
// DB에 메타데이터 저장
const wdb = this.getPuri("w");
const [record] = await wdb
.table("files")
.insert({
filename: file.filename,
mime_type: file.mimetype,
size: file.size,
url,
title,
})
.returning({ id: "id" });
return {
fileId: record.id,
url,
};
}
}
다중 파일 업로드
복사
class FileModel extends BaseModelClass {
@api({ httpMethod: "POST" })
@upload({ mode: "multiple" })
async uploadMultiple(params: {
files: UploadedFile[];
category?: string;
}): Promise<{
uploadedFiles: Array<{
fileId: number;
filename: string;
url: string;
}>;
}> {
const { files, category } = params;
if (!files || files.length === 0) {
throw new Error("At least one file is required");
}
const uploadedFiles = [];
const wdb = this.getPuri("w");
for (const file of files) {
// 파일 저장
const key = `uploads/${Date.now()}-${file.filename}`;
const url = await file.saveToDisk(key);
// DB에 저장
const [record] = await wdb
.table("files")
.insert({
filename: file.filename,
mime_type: file.mimetype,
size: file.size,
url,
category,
})
.returning({ id: "id" });
uploadedFiles.push({
fileId: record.id,
filename: file.filename,
url,
});
}
return { uploadedFiles };
}
}
Storage 설정
Storage Manager
Sonamu는 여러 스토리지를 통합 관리하는 Storage Manager를 제공합니다.복사
// 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!,
},
},
},
};
환경 변수 설정
복사
# .env
# 앱 URL
APP_URL=http://localhost:3000
# AWS S3
S3_BUCKET=my-app-uploads
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
# S3_ENDPOINT=http://localhost:9000 # MinIO 등
# Public S3 (선택)
PUBLIC_S3_BUCKET=my-app-public
# Google Cloud Storage
GCS_BUCKET=my-app-uploads
GCS_PROJECT_ID=my-project
GCS_KEY_FILENAME=/path/to/service-account-key.json
디스크 선택
특정 디스크에 저장
복사
class FileModel extends BaseModelClass {
@api({ httpMethod: "POST" })
@upload({ mode: "single" })
async uploadToS3(params: {
file: UploadedFile;
}): Promise<{ url: string }> {
const { file } = params;
// S3 디스크에 저장
const url = await file.saveToDisk(
`uploads/${Date.now()}-${file.filename}`,
"s3" // 디스크 이름
);
return { url };
}
@api({ httpMethod: "POST" })
@upload({ mode: "single" })
async uploadPublic(params: {
file: UploadedFile;
}): Promise<{ url: string }> {
const { file } = params;
// Public 디스크에 저장
const url = await file.saveToDisk(
`public/${Date.now()}-${file.filename}`,
"public"
);
return { url };
}
}
Fastify Multipart 설정 (필수)
Sonamu는 내부적으로 Fastify를 사용하므로, multipart 플러그인을 등록해야 합니다.서버 설정
복사
// server.ts
import fastify from "fastify";
import multipart from "@fastify/multipart";
const app = fastify({
bodyLimit: 50 * 1024 * 1024, // 50MB
});
// Multipart 플러그인 등록
app.register(multipart, {
limits: {
fileSize: 50 * 1024 * 1024, // 파일 최대 크기 (50MB)
files: 10, // 최대 파일 개수
fields: 10, // 최대 필드 개수
},
});
// Sonamu 라우트 등록
// ...
Sonamu의
@upload 데코레이터는 Fastify multipart를 자동으로 처리합니다.파일 검증
크기 및 타입 검증
복사
class ImageModel extends BaseModelClass {
@api({ httpMethod: "POST" })
@upload({ mode: "single" })
async uploadImage(params: {
image: UploadedFile;
}): Promise<{ imageId: number; url: string }> {
const { image } = params;
// 파일 존재 확인
if (!image) {
throw new Error("Image is required");
}
// 크기 확인
const maxSize = 5 * 1024 * 1024; // 5MB
if (image.size > maxSize) {
throw new Error(`Image too large (max ${maxSize / 1024 / 1024}MB)`);
}
// MIME 타입 확인
const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
if (!allowedTypes.includes(image.mimetype)) {
throw new Error(`Invalid image type: ${image.mimetype}`);
}
// 확장자 확인
const ext = image.extname;
if (!ext || !["jpg", "jpeg", "png", "gif", "webp"].includes(ext)) {
throw new Error(`Invalid image extension: ${ext}`);
}
// 이미지 저장
const url = await image.saveToDisk(`images/${Date.now()}.${ext}`);
// DB에 저장
const wdb = this.getPuri("w");
const [record] = await wdb
.table("images")
.insert({
filename: image.filename,
mime_type: image.mimetype,
size: image.size,
url,
})
.returning({ id: "id" });
return {
imageId: record.id,
url,
};
}
}
실전 예제
프로필 이미지 업로드
복사
class UserModel extends BaseModelClass {
@api({ httpMethod: "POST" })
@upload({ mode: "single" })
async uploadProfileImage(params: {
image: UploadedFile;
}): Promise<{
imageId: number;
url: string;
}> {
const context = Sonamu.getContext();
if (!context.user) {
throw new Error("Authentication required");
}
const { image } = params;
// 검증
if (!image) {
throw new Error("Image is required");
}
if (image.size > 5 * 1024 * 1024) {
throw new Error("Image too large (max 5MB)");
}
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
if (!allowedTypes.includes(image.mimetype)) {
throw new Error("Invalid image type");
}
// 파일 저장
const ext = image.extname;
const key = `profiles/${context.user.id}/${Date.now()}.${ext}`;
const url = await image.saveToDisk(key);
// 기존 프로필 이미지 삭제
const rdb = this.getPuri("r");
const oldImage = await rdb
.table("profile_images")
.where("user_id", context.user.id)
.first();
if (oldImage) {
// 기존 이미지 삭제 (Storage Manager 사용)
const disk = Sonamu.storage.use();
await disk.delete(oldImage.key);
// DB 레코드 삭제
const wdb = this.getPuri("w");
await wdb
.table("profile_images")
.where("id", oldImage.id)
.delete();
}
// 새 이미지 저장
const wdb = this.getPuri("w");
const [record] = await wdb
.table("profile_images")
.insert({
user_id: context.user.id,
key,
filename: image.filename,
mime_type: image.mimetype,
size: image.size,
url,
})
.returning({ id: "id" });
return {
imageId: record.id,
url,
};
}
}
게시글 첨부파일
복사
class PostModel extends BaseModelClass {
@api({ httpMethod: "POST" })
@upload({ mode: "multiple" })
async createWithAttachments(params: {
title: string;
content: string;
attachments?: UploadedFile[];
}): Promise<{
postId: number;
attachmentUrls: string[];
}> {
const context = Sonamu.getContext();
if (!context.user) {
throw new Error("Authentication required");
}
const { title, content, attachments } = params;
const wdb = this.getPuri("w");
// 게시글 생성
const [post] = await wdb
.table("posts")
.insert({
user_id: context.user.id,
title,
content,
})
.returning({ id: "id" });
const attachmentUrls: string[] = [];
// 첨부파일 처리
if (attachments && attachments.length > 0) {
for (const file of attachments) {
// 검증
if (file.size > 20 * 1024 * 1024) {
throw new Error(`File ${file.filename} too large (max 20MB)`);
}
// 저장
const ext = file.extname;
const key = `posts/${post.id}/${Date.now()}.${ext}`;
const url = await file.saveToDisk(key);
// DB에 첨부파일 정보 저장
await wdb.table("post_attachments").insert({
post_id: post.id,
key,
filename: file.filename,
mime_type: file.mimetype,
size: file.size,
url,
});
attachmentUrls.push(url);
}
}
return {
postId: post.id,
attachmentUrls,
};
}
}
클라이언트 예제
HTML Form
복사
<!DOCTYPE html>
<html>
<head>
<title>File Upload</title>
</head>
<body>
<h1>Upload File</h1>
<form id="uploadForm">
<input type="file" name="file" required />
<input type="text" name="title" placeholder="Title" />
<button type="submit">Upload</button>
</form>
<div id="result"></div>
<script>
document.getElementById('uploadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
try {
const response = await fetch('http://localhost:3000/api/file/uploadSingle', {
method: 'POST',
body: formData,
});
const result = await response.json();
document.getElementById('result').innerHTML = `
<p>Uploaded!</p>
<p>File ID: ${result.fileId}</p>
<p>URL: <a href="${result.url}" target="_blank">${result.url}</a></p>
`;
} catch (error) {
console.error('Upload failed:', error);
}
});
</script>
</body>
</html>
React 예제
복사
import React, { useState } from "react";
export function FileUpload() {
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<any>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append("file", file);
formData.append("title", "My Upload");
try {
const response = await fetch("http://localhost:3000/api/file/uploadSingle", {
method: "POST",
body: formData,
});
const data = await response.json();
setResult(data);
} catch (error) {
console.error("Upload failed:", error);
} finally {
setUploading(false);
}
};
return (
<div>
<h1>Upload File</h1>
<form onSubmit={handleSubmit}>
<input
type="file"
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
<button type="submit" disabled={!file || uploading}>
{uploading ? "Uploading..." : "Upload"}
</button>
</form>
{result && (
<div>
<p>Uploaded!</p>
<p>File ID: {result.fileId}</p>
<p>URL: <a href={result.url}>{result.url}</a></p>
</div>
)}
</div>
);
}
주의사항
파일 업로드 설정 시 주의사항:
- Fastify multipart 플러그인 필수 등록
@upload데코레이터는@api와 함께 사용- 파일 크기 제한 설정
- MIME 타입 검증
- Storage 설정 확인
