> ## 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

> 파일 업로드 API 생성 데코레이터

`@upload` 데코레이터는 파일 업로드를 처리하는 API를 생성합니다. Multipart form-data 요청을 자동으로 파싱하고, 파일 객체를 제공합니다. `@api` 데코레이터 없이도 독립적으로 사용할 수 있으며, 자동으로 `POST` 메서드와 multipart 클라이언트(`axios-multipart`, `tanstack-mutation-multipart`)를 설정합니다.

## 업로드 모드

Sonamu는 두 가지 파일 업로드 모드를 제공합니다:

| 모드              | 옵션                        | Context 속성      | 파일 타입            | 특징                                 |
| --------------- | ------------------------- | --------------- | ---------------- | ---------------------------------- |
| **Buffer** (기본) | `consume: "buffer"` 또는 생략 | `bufferedFiles` | `BufferedFile[]` | 메모리에 로드, MD5 계산/이미지 처리 등 유연한 작업 가능 |
| **Stream**      | `consume: "stream"`       | `uploadedFiles` | `UploadedFile[]` | 즉시 저장소로 스트리밍, 대용량 파일에 적합           |

## Buffer 모드 (기본)

Buffer 모드는 파일을 메모리에 로드한 후 처리합니다. MD5 해시 계산, 이미지 리사이징 등 파일 내용을 직접 다뤄야 할 때 사용합니다.

```typescript theme={null}
import { BaseModelClass, upload, Sonamu } from "sonamu";

class FileModelClass extends BaseModelClass {
  @upload() // 기본값: consume: "buffer"
  async uploadAvatar() {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];

    if (!file) {
      throw new Error("No file uploaded");
    }

    // MD5 해시로 중복 방지
    const md5 = await file.md5();
    const key = `avatars/${md5}.${file.extname}`;
    const url = await file.saveToDisk("fs", key);

    return { url, filename: file.filename, size: file.size };
  }
}
```

### BufferedFile 객체

```typescript theme={null}
class BufferedFile {
  // 원본 파일 이름
  get filename(): string;

  // MIME 타입
  get mimetype(): string;

  // 파일 크기 (bytes)
  get size(): number;

  // 확장자 (점 제외, 예: "jpg", "png")
  get extname(): string | false;

  // 파일 Buffer (메모리에 로드된 데이터)
  get buffer(): Buffer;

  // saveToDisk 후 저장된 URL (Unsigned)
  get url(): string;

  // saveToDisk 후 저장된 Signed URL
  get signedUrl(): string;

  // 원본 Fastify MultipartFile 접근
  get raw(): MultipartFile;

  // MD5 해시 계산
  async md5(): Promise<string>;

  // 파일을 디스크에 저장 (URL 반환)
  async saveToDisk(diskName: DriverKey, key: string): Promise<string>;
}
```

<Warning>`saveToDisk`의 파라미터 순서는 `(diskName, key)` 입니다.</Warning>

## Stream 모드

Stream 모드는 파일을 메모리에 로드하지 않고 즉시 저장소로 스트리밍합니다. 대용량 파일 업로드에 적합합니다.

```typescript theme={null}
@upload({
  consume: "stream",
  destination: "s3",  // 저장할 디스크 이름
  keyGenerator: (file) => `uploads/${Date.now()}-${file.filename}`,  // 키 생성 함수
  limits: { files: 5 }
})
async uploadLargeFiles() {
  const { uploadedFiles } = Sonamu.getContext();

  if (!uploadedFiles || uploadedFiles.length === 0) {
    throw new Error("No files uploaded");
  }

  // 파일은 이미 저장소에 업로드된 상태
  return {
    files: uploadedFiles.map((file) => ({
      filename: file.filename,
      url: file.url,
      key: file.key,
      size: file.size,
    })),
  };
}
```

### UploadedFile 객체

Stream 모드에서는 파일이 이미 저장소에 업로드된 상태로 메타데이터만 접근할 수 있습니다.

```typescript theme={null}
class UploadedFile {
  // 원본 파일 이름
  get filename(): string;

  // MIME 타입
  get mimetype(): string;

  // 파일 크기 (bytes)
  get size(): number;

  // 확장자 (점 제외, 예: "jpg", "png")
  get extname(): string | false;

  // 저장된 URL (Unsigned)
  get url(): string;

  // 저장된 Signed URL
  get signedUrl(): string;

  // 저장소 내 키
  get key(): string;

  // 저장된 디스크 이름
  get diskName(): DriverKey;

  // 저장소에서 파일 다운로드 (나중에 처리해야 할 때)
  async download(): Promise<Buffer>;
}
```

## 옵션

