๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
@upload ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋Š” ํŒŒ์ผ ์—…๋กœ๋“œ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” API๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. Multipart form-data ์š”์ฒญ์„ ์ž๋™์œผ๋กœ ํŒŒ์‹ฑํ•˜๊ณ , ํŒŒ์ผ ๊ฐ์ฒด๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. @api ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ ์—†์ด๋„ ๋…๋ฆฝ์ ์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์ž๋™์œผ๋กœ POST ๋ฉ”์„œ๋“œ์™€ multipart ํด๋ผ์ด์–ธํŠธ(axios-multipart, tanstack-mutation-multipart)๋ฅผ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.

์—…๋กœ๋“œ ๋ชจ๋“œ

Sonamu๋Š” ๋‘ ๊ฐ€์ง€ ํŒŒ์ผ ์—…๋กœ๋“œ ๋ชจ๋“œ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค:
๋ชจ๋“œ์˜ต์…˜Context ์†์„ฑํŒŒ์ผ ํƒ€์ž…ํŠน์ง•
Buffer (๊ธฐ๋ณธ)consume: "buffer" ๋˜๋Š” ์ƒ๋žตbufferedFilesBufferedFile[]๋ฉ”๋ชจ๋ฆฌ์— ๋กœ๋“œ, MD5 ๊ณ„์‚ฐ/์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ ๋“ฑ ์œ ์—ฐํ•œ ์ž‘์—… ๊ฐ€๋Šฅ
Streamconsume: "stream"uploadedFilesUploadedFile[]์ฆ‰์‹œ ์ €์žฅ์†Œ๋กœ ์ŠคํŠธ๋ฆฌ๋ฐ, ๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ์— ์ ํ•ฉ

Buffer ๋ชจ๋“œ (๊ธฐ๋ณธ)

Buffer ๋ชจ๋“œ๋Š” ํŒŒ์ผ์„ ๋ฉ”๋ชจ๋ฆฌ์— ๋กœ๋“œํ•œ ํ›„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. MD5 ํ•ด์‹œ ๊ณ„์‚ฐ, ์ด๋ฏธ์ง€ ๋ฆฌ์‚ฌ์ด์ง• ๋“ฑ ํŒŒ์ผ ๋‚ด์šฉ์„ ์ง์ ‘ ๋‹ค๋ค„์•ผ ํ•  ๋•Œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
import { BaseModelClass, upload, Sonamu } from "sonamu";

class FileModelClass extends BaseModelClass {
  @upload()  // ๊ธฐ๋ณธ๊ฐ’: consume: "buffer"
  async uploadAvatar() {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];

    if (!file) {
      throw new Error("No file uploaded");
    }

    // MD5 ํ•ด์‹œ๋กœ ์ค‘๋ณต ๋ฐฉ์ง€
    const md5 = await file.md5();
    const key = `avatars/${md5}.${file.extname}`;
    const url = await file.saveToDisk("fs", key);

    return { url, filename: file.filename, size: file.size };
  }
}

BufferedFile ๊ฐ์ฒด

class BufferedFile {
  // ์›๋ณธ ํŒŒ์ผ ์ด๋ฆ„
  get filename(): string;

  // MIME ํƒ€์ž…
  get mimetype(): string;

  // ํŒŒ์ผ ํฌ๊ธฐ (bytes)
  get size(): number;

  // ํ™•์žฅ์ž (์  ์ œ์™ธ, ์˜ˆ: "jpg", "png")
  get extname(): string | false;

  // ํŒŒ์ผ Buffer (๋ฉ”๋ชจ๋ฆฌ์— ๋กœ๋“œ๋œ ๋ฐ์ดํ„ฐ)
  get buffer(): Buffer;

  // saveToDisk ํ›„ ์ €์žฅ๋œ URL (Unsigned)
  get url(): string;

  // saveToDisk ํ›„ ์ €์žฅ๋œ Signed URL
  get signedUrl(): string;

  // ์›๋ณธ Fastify MultipartFile ์ ‘๊ทผ
  get raw(): MultipartFile;

  // MD5 ํ•ด์‹œ ๊ณ„์‚ฐ
  async md5(): Promise<string>;

  // ํŒŒ์ผ์„ ๋””์Šคํฌ์— ์ €์žฅ (URL ๋ฐ˜ํ™˜)
  async saveToDisk(diskName: DriverKey, key: string): Promise<string>;
}
saveToDisk์˜ ํŒŒ๋ผ๋ฏธํ„ฐ ์ˆœ์„œ๋Š” (diskName, key) ์ž…๋‹ˆ๋‹ค.

