๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
Context๋Š” Sonamu API ๋ฉ”์„œ๋“œ ์‹คํ–‰ ์ค‘์— HTTP ์š”์ฒญ๊ณผ ๊ด€๋ จ๋œ ์ •๋ณด์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋Š” ๊ฐ์ฒด์ž…๋‹ˆ๋‹ค. API ๋ฉ”์„œ๋“œ์˜ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ฃผ์ž…๋ฐ›์•„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, AsyncLocalStorage๋ฅผ ํ†ตํ•ด ๊ด€๋ฆฌ๋ฉ๋‹ˆ๋‹ค.

ํƒ€์ž… ์ •์˜

type Context = {
  request: FastifyRequest;
  reply: FastifyReply;
  headers: IncomingHttpHeaders;
  createSSE: <T extends ZodObject>(events: T) => ReturnType<typeof createSSEFactory<T>>;
  naiteStore: NaiteStore;
  locale: string;
  /** buffer ๋ชจ๋“œ์—์„œ ์—…๋กœ๋“œ๋œ ํŒŒ์ผ */
  bufferedFiles?: BufferedFile[];
  /** stream ๋ชจ๋“œ์—์„œ ์—…๋กœ๋“œ๋œ ํŒŒ์ผ */
  uploadedFiles?: UploadedFile[];
} & AuthContext & ContextExtend;
Context ํƒ€์ž…์€ **๊ต์ฐจ ํƒ€์ž…(Intersection Type, &)**์œผ๋กœ ๊ตฌ์„ฑ๋ฉ๋‹ˆ๋‹ค:
  • ๊ธฐ๋ณธ Context ๊ฐ์ฒด (request, reply ๋“ฑ)
  • AuthContext: ์ธ์ฆ ๊ด€๋ จ ์†์„ฑ (user, passport)
  • ContextExtend: ํ”„๋กœ์ ํŠธ๋ณ„ ํ™•์žฅ ์†์„ฑ
์ด๋Ÿฌํ•œ ๊ตฌ์กฐ ๋•๋ถ„์— Context๋Š” ํƒ€์ž… ์•ˆ์ „์„ฑ์„ ์œ ์ง€ํ•˜๋ฉด์„œ ํ™•์žฅ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

Context ์†์„ฑ

request

request: FastifyRequest
ํ˜„์žฌ HTTP ์š”์ฒญ์„ ๋‚˜ํƒ€๋‚ด๋Š” Fastify Request ๊ฐ์ฒด์ž…๋‹ˆ๋‹ค. ์š”์ฒญ URL, ๋ฉ”์„œ๋“œ, ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ ๋“ฑ์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

reply

reply: FastifyReply
ํ˜„์žฌ HTTP ์‘๋‹ต์„ ๋‚˜ํƒ€๋‚ด๋Š” Fastify Reply ๊ฐ์ฒด์ž…๋‹ˆ๋‹ค. ์‘๋‹ต ํ—ค๋” ์„ค์ •, ์ƒํƒœ ์ฝ”๋“œ ๋ณ€๊ฒฝ ๋“ฑ์— ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

headers

headers: IncomingHttpHeaders
HTTP ์š”์ฒญ ํ—ค๋”์ž…๋‹ˆ๋‹ค. request.headers์™€ ๋™์ผํ•œ ๊ฐ’์œผ๋กœ, ํŽธ์˜๋ฅผ ์œ„ํ•ด ์ œ๊ณต๋ฉ๋‹ˆ๋‹ค.

createSSE

createSSE: <T extends ZodObject>(events: T) => ReturnType<typeof createSSEFactory<T>>
Server-Sent Events(SSE) ์ŠคํŠธ๋ฆผ์„ ์ƒ์„ฑํ•˜๋Š” ํ•จ์ˆ˜์ž…๋‹ˆ๋‹ค. Zod ์Šคํ‚ค๋งˆ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํƒ€์ž… ์•ˆ์ „ํ•œ ์ด๋ฒคํŠธ ์ŠคํŠธ๋ฆผ์„ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์‚ฌ์šฉ ์˜ˆ์‹œ:
import { z } from "zod";

