๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
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: UploadContext) {
    const file = ctx.file;
    const url = await file.saveToDisk(
      `avatars/${ctx.user.id}.png`,
      'public'  // ๊ณต๊ฐœ ๋””์Šคํฌ
    );
    return url;
  }
  
  // ๊ฐœ์ธ ๋ฌธ์„œ โ†’ ๊ธฐ๋ณธ ๋””์Šคํฌ (private)
  async uploadDocument(ctx: UploadContext) {
    const file = ctx.file;
    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: UploadContext) {
    return ctx.file.saveToDisk('uploads/file.pdf');  // S3
  }
  
  // ์ž„์‹œ ์ฒ˜๋ฆฌ โ†’ ๋กœ์ปฌ
  async processTempFile(ctx: UploadContext) {
    const tempPath = `temp/${Date.now()}.tmp`;
    await ctx.file.saveToDisk(tempPath, 'fs');  // ๋กœ์ปฌ
    
    // ์ฒ˜๋ฆฌ ํ›„ ์‚ญ์ œ
    await Sonamu.storage.use('fs').delete(tempPath);
  }
}

์‹ค์ „ ์˜ˆ์ œ

1. ํ”„๋กœํ•„ ์‚ฌ์ง„ ์—…๋กœ๋“œ

class UserModelClass extends BaseModel {
  @upload({ mode: 'single' })
  @api({ httpMethod: 'POST' })
  async uploadAvatar(ctx: Context) {
    const { file } = Sonamu.getUploadContext();
    
    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({ mode: 'multiple' })
  @api({ httpMethod: 'POST' })
  async uploadImages(postId: number) {
    const { files } = Sonamu.getUploadContext();
    
    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({ mode: 'single' })
  @api({ httpMethod: 'POST' })
  async upload(type: 'public' | 'private') {
    const { file } = Sonamu.getUploadContext();
    
    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 ์„ค์ • ๋“ฑ
    }
    

๋‹ค์Œ ๋‹จ๊ณ„