### guards

업로드 API에 적용할 가드를 지정합니다. 인증이 필요한 업로드 API에서 사용합니다.

```typescript theme={null}
@upload({ guards: ["user"] })
async uploadAvatar() {
  const { user, bufferedFiles } = Sonamu.getContext();
  // user 가드가 적용되어 인증된 사용자만 접근 가능
  // ...
}
```

### description

API 설명을 지정합니다. 자동 생성되는 API 문서에 표시됩니다.

```typescript theme={null}
@upload({ description: "사용자 프로필 이미지 업로드" })
async uploadAvatar() {
  // ...
}
```

### limits

파일 업로드 제한을 설정합니다. Fastify multipart의 `limits` 옵션을 그대로 받습니다.

```typescript theme={null}
type UploadDecoratorOptions = {
  // 공통 옵션
  guards?: GuardKey[]; // 적용할 가드 (예: ["user", "admin"])
  description?: string; // API 설명
  limits?: {
    fileSize?: number; // 최대 파일 크기 (bytes)
    files?: number; // 최대 파일 개수
    fields?: number; // 최대 필드 개수
    fieldSize?: number; // 최대 필드 크기
    parts?: number; // 최대 파트 개수
  };
} &
  // Buffer 모드
  (| { consume?: "buffer" }
    // Stream 모드
    | {
        consume: "stream";
        destination: DriverKey;
        keyGenerator?: (file: { filename: string; mimetype: string }) => string;
      }
  );
```

```typescript theme={null}
@upload({
  limits: {
    fileSize: 10 * 1024 * 1024, // 10MB
    files: 5,                    // 최대 5개 파일
  }
})
async uploadWithLimits() {
  const { bufferedFiles } = Sonamu.getContext();
  // ...
}
```

<Tabs>
  <Tab title="단일 파일 (Buffer)" icon="file">
    ```typescript theme={null}
    @upload()
    async uploadAvatar() {
      const { bufferedFiles } = Sonamu.getContext();
      const file = bufferedFiles?.[0];

      if (!file) {
        throw new Error("No file");
      }

      const md5 = await file.md5();
      return {
        filename: file.filename,
        size: file.size,
        md5
      };
    }
    ```
  </Tab>

  <Tab title="다중 파일 (Buffer)" icon="files">
    ```typescript theme={null}
    @upload({ limits: { files: 10 } })
    async uploadDocuments() {
      const { bufferedFiles } = Sonamu.getContext();

      if (!bufferedFiles || bufferedFiles.length === 0) {
        throw new Error("No files");
      }

      const results = [];
      for (const file of bufferedFiles) {
        const md5 = await file.md5();
        const key = `documents/${md5}.${file.extname}`;
        const url = await file.saveToDisk("fs", key);
        results.push({
          filename: file.filename,
          url
        });
      }

      return results;
    }
    ```
  </Tab>

  <Tab title="Stream 모드" icon="cloud-arrow-up">
    ```typescript theme={null}
    @upload({
      consume: "stream",
      destination: "s3",
      keyGenerator: (file) => `uploads/${Date.now()}-${file.filename}`,
      limits: { files: 5 }
    })
    async uploadToCloud() {
      const { uploadedFiles } = Sonamu.getContext();

      if (!uploadedFiles || uploadedFiles.length === 0) {
        throw new Error("No files");
      }

      // 파일은 이미 S3에 업로드된 상태
      return uploadedFiles.map((file) => ({
        filename: file.filename,
        url: file.url,
        key: file.key
      }));
    }
    ```
  </Tab>
</Tabs>

## 파일 정보 확인

```typescript theme={null}
@upload()
async uploadFile() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  console.log("Filename:", file.filename);
  console.log("MIME type:", file.mimetype);
  console.log("Size:", file.size);
  console.log("Extension:", file.extname);

  return { uploaded: true };
}
```

## 파일 처리 (Buffer 모드)

### Buffer 접근

```typescript theme={null}
@upload()
async processImage() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  // Buffer 직접 접근
  const buffer = file.buffer;

  // 이미지 처리
  const processed = await sharp(buffer)
    .resize(300, 300)
    .toBuffer();

  return { size: processed.length };
}
```

### 파일 저장

```typescript theme={null}
@upload()
async uploadDocument() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  // 디스크에 저장 (diskName, key 순서)
  const md5 = await file.md5();
  const key = `uploads/${md5}.${file.extname}`;
  const url = await file.saveToDisk("fs", key);

  // 저장된 URL 접근
  console.log("URL:", file.url);
  console.log("Signed URL:", file.signedUrl);

  return { url, filename: file.filename, size: file.size };
}
```

