파일 업로드 흐름
@upload 데코레이터
파일 업로드 API를 만들 때 사용합니다.단일 파일 업로드
복사
import { BaseModel, api, upload, Sonamu } from "sonamu";
class UserModelClass extends BaseModel {
@upload({ mode: 'single' })
@api({ httpMethod: 'POST' })
async uploadAvatar(ctx: Context) {
const { file } = Sonamu.getUploadContext();
if (!file) {
throw new Error('파일이 없습니다');
}
// 파일 저장
const url = await file.saveToDisk(`avatars/${ctx.user.id}.png`);
return { url };
}
}
복사
const formData = new FormData();
formData.append('file', file);
await fetch('/api/user/uploadAvatar', {
method: 'POST',
body: formData,
});
다중 파일 업로드
복사
class PostModelClass extends BaseModel {
@upload({ mode: 'multiple' })
@api({ httpMethod: 'POST' })
async uploadImages(postId: number) {
const { files } = Sonamu.getUploadContext();
if (files.length === 0) {
throw new Error('파일이 없습니다');
}
// 모든 파일 저장
const urls = await Promise.all(
files.map(async (file, index) => {
const key = `posts/${postId}/image-${index}.${file.extname}`;
return await file.saveToDisk(key);
})
);
return { urls };
}
}
복사
const formData = new FormData();
formData.append('file', file1);
formData.append('file', file2);
formData.append('file', file3);
await fetch('/api/post/uploadImages?postId=1', {
method: 'POST',
body: formData,
});
UploadContext
**Sonamu.getUploadContext()**로 업로드된 파일에 접근합니다.복사
type UploadContext = {
file?: UploadedFile; // single 모드
files: UploadedFile[]; // multiple 모드
};
사용 예제
복사
@upload({ mode: 'single' })
@api({ httpMethod: 'POST' })
async upload() {
const { file } = Sonamu.getUploadContext();
if (!file) {
throw new Error('파일이 필요합니다');
}
// file은 UploadedFile 인스턴스
console.log(file.filename); // 원본 파일명
console.log(file.mimetype); // MIME 타입
console.log(file.size); // 파일 크기
return await file.saveToDisk('uploads/file.pdf');
}
UploadedFile 클래스
업로드된 파일을 래핑하는 클래스입니다.속성
- filename
- mimetype
- size
- extname
- url
- signedUrl
원본 파일명
복사
const { file } = Sonamu.getUploadContext();
console.log(file.filename); // 'avatar.png'
MIME 타입
복사
console.log(file.mimetype);
// 'image/png'
// 'application/pdf'
// 'video/mp4'
파일 크기 (바이트)
복사
console.log(file.size); // 1024000 (1MB)
console.log(`${(file.size / 1024 / 1024).toFixed(2)}MB`); // '0.98MB'
확장자 (점 제외)
복사
console.log(file.extname);
// 'png'
// 'pdf'
// 'mp4'
// false (확장자 없음)
저장 후 URL (unsigned)
복사
await file.saveToDisk('uploads/file.pdf');
console.log(file.url);
// '/uploads/file.pdf' (fs)
// 'https://bucket.s3.amazonaws.com/uploads/file.pdf' (s3)
저장 후 서명된 URL (S3)용도: 임시 접근 URL (만료 시간 있음)
복사
await file.saveToDisk('uploads/file.pdf');
console.log(file.signedUrl);
// 'https://bucket.s3.amazonaws.com/uploads/file.pdf?X-Amz-...'
메서드
toBuffer()
파일을 Buffer로 변환합니다.복사
const { file } = Sonamu.getUploadContext();
const buffer = await file.toBuffer();
console.log(buffer instanceof Buffer); // true
console.log(buffer.length); // 파일 크기 (바이트)
md5()
파일의 MD5 해시를 계산합니다.복사
const { file } = Sonamu.getUploadContext();
const hash = await file.md5();
console.log(hash); // '5d41402abc4b2a76b9719d911017c592'
- 파일 중복 체크
- 파일 무결성 검증
- 고유한 파일명 생성
saveToDisk(key, diskName?)
파일을 디스크에 저장합니다.복사
const { file } = Sonamu.getUploadContext();
// 기본 디스크에 저장
const url = await file.saveToDisk('uploads/file.pdf');
// 특정 디스크에 저장
const url = await file.saveToDisk('uploads/file.pdf', 's3');
실전 예제
1. 프로필 사진 업로드
복사
class UserModelClass extends BaseModel {
@upload({ mode: 'single' })
@api({ httpMethod: 'POST' })
async uploadAvatar(ctx: Context) {
const { file } = Sonamu.getUploadContext();
if (!file) {
throw new Error('파일이 없습니다');
}
// 이미지 파일만 허용
if (!file.mimetype.startsWith('image/')) {
throw new Error('이미지 파일만 업로드 가능합니다');
}
// 파일 크기 제한 (5MB)
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) {
throw new Error('파일 크기는 5MB 이하여야 합니다');
}
// 파일명: user-{id}-{timestamp}.{ext}
const timestamp = Date.now();
const ext = file.extname || 'png';
const key = `avatars/user-${ctx.user.id}-${timestamp}.${ext}`;
// 저장
const url = await file.saveToDisk(key);
// DB 업데이트
await this.updateOne(['id', ctx.user.id], {
avatar_url: url,
updated_at: new Date(),
});
return { url };
}
}
2. 다중 이미지 업로드
복사
class PostModelClass extends BaseModel {
@upload({ mode: 'multiple' })
@api({ httpMethod: 'POST' })
async uploadImages(postId: number, ctx: Context) {
const { files } = Sonamu.getUploadContext();
// 파일 개수 제한
if (files.length > 10) {
throw new Error('최대 10개까지 업로드 가능합니다');
}
// 이미지만 필터링
const imageFiles = files.filter(file =>
file.mimetype.startsWith('image/')
);
if (imageFiles.length === 0) {
throw new Error('이미지 파일이 없습니다');
}
// 모든 이미지 저장
const urls = await Promise.all(
imageFiles.map(async (file, index) => {
const timestamp = Date.now();
const ext = file.extname || 'png';
const key = `posts/${postId}/image-${index}-${timestamp}.${ext}`;
return await file.saveToDisk(key);
})
);
return { urls, count: urls.length };
}
}
3. 파일 타입별 검증
복사
class FileModelClass extends BaseModel {
@upload({ mode: 'single' })
@api({ httpMethod: 'POST' })
async uploadDocument(type: 'pdf' | 'excel' | 'word') {
const { file } = Sonamu.getUploadContext();
if (!file) {
throw new Error('파일이 없습니다');
}
// MIME 타입 검증
const allowedMimeTypes = {
pdf: ['application/pdf'],
excel: [
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
],
word: [
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
],
};
if (!allowedMimeTypes[type].includes(file.mimetype)) {
throw new Error(`${type} 파일만 업로드 가능합니다`);
}
// 파일 저장
const key = `documents/${type}/${Date.now()}-${file.filename}`;
const url = await file.saveToDisk(key);
return { url, type, filename: file.filename };
}
}
4. MD5 해시로 중복 체크
복사
class MediaModelClass extends BaseModel {
@upload({ mode: 'single' })
@api({ httpMethod: 'POST' })
async uploadMedia() {
const { file } = Sonamu.getUploadContext();
if (!file) {
throw new Error('파일이 없습니다');
}
// MD5 해시 계산
const hash = await file.md5();
// 이미 존재하는 파일인지 확인
const existing = await this.findOne(['md5_hash', hash]);
if (existing) {
// 이미 존재하면 기존 URL 반환
return {
url: existing.url,
duplicate: true,
};
}
// 새 파일 저장
const key = `media/${hash}.${file.extname}`;
const url = await file.saveToDisk(key);
// DB에 저장
await this.saveOne({
md5_hash: hash,
url,
filename: file.filename,
size: file.size,
mimetype: file.mimetype,
});
return { url, duplicate: false };
}
}
5. 디스크 선택
복사
class FileModelClass extends BaseModel {
@upload({ mode: 'single' })
@api({ httpMethod: 'POST' })
async upload(visibility: 'public' | 'private', ctx: Context) {
const { file } = Sonamu.getUploadContext();
if (!file) {
throw new Error('파일이 없습니다');
}
// visibility에 따라 다른 디스크 사용
const disk = visibility === 'public' ? 'public' : 'private';
const key = `${visibility}/${ctx.user.id}/${Date.now()}-${file.filename}`;
// 저장
const url = await file.saveToDisk(key, disk);
// DB에 저장
await this.saveOne({
user_id: ctx.user.id,
url,
visibility,
filename: file.filename,
});
return { url, visibility };
}
}
파일 조회
Storage Manager를 사용하여 저장된 파일을 조회합니다.파일 읽기
복사
import { Sonamu } from "sonamu";
@api({ httpMethod: 'GET' })
async getFile(key: string) {
const disk = Sonamu.storage.use();
// 파일 존재 확인
const exists = await disk.exists(key);
if (!exists) {
throw new Error('파일이 없습니다');
}
// 파일 읽기
const content = await disk.get(key);
return content; // Buffer
}
파일 다운로드 API
복사
@api({
httpMethod: 'GET',
contentType: 'application/octet-stream',
})
async downloadFile(key: string, ctx: Context) {
const disk = Sonamu.storage.use();
// 파일 존재 확인
const exists = await disk.exists(key);
if (!exists) {
throw new Error('파일이 없습니다');
}
// 파일 읽기
const buffer = await disk.get(key);
// 파일명 추출
const filename = key.split('/').pop() || 'download';
// 다운로드 헤더 설정
ctx.reply.header('Content-Disposition', `attachment; filename="${filename}"`);
return buffer;
}
URL 가져오기
복사
@api({ httpMethod: 'GET' })
async getFileUrl(key: string) {
const disk = Sonamu.storage.use();
// 파일 존재 확인
const exists = await disk.exists(key);
if (!exists) {
throw new Error('파일이 없습니다');
}
// URL 가져오기
const url = await disk.getUrl(key);
return { url };
}
Signed URL (S3)
복사
@api({ httpMethod: 'GET' })
async getSignedUrl(key: string) {
const disk = Sonamu.storage.use('s3');
// Signed URL 생성 (1시간 유효)
const signedUrl = await disk.getSignedUrl(key, {
expiresIn: '1h'
});
return { url: signedUrl };
}
파일 삭제
복사
@api({ httpMethod: 'DELETE' })
async deleteFile(key: string) {
const disk = Sonamu.storage.use();
// 파일 존재 확인
const exists = await disk.exists(key);
if (!exists) {
throw new Error('파일이 없습니다');
}
// 파일 삭제
await disk.delete(key);
return { success: true };
}
주의사항
파일 업로드 시 주의사항:
-
파일 검증 필수: 타입, 크기, 확장자 검증
복사
// ✅ 검증 if (!file.mimetype.startsWith('image/')) { throw new Error('이미지만 가능'); } if (file.size > 5 * 1024 * 1024) { throw new Error('5MB 이하'); } -
고유한 파일명 사용: 덮어쓰기 방지
복사
// ❌ 원본 파일명 사용 await file.saveToDisk(`uploads/${file.filename}`); // ✅ 타임스탬프 추가 await file.saveToDisk(`uploads/${Date.now()}-${file.filename}`); // ✅ UUID 사용 await file.saveToDisk(`uploads/${uuid()}.${file.extname}`); -
경로 검증: 디렉토리 트래버설 방지
복사
// ❌ 위험: 상위 디렉토리 접근 가능 const key = userInput; // '../../../etc/passwd' // ✅ 안전: 경로 검증 const key = `uploads/${userInput.replace(/\.\./g, '')}`; -
파일 크기 제한: 메모리 초과 방지
복사
const maxSize = 10 * 1024 * 1024; // 10MB if (file.size > maxSize) { throw new Error('파일이 너무 큽니다'); } -
MIME 타입 검증: 확장자만 믿지 말 것
복사
// ❌ 확장자만 체크 if (file.filename.endsWith('.png')) { ... } // ✅ MIME 타입 체크 if (file.mimetype === 'image/png') { ... }