class MyModelClass extends BaseModel {
  @api()
  @stream({
    type: "sse",
    events: z.object({
      progress: z.object({ percent: z.number() }),
      complete: z.object({ result: z.string() })
    })
  })
  async processData(ctx: Context) {
    const sse = ctx.createSSE({
      progress: z.object({ percent: z.number() }),
      complete: z.object({ result: z.string() })
    });

    // ์ง„ํ–‰๋ฅ  ์ „์†ก
    await sse.send("progress", { percent: 50 });
    
    // ์™„๋ฃŒ ์ด๋ฒคํŠธ ์ „์†ก
    await sse.send("complete", { result: "done" });
    
    return sse.getResponse();
  }
}

naiteStore

naiteStore: NaiteStore
Naite ํ…Œ์ŠคํŒ… ํ”„๋ ˆ์ž„์›Œํฌ์—์„œ ์‚ฌ์šฉํ•˜๋Š” ์ €์žฅ์†Œ์ž…๋‹ˆ๋‹ค. ํ…Œ์ŠคํŠธ ์ค‘ ๋ชจํ‚น๋œ ๋ฐ์ดํ„ฐ๋‚˜ ์Šค๋ƒ…์ƒท ์ •๋ณด๋ฅผ ์ €์žฅํ•˜๋Š” ์šฉ๋„๋กœ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.

locale

locale: string
ํ˜„์žฌ ์š”์ฒญ์˜ locale(์–ธ์–ด ์„ค์ •)์ž…๋‹ˆ๋‹ค. ํ•ญ์ƒ ๊ฐ’์ด ์กด์žฌํ•ฉ๋‹ˆ๋‹ค. Accept-Language ํ—ค๋”๋ฅผ ํŒŒ์‹ฑํ•˜์—ฌ ์ง€์›ํ•˜๋Š” locale ์ค‘ ํ•˜๋‚˜๋ฅผ ์ž๋™์œผ๋กœ ์„ ํƒํ•˜๋ฉฐ, ์ผ์น˜ํ•˜๋Š” locale์ด ์—†์œผ๋ฉด defaultLocale์ด ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. ์„ค์ • ์˜ˆ์‹œ:
// sonamu.config.ts
export default {
  i18n: {
    defaultLocale: "ko",
    supportedLocales: ["ko", "en", "ja"]
  }
} satisfies SonamuConfig;
์‚ฌ์šฉ ์˜ˆ์‹œ:
class PostModelClass extends BaseModel {
  @api({ httpMethod: "GET" })
  async findMany(ctx: Context) {
    const locale = ctx.locale; // "ko", "en", "ja" ์ค‘ ํ•˜๋‚˜

    // locale์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ์‘๋‹ต
    const posts = await this.getPuri("r")
      .table("posts")
      .where("locale", locale)
      .select("*");

    return posts;
  }
}

bufferedFiles

bufferedFiles?: BufferedFile[]
Buffer ๋ชจ๋“œ๋กœ ์—…๋กœ๋“œ๋œ ํŒŒ์ผ ๋ชฉ๋ก์ž…๋‹ˆ๋‹ค. @upload ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๊ฐ€ ์ ์šฉ๋œ ๋ฉ”์„œ๋“œ์—์„œ, ๊ธฐ๋ณธ ๋ชจ๋“œ(buffer) ๋˜๋Š” mode: "buffer"๋กœ ์„ค์ •๋œ ๊ฒฝ์šฐ์— ์กด์žฌํ•ฉ๋‹ˆ๋‹ค. ๊ฐ ํŒŒ์ผ์€ ๋ฉ”๋ชจ๋ฆฌ์— ๋กœ๋“œ๋œ ์ƒํƒœ๋กœ, MD5 ๊ณ„์‚ฐ์ด๋‚˜ ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ ๋“ฑ ์œ ์—ฐํ•œ ์ž‘์—…์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. BufferedFile ์ฃผ์š” ์†์„ฑ ๋ฐ ๋ฉ”์„œ๋“œ:
interface BufferedFile {
  // ํŒŒ์ผ ์ •๋ณด
  filename: string;       // ํŒŒ์ผ๋ช…
  mimetype: string;       // MIME ํƒ€์ž… (์˜ˆ: "image/png", "application/pdf")
  size: number;           // ํŒŒ์ผ ํฌ๊ธฐ (bytes)
  extname: string;        // ํ™•์žฅ์ž (์˜ˆ: "png", "pdf")

