๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
SonamuContext๋ฅผ ํ™•์žฅํ•˜์—ฌ ํ”„๋กœ์ ํŠธ์— ํ•„์š”ํ•œ ์ปค์Šคํ…€ ๋ฐ์ดํ„ฐ๋ฅผ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Context ํ™•์žฅ ๊ฐœ์š”

ํƒ€์ž… ํ™•์žฅ

TypeScript ํƒ€์ž… ํ™•์žฅ์ž๋™์™„์„ฑ ์ง€์›

๋ฏธ๋“ค์›จ์–ด

์š”์ฒญ ์ „์ฒ˜๋ฆฌ์ปค์Šคํ…€ ๋ฐ์ดํ„ฐ ์ฃผ์ž…

๊ฒ€์ฆ ๋กœ์ง

์ธ์ฆ/๊ถŒํ•œ ์ฒดํฌ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™ ์ ์šฉ

๊ณตํ†ต ๋ฐ์ดํ„ฐ

์„ธ์…˜, ํ…Œ๋„ŒํŠธ ๋“ฑ์š”์ฒญ ๋ฒ”์œ„ ๋ฐ์ดํ„ฐ

ํƒ€์ž… ํ™•์žฅ

์ปค์Šคํ…€ User ํƒ€์ž…

// types/context.ts

// ๊ธฐ๋ณธ Context ํ™•์žฅ
declare module "sonamu" {
  interface ContextUser {
    id: number;
    email: string;
    username: string;
    role: "admin" | "manager" | "normal";
    
    // ์ปค์Šคํ…€ ํ•„๋“œ ์ถ”๊ฐ€
    organizationId: number;
    departmentId: number;
    permissions: string[];
    lastLoginAt: Date;
    preferredLanguage: "ko" | "en" | "ja";
  }
}

์ปค์Šคํ…€ Context ํ•„๋“œ

// types/context.ts

declare module "sonamu" {
  interface SonamuContext {
    // ๊ธฐ๋ณธ ํ•„๋“œ
    request: FastifyRequest;
    reply: FastifyReply;
    user?: ContextUser;
    
    // ์ปค์Šคํ…€ ํ•„๋“œ ์ถ”๊ฐ€
    tenant?: {
      id: number;
      name: string;
      subdomain: string;
      features: string[];
    };
    
    session?: {
      id: string;
      createdAt: Date;
      expiresAt: Date;
      data: Record<string, any>;
    };
    
    locale: string;
    timezone: string;
    requestId: string;
  }
}
TypeScript์˜ Declaration Merging์„ ์‚ฌ์šฉํ•˜์—ฌ ํƒ€์ž…์„ ํ™•์žฅํ•ฉ๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด IDE์—์„œ ์ž๋™์™„์„ฑ๊ณผ ํƒ€์ž… ์ฒดํฌ๊ฐ€ ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค.

๋ฏธ๋“ค์›จ์–ด๋กœ ๋ฐ์ดํ„ฐ ์ฃผ์ž…

์ธ์ฆ ๋ฏธ๋“ค์›จ์–ด

// middleware/auth.middleware.ts
import { FastifyRequest, FastifyReply } from "fastify";
import jwt from "jsonwebtoken";
import { Sonamu } from "sonamu";

export async function authMiddleware(
  request: FastifyRequest,
  reply: FastifyReply
) {
  // Authorization ํ—ค๋”์—์„œ ํ† ํฐ ์ถ”์ถœ
  const authHeader = request.headers.authorization;
  
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    return; // ํ† ํฐ ์—†์Œ (์„ ํƒ์  ์ธ์ฆ)
  }
  
  const token = authHeader.substring(7);
  
  try {
    // JWT ํ† ํฐ ๊ฒ€์ฆ
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
      userId: number;
      role: string;
      organizationId: number;
      departmentId: number;
      permissions: string[];
    };
    
    // DB์—์„œ ์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ
    const rdb = Sonamu.getPuri("r");
    const user = await rdb
      .table("users")
      .where("id", payload.userId)
      .first();
    
    if (!user) {
      reply.status(401);
      throw new Error("Invalid token");
    }
    
    // Context์— user ์„ค์ •
    const context = Sonamu.getContext();
    context.user = {
      id: user.id,
      email: user.email,
      username: user.username,
      role: user.role,
      organizationId: payload.organizationId,
      departmentId: payload.departmentId,
      permissions: payload.permissions,
      lastLoginAt: user.last_login_at,
      preferredLanguage: user.preferred_language,
    };
    
    // ๋งˆ์ง€๋ง‰ ํ™œ๋™ ์‹œ๊ฐ„ ์—…๋ฐ์ดํŠธ
    await rdb
      .table("users")
      .where("id", user.id)
      .update({ last_activity_at: new Date() });
  } catch (error) {
    reply.status(401);
    throw new Error("Invalid or expired token");
  }
}

