메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°

μ„œλ²„ μž¬μ‹œμž‘ 없이 κ°œλ°œν•˜κΈ°

Sonamu둜 κ°œλ°œν•  λ•ŒλŠ” μ΄λ ‡μŠ΅λ‹ˆλ‹€:
  1. μ½”λ“œ μˆ˜μ •
  2. μ €μž₯ (Cmd+S)
  3. 끝! πŸ‘ˆ λ°”λ‘œ 반영됨
λΈŒλΌμš°μ €λ₯Ό μƒˆλ‘œκ³ μΉ¨ν•˜κ±°λ‚˜, μ„œλ²„λ₯Ό μž¬μ‹œμž‘ν•˜κ±°λ‚˜, λΉŒλ“œ λͺ…λ Ήμ–΄λ₯Ό μ‹€ν–‰ν•  ν•„μš”κ°€ μ—†μŠ΅λ‹ˆλ‹€.

μ‹€μ œ 개발 속도 차이

HMR 없이 (전톡적 방식):
# Entity μˆ˜μ • β†’ μ„œλ²„ μž¬μ‹œμž‘ β†’ ν…ŒμŠ€νŠΈ
1. Sonamu UIμ—μ„œ Entity μˆ˜μ •
2. Ctrl+C (μ„œλ²„ μ’…λ£Œ)
3. pnpm dev (μ„œλ²„ μž¬μ‹œμž‘)
4. 20~30초 λŒ€κΈ°...
5. ν…ŒμŠ€νŠΈ

λ°˜λ³΅ν•  λ•Œλ§ˆλ‹€ 30μ΄ˆμ”© λ‚­λΉ„ 😫
HMR μžˆμ„ λ•Œ (Sonamu 방식):
# Entity μˆ˜μ • β†’ μ¦‰μ‹œ 반영
1. Sonamu UIμ—μ„œ Entity μˆ˜μ •
2. 2초 ν›„ μ½˜μ†”μ— πŸ”„ Invalidated 둜그
3. ν…ŒμŠ€νŠΈ

λ°˜λ³΅ν•΄λ„ 2초면 μΆ©λΆ„ πŸš€
ν•˜λ£¨μ— Entity/APIλ₯Ό 50번 μˆ˜μ •ν•œλ‹€λ©΄?
  • HMR 없이: 50 Γ— 30초 = 25λΆ„ λ‚­λΉ„
  • HMR μžˆμ„ λ•Œ: 50 Γ— 2초 = 1.7λΆ„

λ‹€λ₯Έ ν”„λ ˆμž„μ›Œν¬μ™€μ˜ 차이

ν”„λ ˆμž„μ›Œν¬HMR μ§€μ›μžλ™ 생성 μ½”λ“œ 싱크Entity λ³€κ²½ 반영
NestJS❌ μ—†μŒ-μˆ˜λ™ μž¬μ‹œμž‘ ν•„μš”
Express❌ μ—†μŒ-nodemon μž¬μ‹œμž‘
Fastify⚠️ λΆ€λΆ„ 지원-μˆ˜λ™ μž¬μ‹œμž‘ ν•„μš”
Sonamuβœ… μ™„μ „ μ§€μ›βœ… μžλ™βœ… μ¦‰μ‹œ 반영

Sonamu만의 νŠΉλ³„ν•¨

일반적인 Node.js HMR:
  • λ‹¨μˆœνžˆ 파일 μž¬λ‘œλ“œλ§Œ 함
  • μžλ™ 생성 μ½”λ“œλŠ” 직접 관리 ν•„μš”
  • Entity λ³€κ²½ μ‹œ μ—¬λŸ¬ 파일 μˆ˜λ™ μˆ˜μ •
Sonamu HMR:
  • 파일 μž¬λ‘œλ“œ + Syncer μžλ™ μ‹€ν–‰
  • Entity λ³€κ²½ β†’ λͺ¨λ“  κ΄€λ ¨ μ½”λ“œ μžλ™ 생성/μ—…λ°μ΄νŠΈ
  • μ˜μ‘΄ν•˜λŠ” λͺ¨λ“  파일 μžλ™ μž¬λ‘œλ“œ
  • API μžλ™ μž¬λ“±λ‘

