메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
SonamuλŠ” 파일 μ—…λ‘œλ“œμ™€ μ €μž₯을 μœ„ν•œ 톡합 μŠ€ν† λ¦¬μ§€ μ‹œμŠ€ν…œμ„ μ œκ³΅ν•©λ‹ˆλ‹€. 둜컬 파일 μ‹œμŠ€ν…œ(fs)κ³Ό AWS S3λ₯Ό μ§€μ›ν•˜λ©°, ν™˜κ²½μ— 따라 μ‰½κ²Œ μ „ν™˜ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

기본 ꡬ쑰

import path from "path";
import { defineConfig } from "sonamu";
import { drivers } from "sonamu/storage";

export default defineConfig({
  server: {
    storage: {
      drivers: {
        fs: drivers.fs({
          /* ... */
        }),
        s3: drivers.s3({
          /* ... */
        }),
      },
    },
  },
  // ...
});

drivers

μ‚¬μš©ν•  μŠ€ν† λ¦¬μ§€ λ“œλΌμ΄λ²„λ“€μ„ μ •μ˜ν•©λ‹ˆλ‹€. "fs"(둜컬 파일 μ‹œμŠ€ν…œ)와 "s3"(AWS S3) 두 μ’…λ₯˜λ₯Ό μ§€μ›ν•©λ‹ˆλ‹€. νƒ€μž…: Record<DriverKey, () => DriverContract> (DriverKey = "fs" | "s3")
import { drivers } from "sonamu/storage";

export default defineConfig({
  server: {
    storage: {
      drivers: {
        fs: drivers.fs({
          /* ... */
        }), // 둜컬 파일 μ‹œμŠ€ν…œ
        s3: drivers.s3({
          /* ... */
        }), // AWS S3
      },
    },
  },
});
파일 μ €μž₯ μ‹œ saveToDisk(diskName, key) ν˜ΈμΆœμ—μ„œ μ‚¬μš©ν•  λ“œλΌμ΄λ²„λ₯Ό λͺ…μ‹œμ μœΌλ‘œ μ§€μ •ν•©λ‹ˆλ‹€. ν™˜κ²½λ³„λ‘œ λ‹€λ₯Έ λ“œλΌμ΄λ²„λ₯Ό μ‚¬μš©ν•˜λ €λ©΄ ν™˜κ²½ λ³€μˆ˜λ₯Ό ν™œμš©ν•˜μ—¬ μ½”λ“œ λ ˆλ²¨μ—μ„œ λΆ„κΈ°ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

fs λ“œλΌμ΄λ²„

둜컬 파일 μ‹œμŠ€ν…œμ— νŒŒμΌμ„ μ €μž₯ν•©λ‹ˆλ‹€. 개발 ν™˜κ²½μ— μ ν•©ν•©λ‹ˆλ‹€.
import path from "path";
import { drivers } from "sonamu/storage";

export default defineConfig({
  server: {
    storage: {
      drivers: {
        fs: drivers.fs({
          location: path.join(import.meta.dirname, "/../public/uploaded"),
          visibility: "public",
          urlBuilder: {
            generateURL(key) {
              return `/api/public/uploaded/${key}`;
            },
            generateSignedURL(key) {
              return `/api/public/uploaded/${key}`;
            },
          },
        }),
      },
    },
  },
});

location

파일이 μ €μž₯될 디렉토리 κ²½λ‘œμž…λ‹ˆλ‹€. νƒ€μž…: string (ν•„μˆ˜)
drivers.fs({
  location: path.join(import.meta.dirname, "/../public/uploaded"),
  // ...
});
경둜 μ˜ˆμ‹œ:

visibility

파일의 μ ‘κ·Ό κΆŒν•œμ„ μ„€μ •ν•©λ‹ˆλ‹€. νƒ€μž…: "public" | "private"
  • "public": λˆ„κ΅¬λ‚˜ URL둜 μ ‘κ·Ό κ°€λŠ₯
  • "private": 인증된 μ‚¬μš©μžλ§Œ μ ‘κ·Ό κ°€λŠ₯