Stream ๋ชจ๋“œ

Stream ๋ชจ๋“œ๋Š” ํŒŒ์ผ์„ ๋ฉ”๋ชจ๋ฆฌ์— ๋กœ๋“œํ•˜์ง€ ์•Š๊ณ  ์ฆ‰์‹œ ์ €์žฅ์†Œ๋กœ ์ŠคํŠธ๋ฆฌ๋ฐํ•ฉ๋‹ˆ๋‹ค. ๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ ์—…๋กœ๋“œ์— ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค.
@upload({
  consume: "stream",
  destination: "s3",  // ์ €์žฅํ•  ๋””์Šคํฌ ์ด๋ฆ„
  keyGenerator: (file) => `uploads/${Date.now()}-${file.filename}`,  // ํ‚ค ์ƒ์„ฑ ํ•จ์ˆ˜
  limits: { files: 5 }
})
async uploadLargeFiles() {
  const { uploadedFiles } = Sonamu.getContext();

  if (!uploadedFiles || uploadedFiles.length === 0) {
    throw new Error("No files uploaded");
  }

  // ํŒŒ์ผ์€ ์ด๋ฏธ ์ €์žฅ์†Œ์— ์—…๋กœ๋“œ๋œ ์ƒํƒœ
  return {
    files: uploadedFiles.map((file) => ({
      filename: file.filename,
      url: file.url,
      key: file.key,
      size: file.size,
    })),
  };
}

UploadedFile ๊ฐ์ฒด

Stream ๋ชจ๋“œ์—์„œ๋Š” ํŒŒ์ผ์ด ์ด๋ฏธ ์ €์žฅ์†Œ์— ์—…๋กœ๋“œ๋œ ์ƒํƒœ๋กœ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋งŒ ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
class UploadedFile {
  // ์›๋ณธ ํŒŒ์ผ ์ด๋ฆ„
  get filename(): string;

  // MIME ํƒ€์ž…
  get mimetype(): string;

  // ํŒŒ์ผ ํฌ๊ธฐ (bytes)
  get size(): number;

  // ํ™•์žฅ์ž (์  ์ œ์™ธ, ์˜ˆ: "jpg", "png")
  get extname(): string | false;

  // ์ €์žฅ๋œ URL (Unsigned)
  get url(): string;

  // ์ €์žฅ๋œ Signed URL
  get signedUrl(): string;

  // ์ €์žฅ์†Œ ๋‚ด ํ‚ค
  get key(): string;

  // ์ €์žฅ๋œ ๋””์Šคํฌ ์ด๋ฆ„
  get diskName(): DriverKey;

  // ์ €์žฅ์†Œ์—์„œ ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ (๋‚˜์ค‘์— ์ฒ˜๋ฆฌํ•ด์•ผ ํ•  ๋•Œ)
  async download(): Promise<Buffer>;
}

์˜ต์…˜

guards

์—…๋กœ๋“œ API์— ์ ์šฉํ•  ๊ฐ€๋“œ๋ฅผ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค. ์ธ์ฆ์ด ํ•„์š”ํ•œ ์—…๋กœ๋“œ API์—์„œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
@upload({ guards: ["user"] })
async uploadAvatar() {
  const { user, bufferedFiles } = Sonamu.getContext();
  // user ๊ฐ€๋“œ๊ฐ€ ์ ์šฉ๋˜์–ด ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅ
  // ...
}

description

API ์„ค๋ช…์„ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค. ์ž๋™ ์ƒ์„ฑ๋˜๋Š” API ๋ฌธ์„œ์— ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.
@upload({ description: "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ" })
async uploadAvatar() {
  // ...
}

limits

