메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
Storage λ“œλΌμ΄λ²„λŠ” μ €μž₯된 파일의 URL을 μƒμ„±ν•˜λŠ” 방법을 μ œμ–΄ν•  수 μžˆμŠ΅λ‹ˆλ‹€. URL λΉŒλ”λ₯Ό μ‚¬μš©ν•˜μ—¬ CDN, μ»€μŠ€ν…€ 도메인, Signed URL 등을 κ΅¬ν˜„ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

URL λΉŒλ”λž€?

URL λΉŒλ”λŠ” 파일 ν‚€(key)λ₯Ό URL둜 λ³€ν™˜ν•˜λŠ” ν•¨μˆ˜μž…λ‹ˆλ‹€.
{
  urlBuilder: {
    generateURL: (key: string) => string
  }
}
μ˜ˆμ‹œ:
key: 'uploads/avatar.png'
β†’ '/uploads/avatar.png'  (fs)
β†’ 'https://bucket.s3.amazonaws.com/uploads/avatar.png'  (s3)
β†’ 'https://cdn.example.com/uploads/avatar.png'  (CDN)

fs λ“œλΌμ΄λ²„ URL λΉŒλ”

fs λ“œλΌμ΄λ²„λŠ” λ°˜λ“œμ‹œ urlBuilder 섀정이 ν•„μš”ν•©λ‹ˆλ‹€.

κΈ°λ³Έ μ„€μ •

import { drivers } from "sonamu";

storage: {
  default: 'fs',
  drivers: {
    fs: drivers.fs({
      location: './uploads',
      urlBuilder: {
        generateURL: (key) => `/uploads/${key}`,
      }
    }),
  }
}
κ²°κ³Ό:
// key: 'avatars/user-1.png'
// location: './uploads'
// 파일 경둜: ./uploads/avatars/user-1.png
// URL: /uploads/avatars/user-1.png

μ»€μŠ€ν…€ 도메인

storage: {
  default: 'fs',
  drivers: {
    fs: drivers.fs({
      location: './uploads',
      urlBuilder: {
        generateURL: (key) => 
          `https://files.example.com/uploads/${key}`,
      }
    }),
  }
}
κ²°κ³Ό:
// key: 'avatars/user-1.png'
// URL: https://files.example.com/uploads/avatars/user-1.png
μ‚¬μš© 사둀: 별도 파일 μ„œλ²„λ‚˜ CDN

정적 파일 μ„œλΉ™

fs λ“œλΌμ΄λ²„λ₯Ό μ‚¬μš©ν•  λ•ŒλŠ” Fastify static ν”ŒλŸ¬κ·ΈμΈμ΄ ν•„μš”ν•©λ‹ˆλ‹€.
import path from "path";
import { type SonamuConfig } from "sonamu";

export const config: SonamuConfig = {
  server: {
    // 정적 파일 μ„œλΉ™
    plugins: {
      static: {
        root: path.join(process.cwd(), 'uploads'),
        prefix: '/uploads/',
      }
    },
    
    // Storage μ„€μ •
    storage: {
      default: 'fs',
      drivers: {
        fs: drivers.fs({
          location: './uploads',
          urlBuilder: {
            generateURL: (key) => `/uploads/${key}`,
          }
        }),
      }
    }
  }
};
효과:
http://localhost:3000/uploads/avatars/user-1.png
β†’ ./uploads/avatars/user-1.png 파일 μ„œλΉ™

S3 λ“œλΌμ΄λ²„ URL λΉŒλ”

S3 λ“œλΌμ΄λ²„λŠ” 기본적으둜 S3 URL을 μƒμ„±ν•˜μ§€λ§Œ, urlBuilder둜 μ»€μŠ€ν„°λ§ˆμ΄μ§• κ°€λŠ₯ν•©λ‹ˆλ‹€.

κΈ°λ³Έ λ™μž‘ (urlBuilder μ—†μŒ)

storage: {
  default: 's3',
  drivers: {
    s3: drivers.s3({
      credentials: { /* ... */ },
      region: 'ap-northeast-2',
      bucket: 'my-bucket',
      // urlBuilder μ—†μŒ
    }),
  }
}
κ²°κ³Ό:
// key: 'uploads/avatar.png'
// URL: https://my-bucket.s3.ap-northeast-2.amazonaws.com/uploads/avatar.png

CloudFront CDN

