๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
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: "[email protected]" }
    },
    async () => {
      const ctx = Sonamu.getContext();
      expect(ctx.user?.id).toBe(1);
    }
  );
});

Sonamu.getUploadContext()

ํŒŒ์ผ ์—…๋กœ๋“œ ์š”์ฒญ์—์„œ ์—…๋กœ๋“œ๋œ ํŒŒ์ผ ์ •๋ณด์— ์ ‘๊ทผํ•ฉ๋‹ˆ๋‹ค. @upload ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๊ฐ€ ์ ์šฉ๋œ ๋ฉ”์„œ๋“œ์—์„œ๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

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

Sonamu.getUploadContext(): UploadContext

๋ฐ˜ํ™˜๊ฐ’

type UploadContext = {
  file?: UploadedFile;
  files: UploadedFile[];
};
  • file: ๋‹จ์ผ ํŒŒ์ผ ์—…๋กœ๋“œ ์‹œ ํŒŒ์ผ ๊ฐ์ฒด (@upload({ mode: "single" }))
  • files: ๋‹ค์ค‘ ํŒŒ์ผ ์—…๋กœ๋“œ ์‹œ ํŒŒ์ผ ๋ฐฐ์—ด (@upload({ mode: "multiple" }))

์˜ˆ์™ธ

  • Error: @upload ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ ์—†์ด ํ˜ธ์ถœํ•œ ๊ฒฝ์šฐ

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

๋‹จ์ผ ํŒŒ์ผ ์—…๋กœ๋“œ

class FileModelClass extends BaseModel {
  @api()
  @upload({ mode: "single" })
  async uploadAvatar(ctx: Context) {
    const { file } = Sonamu.getUploadContext();
    
    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"

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

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

๋‹ค์ค‘ ํŒŒ์ผ ์—…๋กœ๋“œ

class FileModelClass extends BaseModel {
  @api()
  @upload({ mode: "multiple" })
  async uploadDocuments(ctx: Context, folderId: number) {
    const { files } = Sonamu.getUploadContext();
    
    if (files.length === 0) {
      throw new BadRequestException("์ตœ์†Œ 1๊ฐœ ์ด์ƒ์˜ ํŒŒ์ผ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");
    }

    const savedFiles = await Promise.all(
      files.map(async (file, i) => {
        const key = `documents/${Date.now()}_${i}_${file.filename}`;
        const url = await file.saveToDisk(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 };
  }
}

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

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

export function getUploadedFiles() {
  const { files } = Sonamu.getUploadContext();
  return files;
}

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

    const files = getUploadedFiles();
    
    // ํŒŒ์ผ ์ฒ˜๋ฆฌ ๋กœ์ง
    // ...
  }
}

๊ด€๋ จ ํƒ€์ž…

UploadedFile

class UploadedFile {
  /** ์›๋ณธ ํŒŒ์ผ๋ช… */
  get filename(): string;
  
  /** MIME ํƒ€์ž… */
  get mimetype(): string;
  
  /** ํŒŒ์ผ ํฌ๊ธฐ (bytes) */
  get size(): number;
  
  /** ํ™•์žฅ์ž (์  ์ œ์™ธ) */
  get extname(): string | false;
  
  /** saveToDisk ํ›„ ์ €์žฅ๋œ URL (Unsigned) */
  get url(): string | undefined;
  
  /** saveToDisk ํ›„ ์ €์žฅ๋œ URL (Signed) */
  get signedUrl(): string | undefined;
  
  /** ์›๋ณธ MultipartFile ์ ‘๊ทผ */
  get raw(): MultipartFile;
  
  /** Buffer๋กœ ๋ณ€ํ™˜ (์บ์‹ฑ๋จ) */
  async toBuffer(): Promise<Buffer>;
  
  /** MD5 ํ•ด์‹œ ๊ณ„์‚ฐ */
  async md5(): Promise<string>;
  
  /**
   * ํŒŒ์ผ์„ ๋””์Šคํฌ์— ์ €์žฅ
   * @param key ์ €์žฅ ๊ฒฝ๋กœ (์˜ˆ: 'uploads/avatar.png')
   * @param diskName ๋””์Šคํฌ ์ด๋ฆ„ (๊ธฐ๋ณธ: default disk)
   * @returns ์ €์žฅ๋œ ํŒŒ์ผ์˜ URL
   */
  async saveToDisk(key: string, diskName?: DriverKey): Promise<string>;
}

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

AsyncLocalStorage ์Šคํƒ

getContext()์™€ getUploadContext()๋Š” 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 ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ ํ•„์ˆ˜

getUploadContext()๋Š” ๋ฐ˜๋“œ์‹œ @upload ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๊ฐ€ ์ ์šฉ๋œ ๋ฉ”์„œ๋“œ์—์„œ๋งŒ ์‚ฌ์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค:
// โœ… ์˜ฌ๋ฐ”๋ฅธ ์‚ฌ์šฉ
@api()
@upload()
async uploadFile() {
  const { file } = Sonamu.getUploadContext(); // OK
}

// โŒ ์ž˜๋ชป๋œ ์‚ฌ์šฉ
@api()
async uploadFile() {
  const { file } = Sonamu.getUploadContext(); // Error!
}

๊ด€๋ จ ๋ฌธ์„œ