### MD5 해시 계산

```typescript theme={null}
@upload()
async uploadWithHash() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  // MD5 해시 계산 (중복 파일 방지에 유용)
  const hash = await file.md5();

  // 해시를 파일명으로 사용
  const key = `uploads/${hash}${file.extname ? `.${file.extname}` : ''}`;
  const url = await file.saveToDisk("fs", key);

  return { url, hash };
}
```

## 스토리지 드라이버 사용

Sonamu는 여러 스토리지 드라이버를 제공합니다.

```typescript theme={null}
@upload()
async uploadToS3() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  // S3 디스크에 저장 (sonamu.config.ts에서 설정)
  const md5 = await file.md5();
  const key = `avatars/${md5}.${file.extname}`;
  const url = await file.saveToDisk("s3", key);

  return { url };
}
```

<Info>
  스토리지 드라이버는 `sonamu.config.ts`에서 설정합니다. 첫 번째 파라미터로 디스크 이름을
  전달합니다.
</Info>

## 다른 데코레이터와 함께 사용

### @transactional과 함께

```typescript theme={null}
@upload()
@transactional()
async uploadAndSave() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

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

  // 파일 저장 + DB 업데이트를 트랜잭션으로
  const md5 = await file.md5();
  const key = `documents/${md5}.${file.extname}`;
  const url = await file.saveToDisk("fs", key);

  await wdb.table("documents").insert({
    filename: file.filename,
    url,
    size: file.size,
    created_at: new Date()
  });

  return { url };
}
```

## 클라이언트 사용 (Web)

Sonamu는 자동으로 파일 업로드 클라이언트 코드를 생성합니다.

### Axios (단일 파일)

```typescript theme={null}
import { FileService } from "@/services/FileService";

const formData = new FormData();
formData.append("file", file);

const result = await FileService.uploadAvatar(formData);
```

### Axios (여러 파일)

```typescript theme={null}
const formData = new FormData();
files.forEach((file) => {
  formData.append("files", file);
});

const result = await FileService.uploadDocuments(formData);
```

### React 예시

```typescript theme={null}
import { useState } from "react";
import { FileService } from "@/services/FileService";

function FileUploader() {
  const [uploading, setUploading] = useState(false);

  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    setUploading(true);
    try {
      const formData = new FormData();
      formData.append("file", file);

      const result = await FileService.uploadAvatar(formData);
      console.log("Uploaded:", result.url);
    } catch (error) {
      console.error("Upload failed:", error);
    } finally {
      setUploading(false);
    }
  };

  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={uploading}
      />
      {uploading && <p>Uploading...</p>}
    </div>
  );
}
```

### TanStack Query 예시

```typescript theme={null}
import { useMutation } from "@tanstack/react-query";
import { FileService } from "@/services/FileService";

function useUploadFile() {
  return useMutation({
    mutationFn: (file: File) => {
      const formData = new FormData();
      formData.append("file", file);
      return FileService.uploadAvatar(formData);
    },
    onSuccess: (data) => {
      console.log("Upload success:", data);
    },
    onError: (error) => {
      console.error("Upload failed:", error);
    }
  });
}

function FileUploader() {
  const upload = useUploadFile();

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      upload.mutate(file);
    }
  };

  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={upload.isPending}
      />
      {upload.isPending && <p>Uploading...</p>}
      {upload.isSuccess && <p>Success: {upload.data.url}</p>}
      {upload.isError && <p>Error: {upload.error.message}</p>}
    </div>
  );
}
```

## 파일 검증

### MIME 타입 검증

```typescript theme={null}
@upload()
async uploadImage() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const allowedTypes = ["image/jpeg", "image/png", "image/gif"];
  if (!allowedTypes.includes(file.mimetype)) {
    throw new Error(`Invalid file type: ${file.mimetype}`);
  }

  return await this.processImage(file);
}
```

### 파일 크기 검증

```typescript theme={null}
@upload()
async uploadDocument() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const maxSize = 10 * 1024 * 1024; // 10MB
  if (file.size > maxSize) {
    throw new Error("File too large");
  }

  return await this.saveDocument(file);
}
```

### 파일 확장자 검증

```typescript theme={null}
@upload()
async uploadFile() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const allowedExtensions = ["jpg", "png", "pdf"];
  if (!file.extname || !allowedExtensions.includes(file.extname)) {
    throw new Error(`Invalid file extension: ${file.extname}`);
  }

  return await this.saveFile(file);
}
```

## 이미지 처리

### Sharp 사용