CDN을 톡해 νŒŒμΌμ„ μ„œλΉ™ν•˜λ €λ©΄:
storage: {
  default: 's3',
  drivers: {
    s3: drivers.s3({
      credentials: { /* ... */ },
      region: 'ap-northeast-2',
      bucket: 'my-bucket',
      urlBuilder: {
        generateURL: (key) => 
          `https://d111111abcdef8.cloudfront.net/${key}`,
      }
    }),
  }
}
CLOUDFRONT_DOMAIN=d111111abcdef8.cloudfront.net
μž₯점:
  • λΉ λ₯Έ 전솑 속도 (μ—£μ§€ λ‘œμΌ€μ΄μ…˜)
  • λŒ€μ—­ν­ λΉ„μš© 절감
  • 캐싱

μ»€μŠ€ν…€ 도메인

storage: {
  default: 's3',
  drivers: {
    s3: drivers.s3({
      credentials: { /* ... */ },
      region: 'ap-northeast-2',
      bucket: 'my-bucket',
      urlBuilder: {
        generateURL: (key) => 
          `https://cdn.example.com/${key}`,
      }
    }),
  }
}
μ„€μ • 방법:
  1. CloudFront 배포 생성
  2. Origin: S3 버킷
  3. CNAME: cdn.example.com
  4. DNS: cdn.example.com β†’ CloudFront

동적 URL 생성

URL λΉŒλ”μ—μ„œ ν™˜κ²½λ³€μˆ˜λ‚˜ λ‘œμ§μ„ μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

ν™˜κ²½λ³„ URL

const baseUrl = process.env.NODE_ENV === 'production'
  ? 'https://cdn.example.com'
  : 'http://localhost:3000/uploads';

storage: {
  default: 'fs',
  drivers: {
    fs: drivers.fs({
      location: './uploads',
      urlBuilder: {
        generateURL: (key) => `${baseUrl}/${key}`,
      }
    }),
  }
}
κ²°κ³Ό:
// 개발: http://localhost:3000/uploads/avatar.png
// ν”„λ‘œλ•μ…˜: https://cdn.example.com/avatar.png

경둜 λ³€ν™˜

storage: {
  default: 'fs',
  drivers: {
    fs: drivers.fs({
      location: './uploads',
      urlBuilder: {
        generateURL: (key) => {
          // 곡백을 ν•˜μ΄ν”ˆμœΌλ‘œ λ³€ν™˜
          const safeKey = key.replace(/\s+/g, '-');
          return `/uploads/${safeKey}`;
        }
      }
    }),
  }
}

버전 관리

const version = 'v1';

storage: {
  default: 'fs',
  drivers: {
    fs: drivers.fs({
      location: './uploads',
      urlBuilder: {
        generateURL: (key) => `/uploads/${version}/${key}`,
      }
    }),
  }
}
κ²°κ³Ό:
// URL: /uploads/v1/avatars/user-1.png

Signed URL (S3)

S3λŠ” μž„μ‹œ μ ‘κ·Ό URL을 생성할 수 μžˆμŠ΅λ‹ˆλ‹€.

getSignedUrl()

import { Sonamu } from "sonamu";

@api({ httpMethod: 'GET' })
async getPrivateFileUrl(key: string) {
  const disk = Sonamu.storage.use('s3');
  
  // Signed URL 생성 (1μ‹œκ°„ 유효)
  const signedUrl = await disk.getSignedUrl(key, {
    expiresIn: '1h'
  });
  
  return { url: signedUrl };
}
κ²°κ³Ό:
https://bucket.s3.amazonaws.com/private/doc.pdf?X-Amz-Algorithm=...&X-Amz-Expires=3600
μš©λ„:
  • Private 파일 μž„μ‹œ 곡유
  • λ³΄μ•ˆμ΄ ν•„μš”ν•œ λ‹€μš΄λ‘œλ“œ
  • μ‹œκ°„ μ œν•œ μ•‘μ„ΈμŠ€

UploadedFile의 signedUrl

@upload()
async uploadPrivateFile() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0]; // 첫 번째 파일 μ‚¬μš©

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

  // μ €μž₯
  await file.saveToDisk('s3', 'private/doc.pdf');

  return {
    url: file.url,           // Unsigned URL
    signedUrl: file.signedUrl  // Signed URL
  };
}

μ‹€μ „ 예제

1. CDN + 버전 관리

const cdnDomain = process.env.CDN_DOMAIN;
const version = process.env.ASSET_VERSION || 'v1';

