๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
Sonamu๋Š” Context์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ๋Š” ์—ฌ๋Ÿฌ ๋ฉ”์„œ๋“œ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ์ด ๋ฉ”์„œ๋“œ๋“ค์€ API ๋ฉ”์„œ๋“œ ์™ธ๋ถ€์—์„œ Context๊ฐ€ ํ•„์š”ํ•œ ๊ฒฝ์šฐ์— ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค.

Sonamu.getContext()

ํ˜„์žฌ ์‹คํ–‰ ์ค‘์ธ ์š”์ฒญ์˜ Context๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. AsyncLocalStorage๋ฅผ ํ†ตํ•ด ๊ด€๋ฆฌ๋˜๋ฏ€๋กœ, ๊ฐ™์€ ์š”์ฒญ ์Šคํƒ ๋‚ด ์–ด๋””์„œ๋“  ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์‹œ๊ทธ๋‹ˆ์ฒ˜

Sonamu.getContext(): Context

๋ฐ˜ํ™˜๊ฐ’

  • Context: ํ˜„์žฌ ์š”์ฒญ์˜ Context ๊ฐ์ฒด
  • ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ: ๋นˆ Context ๊ฐ์ฒด (request์™€ reply๋Š” null)

์˜ˆ์™ธ

  • Error: Context๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†๋Š” ๊ฒฝ์šฐ (ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์—์„œ๋งŒ)

์‚ฌ์šฉ ์˜ˆ์‹œ

ํ—ฌํผ ํ•จ์ˆ˜์—์„œ ์‚ฌ์šฉ

// src/utils/auth-helper.ts
import { Sonamu } from "sonamu";

export function getCurrentUser() {
  const ctx = Sonamu.getContext();
  return ctx.user;
}

export function requireAuth() {
  const user = getCurrentUser();
  if (!user) {
    throw new UnauthorizedException("๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");
  }
  return user;
}
// src/models/post.model.ts
import { requireAuth } from "../utils/auth-helper";

class PostModelClass extends BaseModel {
  @api()
  async createPost(title: string, content: string) {
    // Context๋ฅผ ์ง์ ‘ ๋ฐ›์ง€ ์•Š์•„๋„ ํ—ฌํผ ํ•จ์ˆ˜๋ฅผ ํ†ตํ•ด ์‚ฌ์šฉ์ž ์ •๋ณด ์ ‘๊ทผ
    const user = requireAuth();
    
    return this.create({
      title,
      content,
      authorId: user.id
    });
  }
}

์„œ๋น„์Šค ๋ ˆ์ด์–ด์—์„œ ์‚ฌ์šฉ

// src/services/notification.service.ts
import { Sonamu } from "sonamu";

export class NotificationService {
  async sendNotification(userId: number, message: string) {
    const ctx = Sonamu.getContext();
    
    // Context์˜ locale ์ •๋ณด๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋‹ค๊ตญ์–ด ๋ฉ”์‹œ์ง€ ์ „์†ก
    const localizedMessage = this.localize(message, ctx.locale);
    
    // ์•Œ๋ฆผ ์ „์†ก ๋กœ์ง
    await this.send(userId, localizedMessage);
  }

  private localize(message: string, locale?: string) {
    // ๋‹ค๊ตญ์–ด ์ฒ˜๋ฆฌ ๋กœ์ง
    return message;
  }
}

ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ ๋™์ž‘

ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ๋Š” Context๊ฐ€ ์ฃผ์ž…๋˜์ง€ ์•Š์•„๋„ ์—๋Ÿฌ๋ฅผ ๋ฐœ์ƒ์‹œํ‚ค์ง€ ์•Š๊ณ  ๋นˆ Context๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค:
// test/post.test.ts
import { Sonamu } from "sonamu";