μ‹€μ œ 개발 μ‹œλ‚˜λ¦¬μ˜€

μ‹œλ‚˜λ¦¬μ˜€ 1: Entity ν•„λ“œ μΆ”κ°€ν•˜κΈ°

User Entity에 nickname ν•„λ“œλ₯Ό μΆ”κ°€ν•˜λŠ” κ³Όμ •: 1단계: Sonamu UIμ—μ„œ ν•„λ“œ μΆ”κ°€
// User Entity에 nickname μΆ”κ°€
nickname: StringProp({ maxLength: 50 });
2단계: μ €μž₯ν•˜λ©΄ μ¦‰μ‹œ
  • user.entity.ts μžλ™ μ—…λ°μ΄νŠΈ
  • user.types.ts νƒ€μž… μžλ™ 생성
  • user.zod.ts Zod μŠ€ν‚€λ§ˆ μžλ™ 생성
  • UserModel μžλ™ μž¬λ‘œλ“œ
  • λͺ¨λ“  UserApi λ©”μ„œλ“œ μžλ™ μž¬λ“±λ‘
  • ν”„λ‘ νŠΈμ—”λ“œ UserService μžλ™ μ—…λ°μ΄νŠΈ
3단계: μ½˜μ†” 확인
πŸ”„ Invalidated:
- src/application/user/user.entity.ts
- src/application/user/user.model.ts (with 8 APIs)
- src/application/user/user.api.ts

βœ… All files are synced!
4단계: ν…ŒμŠ€νŠΈ
# μ„œλ²„ μž¬μ‹œμž‘ 없이 λ°”λ‘œ API 호좜
curl http://localhost:3000/api/user/1
{
  "id": 1,
  "name": "John",
  "nickname": null  // πŸ‘ˆ μƒˆ ν•„λ“œκ°€ μ¦‰μ‹œ 반영됨!
}
⏱️ HMR μžˆμ„ λ•Œ: 2초
⏱️ HMR 없을 λ•Œ: 30초 (μ„œλ²„ μž¬μ‹œμž‘ + 재컴파일)

μ‹œλ‚˜λ¦¬μ˜€ 2: API 둜직 μˆ˜μ •ν•˜κΈ°

UserApi.list()에 νŽ˜μ΄μ§€λ„€μ΄μ…˜κ³Ό 검색 κΈ°λŠ₯ μΆ”κ°€:
// user.api.ts μˆ˜μ • μ „
@api({ httpMethod: "GET" })
async list() {
  return UserModel.findMany();
}
// user.api.ts μˆ˜μ • ν›„
@api({ httpMethod: "GET" })
async list(listParams: ListParams & { keyword?: string }) {
  const where = listParams.keyword
    ? { name: { $like: `%${listParams.keyword}%` } }
    : undefined;

  return UserModel.findMany({
    where,
    limit: listParams.num,
    offset: (listParams.page - 1) * listParams.num,
  });
}
μ €μž₯ν•˜λ©΄:
πŸ”„ Invalidated:
- src/application/user/user.api.ts

✨ API re-registered: GET /api/user/list
μ¦‰μ‹œ Postman/Thunder Clientμ—μ„œ ν…ŒμŠ€νŠΈ κ°€λŠ₯!
GET /api/user/list?keyword=john&page=1&num=10
# λ°”λ‘œ λ™μž‘ν•¨ βœ…

μ‹œλ‚˜λ¦¬μ˜€ 3: Model λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 μΆ”κ°€

User의 ν™œμ„± μƒνƒœ 체크 둜직 μΆ”κ°€:
// user.model.ts
export class UserModel extends BaseModelClass {
  static async findActive() {
    return this.findMany({
      where: { status: "active", deletedAt: null },
    });
  }

  isActive(): boolean {
    return this.status === "active" && !this.deletedAt;
  }
}
μ €μž₯ β†’ 2초 ν›„ β†’ λ‹€λ₯Έ APIμ—μ„œ λ°”λ‘œ μ‚¬μš© κ°€λŠ₯:
// post.api.ts
@api({ httpMethod: "POST" })
async create(body: PostForm) {
  const user = await UserModel.findById(body.userId);

  if (!user.isActive()) {  // πŸ‘ˆ 방금 μΆ”κ°€ν•œ λ©”μ„œλ“œ λ°”λ‘œ μ‚¬μš©!
    throw new BadRequestError("User is not active");
  }

  return PostModel.save(body);
}