ํŒŒ์ผ ์—…๋กœ๋“œ ์ œํ•œ์„ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. Fastify multipart์˜ limits ์˜ต์…˜์„ ๊ทธ๋Œ€๋กœ ๋ฐ›์Šต๋‹ˆ๋‹ค.
type UploadDecoratorOptions = {
  // ๊ณตํ†ต ์˜ต์…˜
  guards?: GuardKey[];    // ์ ์šฉํ•  ๊ฐ€๋“œ (์˜ˆ: ["user", "admin"])
  description?: string;   // API ์„ค๋ช…
  limits?: {
    fileSize?: number;    // ์ตœ๋Œ€ ํŒŒ์ผ ํฌ๊ธฐ (bytes)
    files?: number;       // ์ตœ๋Œ€ ํŒŒ์ผ ๊ฐœ์ˆ˜
    fields?: number;      // ์ตœ๋Œ€ ํ•„๋“œ ๊ฐœ์ˆ˜
    fieldSize?: number;   // ์ตœ๋Œ€ ํ•„๋“œ ํฌ๊ธฐ
    parts?: number;       // ์ตœ๋Œ€ ํŒŒํŠธ ๊ฐœ์ˆ˜
  };
} & (
  // Buffer ๋ชจ๋“œ
  | { consume?: "buffer" }
  // Stream ๋ชจ๋“œ
  | {
      consume: "stream";
      destination: DriverKey;
      keyGenerator?: (file: { filename: string; mimetype: string }) => string;
    }
);
@upload({
  limits: {
    fileSize: 10 * 1024 * 1024, // 10MB
    files: 5,                    // ์ตœ๋Œ€ 5๊ฐœ ํŒŒ์ผ
  }
})
async uploadWithLimits() {
  const { bufferedFiles } = Sonamu.getContext();
  // ...
}
@upload()
async uploadAvatar() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const md5 = await file.md5();
  return {
    filename: file.filename,
    size: file.size,
    md5
  };
}

ํŒŒ์ผ ์ •๋ณด ํ™•์ธ

@upload()
async uploadFile() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  console.log("Filename:", file.filename);
  console.log("MIME type:", file.mimetype);
  console.log("Size:", file.size);
  console.log("Extension:", file.extname);

  return { uploaded: true };
}

ํŒŒ์ผ ์ฒ˜๋ฆฌ (Buffer ๋ชจ๋“œ)

Buffer ์ ‘๊ทผ

@upload()
async processImage() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  // Buffer ์ง์ ‘ ์ ‘๊ทผ
  const buffer = file.buffer;

  // ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ
  const processed = await sharp(buffer)
    .resize(300, 300)
    .toBuffer();

  return { size: processed.length };
}

ํŒŒ์ผ ์ €์žฅ

@upload()
async uploadDocument() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  // ๋””์Šคํฌ์— ์ €์žฅ (diskName, key ์ˆœ์„œ)
  const md5 = await file.md5();
  const key = `uploads/${md5}.${file.extname}`;
  const url = await file.saveToDisk("fs", key);

  // ์ €์žฅ๋œ URL ์ ‘๊ทผ
  console.log("URL:", file.url);
  console.log("Signed URL:", file.signedUrl);

  return { url, filename: file.filename, size: file.size };
}

MD5 ํ•ด์‹œ ๊ณ„์‚ฐ

@upload()
async uploadWithHash() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  // MD5 ํ•ด์‹œ ๊ณ„์‚ฐ (์ค‘๋ณต ํŒŒ์ผ ๋ฐฉ์ง€์— ์œ ์šฉ)
  const hash = await file.md5();

  // ํ•ด์‹œ๋ฅผ ํŒŒ์ผ๋ช…์œผ๋กœ ์‚ฌ์šฉ
  const key = `uploads/${hash}${file.extname ? `.${file.extname}` : ''}`;
  const url = await file.saveToDisk("fs", key);

  return { url, hash };
}

์Šคํ† ๋ฆฌ์ง€ ๋“œ๋ผ์ด๋ฒ„ ์‚ฌ์šฉ

Sonamu๋Š” ์—ฌ๋Ÿฌ ์Šคํ† ๋ฆฌ์ง€ ๋“œ๋ผ์ด๋ฒ„๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.
@upload()
async uploadToS3() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  // S3 ๋””์Šคํฌ์— ์ €์žฅ (sonamu.config.ts์—์„œ ์„ค์ •)
  const md5 = await file.md5();
  const key = `avatars/${md5}.${file.extname}`;
  const url = await file.saveToDisk("s3", key);

  return { url };
}
์Šคํ† ๋ฆฌ์ง€ ๋“œ๋ผ์ด๋ฒ„๋Š” sonamu.config.ts์—์„œ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. ์ฒซ ๋ฒˆ์งธ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋””์Šคํฌ ์ด๋ฆ„์„ ์ „๋‹ฌํ•ฉ๋‹ˆ๋‹ค.

๋‹ค๋ฅธ ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ์™€ ํ•จ๊ป˜ ์‚ฌ์šฉ

@transactional๊ณผ ํ•จ๊ป˜

@upload()
@transactional()
async uploadAndSave() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const wdb = this.getDB("w");

  // ํŒŒ์ผ ์ €์žฅ + DB ์—…๋ฐ์ดํŠธ๋ฅผ ํŠธ๋žœ์žญ์…˜์œผ๋กœ
  const md5 = await file.md5();
  const key = `documents/${md5}.${file.extname}`;
  const url = await file.saveToDisk("fs", key);

  await wdb.table("documents").insert({
    filename: file.filename,
    url,
    size: file.size,
    created_at: new Date()
  });

  return { url };
}

