> ## Documentation Index
> Fetch the complete documentation index at: https://sonamu.cartanova.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 파일 업로드 설정

> @upload 데코레이터와 Storage 설정

Sonamu는 `@upload` 데코레이터를 통해 파일 업로드를 간편하게 처리할 수 있습니다.

## 파일 업로드 개요

<CardGroup cols={2}>
  <Card title="@upload 데코레이터" icon="at">
    자동 파일 파싱 단일/다중 파일 지원
  </Card>

  <Card title="UploadedFile 클래스" icon="file">
    파일 정보 접근 saveToDisk() 메서드
  </Card>

  <Card title="Storage Manager" icon="database">
    local, S3, GCS 통합 유연한 스토리지
  </Card>

  <Card title="URL 자동 생성" icon="link">
    Public URL Signed URL
  </Card>
</CardGroup>

## @upload 데코레이터

### 기본 사용법

```typescript theme={null}
import { BaseModelClass, api, upload } from "sonamu";
import type { UploadedFile } from "sonamu";
import type { FileSubsetKey, FileSubsetMapping } from "../sonamu.generated";
import { fileLoaderQueries, fileSubsetQueries } from "../sonamu.generated.sso";

class FileModelClass extends BaseModelClass<
  FileSubsetKey,
  FileSubsetMapping,
  typeof fileSubsetQueries,
  typeof fileLoaderQueries
> {
  constructor() {
    super("File", fileSubsetQueries, fileLoaderQueries);
  }

  // 단일 파일 업로드
  @upload()
  async uploadSingle(params: { title?: string }): Promise<{
    fileId: number;
    url: string;
  }> {
    const { title } = params;
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];

    if (!file) {
      throw new Error("File is required");
    }

    // 파일 정보
    console.log({
      filename: file.filename,
      mimetype: file.mimetype,
      size: file.size,
    });

    // 파일 저장
    const url = await file.saveToDisk("fs", `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");

    return {
      fileId: record.id,
      url,
    };
  }
}
```

### 다중 파일 업로드

```typescript theme={null}
class FileModelClass extends BaseModelClass<
  FileSubsetKey,
  FileSubsetMapping,
  typeof fileSubsetQueries,
  typeof fileLoaderQueries
> {
  constructor() {
    super("File", fileSubsetQueries, fileLoaderQueries);
  }

  @upload()
  async uploadMultiple(params: { category?: string }): Promise<{
    uploadedFiles: Array<{
      fileId: number;
      filename: string;
      url: string;
    }>;
  }> {
    const { category } = params;
    const { bufferedFiles } = Sonamu.getContext();

    if (!bufferedFiles || bufferedFiles.length === 0) {
      throw new Error("At least one file is required");
    }

    const uploadedFiles = [];
    const wdb = this.getPuri("w");

    for (const file of bufferedFiles) {
      // 파일 저장
      const key = `uploads/${Date.now()}-${file.filename}`;
      const url = await file.saveToDisk("fs", key);

      // DB에 저장
      const [record] = await wdb
        .table("files")
        .insert({
          filename: file.filename,
          mime_type: file.mimetype,
          size: file.size,
          url,
          category,
        })
        .returning("id");

      uploadedFiles.push({
        fileId: record.id,
        filename: file.filename,
        url,
      });
    }

    return { uploadedFiles };
  }
}
```

## Storage 설정

### Storage Manager

Sonamu는 여러 스토리지를 통합 관리하는 Storage Manager를 제공합니다.

```typescript theme={null}
// 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 theme={null}
# .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
```

## 디스크 선택

### 특정 디스크에 저장

```typescript theme={null}
class FileModelClass extends BaseModelClass<
  FileSubsetKey,
  FileSubsetMapping,
  typeof fileSubsetQueries,
  typeof fileLoaderQueries
