메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
SonamuλŠ” Flydrive 기반의 톡합 파일 μŠ€ν† λ¦¬μ§€ μ‹œμŠ€ν…œμ„ μ œκ³΅ν•©λ‹ˆλ‹€. 둜컬 파일 μ‹œμŠ€ν…œ(fs)κ³Ό AWS S3λ₯Ό λ™μΌν•œ μΈν„°νŽ˜μ΄μŠ€λ‘œ μ‚¬μš©ν•  수 있으며, ν•„μš”μ— 따라 μ—¬λŸ¬ λ””μŠ€ν¬λ₯Ό 관리할 수 μžˆμŠ΅λ‹ˆλ‹€.

μ™œ Storage Managerκ°€ ν•„μš”ν•œκ°€?

문제 상황

// ❌ 각 λ“œλΌμ΄λ²„λ§ˆλ‹€ λ‹€λ₯Έ API
if (env === 'local') {
  fs.writeFileSync('uploads/avatar.png', buffer);
  url = '/uploads/avatar.png';
} else {
  await s3.putObject({
    Bucket: 'my-bucket',
    Key: 'uploads/avatar.png',
    Body: buffer
  });
  url = 'https://s3.amazonaws.com/my-bucket/uploads/avatar.png';
}
문제점:
  • ν™˜κ²½λ§ˆλ‹€ λ‹€λ₯Έ μ½”λ“œ μž‘μ„±
  • λ“œλΌμ΄λ²„ λ³€κ²½ μ‹œ μ½”λ“œ μˆ˜μ • ν•„μš”
  • URL 생성 둜직이 뢄산됨

Storage Manager μ‚¬μš©

// βœ… ν†΅ν•©λœ μΈν„°νŽ˜μ΄μŠ€
await Sonamu.storage.use().put('uploads/avatar.png', buffer);
const url = await Sonamu.storage.use().getUrl('uploads/avatar.png');
μž₯점:
  • λ“œλΌμ΄λ²„ 독립적인 μ½”λ“œ
  • μ„€μ •λ§Œ λ³€κ²½ν•˜λ©΄ λ“œλΌμ΄λ²„ ꡐ체 κ°€λŠ₯
  • μΌκ΄€λœ URL 생성

κΈ°λ³Έ κ°œλ…

Storage Manager

μ—¬λŸ¬ **λ””μŠ€ν¬(Disk)**λ₯Ό κ΄€λ¦¬ν•˜λŠ” λ§€λ‹ˆμ €μž…λ‹ˆλ‹€.

Disk

λ””μŠ€ν¬λŠ” νŒŒμΌμ„ μ €μž₯ν•˜λŠ” ν•˜λ‚˜μ˜ λ“œλΌμ΄λ²„ μΈμŠ€ν„΄μŠ€μž…λ‹ˆλ‹€.
// κΈ°λ³Έ λ””μŠ€ν¬ μ‚¬μš©
const disk = Sonamu.storage.use();

// νŠΉμ • λ””μŠ€ν¬ μ‚¬μš©
const s3Disk = Sonamu.storage.use('s3');
const fsDisk = Sonamu.storage.use('fs');

κΈ°λ³Έ μ„€μ •

sonamu.config.ts

import { drivers, type SonamuConfig } from "sonamu";

export const config: SonamuConfig = {
  server: {
    storage: {
      default: 'fs',  // κΈ°λ³Έ λ””μŠ€ν¬
      drivers: {
        fs: drivers.fs({
          location: './uploads',  // μ €μž₯ μœ„μΉ˜
          urlBuilder: {
            generateURL: (key) => `/uploads/${key}`,
          }
        }),
      }
    }
  }
};
μ„€μ • μš”μ†Œ:
  • default: 기본으둜 μ‚¬μš©ν•  λ””μŠ€ν¬ 이름
  • drivers: λ””μŠ€ν¬ 이름별 λ“œλΌμ΄λ²„ νŒ©ν† λ¦¬