ํ…Œ๋„ŒํŠธ ๋ฏธ๋“ค์›จ์–ด

// middleware/tenant.middleware.ts
import { FastifyRequest } from "fastify";
import { Sonamu } from "sonamu";

export async function tenantMiddleware(request: FastifyRequest) {
  const context = Sonamu.getContext();
  
  // ์„œ๋ธŒ๋„๋ฉ”์ธ ๋˜๋Š” ํ—ค๋”์—์„œ ํ…Œ๋„ŒํŠธ ์ •๋ณด ์ถ”์ถœ
  const subdomain = getSubdomain(request.hostname);
  const tenantId = request.headers["x-tenant-id"] as string;
  
  if (!subdomain && !tenantId) {
    return; // ํ…Œ๋„ŒํŠธ ์ •๋ณด ์—†์Œ
  }
  
  const rdb = Sonamu.getPuri("r");
  
  // ํ…Œ๋„ŒํŠธ ์กฐํšŒ
  const tenant = await rdb
    .table("tenants")
    .where((qb) => {
      if (subdomain) {
        qb.where("subdomain", subdomain);
      }
      if (tenantId) {
        qb.orWhere("id", parseInt(tenantId));
      }
    })
    .first();
  
  if (!tenant) {
    throw new Error("Tenant not found");
  }
  
  // ํ…Œ๋„ŒํŠธ ํ™œ์„ฑ ์ƒํƒœ ํ™•์ธ
  if (!tenant.is_active) {
    throw new Error("Tenant is inactive");
  }
  
  // Context์— tenant ์„ค์ •
  context.tenant = {
    id: tenant.id,
    name: tenant.name,
    subdomain: tenant.subdomain,
    features: tenant.features || [],
  };
}

function getSubdomain(hostname: string): string | null {
  const parts = hostname.split(".");
  if (parts.length >= 3) {
    return parts[0];
  }
  return null;
}

๋กœ์ผ€์ผ ๋ฏธ๋“ค์›จ์–ด

// middleware/locale.middleware.ts
import { FastifyRequest } from "fastify";
import { Sonamu } from "sonamu";

const SUPPORTED_LOCALES = ["ko", "en", "ja"];
const DEFAULT_LOCALE = "ko";

export function localeMiddleware(request: FastifyRequest) {
  const context = Sonamu.getContext();
  
  // 1. URL ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ
  const queryLocale = request.query?.locale as string;
  if (queryLocale && SUPPORTED_LOCALES.includes(queryLocale)) {
    context.locale = queryLocale;
    return;
  }
  
  // 2. ์‚ฌ์šฉ์ž ์„ค์ • (๋กœ๊ทธ์ธ ์ƒํƒœ)
  if (context.user?.preferredLanguage) {
    context.locale = context.user.preferredLanguage;
    return;
  }
  
  // 3. Accept-Language ํ—ค๋”
  const acceptLanguage = request.headers["accept-language"];
  if (acceptLanguage) {
    const lang = acceptLanguage.split(",")[0].split("-")[0];
    if (SUPPORTED_LOCALES.includes(lang)) {
      context.locale = lang;
      return;
    }
  }
  
  // 4. ๊ธฐ๋ณธ๊ฐ’
  context.locale = DEFAULT_LOCALE;
}

Request ID ๋ฏธ๋“ค์›จ์–ด

// middleware/request-id.middleware.ts
import { FastifyRequest } from "fastify";
import { Sonamu } from "sonamu";
import crypto from "crypto";

export function requestIdMiddleware(request: FastifyRequest) {
  const context = Sonamu.getContext();
  
  // ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ œ๊ณตํ•œ Request ID ๋˜๋Š” ์ƒˆ๋กœ ์ƒ์„ฑ
  const requestId =
    (request.headers["x-request-id"] as string) ||
    crypto.randomUUID();
  
  context.requestId = requestId;
  
  // ์‘๋‹ต ํ—ค๋”์—๋„ ์ถ”๊ฐ€
  context.reply.header("X-Request-Id", requestId);
}

๋ฏธ๋“ค์›จ์–ด ๋“ฑ๋ก

// server.ts
import fastify from "fastify";
import { authMiddleware } from "./middleware/auth.middleware";
import { tenantMiddleware } from "./middleware/tenant.middleware";
import { localeMiddleware } from "./middleware/locale.middleware";
import { requestIdMiddleware } from "./middleware/request-id.middleware";