export const config: SonamuConfig = {
  server: {
    storage: {
      default: 's3',
      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-bucket',
          urlBuilder: {
            generateURL: (key) => 
              `https://${cdnDomain}/${version}/${key}`,
          }
        }),
      }
    }
  }
};
CDN_DOMAIN=d111111abcdef8.cloudfront.net
ASSET_VERSION=v2
κ²°κ³Ό:
// key: 'images/logo.png'
// URL: https://d111111abcdef8.cloudfront.net/v2/images/logo.png

2. 닀쀑 CDN

export const config: SonamuConfig = {
  server: {
    storage: {
      default: 's3',
      drivers: {
        // μ΄λ―Έμ§€μš© CDN
        images: drivers.s3({
          credentials: { /* ... */ },
          region: 'ap-northeast-2',
          bucket: 'images-bucket',
          urlBuilder: {
            generateURL: (key) => 
              `https://images.cdn.example.com/${key}`,
          }
        }),
        
        // λΉ„λ””μ˜€μš© CDN
        videos: drivers.s3({
          credentials: { /* ... */ },
          region: 'ap-northeast-2',
          bucket: 'videos-bucket',
          urlBuilder: {
            generateURL: (key) => 
              `https://videos.cdn.example.com/${key}`,
          }
        }),
      }
    }
  }
};
μ‚¬μš©:
// 이미지
await file.saveToDisk('images', 'photo.jpg');
// URL: https://images.cdn.example.com/photo.jpg

// λΉ„λ””μ˜€
await file.saveToDisk('videos', 'video.mp4');
// URL: https://videos.cdn.example.com/video.mp4

3. ν•΄μ‹œ 기반 URL

import { createHash } from "crypto";

storage: {
  default: 's3',
  drivers: {
    s3: drivers.s3({
      credentials: { /* ... */ },
      region: 'ap-northeast-2',
      bucket: 'my-bucket',
      urlBuilder: {
        generateURL: (key) => {
          // keyλ‘œλΆ€ν„° ν•΄μ‹œ 생성 (μΊμ‹œ λ²„μŠ€νŒ…)
          const hash = createHash('md5')
            .update(key)
            .digest('hex')
            .substring(0, 8);
          
          return `https://cdn.example.com/${key}?v=${hash}`;
        }
      }
    }),
  }
}
κ²°κ³Ό:
// key: 'images/logo.png'
// URL: https://cdn.example.com/images/logo.png?v=5d41402a

4. 쑰건뢀 URL

storage: {
  default: 's3',
  drivers: {
    s3: drivers.s3({
      credentials: { /* ... */ },
      region: 'ap-northeast-2',
      bucket: 'my-bucket',
      urlBuilder: {
        generateURL: (key) => {
          // μ΄λ―Έμ§€λŠ” CDN, λ‚˜λ¨Έμ§€λŠ” S3 직접
          if (key.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
            return `https://cdn.example.com/${key}`;
          }
          return `https://my-bucket.s3.amazonaws.com/${key}`;
        }
      }
    }),
  }
}

5. 리사이징 μ„œλΉ„μŠ€

storage: {
  default: 's3',
  drivers: {
    s3: drivers.s3({
      credentials: { /* ... */ },
      region: 'ap-northeast-2',
      bucket: 'my-bucket',
      urlBuilder: {
        generateURL: (key) => {
          // 이미지 리사이징 μ„œλΉ„μŠ€ 톡과
          if (key.match(/\.(jpg|jpeg|png)$/i)) {
            return `https://img.example.com/resize/800x600/${key}`;
          }
          return `https://cdn.example.com/${key}`;
        }
      }
    }),
  }
}

URL νƒ€μž…

Sonamu StorageλŠ” 두 κ°€μ§€ URL νƒ€μž…μ„ μ œκ³΅ν•©λ‹ˆλ‹€.

Unsigned URL

일반 곡개 URL
const url = await disk.getUrl('uploads/avatar.png');
// => https://bucket.s3.amazonaws.com/uploads/avatar.png
νŠΉμ§•:
  • 영ꡬ적
  • 만료 μ—†μŒ
  • Public 파일용

Signed URL (S3)

μ„œλͺ…λœ μž„μ‹œ URL
const signedUrl = await disk.getSignedUrl('private/doc.pdf', {
  expiresIn: '1h'  // 1μ‹œκ°„ ν›„ 만료
});
// => https://bucket.s3.amazonaws.com/private/doc.pdf?X-Amz-Algorithm=...
νŠΉμ§•:
  • μž„μ‹œμ  (만료 μ‹œκ°„ μ„€μ •)
  • μ„œλͺ…μœΌλ‘œ 보호됨
  • Private 파일용