ํด๋ผ์ด์–ธํŠธ ์‚ฌ์šฉ (Web)

Sonamu๋Š” ์ž๋™์œผ๋กœ ํŒŒ์ผ ์—…๋กœ๋“œ ํด๋ผ์ด์–ธํŠธ ์ฝ”๋“œ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

Axios (๋‹จ์ผ ํŒŒ์ผ)

import { FileService } from "@/services/FileService";

const formData = new FormData();
formData.append("file", file);

const result = await FileService.uploadAvatar(formData);

Axios (์—ฌ๋Ÿฌ ํŒŒ์ผ)

const formData = new FormData();
files.forEach(file => {
  formData.append("files", file);
});

const result = await FileService.uploadDocuments(formData);

React ์˜ˆ์‹œ

import { useState } from "react";
import { FileService } from "@/services/FileService";

function FileUploader() {
  const [uploading, setUploading] = useState(false);

  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    setUploading(true);
    try {
      const formData = new FormData();
      formData.append("file", file);

      const result = await FileService.uploadAvatar(formData);
      console.log("Uploaded:", result.url);
    } catch (error) {
      console.error("Upload failed:", error);
    } finally {
      setUploading(false);
    }
  };

  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={uploading}
      />
      {uploading && <p>Uploading...</p>}
    </div>
  );
}

TanStack Query ์˜ˆ์‹œ

import { useMutation } from "@tanstack/react-query";
import { FileService } from "@/services/FileService";

function useUploadFile() {
  return useMutation({
    mutationFn: (file: File) => {
      const formData = new FormData();
      formData.append("file", file);
      return FileService.uploadAvatar(formData);
    },
    onSuccess: (data) => {
      console.log("Upload success:", data);
    },
    onError: (error) => {
      console.error("Upload failed:", error);
    }
  });
}

function FileUploader() {
  const upload = useUploadFile();

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      upload.mutate(file);
    }
  };

  return (
    <div>
      <input
        type="file"
        onChange={handleFileChange}
        disabled={upload.isPending}
      />
      {upload.isPending && <p>Uploading...</p>}
      {upload.isSuccess && <p>Success: {upload.data.url}</p>}
      {upload.isError && <p>Error: {upload.error.message}</p>}
    </div>
  );
}

ํŒŒ์ผ ๊ฒ€์ฆ

MIME ํƒ€์ž… ๊ฒ€์ฆ

@upload()
async uploadImage() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const allowedTypes = ["image/jpeg", "image/png", "image/gif"];
  if (!allowedTypes.includes(file.mimetype)) {
    throw new Error(`Invalid file type: ${file.mimetype}`);
  }

  return await this.processImage(file);
}

ํŒŒ์ผ ํฌ๊ธฐ ๊ฒ€์ฆ

@upload()
async uploadDocument() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const maxSize = 10 * 1024 * 1024; // 10MB
  if (file.size > maxSize) {
    throw new Error("File too large");
  }

  return await this.saveDocument(file);
}

ํŒŒ์ผ ํ™•์žฅ์ž ๊ฒ€์ฆ

@upload()
async uploadFile() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const allowedExtensions = ["jpg", "png", "pdf"];
  if (!file.extname || !allowedExtensions.includes(file.extname)) {
    throw new Error(`Invalid file extension: ${file.extname}`);
  }

  return await this.saveFile(file);
}

์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ

Sharp ์‚ฌ์šฉ

import sharp from "sharp";

@upload()
async uploadAndResizeImage() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0];

  if (!file) {
    throw new Error("No file");
  }

  const buffer = file.buffer;

  // ์ด๋ฏธ์ง€ ๋ฆฌ์‚ฌ์ด์ง•
  const resized = await sharp(buffer)
    .resize(800, 600, { fit: "inside" })
    .jpeg({ quality: 80 })
    .toBuffer();

  // ์ธ๋„ค์ผ ์ƒ์„ฑ
  const thumbnail = await sharp(buffer)
    .resize(200, 200, { fit: "cover" })
    .jpeg({ quality: 70 })
    .toBuffer();

  // S3์— ์—…๋กœ๋“œ (Buffer๋ฅผ ์ง์ ‘ ์ €์žฅ)
  const imageKey = `images/${Date.now()}.jpg`;
  await Sonamu.storage.use("s3").put(imageKey, resized);
  const imageUrl = await Sonamu.storage.use("s3").getUrl(imageKey);

  const thumbKey = `thumbnails/${Date.now()}.jpg`;
  await Sonamu.storage.use("s3").put(thumbKey, thumbnail);
  const thumbUrl = await Sonamu.storage.use("s3").getUrl(thumbKey);

  return { imageUrl, thumbUrl };
}