λ””μŠ€ν¬ μ‚¬μš©

κΈ°λ³Έ λ””μŠ€ν¬

// κΈ°λ³Έ λ””μŠ€ν¬ μ‚¬μš© (config.default)
const disk = Sonamu.storage.use();

// 파일 μ €μž₯
await disk.put('avatar.png', buffer);

// URL κ°€μ Έμ˜€κΈ°
const url = await disk.getUrl('avatar.png');
// => '/uploads/avatar.png' (fs)
// => 'https://bucket.s3.amazonaws.com/avatar.png' (s3)

νŠΉμ • λ””μŠ€ν¬

// fs λ””μŠ€ν¬
const fsDisk = Sonamu.storage.use('fs');
await fsDisk.put('temp/file.txt', 'content');

// s3 λ””μŠ€ν¬
const s3Disk = Sonamu.storage.use('s3');
await s3Disk.put('backup/file.txt', 'content');

μ—¬λŸ¬ λ””μŠ€ν¬ 관리

닀쀑 λ””μŠ€ν¬ μ„€μ •

export const config: SonamuConfig = {
  server: {
    storage: {
      default: process.env.STORAGE_DISK || 'fs',
      drivers: {
        // 둜컬 개발용
        fs: drivers.fs({
          location: './uploads',
          urlBuilder: {
            generateURL: (key) => `/uploads/${key}`,
          }
        }),
        
        // ν”„λ‘œλ•μ…˜μš© (S3)
        s3: drivers.s3({
          credentials: {
            accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
            secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
          },
          region: process.env.AWS_REGION!,
          bucket: process.env.AWS_BUCKET!,
          urlBuilder: {
            generateURL: (key) => 
              `https://${process.env.AWS_BUCKET}.s3.amazonaws.com/${key}`,
          }
        }),
        
        // 곡개 파일용
        public: drivers.s3({
          credentials: {
            accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
            secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
          },
          region: process.env.AWS_REGION!,
          bucket: process.env.AWS_PUBLIC_BUCKET!,
          urlBuilder: {
            generateURL: (key) => 
              `https://${process.env.AWS_PUBLIC_BUCKET}.s3.amazonaws.com/${key}`,
          }
        }),
      }
    }
  }
};

μš©λ„λ³„ λ””μŠ€ν¬ μ‚¬μš©

class UserModelClass extends BaseModel {
  // ν”„λ‘œν•„ 사진 β†’ 곡개 λ””μŠ€ν¬
  async uploadAvatar(ctx: Context) {
    const { files } = Sonamu.getContext();
    const file = files?.[0]; // 첫 번째 파일 μ‚¬μš©
    const url = await file.saveToDisk(
      `avatars/${ctx.user.id}.png`,
      'public'  // 곡개 λ””μŠ€ν¬
    );
    return url;
  }
  
  // 개인 λ¬Έμ„œ β†’ κΈ°λ³Έ λ””μŠ€ν¬ (private)
  async uploadDocument(ctx: Context) {
    const { files } = Sonamu.getContext();
    const file = files?.[0]; // 첫 번째 파일 μ‚¬μš©
    const url = await file.saveToDisk(
      `documents/${ctx.user.id}/${file.filename}`
      // κΈ°λ³Έ λ””μŠ€ν¬ μ‚¬μš©
    );
    return url;
  }
  
  // λ°±μ—… β†’ 별도 λ””μŠ€ν¬
  async backupData(data: any) {
    const json = JSON.stringify(data);
    await Sonamu.storage
      .use('backup')
      .put(`backups/${Date.now()}.json`, json);
  }
}

ν™˜κ²½λ³„ μ „λž΅

개발/ν”„λ‘œλ•μ…˜ 뢄리

둜컬 파일 μ‹œμŠ€ν…œ μ‚¬μš©
// .env.development
STORAGE_DISK=fs