Sonamu HMR의 ν˜μ‹ : Syncer 톡합

일반적인 Node.js HMR은 β€œνŒŒμΌμ΄ λ°”λ€Œμ—ˆλ„€? λ‹€μ‹œ λΆˆλŸ¬μ˜€μžβ€κ°€ μ „λΆ€μž…λ‹ˆλ‹€. ν•˜μ§€λ§Œ SonamuλŠ” λ‹€λ¦…λ‹ˆλ‹€.

문제: μžλ™ 생성 μ½”λ“œμ˜ λ”œλ ˆλ§ˆ

SonamuλŠ” Entity 기반으둜 μˆ˜λ§Žμ€ μ½”λ“œλ₯Ό μžλ™ μƒμ„±ν•©λ‹ˆλ‹€:
  • TypeScript νƒ€μž… 파일 (*.types.ts)
  • Zod μŠ€ν‚€λ§ˆ (*.zod.ts)
  • API 라우트 등둝
  • ν”„λ‘ νŠΈμ—”λ“œ Service 클래슀
λ§Œμ•½ Syncer와 HMR이 λ”°λ‘œ λ™μž‘ν•œλ‹€λ©΄:
User Entity μˆ˜μ •
  ↓
Syncerκ°€ 파일 생성 μ‹œμž‘...
  ↓
HMR이 κ°μ§€ν•΄μ„œ μž¬λ‘œλ“œ μ‹œμž‘... ← 🚨 아직 생성 쀑인데!
  ↓
타이밍 κΌ¬μž„ β†’ 였λ₯˜ λ°œμƒ

Sonamu의 ν•΄κ²°μ±…

Syncerκ°€ HMR을 직접 μ œμ–΄ν•©λ‹ˆλ‹€:
// syncer.ts
async syncFromWatcher(event: string, diffFilePath: AbsolutePath) {
  // 1. HMRμ—κ²Œ 파일 λ¬΄νš¨ν™” μš”μ²­
  const invalidatedPaths = await hot.invalidateFile(diffFilePath, event);

  // 2. μžλ™ 생성 μ½”λ“œ 싱크 μ™„λ£Œ (μˆœμ„œ 보μž₯!)
  await this.doSyncActions([diffFilePath]);

  // 3. 이제 μ•ˆμ „ν•˜κ²Œ λͺ¨λ“ˆ μž¬λ‘œλ“œ
  await this.autoloadTypes();
  await this.autoloadModels();
  await this.autoloadApis();
  await this.autoloadWorkflows();

  // 4. μ™„λ£Œ!
  this.eventEmitter.emit("onHMRCompleted");
}
μˆœμ„œκ°€ 보μž₯λ˜λ―€λ‘œ:
  1. μ½”λ“œ 생성이 μ™„μ „νžˆ λλ‚œ ν›„
  2. μž¬λ‘œλ“œκ°€ μ‹œμž‘λ¨
  3. 항상 μ΅œμ‹  μ½”λ“œκ°€ λ‘œλ“œλ¨ βœ…
이것이 Sonamu HMR이 λ‹¨μˆœνžˆ β€œλΉ λ₯Έβ€ 것을 λ„˜μ–΄ μ•ˆμ „ν•˜κ³  믿을 수 μžˆλŠ” μ΄μœ μž…λ‹ˆλ‹€.

HMR μ•„ν‚€ν…μ²˜

μ£Όμš” μ»΄ν¬λ„ŒνŠΈ