> {
  constructor() {
    super("File", fileSubsetQueries, fileLoaderQueries);
  }

  @upload()
  async uploadToS3(): Promise<{ url: string }> {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("File is required");

    // S3 디스크에 저장
    const url = await file.saveToDisk("s3", `uploads/${Date.now()}-${file.filename}`);

    return { url };
  }

  @upload()
  async uploadPublic(): Promise<{ url: string }> {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("File is required");

    // Public 디스크에 저장
    const url = await file.saveToDisk("public", `public/${Date.now()}-${file.filename}`);

    return { url };
  }
}
```

## Fastify Multipart 설정 (필수)

Sonamu는 내부적으로 Fastify를 사용하므로, multipart 플러그인을 등록해야 합니다.

### 서버 설정

```typescript theme={null}
// 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 라우트 등록
// ...
```

<Info>Sonamu의 `@upload` 데코레이터는 Fastify multipart를 자동으로 처리합니다.</Info>

## 파일 검증

### 크기 및 타입 검증

```typescript theme={null}
class ImageModelClass extends BaseModelClass<
  ImageSubsetKey,
  ImageSubsetMapping,
  typeof imageSubsetQueries,
  typeof imageLoaderQueries
> {
  constructor() {
    super("Image", imageSubsetQueries, imageLoaderQueries);
  }

  @upload()
  async uploadImage(): Promise<{ imageId: number; url: string }> {
    const { bufferedFiles } = Sonamu.getContext();
    const image = bufferedFiles?.[0];

    // 파일 존재 확인
    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("fs", `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");

    return {
      imageId: record.id,
      url,
    };
  }
}
```

## 실전 예제

### 프로필 이미지 업로드

```typescript theme={null}
class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  typeof userSubsetQueries,
  typeof userLoaderQueries
> {
  constructor() {
    super("User", userSubsetQueries, userLoaderQueries);
  }

  @upload()
  async uploadProfileImage(): Promise<{
    imageId: number;
    url: string;
  }> {
    const context = Sonamu.getContext();

    if (!context.user) {
      throw new Error("Authentication required");
    }

    const { bufferedFiles } = context;
    const image = bufferedFiles?.[0];

    // 검증
    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("fs", 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");

    return {
      imageId: record.id,
      url,
    };
  }
}
```

### 게시글 첨부파일

```typescript theme={null}
class PostModelClass extends BaseModelClass<
  PostSubsetKey,
  PostSubsetMapping,
  typeof postSubsetQueries,
  typeof postLoaderQueries
> {
  constructor() {
    super("Post", postSubsetQueries, postLoaderQueries);
  }

  @upload()
  async createWithAttachments(params: {
    title: string;
    content: string;
  }): Promise<{
    postId: number;
    attachmentUrls: string[];
  }> {
    const context = Sonamu.getContext();

    if (!context.user) {
      throw new Error("Authentication required");
    }

    const { title, content } = params;
    const { bufferedFiles } = context;

    const wdb = this.getPuri("w");

    // 게시글 생성
    const [post] = await wdb
      .table("posts")
      .insert({
        user_id: context.user.id,
        title,
        content,
      })
      .returning("id");

    const attachmentUrls: string[] = [];

    // 첨부파일 처리
    if (bufferedFiles && bufferedFiles.length > 0) {
      for (const file of bufferedFiles) {
        // 검증
        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("fs", 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

```html theme={null}
<!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 예제

```typescript theme={null}
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>
  );
}
```

## 주의사항

<Warning>
  **파일 업로드 설정 시 주의사항**: 1. Fastify multipart 플러그인 필수 등록 2. `@upload`
  데코레이터는 `@api` 없이 독립적으로 사용 3. 파일 크기 제한 설정 4. MIME 타입 검증 5. Storage 설정
  확인
</Warning>

## 다음 단계

<CardGroup cols={2}>
  <Card title="UploadedFile 클래스" icon="file" href="/ko/api-development/file-upload/uploaded-file-class">
    파일 정보와 메서드
  </Card>

  <Card title="파일 저장" icon="floppy-disk" href="/ko/api-development/file-upload/saving-files">
    saveToDisk 상세 설명
  </Card>

  <Card title="URL 생성" icon="link" href="/ko/api-development/file-upload/url-generation">
    URL 생성하기
  </Card>

  <Card title="@api 데코레이터" icon="at" href="/ko/api-development/creating-apis/api-decorator">
    API 기본 사용법
  </Card>
</CardGroup>
