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

# 파일 저장

> Storage Manager와 다양한 스토리지 드라이버

Sonamu의 Storage Manager를 사용하여 로컬, S3, GCS 등에 파일을 저장하는 방법을 알아봅니다.

## Storage Manager 개요

<CardGroup cols={2}>
  <Card title="통합 인터페이스" icon="layer-group">
    단일 API 여러 스토리지 지원
  </Card>

  <Card title="드라이버 시스템" icon="plug">
    local, s3, gcs 확장 가능
  </Card>

  <Card title="saveToDisk()" icon="floppy-disk">
    간편한 저장 URL 자동 생성
  </Card>

  <Card title="디스크 관리" icon="hard-drive">
    여러 디스크 설정 목적별 분리
  </Card>
</CardGroup>

## saveToDisk() 메서드 (권장)

### 기본 사용법

`@upload()` 데코레이터로 업로드된 파일은 `Sonamu.getContext().bufferedFiles`를 통해 접근합니다. `BufferedFile`의 `saveToDisk()` 메서드가 가장 간편한 파일 저장 방법입니다.

```typescript theme={null}
class FileModel extends BaseModelClass {
  @upload()
  async upload(): Promise<{ url: string }> {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("파일이 필요합니다");

    // 파일 저장 (기본 디스크)
    const url = await file.saveToDisk("fs", `uploads/${Date.now()}-${file.filename}`);

    return { url };
  }
}
```

### 디스크 선택

```typescript theme={null}
class FileModel extends BaseModelClass {
  @upload()
  async uploadToS3(): Promise<{ url: string }> {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("파일이 필요합니다");

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

    return { url };
  }
}
```

## Storage Manager 직접 사용

### 기본 사용법

`saveToDisk()`를 사용할 수 없는 경우 Storage Manager를 직접 사용할 수 있습니다.

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

class FileModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async uploadBuffer(params: {
    buffer: Buffer;
    filename: string;
    mimetype: string;
  }): Promise<{ url: string }> {
    const { buffer, filename, mimetype } = params;

    // 기본 디스크 가져오기
    const disk = Sonamu.storage.use();

    // 파일 저장
    const key = `uploads/${Date.now()}-${filename}`;
    await disk.put(key, new Uint8Array(buffer), {
      contentType: mimetype,
    });

    // URL 생성
    const url = await disk.getUrl(key);

    return { url };
  }
}
```

### 특정 디스크 사용

```typescript theme={null}
class FileModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async uploadToS3(params: { buffer: Buffer; filename: string }): Promise<{ url: string }> {
    const { buffer, filename } = params;

    // S3 디스크 가져오기
    const s3Disk = Sonamu.storage.use("s3");

    // 파일 저장
    const key = `uploads/${Date.now()}-${filename}`;
    await s3Disk.put(key, new Uint8Array(buffer), {
      contentType: "application/octet-stream",
    });

    // URL 생성
    const url = await s3Disk.getUrl(key);

    return { url };
  }
}
```

## Storage 설정

### storage.config.ts

```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!,
      },
    },

    // 프라이빗 파일용
    private: {
      driver: "s3",
      bucket: process.env.PRIVATE_S3_BUCKET!,
      region: "us-east-1",
      credentials: {
        accessKeyId: process.env.S3_ACCESS_KEY_ID!,
        secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
      },
    },
  },
};
```

## Storage Driver API

### put() - 파일 저장

```typescript theme={null}
const disk = Sonamu.storage.use();