Sonamu의 HMR μ‹œμŠ€ν…œμ€ μ„Έ κ°€μ§€ 핡심 μ»΄ν¬λ„ŒνŠΈλ‘œ κ΅¬μ„±λ©λ‹ˆλ‹€: 1. @sonamu-kit/hmr-hook hot-hook을 forkν•˜μ—¬ Sonamu에 맞게 μ»€μŠ€ν„°λ§ˆμ΄μ§•ν•œ νŒ¨ν‚€μ§€μž…λ‹ˆλ‹€.
// hmr-hook-register.ts
if (process.env.HOT === "yes" && process.env.API_ROOT_PATH) {
  const { hot } = await import("@sonamu-kit/hmr-hook");

  await hot.init({
    rootDirectory: process.env.API_ROOT_PATH,
    boundaries: [`./src/**/*.ts`], // λͺ¨λ“  .ts 파일이 HMR λŒ€μƒ
  });

  console.log("πŸ”₯ HMR-hook initialized");
}
Node.js의 Module Loader APIλ₯Ό μ‚¬μš©ν•˜μ—¬ λͺ¨λ“ˆ λ‘œλ”© 과정에 κ°œμž…ν•©λ‹ˆλ‹€. 2. Dependency Tree 파일 κ°„ μ˜μ‘΄μ„±μ„ 트리 ꡬ쑰둜 μΆ”μ ν•©λ‹ˆλ‹€:
user.model.ts
  β”œβ”€ user.api.ts
  β”œβ”€ post.api.ts
  └─ admin.api.ts
user.model.tsκ°€ λ³€κ²½λ˜λ©΄ μ˜μ‘΄ν•˜λŠ” 3개 νŒŒμΌλ„ ν•¨κ»˜ μž¬λ‘œλ“œλ©λ‹ˆλ‹€. μ˜ˆμ‹œ: user.model.tsκ°€ λ³€κ²½λ˜λ©΄ μ˜μ‘΄ν•˜λŠ” 3개 파일(user.api.ts, post.api.ts, admin.api.ts)도 ν•¨κ»˜ μž¬λ‘œλ“œλ©λ‹ˆλ‹€. 3. Syncer 파일 변경을 κ°μ§€ν•˜κ³  HMRκ³Ό μ½”λ“œ 생성을 μ‘°μœ¨ν•©λ‹ˆλ‹€:
// 파일 μ›Œμ²˜
watcher.on("change", async (filePath) => {
  await syncer.syncFromWatcher("change", filePath);
});

HMR ν”„λ‘œμ„ΈμŠ€ 상세

κ°œλ°œμžκ°€ νŒŒμΌμ„ μˆ˜μ •ν•˜λ©΄ λ‹€μŒ 과정이 μžλ™μœΌλ‘œ μ‹€ν–‰λ©λ‹ˆλ‹€:

1. 파일 λ³€κ²½ 감지

// chokidar둜 νŒŒμΌμ‹œμŠ€ν…œ κ°μ‹œ
watcher.on("change", (filePath) => {
  console.log(`File changed: ${filePath}`);
});

2. λͺ¨λ“ˆ λ¬΄νš¨ν™”

// ESM μΊμ‹œ 제거
const invalidatedPaths = await hot.invalidateFile(diffFilePath, "change");
// ["src/user/user.model.ts", "src/user/user.api.ts", ...]
λ³€κ²½λœ 파일의 ESM import μΊμ‹œλ₯Ό μ œκ±°ν•˜κ³ , Dependency Treeλ₯Ό νƒμƒ‰ν•˜μ—¬ μ˜μ‘΄ν•˜λŠ” λͺ¨λ“  파일의 μΊμ‹œλ„ μ œκ±°ν•©λ‹ˆλ‹€.

3. μžλ™ 생성 μ½”λ“œ 싱크

Entityλ‚˜ Model이 λ³€κ²½λœ 경우, Syncerκ°€ κ΄€λ ¨ μ½”λ“œλ₯Ό μžλ™ μƒμ„±ν•©λ‹ˆλ‹€:
await this.doSyncActions([diffFilePath]);
μƒμ„±λ˜λŠ” 파일:
  • TypeScript νƒ€μž… 파일 (user.types.ts)
  • Zod μŠ€ν‚€λ§ˆ (user.zod.ts)
  • ν”„λ‘ νŠΈμ—”λ“œ Service (UserService.ts)

4. λͺ¨λ“ˆ μž¬λ‘œλ“œ

await this.autoloadTypes();
await this.autoloadModels();
await this.autoloadApis();
await this.autoloadWorkflows();
λ¬΄νš¨ν™”λœ λͺ¨λ“ˆμ€ μΊμ‹œκ°€ 제거된 μƒνƒœμ΄λ―€λ‘œ μ΅œμ‹  μ½”λ“œκ°€ λ‘œλ“œλ©λ‹ˆλ‹€.