// sonamu.config.ts
storage: {
  default: process.env.STORAGE_DISK || 'fs',
  drivers: {
    fs: drivers.fs({
      location: './uploads',
      urlBuilder: {
        generateURL: (key) => `/uploads/${key}`,
      }
    }),
  }
}
μž₯점:
  • AWS 계정 λΆˆν•„μš”
  • λΉ λ₯Έ 개발
  • λΉ„μš© μ—†μŒ

ν•˜μ΄λΈŒλ¦¬λ“œ μ „λž΅

export const config: SonamuConfig = {
  server: {
    storage: {
      default: 's3',
      drivers: {
        // 메인 μ €μž₯μ†Œ: S3
        s3: drivers.s3({ /* ... */ }),
        
        // μž„μ‹œ 파일: 둜컬
        fs: drivers.fs({
          location: './temp',
          urlBuilder: {
            generateURL: (key) => `/temp/${key}`,
          }
        }),
      }
    }
  }
};

// μ‚¬μš©
class FileModelClass extends BaseModel {
  // 영ꡬ μ €μž₯ β†’ S3
  async upload(ctx: Context) {
    const { files } = Sonamu.getContext();
    const file = files?.[0]; // 첫 번째 파일 μ‚¬μš©
    return file.saveToDisk('uploads/file.pdf');  // S3
  }

  // μž„μ‹œ 처리 β†’ 둜컬
  async processTempFile(ctx: Context) {
    const { files } = Sonamu.getContext();
    const file = files?.[0]; // 첫 번째 파일 μ‚¬μš©
    const tempPath = `temp/${Date.now()}.tmp`;
    await file.saveToDisk(tempPath, 'fs');  // 둜컬
    
    // 처리 ν›„ μ‚­μ œ
    await Sonamu.storage.use('fs').delete(tempPath);
  }
}

μ‹€μ „ 예제

1. ν”„λ‘œν•„ 사진 μ—…λ‘œλ“œ

class UserModelClass extends BaseModel {
  @upload()
  async uploadAvatar(ctx: Context) {
    const { files } = Sonamu.getContext();
    const file = files?.[0]; // 첫 번째 파일 μ‚¬μš©

    if (!file) {
      throw new Error('파일이 μ—†μŠ΅λ‹ˆλ‹€');
    }

    // 파일λͺ…: user-{id}-{timestamp}.{ext}
    const filename = `user-${ctx.user.id}-${Date.now()}.${file.extname}`;
    const key = `avatars/${filename}`;

    // κΈ°λ³Έ λ””μŠ€ν¬μ— μ €μž₯
    const url = await file.saveToDisk(key);

    // DB μ—…λ°μ΄νŠΈ
    await this.updateOne(['id', ctx.user.id], { avatar_url: url });

    return { url };
  }
}

2. 닀쀑 파일 μ—…λ‘œλ“œ

class PostModelClass extends BaseModel {
  @upload()
  async uploadImages(postId: number) {
    const { files } = Sonamu.getContext();

    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 };
  }
}

3. λ””μŠ€ν¬λ³„ 뢄리

class FileModelClass extends BaseModel {
  @upload()
  async upload(type: 'public' | 'private') {
    const { files } = Sonamu.getContext();
    const file = files?.[0]; // 첫 번째 파일 μ‚¬μš©

    if (!file) {
      throw new Error('파일이 μ—†μŠ΅λ‹ˆλ‹€');
    }
    
    // νƒ€μž…μ— 따라 λ‹€λ₯Έ λ””μŠ€ν¬ μ‚¬μš©
    const disk = type === 'public' ? 'public' : undefined;
    const key = `${type}/${Date.now()}-${file.filename}`;
    
    const url = await file.saveToDisk(key, disk);
    
    return { url, type };
  }
}

Storage Manager API

use(diskName?)

λ””μŠ€ν¬ μΈμŠ€ν„΄μŠ€λ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€.
// κΈ°λ³Έ λ””μŠ€ν¬
const disk = Sonamu.storage.use();

