메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
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 BaseModelClass {
  @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 BaseModelClass {
  @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 BaseModelClass {
  @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 BaseModelClass {
  @upload({ consume: "stream", destination: "s3" })
  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 BaseModelClass {
  @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 BaseModelClass {
  @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 BaseModelClass {
  @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({ consume: "stream", destination: "s3" })
async uploadFile() {
  const { uploadedFiles } = Sonamu.getContext();
  const file = uploadedFiles?.[0]; // OK
}

// ❌ 잘λͺ»λœ μ‚¬μš©
@api()
async uploadFile() {
  const { bufferedFiles } = Sonamu.getContext();
  // bufferedFilesλŠ” undefined
}

κ΄€λ ¨ λ¬Έμ„œ