Boundary๋ HMR ์์คํ
์ด ๋ชจ๋์ ์์ ํ๊ฒ ๊ต์ฒดํ ์ ์๋ ๊ฒฝ๊ณ๋ฅผ ์ ์ํฉ๋๋ค. Sonamu๋ก ๊ฐ๋ฐํ ๋ Boundary๋ฅผ ์ ๋๋ก ์ดํดํ๋ฉด HMR์ ์ต๋ํ ํ์ฉํ ์ ์์ต๋๋ค.
Boundary๋?
Boundary๋ โ์ฌ๋ก๋ ๊ฐ๋ฅํ ๊ฒฝ๊ณโ๋ฅผ ์๋ฏธํฉ๋๋ค. HMR ์์คํ
์ ๋ค์๊ณผ ๊ฐ์ ๊ท์น์ผ๋ก ๋์ํฉ๋๋ค:
- Boundary ํ์ผ: ๋ณ๊ฒฝ ์ ๋ค์ ๋ก๋๋ ์ ์๋ ํ์ผ (Model, API, Entity ๋ฑ)
- Non-boundary ํ์ผ: ๋ณ๊ฒฝ ์ ์ ์ฒด ์๋ฒ ์ฌ์์์ด ํ์ํ ํ์ผ (์ค์ , ํ๊ฒฝ๋ณ์ ๋ฑ)
โ
Boundary ํ์ผ: ์์ ์ HMR๋ก ์ฆ์ ๋ฐ์
โ Non-boundary ํ์ผ: ์์ ์ ์๋ฒ ์ฌ์์ ํ์
์ Boundary๊ฐ ํ์ํ๊ฐ?
์ ์ import๋ Node.js ์์ ์ ํ ๋ฒ๋ง ์คํ๋๋ฏ๋ก ๋ฐํ์์ ๊ต์ฒดํ ์ ์์ต๋๋ค:
// โ ์ ์ import - HMR ๋ถ๊ฐ
import { UserModel } from "./user.model";
// โ
๋์ import - HMR ๊ฐ๋ฅ
const { UserModel } = await import("./user.model");
Boundary๋ก ์ง์ ๋ ํ์ผ์ ๋ฐ๋์ ๋์ ์ผ๋ก import๋์ด์ผ ํ๋ฉฐ, ๊ทธ๋์ผ๋ง ๋ณ๊ฒฝ ์ ์ต์ ์ฝ๋๋ฅผ ๊ฐ์ ธ์ฌ ์ ์์ต๋๋ค.
Sonamu์ Boundary ์ค์
Sonamu๋ ๊ธฐ๋ณธ์ ์ผ๋ก ํ๋ก์ ํธ์ ๋ชจ๋ TypeScript ํ์ผ์ Boundary๋ก ์ค์ ํฉ๋๋ค:
// hmr-hook-register.ts
await hot.init({
rootDirectory: process.env.API_ROOT_PATH,
boundaries: [`./src/**/*.ts`], // ๋ชจ๋ .ts ํ์ผ์ด boundary
});
์ด ์ค์ ์ผ๋ก ์ธํด src/ ์๋์ ๋ชจ๋ ํ์ผ์ HMR์ด ๊ฐ๋ฅํ๋ฉฐ, ๋ณ๊ฒฝ ์ ์๋์ผ๋ก ์ฌ๋ก๋๋ฉ๋๋ค.
Boundary ํ์ผ ์์
Boundary ํ์ผ๋ค (HMR ๊ฐ๋ฅ):
user.model.ts - Model ํ์ผ
user.api.ts - API ํ์ผ
user.entity.ts - Entity ์ ์
helpers.ts - ์ ํธ๋ฆฌํฐ ํจ์
constants.ts - ์์ ์ ์
Non-boundary ํ์ผ๋ค (์ฌ์์ ํ์):
sonamu.config.ts - Sonamu ์ค์
.env - ํ๊ฒฝ๋ณ์
package.json - ์์กด์ฑ
Boundary ๊ท์น
1. ๋์ Import ํ์
Boundary ํ์ผ์ ๋ฐ๋์ ๋์ ์ผ๋ก import๋์ด์ผ ํฉ๋๋ค.
โ ์๋ชป๋ ์: ์ ์ import
// server.ts
import { UserModel } from "./application/user/user.model";
// UserModel์ ์์ ํด๋ ๋ฐ์๋์ง ์์!
app.get("/users", async (req, res) => {
const users = await UserModel.findMany();
res.json(users);
});
โ
์ฌ๋ฐ๋ฅธ ์: ๋์ import
// server.ts
app.get("/users", async (req, res) => {
const { UserModel } = await import("./application/user/user.model");
// UserModel์ ์์ ํ๋ฉด ๋ค์ ์์ฒญ ์ ์ต์ ์ฝ๋๊ฐ ๋ก๋๋จ!
const users = await UserModel.findMany();
res.json(users);
});
Sonamu๋ ์๋์ผ๋ก ์ฒ๋ฆฌํฉ๋๋ค
๋คํํ Sonamu์ Syncer๋ Entity ๊ธฐ๋ฐ ํ์ผ๋ค์ ์๋์ผ๋ก ๋์ importํฉ๋๋ค:
// syncer.ts - autoloadModels()
async autoloadModels() {
for (const entity of entities) {
const modelPath = `${entity.path}/${entity.id}.model`;
// ๋์ import๋ก ๋ก๋ โ
const module = await import(modelPath);
this.models[entity.id] = module[`${entity.id}Model`];
}
}
์ฆ, ์ผ๋ฐ์ ์ธ Sonamu ๊ฐ๋ฐ์์๋ ์ ๊ฒฝ ์ธ ํ์๊ฐ ์์ต๋๋ค!
2. Boundary ๊ฐ ์ ์ Import ํ์ฉ
Boundary ํ์ผ๋ผ๋ฆฌ๋ ์ ์ import๊ฐ ๊ฐ๋ฅํฉ๋๋ค (Sonamu์ ๊ฐ์ ์ฌํญ):
// user.model.ts (boundary)
import { PostModel } from "./post.model"; // โ
OK - ๋ ๋ค boundary
export class UserModel extends BaseModel {
async getPosts() {
return PostModel.findMany({ where: { userId: this.id } });
}
}
์ด๊ฒ์ด ๊ฐ๋ฅํ ์ด์ :
- ๋ ํ์ผ ๋ชจ๋ Boundary์ด๋ฏ๋ก
- ๋ ๋ค Syncer์ ์ํด ๋์ ์ผ๋ก ๋ก๋๋จ
- ์๋ก ์ฐธ์กฐํด๋ HMR์ด ์ ์ ์๋ํจ
3. Non-boundary์ Boundary Import ์ ํ
Non-boundary ํ์ผ์ด Boundary๋ฅผ ์ ์ importํ๋ฉด HMR์ด ์๋ํ์ง ์์ต๋๋ค:
// server.ts (non-boundary)
import { UserModel } from "./user.model"; // โ ์ ์ฒด ์ฌ์์ ํ์
// server.ts (non-boundary) - ์ฌ๋ฐ๋ฅธ ๋ฐฉ๋ฒ
async function loadModels() {
const { UserModel } = await import("./user.model"); // โ
OK
}
์ค์ ์๋๋ฆฌ์ค
User-Post ๊ด๊ณ์์ HMR ํ์ฉํ๊ธฐ
์ํฉ: User Entity์ role ํ๋๋ฅผ ์ถ๊ฐํ๊ณ , Post API์์ ์์ฑ์ ๊ถํ ์ฒดํฌ๋ฅผ ์ถ๊ฐํ๋ ค๊ณ ํฉ๋๋ค.
1๋จ๊ณ: Entity ์์
// Sonamu UI์์ User์ role ์ถ๊ฐ
role: EnumProp<"admin" | "user">
์ ์ฅํ๋ฉด HMR์ด ์๋์ผ๋ก:
user.entity.ts ์
๋ฐ์ดํธ
user.types.ts ์ฌ์์ฑ
UserModel ์ฌ๋ก๋
์ฝ์ ์ถ๋ ฅ:
๐ Invalidated:
- src/application/user/user.entity.ts
- src/application/user/user.model.ts
2๋จ๊ณ: Model ๋ก์ง ์ถ๊ฐ
// user.model.ts
export class UserModel extends BaseModel {
isAdmin(): boolean {
return this.role === "admin";
}
canCreatePost(): boolean {
return this.isAdmin() || this.role === "user";
}
}
์ ์ฅํ๋ฉด HMR์ด:
UserModel ์ฌ๋ก๋
UserModel์ ์ฌ์ฉํ๋ ๋ชจ๋ API ์ฌ๋ก๋
3๋จ๊ณ: API์ ๊ถํ ์ฒดํฌ ์ถ๊ฐ
// post.api.ts
import { UserModel } from "../user/user.model"; // โ
๋ ๋ค boundary
@api({ httpMethod: "POST" })
async create(body: PostForm) {
const user = await UserModel.findById(body.userId);
if (!user.canCreatePost()) { // ๐ ๋ฐฉ๊ธ ์ถ๊ฐํ ๋ฉ์๋ ๋ฐ๋ก ์ฌ์ฉ!
throw new UnauthorizedError("You don't have permission to create posts");
}
return PostModel.save(body);
}
์ ์ฅํ๋ฉด HMR์ด:
PostApi.create ์ฌ๋ฑ๋ก
- ์๋ฒ ์ฌ์์ ์์ด ๋ฐ๋ก ํ
์คํธ ๊ฐ๋ฅ!
๐ Invalidated:
- src/application/post/post.api.ts
โจ API re-registered: POST /api/post/create
์ ํต์ ๋ฐฉ์์ด๋ผ๋ฉด:
- Entity ์์
- Types ํ์ผ ์๋ ์์ฑ
- Model ํ์ผ ์์
- ์๋ฒ ์ฌ์์ (30์ด)
- API ํ์ผ ์์
- ์๋ฒ ์ฌ์์ (30์ด)
- ํ
์คํธ
Sonamu + HMR:
- Entity ์์ (์๋ ์์ฑ)
- Model ํ์ผ ์์ (์๋ ์ฌ๋ก๋)
- API ํ์ผ ์์ (์๋ ์ฌ๋ก๋)
- ํ
์คํธ โ
3๋ฒ์ ์ฌ์์(90์ด)์ด 0๋ฒ์ผ๋ก!
๋ณต์กํ ๋น์ฆ๋์ค ๋ก์ง ๊ฐ๋ฐ
์ํฉ: ์ฃผ๋ฌธ ์์ฑ ์ ์ฌ๊ณ ํ์ธ, ๊ฒฐ์ ์ฒ๋ฆฌ, ์๋ฆผ ๋ฐ์ก์ ๊ตฌํํฉ๋๋ค.
ํ์ผ ๊ตฌ์กฐ:
1๋จ๊ณ: ์ฌ๊ณ ์ฒดํฌ ๋ก์ง
// product.model.ts
export class ProductModel extends BaseModel {
hasStock(quantity: number): boolean {
return this.stock >= quantity;
}
async reserveStock(quantity: number) {
if (!this.hasStock(quantity)) {
throw new BadRequestError("Out of stock");
}
await this.update({ stock: this.stock - quantity });
}
}
์ ์ฅ โ 2์ด ํ โ ์ฆ์ ๋ฐ์ โ
2๋จ๊ณ: ์ฃผ๋ฌธ ์์ฑ ๋ก์ง
// order.model.ts
import { ProductModel } from "../product/product.model"; // โ
OK
import { PaymentService } from "../payment/payment.service";
export class OrderModel extends BaseModel {
static async createOrder(productId: number, quantity: number, userId: number) {
const product = await ProductModel.findById(productId);
// ProductModel.reserveStock()์ ๋ฐ๋ก ์ฌ์ฉ ๊ฐ๋ฅ!
await product.reserveStock(quantity);
const order = await this.save({
productId,
quantity,
userId,
totalPrice: product.price * quantity,
status: "pending",
});
return order;
}
}
์ ์ฅ โ ProductModel ์์กด์ฑ๋ ์๋ ์ฌ๋ก๋ โ
3๋จ๊ณ: API ์๋ํฌ์ธํธ
// order.api.ts
import { OrderModel } from "./order.model";
@api({ httpMethod: "POST" })
async create(body: { productId: number; quantity: number }) {
const userId = getSonamuContext().userId!;
// OrderModel.createOrder()๋ฅผ ๋ฐ๋ก ์ฌ์ฉ!
const order = await OrderModel.createOrder(
body.productId,
body.quantity,
userId
);
return order;
}
์ ์ฅ โ API ์ฌ๋ฑ๋ก โ ์ฆ์ Postman์์ ํ
์คํธ!
HMR ๋ก๊ทธ:
๐ Invalidated:
- src/application/product/product.model.ts
- src/application/order/order.model.ts
- src/application/order/order.api.ts (with 3 APIs)
โจ API re-registered: POST /api/order/create
โ
All files are synced!
Boundary ํ์ผ์์๋ import.meta.hot API๋ฅผ ์ฌ์ฉํ์ฌ HMR ๋์์ ์ธ๋ฐํ๊ฒ ์ ์ดํ ์ ์์ต๋๋ค.
dispose() - ๋ฆฌ์์ค ์ ๋ฆฌ
๋ชจ๋์ด ์ฌ๋ก๋๋๊ธฐ ์ ์ ์ ๋ฆฌ ์์
์ ์ํํฉ๋๋ค:
// notification.service.ts
export class NotificationService {
private static timer: NodeJS.Timeout;
static startPolling() {
this.timer = setInterval(() => {
console.log("Checking notifications...");
}, 5000);
}
}
// ์ฌ๋ก๋ ์ ํ์ด๋จธ ์ ๋ฆฌ
import.meta.hot?.dispose(() => {
clearInterval(NotificationService.timer);
console.log("Cleaned up notification polling timer");
});
๋ฆฌ์์ค ์ ๋ฆฌ๊ฐ ํ์ํ ๊ฒฝ์ฐ:
- ํ์ด๋จธ/์ธํฐ๋ฒ ์ ๋ฆฌ
- ์ด๋ฒคํธ ๋ฆฌ์ค๋ ์ ๊ฑฐ
- WebSocket ์ฐ๊ฒฐ ์ข
๋ฃ
- ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ปค๋ฅ์
ํ ์ ๋ฆฌ
- ํ์ผ ํธ๋ค ๋ซ๊ธฐ
์ค์ ์ฌ์ฉ ์์:
// websocket-manager.ts
class WebSocketManager {
private static connections = new Map<string, WebSocket>();
static connect(url: string) {
const ws = new WebSocket(url);
this.connections.set(url, ws);
return ws;
}
}
import.meta.hot?.dispose(() => {
// ๋ชจ๋ WebSocket ์ฐ๊ฒฐ ์ข
๋ฃ
for (const [url, ws] of WebSocketManager.connections) {
ws.close();
console.log(`Closed WebSocket: ${url}`);
}
WebSocketManager.connections.clear();
});
decline() - ์ ์ฒด ์ฌ์์ ์๊ตฌ
ํน์ ๋ชจ๋์ HMR์์ ์ ์ธํ๊ณ ์ ์ฒด ์ฌ์์์ ์๊ตฌํฉ๋๋ค:
// config.ts
export const config = {
database: {
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
},
redis: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT),
},
};
// ์ด ํ์ผ์ด ๋ณ๊ฒฝ๋๋ฉด ์ ์ฒด ์ฌ์์
import.meta.hot?.decline();
decline()์ ์ฌ์ฉํด์ผ ํ๋ ๊ฒฝ์ฐ:
- ์ ์ญ ์ค์ ํ์ผ
- ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ฒฐ ์ค์
- ์ด๊ธฐํ๊ฐ ๋ณต์กํ ์ฑ๊ธํค
- ์ํ ๊ด๋ฆฌ๊ฐ ๊น๋ค๋ก์ด ๋ชจ๋
์ค์ ์ฌ์ฉ ์์:
// database.config.ts
import { Sonamu } from "@sonamu-kit/sonamu";
export const dbPool = new Pool({
host: Sonamu.config.database.host,
port: Sonamu.config.database.port,
user: Sonamu.config.database.user,
password: Sonamu.config.database.password,
database: Sonamu.config.database.database,
});
// DB ์ฐ๊ฒฐ ์ค์ ๋ณ๊ฒฝ์ ์ฌ์์ ํ์
import.meta.hot?.decline();
boundary ๊ฐ์ฒด - ์ํ ๊ณต์
Boundary ๊ฐ ๋ฐ์ดํฐ๋ฅผ ๊ณต์ ํ์ฌ ์ฌ๋ก๋ ํ์๋ ์ํ๋ฅผ ์ ์งํฉ๋๋ค:
// cache-manager.ts
const cache = import.meta.hot?.boundary.userCache || new Map();
export class CacheManager {
static set(key: string, value: any) {
cache.set(key, value);
}
static get(key: string) {
return cache.get(key);
}
}
// ์ฌ๋ก๋ ์์๋ ์บ์ ์ ์ง
import.meta.hot?.boundary.userCache = cache;
์ค์ ์ฌ์ฉ ์์:
// rate-limiter.ts
const requestCounts = import.meta.hot?.boundary.requestCounts || new Map<string, number>();
export class RateLimiter {
static check(userId: string): boolean {
const count = requestCounts.get(userId) || 0;
if (count >= 100) {
return false; // Rate limit exceeded
}
requestCounts.set(userId, count + 1);
return true;
}
}
// HMR ํ์๋ ์์ฒญ ์นด์ดํธ ์ ์ง
import.meta.hot?.boundary.requestCounts = requestCounts;
boundary ๊ฐ์ฒด๋ HMR ์ฌ์ดํด ๊ฐ ๋ฐ์ดํฐ๋ฅผ ์ ์งํ์ง๋ง, ์๋ฒ ์ฌ์์ ์์๋ ์ด๊ธฐํ๋ฉ๋๋ค. ์๊ตฌ ์ ์ฅ์ด ํ์ํ ๋ฐ์ดํฐ๋ ๋ฐ์ดํฐ๋ฒ ์ด์ค๋ Redis๋ฅผ ์ฌ์ฉํ์ธ์.
ํ์
์ ์
TypeScript์์ import.meta.hot์ ์ฌ์ฉํ๋ ค๋ฉด ํ์
์ ์๊ฐ ํ์ํฉ๋๋ค:
// src/types/import-meta.d.ts
interface ImportMeta {
readonly hot?: {
dispose(callback: () => Promise<void> | void): void;
decline(): void;
boundary: Record<string, any>;
};
}
Sonamu ํ๋ก์ ํธ์๋ ์ด๋ฏธ ํ์
์ ์๊ฐ ํฌํจ๋์ด ์์ผ๋ฏ๋ก ๋ณ๋ ์ค์ ์ด ํ์ ์์ต๋๋ค.
๋๋ฒ๊น
Boundary ์ค์ ํ์ธ
ํน์ ํ์ผ์ด Boundary๋ก ์ค์ ๋์ด ์๋์ง ํ์ธ:
const dump = await hot.dump();
const userModel = dump.find(d => d.nodePath.includes("user.model.ts"));
console.log(`Boundary: ${userModel?.boundary}`); // true
console.log(`Reloadable: ${userModel?.reloadable}`); // true
console.log(`Children:`, userModel?.children); // ์์กดํ๋ ํ์ผ๋ค
์ถ๋ ฅ ์์:
{
"nodePath": "/project/src/application/user/user.model.ts",
"boundary": true,
"reloadable": true,
"children": [
"/project/src/application/user/user.api.ts",
"/project/src/application/post/post.api.ts",
"/project/src/application/admin/admin.api.ts"
]
}
์์กด์ฑ ํธ๋ฆฌ ์๊ฐํ
const dump = await hot.dump();
for (const node of dump) {
if (node.boundary) {
console.log(`๐ฆ ${node.nodePath}`);
for (const child of node.children || []) {
console.log(` โโ ${child}`);
}
}
}
์ถ๋ ฅ:
๐ฆ /project/src/application/user/user.model.ts
โโ /project/src/application/user/user.api.ts
โโ /project/src/application/post/post.api.ts
๐ฆ /project/src/application/post/post.model.ts
โโ /project/src/application/post/post.api.ts
๊ฐ๋ฐ ์ค HMR์ด ์๋ํ์ง ์๋๋ค๋ฉด:
- ํด๋น ํ์ผ์ด Boundary๋ก ์ง์ ๋์ด ์๋์ง ํ์ธ
- ๋์ ์ผ๋ก import๋๋์ง ํ์ธ
- ์ํ ์์กด์ฑ์ด ์๋์ง ํ์ธ
์ฑ๋ฅ ์ต์ ํ
๋ถํ์ํ Boundary ์ ์ธ
๋ชจ๋ ํ์ผ์ Boundary๋ก ์ค์ ํ๋ฉด ์์กด์ฑ ํธ๋ฆฌ๊ฐ ์ปค์ ธ์ ๋๋ ค์ง ์ ์์ต๋๋ค:
// hmr-hook-register.ts
await hot.init({
rootDirectory: process.env.API_ROOT_PATH,
boundaries: [
// ์์ฃผ ๋ณ๊ฒฝ๋๋ ํ์ผ๋ง Boundary๋ก
"./src/**/*.model.ts",
"./src/**/*.api.ts",
"./src/**/*.service.ts",
],
});
์์กด์ฑ ์ต์ํ
Model ๊ฐ ์ํ ์ฐธ์กฐ๋ฅผ ํผํ๊ณ , ํ์ํ ๊ฒ๋ง import:
// โ ๋์ ์
import { UserModel } from "../user/user.model";
import { PostModel } from "../post/post.model";
import { CommentModel } from "../comment/comment.model";
// ... ๋ง์ import
// โ
์ข์ ์
import { UserModel } from "../user/user.model"; // ์ค์ ๋ก ์ฌ์ฉํ๋ ๊ฒ๋ง
Sonamu์ Boundary ์์คํ
:
โ
์๋ ์ฒ๋ฆฌ: Syncer๊ฐ Entity ๊ธฐ๋ฐ ํ์ผ์ ์๋์ผ๋ก ๋์ import
โ
Boundary ๊ฐ ์ฐธ์กฐ ๊ฐ๋ฅ: Model๋ผ๋ฆฌ ์ ์ import ํ์ฉ
โ
์ธ๋ฐํ ์ ์ด: import.meta.hot API๋ก ๋ฆฌ์์ค ์ ๋ฆฌ, ์ํ ์ ์ง
โ
๋๋ฒ๊น
๋๊ตฌ: hot.dump()๋ก ์์กด์ฑ ํธ๋ฆฌ ํ์ธ
๋๋ถ๋ถ์ ๊ฒฝ์ฐ Boundary๋ฅผ ์ ๊ฒฝ ์ธ ํ์ ์์ด Sonamu๊ฐ ์๋์ผ๋ก ์ฒ๋ฆฌํฉ๋๋ค!