await disk.put(key, data, options);
```

**파라미터**:

* `key: string` - 저장 경로
* `data: Uint8Array` - 파일 데이터
* `options: { contentType?: string }` - 옵션

### get() - 파일 읽기

```typescript theme={null}
const buffer = await disk.get(key);
```

### delete() - 파일 삭제

```typescript theme={null}
await disk.delete(key);
```

### exists() - 파일 존재 확인

```typescript theme={null}
const exists = await disk.exists(key);
```

### getUrl() - Public URL 생성

```typescript theme={null}
const url = await disk.getUrl(key);
```

### getSignedUrl() - Signed URL 생성

```typescript theme={null}
const signedUrl = await disk.getSignedUrl(key, expiresIn);
```

## 실전 예제

### 날짜별 폴더 구조

```typescript theme={null}
class FileModel extends BaseModelClass {
  @upload()
  async upload(): Promise<{ url: string }> {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("파일이 필요합니다");

    // 날짜별 경로 생성
    const now = new Date();
    const year = now.getFullYear();
    const month = String(now.getMonth() + 1).padStart(2, "0");
    const day = String(now.getDate()).padStart(2, "0");

    const key = `uploads/${year}/${month}/${day}/${Date.now()}.${file.extname}`;

    const url = await file.saveToDisk("fs", key);

    return { url };
  }
}
```

### 사용자별 폴더 구조

```typescript theme={null}
class FileModel extends BaseModelClass {
  @upload()
  async upload(): Promise<{ url: string }> {
    const context = Sonamu.getContext();

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

    const { bufferedFiles } = context;
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("파일이 필요합니다");

    // 사용자별 경로
    const key = `users/${context.user.id}/${Date.now()}.${file.extname}`;

    const url = await file.saveToDisk("fs", key);

    return { url };
  }
}
```

### 카테고리별 디스크

```typescript theme={null}
class MediaModel extends BaseModelClass {
  @upload()
  async uploadMedia(params: { category: "public" | "private" }): Promise<{ url: string }> {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("파일이 필요합니다");
    const { category } = params;

    // 카테고리별 디스크 선택
    const diskName = category === "public" ? "public" : "private";

    const key = `media/${Date.now()}.${file.extname}`;
    const url = await file.saveToDisk(diskName, key);

    return { url };
  }
}
```

### 이미지 최적화 후 저장

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

class ImageModel extends BaseModelClass {
  @upload()
  async uploadOptimized(): Promise<{
    originalUrl: string;
    thumbnailUrl: string;
    mediumUrl: string;
  }> {
    const { bufferedFiles } = Sonamu.getContext();
    const image = bufferedFiles?.[0];
    if (!image) throw new Error("이미지가 필요합니다");

    // 원본 이미지 Buffer
    const buffer = image.buffer;

    const timestamp = Date.now();
    const ext = image.extname;

    // 원본 저장
    const originalKey = `images/original/${timestamp}.${ext}`;
    const originalUrl = await image.saveToDisk("fs", originalKey);

    // Storage Manager 가져오기
    const disk = Sonamu.storage.use();

    // 썸네일 생성 및 저장 (200x200)
    const thumbnailBuffer = await sharp(buffer)
      .resize(200, 200, { fit: "cover" })
      .jpeg({ quality: 80 })
      .toBuffer();

    const thumbnailKey = `images/thumbnail/${timestamp}.jpg`;
    await disk.put(thumbnailKey, new Uint8Array(thumbnailBuffer), {
      contentType: "image/jpeg",
    });
    const thumbnailUrl = await disk.getUrl(thumbnailKey);

    // 중간 크기 생성 및 저장 (800x800)
    const mediumBuffer = await sharp(buffer)
      .resize(800, 800, { fit: "inside" })
      .jpeg({ quality: 85 })
      .toBuffer();

    const mediumKey = `images/medium/${timestamp}.jpg`;
    await disk.put(mediumKey, new Uint8Array(mediumBuffer), {
      contentType: "image/jpeg",
    });
    const mediumUrl = await disk.getUrl(mediumKey);

    return {
      originalUrl,
      thumbnailUrl,
      mediumUrl,
    };
  }
}
```

### 여러 디스크에 동시 저장

```typescript theme={null}
class FileModel extends BaseModelClass {
  @upload()
  async uploadWithBackup(): Promise<{
    primaryUrl: string;
    backupUrl: string;
  }> {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("파일이 필요합니다");

    const key = `uploads/${Date.now()}.${file.extname}`;
    const buffer = file.buffer;

    // Primary 디스크에 저장
    const primaryUrl = await file.saveToDisk("s3", key);

    // Backup 디스크에도 저장
    const backupDisk = Sonamu.storage.use("backup");
    await backupDisk.put(key, new Uint8Array(buffer), {
      contentType: file.mimetype,
    });
    const backupUrl = await backupDisk.getUrl(key);

    return {
      primaryUrl,
      backupUrl,
    };
  }
}
```