describe("PostModel", () => {
  it("should create post", async () => {
    // ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ๋Š” Context ์—†์ด๋„ ๋™์ž‘
    const ctx = Sonamu.getContext();
    console.log(ctx.request); // null (์—๋Ÿฌ ์—†์Œ)
  });
});
์‹ค์ œ Context๊ฐ€ ํ•„์š”ํ•œ ํ…Œ์ŠคํŠธ์—์„œ๋Š” runWithMockContext๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”:
import { runWithMockContext } from "sonamu/test";

it("should use context in test", async () => {
  await runWithMockContext(
    {
      user: { id: 1, email: "test@example.com" }
    },
    async () => {
      const ctx = Sonamu.getContext();
      expect(ctx.user?.id).toBe(1);
    }
  );
});

ํŒŒ์ผ ์—…๋กœ๋“œ ์ปจํ…์ŠคํŠธ

ํŒŒ์ผ ์—…๋กœ๋“œ ์š”์ฒญ์—์„œ ์—…๋กœ๋“œ๋œ ํŒŒ์ผ ์ •๋ณด๋Š” Sonamu.getContext()๋ฅผ ํ†ตํ•ด ์ ‘๊ทผํ•ฉ๋‹ˆ๋‹ค. @upload ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๊ฐ€ ์ ์šฉ๋œ ๋ฉ”์„œ๋“œ์—์„œ๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์—…๋กœ๋“œ ๋ชจ๋“œ์— ๋”ฐ๋ผ ๋‘ ๊ฐ€์ง€ ์†์„ฑ์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค:
  • Buffer ๋ชจ๋“œ (๊ธฐ๋ณธ): bufferedFiles - ๋ฉ”๋ชจ๋ฆฌ์— ๋กœ๋“œ๋œ ํŒŒ์ผ, MD5 ๊ณ„์‚ฐ/์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ์— ์ ํ•ฉ
  • Stream ๋ชจ๋“œ: uploadedFiles - ์ €์žฅ์†Œ๋กœ ์ง์ ‘ ์ŠคํŠธ๋ฆฌ๋ฐ๋œ ํŒŒ์ผ, ๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ์— ์ ํ•ฉ

์‹œ๊ทธ๋‹ˆ์ฒ˜

// Buffer ๋ชจ๋“œ (๊ธฐ๋ณธ)
const { bufferedFiles } = Sonamu.getContext();
// bufferedFiles: BufferedFile[]

// Stream ๋ชจ๋“œ
const { uploadedFiles } = Sonamu.getContext();
// uploadedFiles: UploadedFile[]

ํƒ€์ž…

// Context์˜ ํŒŒ์ผ ์—…๋กœ๋“œ ์†์„ฑ
bufferedFiles?: BufferedFile[];  // buffer ๋ชจ๋“œ
uploadedFiles?: UploadedFile[];  // stream ๋ชจ๋“œ

์ฃผ์˜์‚ฌํ•ญ

  • ํŒŒ์ผ ์†์„ฑ์€ @upload ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋ฅผ ์‚ฌ์šฉํ•œ ๊ฒฝ์šฐ์—๋งŒ ์„ค์ •๋ฉ๋‹ˆ๋‹ค
  • Buffer ๋ชจ๋“œ(๊ธฐ๋ณธ)์—์„œ๋Š” bufferedFiles๋ฅผ, Stream ๋ชจ๋“œ์—์„œ๋Š” uploadedFiles๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค
  • ๋‹จ์ผ ํŒŒ์ผ ์—…๋กœ๋“œ์˜ ๊ฒฝ์šฐ bufferedFiles?.[0] ๋˜๋Š” uploadedFiles?.[0]์œผ๋กœ ์ ‘๊ทผํ•ฉ๋‹ˆ๋‹ค

์‚ฌ์šฉ ์˜ˆ์‹œ

๋‹จ์ผ ํŒŒ์ผ ์—…๋กœ๋“œ (Buffer ๋ชจ๋“œ)