drivers.fs({
  location: path.join(import.meta.dirname, "/../public/uploaded"),
  visibility: "public", // 곡개 파일
  // ...
});

urlBuilder

파일 URL을 μƒμ„±ν•˜λŠ” ν•¨μˆ˜λ“€μ„ μ •μ˜ν•©λ‹ˆλ‹€.
drivers.fs({
  // ...
  urlBuilder: {
    // 일반 URL 생성
    generateURL(key) {
      return `/api/public/uploaded/${key}`;
    },

    // μ„œλͺ…λœ URL 생성 (μž„μ‹œ μ ‘κ·Ό)
    generateSignedURL(key, expiresIn) {
      // fsμ—μ„œλŠ” 보톡 일반 URLκ³Ό 동일
      return `/api/public/uploaded/${key}`;
    },
  },
});
generateURL: 파일의 곡개 URL을 μƒμ„±ν•©λ‹ˆλ‹€.
  • key: 파일의 고유 ν‚€ (예: "profile-images/user-123.jpg")
  • λ°˜ν™˜: μ ‘κ·Ό κ°€λŠ₯ν•œ URL
generateSignedURL: μž„μ‹œλ‘œ μ ‘κ·Ό κ°€λŠ₯ν•œ μ„œλͺ…λœ URL을 μƒμ„±ν•©λ‹ˆλ‹€.
  • key: 파일의 고유 ν‚€
  • expiresIn: 만료 μ‹œκ°„ (초, 선택적)
  • λ°˜ν™˜: μž„μ‹œ URL

s3 λ“œλΌμ΄λ²„

AWS S3에 νŒŒμΌμ„ μ €μž₯ν•©λ‹ˆλ‹€. ν”„λ‘œλ•μ…˜ ν™˜κ²½μ— μ ν•©ν•©λ‹ˆλ‹€.
import { drivers } from "sonamu/storage";

export default defineConfig({
  server: {
    storage: {
      drivers: {
        s3: drivers.s3({
          credentials: {
            accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "",
            secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "",
          },
          region: "ap-northeast-2",
          bucket: "my-app-uploads",
          visibility: "private",
        }),
      },
    },
  },
});

credentials

AWS 인증 정보λ₯Ό μ„€μ •ν•©λ‹ˆλ‹€. νƒ€μž…: (ν•„μˆ˜)
credentials: {
  accessKeyId: string;
  secretAccessKey: string;
}
drivers.s3({
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "",
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "",
  },
  // ...
});
AWS 인증 μ •λ³΄λŠ” λ°˜λ“œμ‹œ ν™˜κ²½ λ³€μˆ˜λ‘œ κ΄€λ¦¬ν•˜μ„Έμš”. μ½”λ“œμ— 직접 μž‘μ„±ν•˜μ§€ λ§ˆμ„Έμš”!
.env:
AWS_ACCESS_KEY_ID=your_access_key_here
AWS_SECRET_ACCESS_KEY=your_secret_key_here

region

S3 버킷이 μœ„μΉ˜ν•œ AWS λ¦¬μ „μž…λ‹ˆλ‹€. νƒ€μž…: string (ν•„μˆ˜)
drivers.s3({
  // ...
  region: "ap-northeast-2", // μ„œμšΈ 리전
});
μ£Όμš” 리전:
  • ap-northeast-2 - μ„œμšΈ
  • us-east-1 - λ²„μ§€λ‹ˆμ•„ 뢁뢀
  • us-west-2 - 였레곀
  • eu-west-1 - μ•„μΌλžœλ“œ
  • ap-southeast-1 - 싱가포λ₯΄

bucket