만료 μ‹œκ°„ μ˜΅μ…˜:
{ expiresIn: '1h' }    // 1μ‹œκ°„
{ expiresIn: '30m' }   // 30λΆ„
{ expiresIn: '1d' }    // 1일
{ expiresIn: '7d' }    // 7일

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

URL λΉŒλ” μ‚¬μš© μ‹œ μ£Όμ˜μ‚¬ν•­:
  1. fsλŠ” urlBuilder ν•„μˆ˜: fs λ“œλΌμ΄λ²„λŠ” λ°˜λ“œμ‹œ urlBuilder μ„€μ •
    // ❌ urlBuilder μ—†μŒ
    fs: drivers.fs({
      location: './uploads',
    })
    
    // βœ… urlBuilder μ„€μ •
    fs: drivers.fs({
      location: './uploads',
      urlBuilder: {
        generateURL: (key) => `/uploads/${key}`,
      }
    })
    
  2. 정적 파일 μ„œλΉ™: fs μ‚¬μš© μ‹œ static ν”ŒλŸ¬κ·ΈμΈ ν•„μš”
    plugins: {
      static: {
        root: path.join(process.cwd(), 'uploads'),
        prefix: '/uploads/',
      }
    }
    
  3. HTTPS μ‚¬μš©: ν”„λ‘œλ•μ…˜μ—μ„œλŠ” HTTPS URL μ‚¬μš©
    // ❌ HTTP
    generateURL: (key) => `http://cdn.example.com/${key}`
    
    // βœ… HTTPS
    generateURL: (key) => `https://cdn.example.com/${key}`
    
  4. 경둜 인코딩: 특수 문자 처리
    // βœ… μ•ˆμ „ν•œ 처리
    generateURL: (key) => {
      const encoded = encodeURIComponent(key);
      return `/uploads/${encoded}`;
    }
    
  5. μΊμ‹œ λ¬΄νš¨ν™”: URL λ³€κ²½ μ‹œ CDN μΊμ‹œ κ³ λ €
    // 버전 νŒŒλΌλ―Έν„° μΆ”κ°€λ‘œ μΊμ‹œ λ¬΄νš¨ν™”
    generateURL: (key) => 
      `/uploads/${key}?v=${Date.now()}`
    

CloudFront μ„€μ •

CloudFrontλ₯Ό URL λΉŒλ”μ™€ ν•¨κ»˜ μ‚¬μš©ν•˜λŠ” λ°©λ²•μž…λ‹ˆλ‹€.

1. CloudFront 배포 생성

  1. AWS Console β†’ CloudFront
  2. β€œCreate Distribution” 클릭
  3. Origin:
    • Origin Domain: my-bucket.s3.ap-northeast-2.amazonaws.com
    • Origin Path: (λΉ„μ›Œλ‘ )
  4. Default Cache Behavior:
    • Viewer Protocol Policy: Redirect HTTP to HTTPS
    • Allowed HTTP Methods: GET, HEAD, OPTIONS
  5. Create Distribution

2. Domain 확인

배포 생성 ν›„ Domain Name 확인:
d111111abcdef8.cloudfront.net

3. URL λΉŒλ” μ„€μ •

storage: {
  default: 's3',
  drivers: {
    s3: drivers.s3({
      credentials: { /* ... */ },
      region: 'ap-northeast-2',
      bucket: 'my-bucket',
      urlBuilder: {
        generateURL: (key) => 
          `https://d111111abcdef8.cloudfront.net/${key}`,
      }
    }),
  }
}

4. μ»€μŠ€ν…€ 도메인 (선택)

  1. Route 53μ—μ„œ CNAME λ ˆμ½”λ“œ 생성:
    cdn.example.com β†’ d111111abcdef8.cloudfront.net
    
  2. CloudFront 배포 μ„€μ •:
    • Alternate Domain Names (CNAMEs): cdn.example.com
    • SSL Certificate: μΈμ¦μ„œ μΆ”κ°€
  3. URL λΉŒλ” μˆ˜μ •:
    urlBuilder: {
      generateURL: (key) => 
        `https://cdn.example.com/${key}`,
    }
    

λ‹€μŒ 단계

Storage Manager

μ—¬λŸ¬ λ””μŠ€ν¬ κ΄€λ¦¬ν•˜κΈ°

μŠ€ν† λ¦¬μ§€ λ“œλΌμ΄λ²„

fs, S3 λ“œλΌμ΄λ²„ μ„€μ •

파일 μ €μž₯ 및 쑰회

파일 μ—…λ‘œλ“œ 및 λ‹€μš΄λ‘œλ“œ