์ œ์•ฝ์‚ฌํ•ญ

1. @api ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ ์—†์ด ๋…๋ฆฝ ์‚ฌ์šฉ

@upload๋Š” @api ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ ์—†์ด ๋…๋ฆฝ์ ์œผ๋กœ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค:
// ์˜ฌ๋ฐ”๋ฅธ ์‚ฌ์šฉ๋ฒ•
@upload()
async uploadFile() {}

// ๋ถˆํ•„์š” - @upload๊ฐ€ ์ž๋™์œผ๋กœ API ์„ค์ •
@api({ httpMethod: "POST" })
@upload()
async uploadFile() {}

2. httpMethod๋Š” POST

@upload๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์ž๋™์œผ๋กœ httpMethod: "POST"๊ฐ€ ์„ค์ •๋ฉ๋‹ˆ๋‹ค.

3. clients ์ž๋™ ์„ค์ •

@upload๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด clients ์˜ต์…˜์ด ์ž๋™์œผ๋กœ ["axios-multipart", "tanstack-mutation-multipart"]๋กœ ์„ค์ •๋ฉ๋‹ˆ๋‹ค:
@upload()
async uploadFile() {
  // clients: ["axios-multipart", "tanstack-mutation-multipart"]
}

์˜ˆ์‹œ ๋ชจ์Œ

class UserModelClass extends BaseModelClass {
  @upload()
  @transactional()
  async uploadAvatar() {
    const { user, bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0];

    if (!file) {
      throw new Error("No file uploaded");
    }

    // ์ด๋ฏธ์ง€ ํƒ€์ž… ๊ฒ€์ฆ
    const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
    if (!allowedTypes.includes(file.mimetype)) {
      throw new Error("Invalid image type");
    }

    // ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ
    const buffer = file.buffer;
    const processed = await sharp(buffer)
      .resize(300, 300, { fit: "cover" })
      .jpeg({ quality: 85 })
      .toBuffer();

    // S3 ์—…๋กœ๋“œ
    const key = `avatars/${user.id}/${Date.now()}.jpg`;
    await Sonamu.storage.use("s3").put(key, processed);
    const url = await Sonamu.storage.use("s3").getUrl(key);

    // DB ์—…๋ฐ์ดํŠธ
    const wdb = this.getDB("w");
    await wdb.table("users")
      .where("id", user.id)
      .update({ avatar_url: url });

    return { url };
  }
}

์ฐธ๊ณ  ์‚ฌํ•ญ

@api ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ์™€์˜ ๊ด€๊ณ„

@upload ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋Š” ๋‚ด๋ถ€์ ์œผ๋กœ ์ž๋™์œผ๋กœ API ์—”๋“œํฌ์ธํŠธ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ @api ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋ฅผ ๋ณ„๋„๋กœ ์‚ฌ์šฉํ•  ํ•„์š”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์ž๋™ ์„ค์ •๋˜๋Š” ๊ฐ’:
  • httpMethod: "POST" (๊ณ ์ •)
  • clients: ["axios-multipart", "tanstack-mutation-multipart"] (multipart ์ „์šฉ ํด๋ผ์ด์–ธํŠธ)
  • guards: @upload ์˜ต์…˜์˜ guards ๊ฐ’์ด API์— ์ „๋‹ฌ๋จ
  • description: @upload ์˜ต์…˜์˜ description ๊ฐ’์ด API์— ์ „๋‹ฌ๋จ
// @upload๋งŒ ์‚ฌ์šฉ (๊ถŒ์žฅ)
@upload()
async uploadFile() {
  // ...
}

// ๋ถˆํ•„์š” - @api๋ฅผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•  ํ•„์š” ์—†์Œ
@api({ httpMethod: "POST" })  // ๋ถˆํ•„์š”
@upload()
async uploadFile() {
  // ...
}
@upload๋Š” ํŒŒ์ผ ์—…๋กœ๋“œ์— ์ตœ์ ํ™”๋œ ์„ค์ •์„ ์ž๋™์œผ๋กœ ์ ์šฉํ•˜๋ฏ€๋กœ, @api ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋ฅผ ์ถ”๊ฐ€๋กœ ์‚ฌ์šฉํ•  ํ•„์š”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

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