const app = fastify();

// ๋ฏธ๋“ค์›จ์–ด ๋“ฑ๋ก (์ˆœ์„œ ์ค‘์š”!)
app.addHook("preHandler", requestIdMiddleware);
app.addHook("preHandler", tenantMiddleware);
app.addHook("preHandler", authMiddleware);
app.addHook("preHandler", localeMiddleware);

// API ๋ผ์šฐํŠธ ๋“ฑ๋ก
// ...

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

๋ฉ€ํ‹ฐ ํ…Œ๋„ŒํŠธ API

class ProductModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async list(): Promise<Product[]> {
    const context = Sonamu.getContext();
    
    // ํ…Œ๋„ŒํŠธ ํ™•์ธ
    if (!context.tenant) {
      throw new Error("Tenant required");
    }
    
    const rdb = this.getPuri("r");
    
    // ํ…Œ๋„ŒํŠธ๋ณ„ ๋ฐ์ดํ„ฐ๋งŒ ์กฐํšŒ
    const products = await rdb
      .table("products")
      .where("tenant_id", context.tenant.id)
      .select("*");
    
    return products;
  }
  
  @api({ httpMethod: "POST" })
  async create(params: ProductSaveParams): Promise<{ productId: number }> {
    const context = Sonamu.getContext();
    
    if (!context.tenant) {
      throw new Error("Tenant required");
    }
    
    const wdb = this.getPuri("w");
    
    // ํ…Œ๋„ŒํŠธ ID ์ž๋™ ์ถ”๊ฐ€
    const [product] = await wdb
      .table("products")
      .insert({
        ...params,
        tenant_id: context.tenant.id,
      })
      .returning({ id: "id" });
    
    return { productId: product.id };
  }
}

๊ถŒํ•œ ๊ธฐ๋ฐ˜ ์ ‘๊ทผ ์ œ์–ด

class BaseModelClass {
  protected requirePermission(permission: string): void {
    const context = Sonamu.getContext();
    
    if (!context.user) {
      context.reply.status(401);
      throw new Error("Authentication required");
    }
    
    if (!context.user.permissions.includes(permission)) {
      context.reply.status(403);
      throw new Error(`Permission denied: ${permission} required`);
    }
  }
}

class UserModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async create(params: UserSaveParams): Promise<{ userId: number }> {
    // ๊ถŒํ•œ ํ™•์ธ
    this.requirePermission("user:create");
    
    const wdb = this.getPuri("w");
    const [user] = await wdb
      .table("users")
      .insert(params)
      .returning({ id: "id" });
    
    return { userId: user.id };
  }
  
  @api({ httpMethod: "DELETE" })
  async remove(userId: number): Promise<void> {
    this.requirePermission("user:delete");
    
    const wdb = this.getPuri("w");
    await wdb.table("users").where("id", userId).delete();
  }
}

๋‹ค๊ตญ์–ด ๋ฉ”์‹œ์ง€

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

const messages: Record<string, Record<string, string>> = {
  ko: {
    "user.not_found": "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค",
    "user.created": "์‚ฌ์šฉ์ž๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค",
    "permission.denied": "๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค",
  },
  en: {
    "user.not_found": "User not found",
    "user.created": "User created",
    "permission.denied": "Permission denied",
  },
  ja: {
    "user.not_found": "ใƒฆใƒผใ‚ถใƒผใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“",
    "user.created": "ใƒฆใƒผใ‚ถใƒผใŒไฝœๆˆใ•ใ‚Œใพใ—ใŸ",
    "permission.denied": "ๆจฉ้™ใŒใ‚ใ‚Šใพใ›ใ‚“",
  },
};

export function t(key: string): string {
  const context = Sonamu.getContext();
  const locale = context.locale || "ko";
  
  return messages[locale]?.[key] || key;
}

// ์‚ฌ์šฉ
class UserModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async get(id: number): Promise<User> {
    const rdb = this.getPuri("r");
    const user = await rdb.table("users").where("id", id).first();
    
    if (!user) {
      // ๋‹ค๊ตญ์–ด ์—๋Ÿฌ ๋ฉ”์‹œ์ง€
      throw new Error(t("user.not_found"));
    }
    
    return user;
  }
}

๊ฐ์‚ฌ ๋กœ๊ทธ ์ž๋™ํ™”