  // ํŒŒ์ผ ์ฒ˜๋ฆฌ ๋ฉ”์„œ๋“œ
  buffer: Buffer;                                          // ํŒŒ์ผ Buffer (getter)
  md5(): Promise<string>;                                  // MD5 ํ•ด์‹œ ์ƒ์„ฑ
  saveToDisk(diskName: string, key: string): Promise<string>;  // ํŒŒ์ผ ์ €์žฅ ํ›„ URL ๋ฐ˜ํ™˜
}
๊ธฐ๋ณธ ์‚ฌ์šฉ ์˜ˆ์‹œ:
class FileModelClass extends BaseModel {
  @upload({ limits: { files: 10 } })
  async upload(): Promise<{ files: SonamuFile[] }> {
    const { bufferedFiles } = Sonamu.getContext();

    if (!bufferedFiles || bufferedFiles.length === 0) {
      throw new BadRequestException("ํŒŒ์ผ์ด ์—…๋กœ๋“œ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค");
    }

    const processedFiles = await Promise.all(
      bufferedFiles.map(async (file) => {
        const md5 = await file.md5();
        const key = `${md5}.${file.extname}`;
        return {
          name: file.filename,
          url: await file.saveToDisk("fs", key),
          mime_type: file.mimetype,
          size: file.size,
        };
      })
    );

    return { files: processedFiles };
  }
}
์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ ์˜ˆ์‹œ:
import sharp from "sharp";

class ImageModelClass extends BaseModel {
  @upload({ limits: { files: 1 } })
  async uploadThumbnail(): Promise<{ url: string }> {
    const { bufferedFiles } = Sonamu.getContext();

    if (!bufferedFiles || bufferedFiles.length === 0) {
      throw new BadRequestException("์ด๋ฏธ์ง€๊ฐ€ ์—…๋กœ๋“œ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค");
    }

    const file = bufferedFiles[0];

    // Buffer๋กœ ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ (getter ์‚ฌ์šฉ)
    const thumbnail = await sharp(file.buffer)
      .resize(200, 200)
      .jpeg({ quality: 80 })
      .toBuffer();

    // ์ฒ˜๋ฆฌ๋œ ์ด๋ฏธ์ง€ ์ €์žฅ
    const disk = Sonamu.storage.use("fs");
    const key = `thumbnails/${await file.md5()}.jpg`;
    await disk.put(key, thumbnail);

    return { url: await disk.getUrl(key) };
  }
}

uploadedFiles

uploadedFiles?: UploadedFile[]
Stream ๋ชจ๋“œ๋กœ ์—…๋กœ๋“œ๋œ ํŒŒ์ผ ๋ชฉ๋ก์ž…๋‹ˆ๋‹ค. @upload({ mode: "stream" }) ์„ค์ • ์‹œ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค. ํŒŒ์ผ์ด ์ด๋ฏธ ์ €์žฅ์†Œ์— ์ŠคํŠธ๋ฆฌ๋ฐ ์™„๋ฃŒ๋œ ์ƒํƒœ๋กœ, URL/key ๋“ฑ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋งŒ ์ ‘๊ทผ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ ์—…๋กœ๋“œ์— ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค. UploadedFile ์ฃผ์š” ์†์„ฑ ๋ฐ ๋ฉ”์„œ๋“œ:
interface UploadedFile {
  // ํŒŒ์ผ ์ •๋ณด
  filename: string;       // ํŒŒ์ผ๋ช…
  mimetype: string;       // MIME ํƒ€์ž… (์˜ˆ: "image/png", "application/pdf")
  size: number;           // ํŒŒ์ผ ํฌ๊ธฐ (bytes)
  extname: string;        // ํ™•์žฅ์ž (์˜ˆ: "png", "pdf")
  url: string;            // ์ €์žฅ๋œ ํŒŒ์ผ์˜ URL
  signedUrl: string;      // Signed URL (๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์žˆ์Œ)
  key: string;            // ์ €์žฅ์†Œ ๋‚ด ํ‚ค
  diskName: string;       // ์ €์žฅ๋œ ๋””์Šคํฌ ์ด๋ฆ„