## 파일 삭제

### 파일 삭제

```typescript theme={null}
class FileModel extends BaseModelClass {
  @api({ httpMethod: "DELETE" })
  async remove(fileId: number): Promise<void> {
    const rdb = this.getPuri("r");

    // 파일 정보 조회
    const file = await rdb.table("files").where("id", fileId).first();

    if (!file) {
      throw new Error("File not found");
    }

    // Storage에서 삭제
    const disk = Sonamu.storage.use(file.disk_name);
    await disk.delete(file.key);

    // DB에서 삭제
    const wdb = this.getPuri("w");
    await wdb.table("files").where("id", fileId).delete();
  }
}
```

### 파일 존재 확인 후 삭제

```typescript theme={null}
class FileModel extends BaseModelClass {
  @api({ httpMethod: "DELETE" })
  async removeSafe(fileId: number): Promise<void> {
    const rdb = this.getPuri("r");

    const file = await rdb.table("files").where("id", fileId).first();

    if (!file) {
      throw new Error("File not found");
    }

    const disk = Sonamu.storage.use(file.disk_name);

    // 파일 존재 확인
    const exists = await disk.exists(file.key);

    if (exists) {
      await disk.delete(file.key);
    } else {
      console.warn(`File not found in storage: ${file.key}`);
    }

    // DB에서 삭제
    const wdb = this.getPuri("w");
    await wdb.table("files").where("id", fileId).delete();
  }
}
```

## 고급 사용

### 스트리밍 업로드 (대용량 파일)

대용량 파일의 경우 `@upload({ consume: "stream" })` 옵션을 사용하면 파일이 지정된 스토리지에 직접 스트리밍 저장됩니다. 이 경우 `context.uploadedFiles`를 통해 이미 업로드된 파일 정보에 접근합니다.

```typescript theme={null}
class FileModel extends BaseModelClass {
  @upload({ consume: "stream", destination: "s3" })
  async uploadLarge(): Promise<{ url: string }> {
    const { uploadedFiles } = Sonamu.getContext();
    const file = uploadedFiles?.[0];
    if (!file) throw new Error("파일이 필요합니다");

    // stream 모드에서는 파일이 이미 destination에 저장되어 있음
    return { url: file.url };
  }
}
```

### 파일 메타데이터 저장

```typescript theme={null}
class FileModel extends BaseModelClass {
  @upload()
  async uploadWithMetadata(params: {
    title?: string;
    description?: string;
    tags?: string[];
  }): Promise<{ fileId: number; url: string }> {
    const context = Sonamu.getContext();

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

    const { bufferedFiles } = context;
    const file = bufferedFiles?.[0];
    if (!file) throw new Error("파일이 필요합니다");
    const { title, description, tags } = params;

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

    // DB에 메타데이터 저장
    const wdb = this.getPuri("w");
    const [record] = await wdb
      .table("files")
      .insert({
        user_id: context.user.id,
        key,
        filename: file.filename,
        mime_type: file.mimetype,
        size: file.size,
        url,
        title,
        description,
        tags: tags ? JSON.stringify(tags) : null,
        uploaded_at: new Date(),
      })
      .returning("id");

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

## 주의사항

<Warning>
  **파일 저장 시 주의사항**: 1. `saveToDisk()`가 가장 간편한 방법 2. 디스크 설정 확인 3. 파일 크기
  제한 설정 4. 에러 처리 필수 5. 대용량 파일은 스트리밍 고려
</Warning>

## 다음 단계

<CardGroup cols={2}>
  <Card title="파일 업로드 설정" icon="gear" href="/ko/api-development/file-upload/setup">
    @upload 데코레이터
  </Card>

  <Card title="UploadedFile 클래스" icon="file" href="/ko/api-development/file-upload/uploaded-file-class">
    파일 정보 접근
  </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>