class FileModelClass extends BaseModel {
  @upload()
  async uploadAvatar(ctx: Context) {
    const { bufferedFiles } = Sonamu.getContext();
    const file = bufferedFiles?.[0]; // ์ฒซ ๋ฒˆ์งธ ํŒŒ์ผ ์‚ฌ์šฉ

    if (!file) {
      throw new BadRequestException("ํŒŒ์ผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");
    }

    // ํŒŒ์ผ ์ •๋ณด ํ™•์ธ
    console.log(file.filename);    // "avatar.jpg"
    console.log(file.mimetype);    // "image/jpeg"
    console.log(file.size);        // 102400 (bytes)
    console.log(file.extname);     // "jpg"

    // ํŒŒ์ผ ์ €์žฅ (diskName, key ์ˆœ์„œ)
    const key = `avatars/${Date.now()}_${file.filename}`;
    const url = await file.saveToDisk("fs", key);

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

๋‹ค์ค‘ ํŒŒ์ผ ์—…๋กœ๋“œ (Buffer ๋ชจ๋“œ)

class FileModelClass extends BaseModel {
  @upload()
  async uploadDocuments(ctx: Context, folderId: number) {
    const { bufferedFiles } = Sonamu.getContext();

    if (!bufferedFiles || bufferedFiles.length === 0) {
      throw new BadRequestException("์ตœ์†Œ 1๊ฐœ ์ด์ƒ์˜ ํŒŒ์ผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");
    }

    const savedFiles = await Promise.all(
      bufferedFiles.map(async (file, i) => {
        const key = `documents/${Date.now()}_${i}_${file.filename}`;
        const url = await file.saveToDisk("fs", key);
        return { url, filename: file.filename, size: file.size };
      })
    );

    // ํŒŒ์ผ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ DB ์ €์žฅ
    await this.createMany(
      savedFiles.map((file) => ({
        folderId,
        filename: file.filename,
        url: file.url,
        size: file.size
      }))
    );

    return { count: savedFiles.length, files: savedFiles };
  }
}

๋Œ€์šฉ๋Ÿ‰ ํŒŒ์ผ ์—…๋กœ๋“œ (Stream ๋ชจ๋“œ)

class FileModelClass extends BaseModel {
  @upload({ mode: "stream" })
  async uploadLargeFiles(ctx: Context) {
    const { uploadedFiles } = Sonamu.getContext();

    if (!uploadedFiles || uploadedFiles.length === 0) {
      throw new BadRequestException("ํŒŒ์ผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");
    }

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

ํ—ฌํผ ํ•จ์ˆ˜์—์„œ ์‚ฌ์šฉ

// src/utils/file-helper.ts
import { Sonamu } from "sonamu";

export function getBufferedFiles() {
  const { bufferedFiles } = Sonamu.getContext();
  return bufferedFiles;
}

export function validateFileTypes(allowedTypes: string[]) {
  const { bufferedFiles } = Sonamu.getContext();

  if (!bufferedFiles) return;

  for (const file of bufferedFiles) {
    if (!allowedTypes.includes(file.mimetype)) {
      throw new BadRequestException(
        `ํ—ˆ์šฉ๋˜์ง€ ์•Š๋Š” ํŒŒ์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค: ${file.mimetype}`
      );
    }
  }
}
class FileModelClass extends BaseModel {
  @api()
  @upload()
  async uploadImages(ctx: Context) {
    // ์ด๋ฏธ์ง€ ํŒŒ์ผ๋งŒ ํ—ˆ์šฉ
    validateFileTypes([
      "image/jpeg",
      "image/png",
      "image/gif",
      "image/webp"
    ]);

    const files = getBufferedFiles();

    // ํŒŒ์ผ ์ฒ˜๋ฆฌ ๋กœ์ง
    // ...
  }
}

๊ด€๋ จ ํƒ€์ž…

BufferedFile (Buffer ๋ชจ๋“œ)

class BufferedFile {
  /** ์›๋ณธ ํŒŒ์ผ๋ช… */
  get filename(): string;

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

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

  /** ํ™•์žฅ์ž (์  ์ œ์™ธ) */
  get extname(): string | false;

  /** ํŒŒ์ผ Buffer (getter) */
  get buffer(): Buffer;

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

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

  /**
   * ํŒŒ์ผ์„ ๋””์Šคํฌ์— ์ €์žฅ
   * @param diskName ๋””์Šคํฌ ์ด๋ฆ„ (์˜ˆ: 'fs', 's3')
   * @param key ์ €์žฅ ๊ฒฝ๋กœ (์˜ˆ: 'uploads/avatar.png')
   * @returns ์ €์žฅ๋œ ํŒŒ์ผ์˜ URL
   */
  async saveToDisk(diskName: DriverKey, key: string): Promise<string>;
}

UploadedFile (Stream ๋ชจ๋“œ)

class UploadedFile {
  /** ์›๋ณธ ํŒŒ์ผ๋ช… */
  get filename(): string;

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

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

  /** ํ™•์žฅ์ž (์  ์ œ์™ธ) */
  get extname(): string | false;

  /** ์ €์žฅ๋œ ํŒŒ์ผ์˜ URL (Unsigned) */
  get url(): string;

  /** ์ €์žฅ๋œ ํŒŒ์ผ์˜ URL (Signed, ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์žˆ์Œ) */
  get signedUrl(): string;

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

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

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

์ฃผ์˜์‚ฌํ•ญ

AsyncLocalStorage ์Šคํƒ

getContext()๋Š” AsyncLocalStorage๋ฅผ ํ†ตํ•ด ๋™์ž‘ํ•˜๋ฏ€๋กœ, ๋ฐ˜๋“œ์‹œ ๊ฐ™์€ ๋น„๋™๊ธฐ ์‹คํ–‰ ์Šคํƒ ๋‚ด์—์„œ ํ˜ธ์ถœํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค:
// โœ… ์˜ฌ๋ฐ”๋ฅธ ์‚ฌ์šฉ
class PostModelClass extends BaseModel {
  @api()
  async createPost(title: string) {
    const user = this.getCurrentUser(); // ๊ฐ™์€ ์Šคํƒ ๋‚ด
    return this.create({ title, authorId: user.id });
  }

  private getCurrentUser() {
    return Sonamu.getContext().user;
  }
}

// โŒ ์ž˜๋ชป๋œ ์‚ฌ์šฉ
class PostModelClass extends BaseModel {
  @api()
  async createPost(title: string) {
    // setTimeout์€ ์ƒˆ๋กœ์šด ์‹คํ–‰ ์Šคํƒ์„ ์ƒ์„ฑ
    setTimeout(() => {
      const ctx = Sonamu.getContext(); // Error!
    }, 1000);
  }
}

@upload ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ ํ•„์ˆ˜

bufferedFiles/uploadedFiles ์†์„ฑ์€ ๋ฐ˜๋“œ์‹œ @upload ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๊ฐ€ ์ ์šฉ๋œ ๋ฉ”์„œ๋“œ์—์„œ๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:
// โœ… ์˜ฌ๋ฐ”๋ฅธ ์‚ฌ์šฉ (Buffer ๋ชจ๋“œ)
@api()
@upload()
async uploadFile() {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0]; // OK
}

// โœ… ์˜ฌ๋ฐ”๋ฅธ ์‚ฌ์šฉ (Stream ๋ชจ๋“œ)
@api()
@upload({ mode: "stream" })
async uploadFile() {
  const { uploadedFiles } = Sonamu.getContext();
  const file = uploadedFiles?.[0]; // OK
}

// โŒ ์ž˜๋ชป๋œ ์‚ฌ์šฉ
@api()
async uploadFile() {
  const { bufferedFiles } = Sonamu.getContext();
  // bufferedFiles๋Š” undefined
}

๊ด€๋ จ ๋ฌธ์„œ