Skip to content

NestJS Integration

NestJS 통합이 필요한 이유

NestJS는 의존성 주입(DI)으로 서비스를 연결해요. constructor(private readonly userService: UserService)라고 쓰면, NestJS가 DI 컨테이너에서 UserService를 찾아서 자동으로 주입해줘요. 하지만 ORM의 EntityManager나 Repository는 기본적으로 NestJS 서비스가 아니라서, NestJS가 이들을 어떻게 생성하고 언제 DB 연결을 초기화하고 종료해야 하는지 알 수 없어요.

Stingerloom NestJS 통합 모듈은 세 가지 문제를 해결해요:

  1. 라이프사이클 관리 -- NestJS 시작 시 EntityManager를 생성하고 DB 연결을 수립하며, 종료 시 깔끔하게 정리해요.
  2. Repository 주입 -- 엔티티별 Repository를 NestJS provider로 등록해서, 데코레이터 하나로 주입할 수 있어요.
  3. 멀티 DB 지원 -- 여러 데이터베이스에 연결할 때 각 Repository가 올바른 EntityManager에 연결되도록 scoped DI 토큰을 관리해요.

Installation

NestJS 통합은 subpath export로 제공돼요. 별도 패키지 설치가 필요 없어요.

typescript
import { StinglerloomOrmModule } from "@stingerloom/orm/nestjs";

Quick Start

Step 1: forRoot()으로 모듈 등록하기

루트 AppModule에서 forRoot()을 호출해요. DB 연결이 여기서 수립돼요.

typescript
// app.module.ts
import { Module } from "@nestjs/common";
import { StinglerloomOrmModule } from "@stingerloom/orm/nestjs";
import { CatsModule } from "./cats/cats.module";
import { Cat } from "./entities/cat.entity";

@Module({
  imports: [
    StinglerloomOrmModule.forRoot({
      type: "mysql",
      host: "localhost",
      port: 3306,
      username: "root",
      password: "password",
      database: "mydb",
      entities: [Cat],
      synchronize: true, // Development only
    }),
    CatsModule,
  ],
})
export class AppModule {}

forRoot() 내부 동작

StinglerloomOrmModule.forRoot(options)이 처리될 때 네 가지 일이 일어나요:

  1. EntityManager 생성 -- 새 EntityManager 인스턴스를 만들어요.
  2. em.register(options) 호출 -- DB에 연결하고, 엔티티를 스캔하고, 옵션에 따라 스키마를 동기화해요. NestJS factory provider 안에서 실행되기 때문에 모듈 초기화 시점에 동작해요.
  3. EntityManager를 global provider로 등록 -- NestJS DI 컨테이너에 특정 토큰으로 등록돼요. global: true로 설정되어 있어서, 다른 모듈에서 다시 import하지 않아도 어디서든 사용할 수 있어요.
  4. StinglerloomOrmService 생성 -- EntityManager를 감싸고, NestJS 라이프사이클 훅을 구현해요: OnModuleInit으로 초기화하고 OnApplicationShutdown으로 propagateShutdown()을 호출해서 연결을 정리해요.

핵심 포인트: forRoot()는 DB 연결당 한 번만 루트 모듈에서 호출해야 해요.

Step 2: forFeature()로 Feature Module에 엔티티 등록하기

typescript
// cats.module.ts
import { Module } from "@nestjs/common";
import { StinglerloomOrmModule } from "@stingerloom/orm/nestjs";
import { Cat } from "../entities/cat.entity";
import { CatsService } from "./cats.service";
import { CatsController } from "./cats.controller";

@Module({
  imports: [StinglerloomOrmModule.forFeature([Cat])],
  providers: [CatsService],
  controllers: [CatsController],
})
export class CatsModule {}

forFeature() 내부 동작

StinglerloomOrmModule.forFeature([Cat])이 처리되면 각 엔티티에 대해 provider를 만들어요:

  1. 고유 DI 토큰 생성 -- Symbol("INJECT_REPOSITORIES_TOKEN_Cat") 같은 Symbol이에요. 엔티티 클래스를 키로 하는 WeakMap에 캐시되기 때문에, 여러 모듈에서 forFeature([Cat])을 호출해도 항상 같은 토큰을 사용해요.
  2. factory provider 생성 -- forRoot()에서 등록된 EntityManager를 받아서 em.getRepository(Cat)으로 BaseRepository<Cat>을 가져와요.
  3. provider export -- 해당 모듈의 서비스와 컨트롤러에서 주입할 수 있도록 export해요.

