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 ์ฌ์ฉ ์ ์ฃผ์์ฌํญ:
- ํ์ ํ์ฅ์ ํ๋ก์ ํธ ์ด๊ธฐ์ ์ ์
- ๋ฏธ๋ค์จ์ด ์คํ ์์ ์ค์
- Context ๋ฐ์ดํฐ๋ ๋ถ๋ณ์ผ๋ก ์ทจ๊ธ
- ๋ฏผ๊ฐํ ์ ๋ณด๋ ์ํธํ ๊ณ ๋ ค
- ๋ฉ๋ชจ๋ฆฌ ๋์ ๋ฐฉ์ง (ํฐ ๋ฐ์ดํฐ ์ ์ฅ ์ง์)