// νŠΉμ • λ””μŠ€ν¬
const s3 = Sonamu.storage.use('s3');
const fs = Sonamu.storage.use('fs');
λ°˜ν™˜κ°’: Disk μΈμŠ€ν„΄μŠ€ (Flydrive)

defaultDisk

κΈ°λ³Έ λ””μŠ€ν¬ 이름을 λ°˜ν™˜ν•©λ‹ˆλ‹€.
const defaultName = Sonamu.storage.defaultDisk;
// => 'fs' λ˜λŠ” 's3' λ“±

Disk API (Flydrive)

Storage Managerκ°€ λ°˜ν™˜ν•˜λŠ” DiskλŠ” Flydrive의 Disk μΈμŠ€ν„΄μŠ€μž…λ‹ˆλ‹€.

μ£Όμš” λ©”μ„œλ“œ

const disk = Sonamu.storage.use();

// 파일 μ €μž₯
await disk.put('path/to/file.txt', 'content');
await disk.put('path/to/file.txt', buffer);

// 파일 읽기
const content = await disk.get('path/to/file.txt');

// 파일 쑴재 확인
const exists = await disk.exists('path/to/file.txt');

// 파일 μ‚­μ œ
await disk.delete('path/to/file.txt');

// URL κ°€μ Έμ˜€κΈ°
const url = await disk.getUrl('path/to/file.txt');

// Signed URL κ°€μ Έμ˜€κΈ° (S3)
const signedUrl = await disk.getSignedUrl('path/to/file.txt', {
  expiresIn: '1h'
});

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

Storage Manager μ‚¬μš© μ‹œ μ£Όμ˜μ‚¬ν•­:
  1. ν™˜κ²½λ³€μˆ˜ ν•„μˆ˜: S3 μ‚¬μš© μ‹œ AWS 자격증λͺ… ν•„μš”
    // ❌ ν•˜λ“œμ½”λ”© κΈˆμ§€
    credentials: {
      accessKeyId: 'AKIA...',
      secretAccessKey: '...',
    }
    
    // βœ… ν™˜κ²½λ³€μˆ˜ μ‚¬μš©
    credentials: {
      accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
    }
    
  2. λ””μŠ€ν¬ 이름 μ˜€νƒ€: μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λ””μŠ€ν¬ μ‚¬μš© μ‹œ μ—λŸ¬
    // ❌ μ˜€νƒ€
    Sonamu.storage.use('S3')  // μ—λŸ¬!
    
    // βœ… μ •ν™•ν•œ 이름
    Sonamu.storage.use('s3')
    
  3. 경둜 κ΅¬λΆ„μž: ν”Œλž«νΌ λ…λ¦½μ μœΌλ‘œ / μ‚¬μš©
    // βœ… 항상 μŠ¬λž˜μ‹œ
    await disk.put('uploads/avatar.png', buffer);
    
    // ❌ λ°±μŠ¬λž˜μ‹œ μ‚¬μš© κΈˆμ§€
    await disk.put('uploads\\avatar.png', buffer);
    
  4. URL λΉŒλ” ν•„μˆ˜: fs λ“œλΌμ΄λ²„λŠ” urlBuilder μ„€μ • ν•„μš”
    // ❌ urlBuilder μ—†μŒ
    fs: drivers.fs({
      location: './uploads',
    })
    
    // βœ… urlBuilder μ„€μ •
    fs: drivers.fs({
      location: './uploads',
      urlBuilder: {
        generateURL: (key) => `/uploads/${key}`,
      }
    })
    
  5. Lazy Initialization: λ””μŠ€ν¬λŠ” 첫 μ‚¬μš© μ‹œ μ΄ˆκΈ°ν™”λ¨
    // μ„€μ • 였λ₯˜λŠ” use() 호좜 μ‹œ 발견됨
    try {
      const disk = Sonamu.storage.use('s3');
    } catch (error) {
      // 잘λͺ»λœ S3 μ„€μ • λ“±
    }
    

λ‹€μŒ 단계