  // ํŒŒ์ผ ์ฒ˜๋ฆฌ ๋ฉ”์„œ๋“œ
  download(): Promise<Buffer>;  // ์ €์žฅ์†Œ์—์„œ ํŒŒ์ผ ๋‹ค์šด๋กœ๋“œ
}
Stream ๋ชจ๋“œ ์‚ฌ์šฉ ์˜ˆ์‹œ:
class FileModelClass extends BaseModel {
  @upload({ mode: "stream", limits: { files: 5 } })
  async uploadLargeFiles(): Promise<{ files: SonamuFile[] }> {
    const { uploadedFiles } = Sonamu.getContext();

    if (!uploadedFiles || uploadedFiles.length === 0) {
      throw new BadRequestException("ํŒŒ์ผ์ด ์—…๋กœ๋“œ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค");
    }

    // ์ด๋ฏธ ์ €์žฅ์†Œ์— ์—…๋กœ๋“œ ์™„๋ฃŒ๋œ ์ƒํƒœ
    return {
      files: uploadedFiles.map((file) => ({
        name: file.filename,
        url: file.url,
        mime_type: file.mimetype,
        size: file.size,
      })),
    };
  }
}
ํŒŒ์ผ ์—…๋กœ๋“œ ์ฒ˜๋ฆฌ:
  • @upload ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค
  • Buffer ๋ชจ๋“œ(๊ธฐ๋ณธ): bufferedFiles ์‚ฌ์šฉ - ๋ฉ”๋ชจ๋ฆฌ์— ๋กœ๋“œ, MD5 ๊ณ„์‚ฐ/์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ์— ์ ํ•ฉ
  • Stream ๋ชจ๋“œ: uploadedFiles ์‚ฌ์šฉ - ์ €์žฅ์†Œ๋กœ ์ง์ ‘ ์ŠคํŠธ๋ฆฌ๋ฐ, ๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ์— ์ ํ•ฉ
  • ์ž์„ธํ•œ ๋‚ด์šฉ์€ @upload ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•˜์„ธ์š”

AuthContext ์†์„ฑ

Context๋Š” AuthContext์˜ ์†์„ฑ๋“ค๋„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค:
  • user: ํ˜„์žฌ ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž ์ •๋ณด
  • passport: ์ธ์ฆ ๊ด€๋ จ ๋ฉ”์„œ๋“œ (login, logout)

Context ํ™•์žฅ

ํ”„๋กœ์ ํŠธ์—์„œ Context์— ์ปค์Šคํ…€ ์†์„ฑ์„ ์ถ”๊ฐ€ํ•˜๋ ค๋ฉด ContextExtend ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ํ™•์žฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:
// src/types.ts
declare module "sonamu" {
  interface ContextExtend {
    customProperty: string;
    helperMethod: () => void;
  }
}
์ด๋ ‡๊ฒŒ ํ™•์žฅ๋œ ์†์„ฑ์€ contextProvider๋ฅผ ํ†ตํ•ด ์ฃผ์ž…ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:
// sonamu.config.ts
export default {
  server: {
    apiConfig: {
      contextProvider: async (baseContext) => {
        return {
          ...baseContext,
          customProperty: "custom value",
          helperMethod: () => console.log("helper")
        };
      }
    }
  }
} satisfies SonamuConfig;

๊ด€๋ จ ๋ฌธ์„œ