5. API μž¬λ“±λ‘

Model 파일이 λ³€κ²½λ˜λ©΄ ν•΄λ‹Ή Model의 λͺ¨λ“  APIλ₯Ό μž¬λ“±λ‘ν•©λ‹ˆλ‹€:
removeInvalidatedRegisteredApis(invalidatedPath: AbsolutePath) {
  const entityId = EntityManager.getEntityIdFromPath(invalidatedPath);
  const toRemove = registeredApis.filter((api) => api.modelName === `${entityId}Model`);

  for (const api of toRemove) {
    registeredApis.splice(registeredApis.indexOf(api), 1);
  }

  return toRemove;
}
μ½˜μ†” 좜λ ₯:
πŸ”„ Invalidated:
- src/user/user.model.ts (with 8 APIs)

원본 hot-hook과의 차이점

Sonamu의 @sonamu-kit/hmr-hook은 원본 hot-hook을 forkν•˜μ—¬ λ‹€μŒκ³Ό 같이 κ°œμ„ ν–ˆμŠ΅λ‹ˆλ‹€:

1. λ³€μˆ˜ 기반 동적 Import ν—ˆμš©

// 원본 hot-hook: ❌ λΆˆκ°€λŠ₯
const modelPath = `./models/${entityId}.model`;
await import(modelPath); // μ—λŸ¬ λ°œμƒ!

// Sonamu hmr-hook: βœ… κ°€λŠ₯
const modelPath = `./models/${entityId}.model`;
await import(modelPath); // 정상 λ™μž‘
SonamuλŠ” Entity 기반 ꡬ쑰라 경둜λ₯Ό λ™μ μœΌλ‘œ 생성해야 ν•˜λ―€λ‘œ 이 κΈ°λŠ₯이 ν•„μˆ˜μ μž…λ‹ˆλ‹€.

2. Boundary κ°„ 정적 Import ν—ˆμš©

// user.model.ts (boundary)
import { PostModel } from "./post.model"; // 원본: ❌ / Sonamu: βœ…

export class UserModel extends BaseModelClass {
  async getPosts() {
    return PostModel.findMany({ where: { userId: this.id } });
  }
}
Model κ°„ μ°Έμ‘°κ°€ 자주 λ°œμƒν•˜λ―€λ‘œ 정적 importλ₯Ό ν—ˆμš©ν–ˆμŠ΅λ‹ˆλ‹€.

3. νŒŒμΌμ‹œμŠ€ν…œ μ›Œμ²˜ λΉ„ν™œμ„±ν™”

원본은 자체 파일 μ›Œμ²˜λ₯Ό μ‚¬μš©ν•˜μ§€λ§Œ, SonamuλŠ” Syncer의 μ›Œμ²˜λ§Œ μ‚¬μš©ν•©λ‹ˆλ‹€:
// Syncerκ°€ 파일 변경을 κ°μ§€ν•˜λ©΄ HMR에 직접 μ•Œλ¦Ό
await hot.invalidateFile(filePath, "change");
이λ₯Ό 톡해 μ½”λ“œ 생성과 λͺ¨λ“ˆ λ¬΄νš¨ν™”μ˜ μˆœμ„œλ₯Ό μ •ν™•νžˆ μ œμ–΄ν•©λ‹ˆλ‹€.

4. ts-loaderμ™€μ˜ 톡합 κ°œμ„ 