결과적으로 NestJS가 생성자에서 @InjectRepository(Cat)을 만나면, 어떤 토큰을 조회해서 어떤 BaseRepository<Cat> 인스턴스를 주입할지 정확히 알 수 있어요.

Step 3: 서비스에 주입하기

typescript
// cats.service.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@stingerloom/orm/nestjs";
import { BaseRepository } from "@stingerloom/orm";
import { Cat } from "../entities/cat.entity";

@Injectable()
export class CatsService {
  constructor(
    @InjectRepository(Cat)
    private readonly catRepo: BaseRepository<Cat>,
  ) {}

  async findAll() {
    return this.catRepo.find();
  }

  async findOne(id: number) {
    return this.catRepo.findOne({ where: { id } as any });
  }

  async create(data: Partial<Cat>) {
    return this.catRepo.save(data);
  }

  async remove(id: number) {
    return this.catRepo.delete({ id });
  }
}

Decorators

@InjectRepository(Entity, connectionName?)

지정한 엔티티의 BaseRepository<T>를 주입해요.

typescript
@InjectRepository(Cat)
private readonly catRepo: BaseRepository<Cat>;

이 데코레이터가 필요한 이유

NestJS에서 생성자 주입은 파라미터 타입을 DI 토큰과 매칭시켜 동작해요. 하지만 BaseRepository<Cat>BaseRepository<Dog>는 런타임에서 같은 타입이에요 -- TypeScript 제네릭은 런타임에 지워지거든요. NestJS가 둘을 구분할 방법이 없어요.

@InjectRepository(Cat)은 생성자 파라미터에 고유한 DI 토큰(Symbol)을 부착해서 이 문제를 해결해요. 토큰이 엔티티 클래스에서 파생되기 때문에 @InjectRepository(Cat)@InjectRepository(Dog)는 서로 다른 토큰을 생성하고, NestJS가 올바른 Repository를 주입할 수 있어요.

내부적으로 @InjectRepository(Cat)@Inject(Symbol("INJECT_REPOSITORIES_TOKEN_Cat"))과 동일해요. 토큰을 수동으로 관리하지 않아도 되도록 데코레이터가 존재하는 거예요.

BaseRepository가 제공하는 메서드: find, findOne, findWithCursor, findAndCount, save, delete, softDelete, restore, insertMany, deleteMany, count, sum, avg, min, max, explain, upsert, stream, createQueryBuilder 등.

@InjectEntityManager(connectionName?)

EntityManager를 직접 주입해요. Repository가 제공하지 않는 기능이 필요할 때 사용해요 -- raw query, 트랜잭션, subscriber 등.

typescript
import { InjectEntityManager } from "@stingerloom/orm/nestjs";
import { EntityManager } from "@stingerloom/orm";

@Injectable()
export class CatsService {
  constructor(
    @InjectEntityManager()
    private readonly em: EntityManager,
  ) {}

  async complexQuery() {
    return this.em.query<{ count: number }>(
      "SELECT COUNT(*) as count FROM cat WHERE age > ?",
      [5],
    );
  }

  async transferWithTransaction(fromId: number, toId: number) {
    return this.em.transaction(async (txEm) => {
      // ... transactional operations
    });
  }
}

Lifecycle Management

StinglerloomOrmService가 NestJS 훅을 통해 EntityManager 라이프사이클을 자동으로 관리해요:

  • OnModuleInit -- EntityManager를 내부 레지스트리에 등록하고 초기화 로그를 남겨요.
  • OnApplicationShutdown -- em.propagateShutdown()을 호출해서 DB 연결 종료, 이벤트 리스너 제거, 엔티티 subscriber 정리, QueryTracker 중지, 플러그인 역순 종료를 수행해요.

NestJS 종료 훅을 사용하려면 main.ts에서 활성화해야 해요:

typescript
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.enableShutdownHooks(); // Required for graceful shutdown
  await app.listen(3000);
}
bootstrap();

enableShutdownHooks()가 없으면 NestJS가 onApplicationShutdown()을 호출하지 않아서, 프로세스 종료 시 DB 연결이 누수될 수 있어요.


Multi-Database Connections

멀티 DB가 필요한 경우

