메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
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, session)
  • 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 BaseModelClass {
  @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.publish("progress", { percent: 50 });

    // μ™„λ£Œ 이벀트 전솑
    await sse.publish("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 BaseModelClass {
  @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 BaseModelClass {
  @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 BaseModelClass {
  @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({ consume: "stream", destination: "..." }) μ„€μ • μ‹œ μ‘΄μž¬ν•©λ‹ˆλ‹€. 파일이 이미 μ €μž₯μ†Œμ— 슀트리밍 μ™„λ£Œλœ μƒνƒœλ‘œ, 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 BaseModelClass {
  @upload({ consume: "stream", destination: "s3", 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: ν˜„μž¬ 인증된 μ‚¬μš©μž 정보 (User | null)
  • session: ν˜„μž¬ μ„Έμ…˜ 정보 (Session | null)

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;

κ΄€λ ¨ λ¬Έμ„œ