νŒŒμΌμ„ μ €μž₯ν•  S3 버킷 μ΄λ¦„μž…λ‹ˆλ‹€. νƒ€μž…: string (ν•„μˆ˜)
drivers.s3({
  // ...
  bucket: "my-app-uploads",
});
ν™˜κ²½λ³„λ‘œ λ‹€λ₯Έ 버킷 μ‚¬μš©:
drivers.s3({
  // ...
  bucket: process.env.S3_BUCKET ?? "my-app-dev",
});

visibility

S3 객체의 ACL(Access Control List)을 μ„€μ •ν•©λ‹ˆλ‹€. νƒ€μž…: "public" | "private"
drivers.s3({
  // ...
  visibility: "private", // 인증 ν•„μš”
});
  • "public": 퍼블릭 읽기 ν—ˆμš© (public-read ACL)
  • "private": 프라이빗 (private ACL, κΈ°λ³Έκ°’)

μ‹€μ „ μ˜ˆμ‹œ

개발 ν™˜κ²½: fs만 μ‚¬μš©

import path from "path";
import { defineConfig } from "sonamu";
import { drivers } from "sonamu/storage";

export default defineConfig({
  server: {
    storage: {
      drivers: {
        fs: drivers.fs({
          location: path.join(import.meta.dirname, "/../public/uploaded"),
          visibility: "public",
          urlBuilder: {
            generateURL(key) {
              return `/api/public/uploaded/${key}`;
            },
            generateSignedURL(key) {
              return `/api/public/uploaded/${key}`;
            },
          },
        }),
      },
    },
  },
  // ...
});

fs + S3 (ν™˜κ²½λ³„ μ „ν™˜)

두 λ“œλΌμ΄λ²„λ₯Ό λͺ¨λ‘ λ“±λ‘ν•˜κ³ , μ½”λ“œμ—μ„œ ν™˜κ²½ λ³€μˆ˜λ₯Ό 기반으둜 μ‚¬μš©ν•  λ“œλΌμ΄λ²„λ₯Ό μ„ νƒν•©λ‹ˆλ‹€.
import path from "path";
import { defineConfig } from "sonamu";
import { drivers } from "sonamu/storage";

export default defineConfig({
  server: {
    storage: {
      drivers: {
        // 개발: 둜컬 파일 μ‹œμŠ€ν…œ
        fs: drivers.fs({
          location: path.join(import.meta.dirname, "/../public/uploaded"),
          visibility: "public",
          urlBuilder: {
            generateURL(key) {
              return `/api/public/uploaded/${key}`;
            },
            generateSignedURL(key) {
              return `/api/public/uploaded/${key}`;
            },
          },
        }),

        // ν”„λ‘œλ•μ…˜: AWS S3
        s3: drivers.s3({
          credentials: {
            accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "",
            secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "",
          },
          region: process.env.S3_REGION ?? "ap-northeast-2",
          bucket: process.env.S3_BUCKET ?? "my-app-prod",
          visibility: "private",
        }),
      },
    },
  },
  // ...
});
.env.development:
DRIVE_DISK=fs
.env.production:
DRIVE_DISK=s3
S3_REGION=ap-northeast-2
S3_BUCKET=my-app-prod-uploads
AWS_ACCESS_KEY_ID=your_access_key_here
AWS_SECRET_ACCESS_KEY=your_secret_key_here
ν˜„μž¬ DriverKeyλŠ” "fs"와 "s3" 두 κ°€μ§€λ§Œ μ§€μ›ν•©λ‹ˆλ‹€. 곡개/λΉ„κ³΅κ°œ νŒŒμΌμ„ 별도 버킷에 λ‚˜λˆ„μ–΄ μ €μž₯ν•˜λŠ” 닀쀑 버킷 ꡬ성은 ν˜„μž¬ μ§€μ›λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.

파일 μ—…λ‘œλ“œ μ‚¬μš©