사용자/비즈니스 데이터는 MySQL에, 분석 이벤트는 PostgreSQL에, 감사 로그는 컴플라이언스를 위해 별도 DB에 저장하는 경우가 있어요. 각 DB마다 별도의 EntityManager, 연결, Repository 세트가 필요해요.

DI 프레임워크에서의 문제: 서비스가 BaseRepository<Event>를 요청할 때, NestJS는 어떤 DB에서 가져와야 하는지 어떻게 알 수 있을까요? 답은 token scoping이에요 -- 연결마다 고유한 DI 토큰 세트를 가져요.

Token scoping 동작 원리

forRoot(options, "analytics")를 호출하면, EntityManager가 기본 EntityManager 클래스 토큰 대신 "STINGERLOOM_ENTITY_MANAGER_analytics" 토큰으로 생성돼요. forFeature([Event], "analytics")를 호출하면, Event의 Repository가 Symbol("INJECT_REPOSITORIES_TOKEN_Event") 대신 Symbol("INJECT_REPOSITORIES_TOKEN_Event_analytics")로 등록돼요.

따라서 @InjectRepository(Event)(connection name 없음)과 @InjectRepository(Event, "analytics")는 같은 엔티티 클래스라도 완전히 다른 provider로 resolve돼요.

Setup

typescript
// app.module.ts
@Module({
  imports: [
    // Default connection (MySQL)
    StinglerloomOrmModule.forRoot({
      type: "mysql",
      host: "localhost",
      database: "main_db",
      entities: [User, Post],
      // ...
    }),

    // Named connection (PostgreSQL)
    StinglerloomOrmModule.forRoot(
      {
        type: "postgres",
        host: "analytics-db.example.com",
        database: "analytics",
        entities: [Event, Metric],
        // ...
      },
      "analytics",  // connection name
    ),

    UsersModule,
    AnalyticsModule,
  ],
})
export class AppModule {}

Feature Modules

typescript
// analytics.module.ts
@Module({
  imports: [
    StinglerloomOrmModule.forFeature([Event, Metric], "analytics"),
  ],
  providers: [AnalyticsService],
})
export class AnalyticsModule {}

Named Connection 주입하기

데코레이터의 두 번째 인자로 connection name을 전달해요:

typescript
// analytics.service.ts
@Injectable()
export class AnalyticsService {
  constructor(
    @InjectRepository(Event, "analytics")
    private readonly eventRepo: BaseRepository<Event>,

    @InjectEntityManager("analytics")
    private readonly analyticsEm: EntityManager,
  ) {}
}

connection name을 생략하면 항상 "default" 연결을 사용해요. 기존 단일 DB 앱은 코드 변경 없이 그대로 동작해요.


Event Subscriber 등록하기

모듈 초기화 시점에 EntitySubscriber 인스턴스를 등록해요:

typescript
import { Injectable, OnModuleInit, Inject } from "@nestjs/common";
import { StinglerloomOrmService } from "@stingerloom/orm/nestjs";

@Injectable()
export class CatsSubscriberService implements OnModuleInit {
  constructor(
    @Inject(StinglerloomOrmService)
    private readonly ormService: StinglerloomOrmService,
  ) {}

  onModuleInit() {
    const em = this.ormService.getEntityManager();
    em.addSubscriber(new CatAuditSubscriber());
  }
}

StinglerloomOrmService

이 서비스를 주입해서 EntityManager에 직접 접근할 수 있어요:

typescript
@Inject(StinglerloomOrmService)
private readonly ormService: StinglerloomOrmService;

// Access EntityManager
const em = ormService.getEntityManager();

// Get any repository
const repo = ormService.getRepository(User);

Exported API

모든 항목은 @stingerloom/orm/nestjs에서 import해요:

ExportDescription
StinglerloomOrmModuleforRoot()forFeature()를 제공하는 메인 모듈
StinglerloomOrmServiceEntityManager 라이프사이클을 관리하는 서비스
InjectRepositoryRepository 주입 데코레이터
InjectEntityManagerEntityManager 주입 데코레이터
getEntityManagerToken(name?)수동 DI용 토큰 헬퍼
getOrmServiceToken(name?)서비스 주입용 토큰 헬퍼
makeInjectRepositoryToken(entity, name?)Repository provider용 토큰 헬퍼

Next Steps

Released under the MIT License.