```typescript theme={null}
import sharp from "sharp";

@upload()
async uploadAndResizeImage() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const buffer = file.buffer;

  // 이미지 리사이징
  const resized = await sharp(buffer)
    .resize(800, 600, { fit: "inside" })
    .jpeg({ quality: 80 })
    .toBuffer();

  // 썸네일 생성
  const thumbnail = await sharp(buffer)
    .resize(200, 200, { fit: "cover" })
    .jpeg({ quality: 70 })
    .toBuffer();

  // S3에 업로드 (Buffer를 직접 저장)
  const imageKey = `images/${Date.now()}.jpg`;
  await Sonamu.storage.use("s3").put(imageKey, resized);
  const imageUrl = await Sonamu.storage.use("s3").getUrl(imageKey);

  const thumbKey = `thumbnails/${Date.now()}.jpg`;
  await Sonamu.storage.use("s3").put(thumbKey, thumbnail);
  const thumbUrl = await Sonamu.storage.use("s3").getUrl(thumbKey);

  return { imageUrl, thumbUrl };
}
```

## 제약사항

### 1. @api 데코레이터 없이 독립 사용

`@upload`는 `@api` 데코레이터 없이 독립적으로 사용합니다:

```typescript theme={null}
// 올바른 사용법
@upload()
async uploadFile() {}

// 불필요 - @upload가 자동으로 API 설정
@api({ httpMethod: "POST" })
@upload()
async uploadFile() {}
```

### 2. httpMethod는 POST

`@upload`를 사용하면 자동으로 `httpMethod: "POST"`가 설정됩니다.

### 3. clients 자동 설정

`@upload`를 사용하면 `clients` 옵션이 자동으로 `["axios-multipart", "tanstack-mutation-multipart"]`로 설정됩니다:

```typescript theme={null}
@upload()
async uploadFile() {
  // clients: ["axios-multipart", "tanstack-mutation-multipart"]
}
```

## 예시 모음