class BaseModelClass {
  protected async audit(
    action: string,
    resourceType: string,
    resourceId: number,
    details?: any
  ): Promise<void> {
    const context = Sonamu.getContext();
    const wdb = this.getPuri("w");
    
    await wdb.table("audit_logs").insert({
      user_id: context.user?.id,
      tenant_id: context.tenant?.id,
      action,
      resource_type: resourceType,
      resource_id: resourceId,
      details: JSON.stringify(details || {}),
      ip_address: context.request.ip,
      user_agent: context.request.headers["user-agent"],
      request_id: context.requestId,
      timestamp: new Date(),
    });
  }
}

class UserModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async create(params: UserSaveParams): Promise<{ userId: number }> {
    const wdb = this.getPuri("w");
    const [user] = await wdb
      .table("users")
      .insert(params)
      .returning({ id: "id" });
    
    // ๊ฐ์‚ฌ ๋กœ๊ทธ ์ž๋™ ๊ธฐ๋ก
    await this.audit("CREATE", "user", user.id, { email: params.email });
    
    return { userId: user.id };
  }
  
  @api({ httpMethod: "DELETE" })
  async remove(userId: number): Promise<void> {
    const rdb = this.getPuri("r");
    const user = await rdb.table("users").where("id", userId).first();
    
    await this.audit("DELETE", "user", userId, { username: user.username });
    
    const wdb = this.getPuri("w");
    await wdb.table("users").where("id", userId).delete();
  }
}

์กฐ์ง/๋ถ€์„œ ํ•„ํ„ฐ๋ง

class DocumentModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async list(): Promise<Document[]> {
    const context = Sonamu.getContext();
    
    if (!context.user) {
      throw new Error("Authentication required");
    }
    
    const rdb = this.getPuri("r");
    
    let query = rdb.table("documents");
    
    // ๊ด€๋ฆฌ์ž๊ฐ€ ์•„๋‹ˆ๋ฉด ์ž๊ธฐ ์กฐ์ง/๋ถ€์„œ๋งŒ
    if (context.user.role !== "admin") {
      query = query.where((qb) => {
        qb.where("organization_id", context.user!.organizationId)
          .orWhere("department_id", context.user!.departmentId)
          .orWhere("user_id", context.user!.id);
      });
    }
    
    return query.select("*");
  }
}

Context ๊ธฐ๋ฐ˜ ์บ์‹ฑ

class CacheService {
  private cache = new Map<string, any>();
  
  get(key: string): any {
    const context = Sonamu.getContext();
    const cacheKey = `${context.tenant?.id || "global"}:${key}`;
    return this.cache.get(cacheKey);
  }
  
  set(key: string, value: any, ttl: number = 3600): void {
    const context = Sonamu.getContext();
    const cacheKey = `${context.tenant?.id || "global"}:${key}`;
    this.cache.set(cacheKey, value);
    
    // TTL ํ›„ ์‚ญ์ œ
    setTimeout(() => {
      this.cache.delete(cacheKey);
    }, ttl * 1000);
  }
}

// ์‚ฌ์šฉ
class ProductModel extends BaseModelClass {
  private cacheService = new CacheService();
  
  @api({ httpMethod: "GET" })
  async list(): Promise<Product[]> {
    // ํ…Œ๋„ŒํŠธ๋ณ„ ์บ์‹œ
    const cached = this.cacheService.get("products:list");
    if (cached) {
      return cached;
    }
    
    const context = Sonamu.getContext();
    const rdb = this.getPuri("r");
    
    const products = await rdb
      .table("products")
      .where("tenant_id", context.tenant?.id)
      .select("*");
    
    this.cacheService.set("products:list", products);
    
    return products;
  }
}

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

์ปค์Šคํ…€ Context ์‚ฌ์šฉ ์‹œ ์ฃผ์˜์‚ฌํ•ญ:
  1. ํƒ€์ž… ํ™•์žฅ์€ ํ”„๋กœ์ ํŠธ ์ดˆ๊ธฐ์— ์ •์˜
  2. ๋ฏธ๋“ค์›จ์–ด ์‹คํ–‰ ์ˆœ์„œ ์ค‘์š”
  3. Context ๋ฐ์ดํ„ฐ๋Š” ๋ถˆ๋ณ€์œผ๋กœ ์ทจ๊ธ‰
  4. ๋ฏผ๊ฐํ•œ ์ •๋ณด๋Š” ์•”ํ˜ธํ™” ๊ณ ๋ ค
  5. ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ๋ฐฉ์ง€ (ํฐ ๋ฐ์ดํ„ฐ ์ €์žฅ ์ง€์–‘)

๋‹ค์Œ ๋‹จ๊ณ„