TypeScript 컴파일 ν›„ dist/*.js κ²½λ‘œμ™€ 원본 src/*.ts 경둜의 뢈일치λ₯Ό ν•΄κ²°ν–ˆμŠ΅λ‹ˆλ‹€.

μ„±λŠ₯ μ΅œμ ν™”

선택적 λ¬΄νš¨ν™”

λ³€κ²½λœ 파일과 κ·Έ μ˜μ‘΄μ„±λ§Œ λ¬΄νš¨ν™”ν•˜λ―€λ‘œ λΆˆν•„μš”ν•œ μž¬λ‘œλ“œλ₯Ό λ°©μ§€ν•©λ‹ˆλ‹€:
// user.types.ts λ³€κ²½ β†’ user.model.ts만 μž¬λ‘œλ“œ
// 전체 ν”„λ‘œμ νŠΈκ°€ μ•„λ‹Œ 영ν–₯λ°›λŠ” 파일만!

체크섬 기반 λ³€κ²½ 감지

μ‹€μ œ λ‚΄μš©μ΄ λ³€κ²½λœ 파일만 μ²˜λ¦¬ν•©λ‹ˆλ‹€:
const changedFiles = await findChangedFilesUsingChecksums();
if (changedFiles.length === 0) {
  console.log(chalk.black.bgGreen("All files are synced!"));
  return;
}
파일이 μ €μž₯λ˜μ—ˆμ–΄λ„ λ‚΄μš©μ΄ κ°™μœΌλ©΄ μ‹±ν¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.

Graceful Shutdown 처리

싱크 μž‘μ—… 쀑 ν”„λ‘œμ„ΈμŠ€ μ’…λ£Œλ₯Ό λ°©μ§€ν•©λ‹ˆλ‹€:
await runWithGracefulShutdown(
  async () => {
    await this.doSyncActions(changedFiles);
    await renewChecksums();
  },
  { whenThisHappens: "SIGUSR2", waitForUpTo: 20000 },
);
Nodemon μž¬μ‹œμž‘ μ‹œκ·Έλ„μ„ 받아도 싱크가 μ™„λ£Œλ  λ•ŒκΉŒμ§€ 20μ΄ˆκ°„ λŒ€κΈ°ν•©λ‹ˆλ‹€.

HMR ν™œμ„±ν™”

개발 λͺ¨λ“œμ—μ„œ μžλ™μœΌλ‘œ ν™œμ„±ν™”λ©λ‹ˆλ‹€:
pnpm dev  # HOT=yesκ°€ μžλ™ 섀정됨
ν™˜κ²½λ³€μˆ˜λ‘œ μ œμ–΄ν•  μˆ˜λ„ μžˆμŠ΅λ‹ˆλ‹€:
# HMR ν™œμ„±ν™”
HOT=yes pnpm dev

# HMR λΉ„ν™œμ„±ν™” (디버깅 μ‹œ)
HOT=no pnpm dev
ν”„λ‘œλ•μ…˜ λΉŒλ“œμ—μ„œλŠ” HMR이 μžλ™μœΌλ‘œ λΉ„ν™œμ„±ν™”λ˜λ©°, 정적 λΉŒλ“œκ°€ μƒμ„±λ©λ‹ˆλ‹€.

개발 팁

HMR μƒνƒœ 확인

// 터미널에 좜λ ₯λ˜λŠ” 둜그 확인
πŸ”₯ HMR-hook initialized

// 파일 λ³€κ²½ μ‹œ
πŸ”„ Invalidated:
- src/user/user.model.ts (with 8 APIs)
- src/user/user.api.ts

βœ… All files are synced!

λŠλ €μ§„ 것 κ°™λ‹€λ©΄

νŒŒμΌμ„ μˆ˜μ •ν–ˆλŠ”λ° 반영이 λŠλ €μ§„ 것 κ°™λ‹€λ©΄:
# μ˜μ‘΄μ„± 트리 확인
const dump = await hot.dump();
console.log(`Total modules: ${dump.length}`);
console.log(`Boundaries: ${dump.filter(d => d.boundary).length}`);
μ˜μ‘΄μ„±μ΄ λ„ˆλ¬΄ 많으면 import ꡬ쑰λ₯Ό κ°œμ„ ν•˜μ„Έμš”.

μž¬μ‹œμž‘μ΄ ν•„μš”ν•œ 변경사항

λ‹€μŒ νŒŒμΌλ“€μ„ μˆ˜μ •ν•˜λ©΄ μ„œλ²„λ₯Ό μž¬μ‹œμž‘ν•΄μ•Ό ν•©λ‹ˆλ‹€:
  • sonamu.config.ts - μ„€μ • 파일
  • .env - ν™˜κ²½λ³€μˆ˜
  • package.json - μ˜μ‘΄μ„±
이런 νŒŒμΌλ“€μ€ import.meta.hot?.decline()으둜 ν‘œμ‹œλ˜μ–΄ μžˆμ–΄ λ³€κ²½ μ‹œ μžλ™μœΌλ‘œ μž¬μ‹œμž‘μ„ μš”κ΅¬ν•©λ‹ˆλ‹€.