<Tabs>
  <Tab title="프로필 이미지" icon="user-circle">
    ```typescript theme={null}
    class UserModelClass extends BaseModelClass {
      @upload()
      @transactional()
      async uploadAvatar() {
        const { user, bufferedFiles } = Sonamu.getContext();
        const file = bufferedFiles?.[0];

        if (!file) {
          throw new Error("No file uploaded");
        }

        // 이미지 타입 검증
        const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
        if (!allowedTypes.includes(file.mimetype)) {
          throw new Error("Invalid image type");
        }

        // 이미지 처리
        const buffer = file.buffer;
        const processed = await sharp(buffer)
          .resize(300, 300, { fit: "cover" })
          .jpeg({ quality: 85 })
          .toBuffer();

        // S3 업로드
        const key = `avatars/${user.id}/${Date.now()}.jpg`;
        await Sonamu.storage.use("s3").put(key, processed);
        const url = await Sonamu.storage.use("s3").getUrl(key);

        // DB 업데이트
        const wdb = this.getDB("w");
        await wdb.table("users")
          .where("id", user.id)
          .update({ avatar_url: url });

        return { url };
      }
    }
    ```
  </Tab>

  <Tab title="여러 파일 업로드" icon="images">
    ```typescript theme={null}
    class DocumentModelClass extends BaseModelClass {
      @upload({ limits: { files: 10 } })
      @transactional()
      async uploadDocuments(params: { projectId: number }) {
        const { bufferedFiles } = Sonamu.getContext();
        const { projectId } = params;

        if (!bufferedFiles || bufferedFiles.length === 0) {
          throw new Error("No files uploaded");
        }

        const wdb = this.getDB("w");
        const results = [];

        for (const file of bufferedFiles) {
          // 파일 타입 검증
          const allowedTypes = [
            "application/pdf",
            "application/msword",
            "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
          ];

          if (!allowedTypes.includes(file.mimetype)) {
            throw new Error(`Invalid file type: ${file.filename}`);
          }

          // 파일 저장
          const md5 = await file.md5();
          const key = `documents/${projectId}/${md5}.${file.extname}`;
          const url = await file.saveToDisk("fs", key);

          // DB에 기록
          const doc = await wdb.table("documents").insert({
            project_id: projectId,
            filename: file.filename,
            url,
            size: file.size,
            mimetype: file.mimetype,
            created_at: new Date()
          }).returning("*");

          results.push(doc[0]);
        }

        return results;
      }
    }
    ```
  </Tab>

  <Tab title="CSV 파일 처리" icon="file-csv">
    ```typescript theme={null}
    import Papa from "papaparse";

    class ImportModelClass extends BaseModelClass {
      @upload()
      @transactional()
      async importUsers() {
        const { bufferedFiles } = Sonamu.getContext();
        const file = bufferedFiles?.[0];

        if (!file) {
          throw new Error("No file");
        }

        if (file.mimetype !== "text/csv") {
          throw new Error("CSV file required");
        }

        // CSV 파싱 (Buffer 직접 사용)
        const text = file.buffer.toString("utf-8");

        const parsed = Papa.parse<UserImportRow>(text, {
          header: true,
          skipEmptyLines: true
        });

        if (parsed.errors.length > 0) {
          throw new Error("CSV parsing failed");
        }

        // 데이터 검증 및 저장
        const wdb = this.getDB("w");
        const results = [];

        for (const row of parsed.data) {
          // 검증
          if (!row.email || !row.name) {
            throw new Error(`Invalid row: ${JSON.stringify(row)}`);
          }

          // 저장
          const user = await wdb.table("users")
            .insert({
              email: row.email,
              name: row.name,
              phone: row.phone || null,
              created_at: new Date()
            })
            .returning("*");

          results.push(user[0]);
        }

        return {
          imported: results.length,
          users: results
        };
      }
    }
    ```
  </Tab>

  <Tab title="MD5 해시 기반 저장" icon="hashtag">
    ```typescript theme={null}
    class FileModelClass extends BaseModelClass {
      @upload({ limits: { files: 10 } })
      async uploadWithDeduplication() {
        const { bufferedFiles } = Sonamu.getContext();

        if (!bufferedFiles || bufferedFiles.length === 0) {
          throw new Error("No files");
        }

        const results = [];
        for (const file of bufferedFiles) {
          // MD5 해시 계산
          const hash = await file.md5();

          // 해시로 파일 저장 (중복 방지)
          const ext = file.extname || "bin";
          const key = `uploads/${hash}.${ext}`;
          const url = await file.saveToDisk("fs", key);

          results.push({
            filename: file.filename,
            hash,
            url,
            size: file.size
          });
        }

        return results;
      }
    }
    ```
  </Tab>

  <Tab title="Stream 모드 대용량" icon="cloud-arrow-up">
    ```typescript theme={null}
    class FileModelClass extends BaseModelClass {
      @upload({
        consume: "stream",
        destination: "s3",
        keyGenerator: (file) => `large-files/${Date.now()}-${file.filename}`,
        limits: { files: 3, fileSize: 100 * 1024 * 1024 }  // 100MB
      })
      async uploadLargeFiles() {
        const { uploadedFiles } = Sonamu.getContext();

        if (!uploadedFiles || uploadedFiles.length === 0) {
          throw new Error("No files");
        }

        // 파일은 이미 S3에 업로드된 상태
        return {
          files: uploadedFiles.map((file) => ({
            filename: file.filename,
            url: file.url,
            signedUrl: file.signedUrl,
            key: file.key,
            size: file.size,
          })),
        };
      }
    }
    ```
  </Tab>
</Tabs>

## 참고 사항

### @api 데코레이터와의 관계

`@upload` 데코레이터는 내부적으로 자동으로 API 엔드포인트를 생성합니다. 따라서 `@api` 데코레이터를 별도로 사용할 필요가 없습니다.

**자동 설정되는 값:**

* `httpMethod`: `"POST"` (고정)
* `clients`: `["axios-multipart", "tanstack-mutation-multipart"]` (multipart 전용 클라이언트)
* `guards`: `@upload` 옵션의 `guards` 값이 API에 전달됨
* `description`: `@upload` 옵션의 `description` 값이 API에 전달됨

```typescript theme={null}
// @upload만 사용 (권장)
@upload()
async uploadFile() {
  // ...
}

// 불필요 - @api를 함께 사용할 필요 없음
@api({ httpMethod: "POST" })  // 불필요
@upload()
async uploadFile() {
  // ...
}
```

<Info>
  `@upload`는 파일 업로드에 최적화된 설정을 자동으로 적용하므로, `@api` 데코레이터를 추가로 사용할
  필요가 없습니다.
</Info>

## 다음 단계

<CardGroup cols={2}>
  <Card title="@api" icon="plug" href="/ko/api-reference/decorators/api">
    API 엔드포인트 만들기
  </Card>

  <Card title="@transactional" icon="arrows-rotate" href="/ko/api-reference/decorators/transactional">
    트랜잭션으로 안전하게 저장하기
  </Card>

  <Card title="Storage 드라이버" icon="hard-drive" href="/ko/core-concepts/storage/drivers">
    S3, Local 등 스토리지 사용하기
  </Card>

  <Card title="파일 처리" icon="file-image" href="/ko/guides/file-handling">
    이미지/문서 처리 가이드
  </Card>
</CardGroup>