μŠ€ν† λ¦¬μ§€ μ„€μ • ν›„ @upload λ°μ½”λ ˆμ΄ν„°μ™€ BufferedFile.saveToDisk()λ₯Ό μ‚¬μš©ν•˜μ—¬ νŒŒμΌμ„ μ—…λ‘œλ“œν•  수 μžˆμŠ΅λ‹ˆλ‹€.
import { type DriverKey } from "sonamu/storage";
import { Sonamu } from "sonamu";
import { upload } from "sonamu/decorators";
import { BaseModelClass } from "sonamu";

export class FileModel extends BaseModelClass {
  @upload()
  static async upload() {
    const ctx = Sonamu.getContext();
    const file = ctx.bufferedFiles[0];
    if (!file) throw new Error("μ—…λ‘œλ“œλœ 파일이 μ—†μŠ΅λ‹ˆλ‹€.");

    // ν™˜κ²½ λ³€μˆ˜λ‘œ λ“œλΌμ΄λ²„ 선택
    const diskName: DriverKey = process.env.DRIVE_DISK === "s3" ? "s3" : "fs";
    const url = await file.saveToDisk(diskName, `uploads/${Date.now()}-${file.filename}`);

    return { url };
  }
}
β†’ 파일 μ—…λ‘œλ“œ κ°€μ΄λ“œ

S3 버킷 μ„€μ •

S3λ₯Ό μ‚¬μš©ν•˜κΈ° 전에 AWS μ½˜μ†”μ—μ„œ 버킷을 μƒμ„±ν•˜κ³  μ„€μ •ν•΄μ•Ό ν•©λ‹ˆλ‹€.

1. 버킷 생성

# AWS CLI둜 버킷 생성
aws s3 mb s3://my-app-uploads --region ap-northeast-2

2. CORS μ„€μ •

ν”„λ‘ νŠΈμ—”λ“œμ—μ„œ 직접 μ—…λ‘œλ“œν•˜λ €λ©΄ CORSλ₯Ό μ„€μ •ν•©λ‹ˆλ‹€. S3 μ½˜μ†” β†’ 버킷 β†’ κΆŒν•œ β†’ CORS ꡬ성:
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
    "AllowedOrigins": ["https://myapp.com"],
    "ExposeHeaders": ["ETag"]
  }
]

3. IAM κΆŒν•œ

Sonamuκ°€ S3에 μ ‘κ·Όν•˜λ €λ©΄ μ μ ˆν•œ IAM κΆŒν•œμ΄ ν•„μš”ν•©λ‹ˆλ‹€. μ΅œμ†Œ κΆŒν•œ μ •μ±…:
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"],
      "Resource": ["arn:aws:s3:::my-app-uploads", "arn:aws:s3:::my-app-uploads/*"]
    }
  ]
}

μ£Όμ˜μ‚¬ν•­

1. ν™˜κ²½ λ³€μˆ˜ λ³΄μ•ˆ

// ❌ λ‚˜μœ 예: μ½”λ“œμ— 인증 정보 직접 μž‘μ„±
drivers.s3({
  credentials: {
    accessKeyId: "AKIAXXXXXXXXXXXXXXXX",
    secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCY...",
  },
});

// βœ… 쒋은 예: ν™˜κ²½ λ³€μˆ˜ μ‚¬μš©
drivers.s3({
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "",
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "",
  },
});

2. visibility 선택

// 곡개 파일 (ν”„λ‘œν•„ 이미지, μƒν’ˆ 이미지 λ“±)
visibility: "public";

// 프라이빗 파일 (개인 λ¬Έμ„œ, 영수증, κ³„μ•½μ„œ λ“±)
visibility: "private";

3. 파일 경둜 섀계

// βœ… 쒋은 예: 체계적인 경둜 ꡬ쑰
const key = `users/${userId}/profile/${Date.now()}.jpg`;
const key = `documents/${year}/${month}/${documentId}.pdf`;

// ❌ λ‚˜μœ 예: ν‰ν‰ν•œ ꡬ쑰
const key = `${Date.now()}.jpg`;

λ‹€μŒ 단계

μŠ€ν† λ¦¬μ§€ 섀정을 μ™„λ£Œν–ˆλ‹€λ©΄: