01 — NestJS 통합
한 줄 답: NestJS의 DI 컨테이너에
@nestjs/typeorm이forRoot/forFeature패턴으로 자연스럽게 녹으면서 — TypeORM은 NestJS 생태계의 사실상 표준 ORM이 되었다. 같은 데코레이터 철학(Reflect Metadata), 같은 모듈 시스템(@Module), 같은 클래스 일등시민 가정이 두 라이브러리를 쌍둥이처럼 만든 결과다.
Why — 왜 NestJS의 기본 ORM이 되었나
TypeScript 백엔드 진영에서 NestJS가 de facto 프레임워크가 된 것은 2019~2021년 사이다. 같은 시기 TypeORM은 유일한 본격 데코레이터 ORM이었다. 이 둘은 서로를 끌어올렸다 — 흔한 오해 셋을 정리하자.
| 흔한 오해 | 실제 |
|---|---|
| ”NestJS는 어떤 ORM이든 균등하게 지원한다” | 공식 문서 차원에서는 그렇다 — 하지만 데코레이터 친화·forRoot/forFeature 패턴이 가장 자연스럽게 맞는 ORM은 TypeORM이다 |
”@nestjs/typeorm은 단순 래퍼다” | DI 컨테이너에 DataSource·EntityManager·Repository<T>를 각각 다른 scope로 등록하는 비자명한 결정이 들어 있다 |
| ”TypeORM은 NestJS 없이도 같다” | 같은 코드는 같지만, NestJS의 lifecycle hook + DI가 connection management의 절반을 대신한다 — 단독으로 쓰면 그 절반을 직접 짜야 한다 |
NestJS의 사례는 왜 데코레이터 ORM이 데코레이터 프레임워크와 만나면 강력해지는가를 보여준다 — 그리고 그 결합이 왜 떠나기 어렵게 만드는가도.
How — 어떻게 결합되는가
1) forRoot — DataSource 한 번만 등록
NestJS 앱의 루트 모듈에서 TypeOrmModule.forRoot()를 한 번 호출하면, *전역 DataSource*가 DI 컨테이너에 등록된다.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './users/user.entity';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'app',
password: 'secret',
database: 'app_dev',
entities: [User],
synchronize: false, // 06-migration-schema 참고 — 절대 production에서 true 금지
}),
],
})
export class AppModule {}이 한 번의 호출이 만드는 것은 세 개의 DI provider다.
2) forFeature — 모듈마다 Repository 주입
각 기능 모듈은 자신이 쓰는 entity의 Repository를 forFeature로 받는다.
// src/users/users.module.ts
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UsersService],
controllers: [UsersController],
})
export class UsersModule {}
// src/users/users.service.ts
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
findById(id: number) {
return this.users.findOne({ where: { id } });
}
}@InjectRepository(User)가 하는 일은 DI 컨테이너에서 Repository<User> 토큰을 꺼내 주는 것이다 — TypeORM의 DataSource.getRepository(User) 호출을 컴파일타임에 박은 단축어.
3) forRootAsync — 환경 변수·secret으로 늦은 설정
forRoot는 정적이라 환경 변수가 늦게 도착하는 경우 못 쓴다. forRootAsync가 그 자리를 채운다.
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get('DB_HOST'),
username: config.get('DB_USER'),
password: config.get('DB_PASS'),
database: config.get('DB_NAME'),
entities: [__dirname + '/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
}),
});이 패턴은 NestJS 공식 문서(
docs.nestjs.com/techniques/database)의 권장 형태이고, 거의 모든 production NestJS 앱이 따른다.
4) Transaction — @Transactional() vs dataSource.transaction()
NestJS에서 트랜잭션을 묶는 방법은 세 가지가 공존한다.
| 방식 | 출처 | 특징 |
|---|---|---|
dataSource.transaction(async (manager) => { ... }) | TypeORM 공식 | 명시적 — manager가 새로 들어옴, repository 다시 꺼내야 함 |
@Transactional() 데코레이터 | typeorm-transactional (3rd party) | async hooks로 같은 manager를 암묵적으로 흐르게 함 — 가독성 ↑ |
QueryRunner 수동 | TypeORM 저레벨 | connect → startTransaction → commitTransaction 직접 — 가장 무겁지만 가장 정확 |
NestJS production 코드의 현실은 typeorm-transactional 라이브러리가 사실상 표준에 가깝게 쓰인다 — 명시적 manager 전달이 서비스 메서드 시그니처를 오염시키기 때문이다.
// 3rd-party typeorm-transactional 사용 예
import { Transactional } from 'typeorm-transactional';
@Injectable()
export class OrdersService {
@Transactional()
async placeOrder(userId: number, items: OrderItem[]) {
// 이 메서드 안의 모든 repository 호출이 같은 transaction
await this.orders.save(...);
await this.inventory.decrement(...);
}
}What — 구체 사양
@nestjs/typeorm이 등록하는 DI provider 목록
출처:
nestjs/typeorm저장소lib/typeorm-core.module.ts및lib/typeorm.providers.ts
| Provider 토큰 | 용도 | scope |
|---|---|---|
DataSource (또는 getDataSourceToken(name)) | 전역 DataSource | singleton |
EntityManager (또는 getEntityManagerToken(name)) | DataSource.manager 위임 | singleton |
Repository<Entity> (getRepositoryToken(Entity)) | entity별 repository | singleton |
TreeRepository<Entity> | tree 구조 entity | singleton (해당 entity만) |
MongoRepository<Entity> | Mongo 전용 | singleton (해당 entity만) |
명명된 connection (name 옵션)
여러 DB를 동시에 쓰는 경우 — 각 connection에 이름을 붙여 별도 토큰으로 주입한다.
@Module({
imports: [
TypeOrmModule.forRoot({ name: 'main', /* ... */ }),
TypeOrmModule.forRoot({ name: 'analytics', /* ... */ }),
TypeOrmModule.forFeature([Event], 'analytics'),
],
})
export class AppModule {}
@Injectable()
export class EventService {
constructor(
@InjectRepository(Event, 'analytics')
private readonly events: Repository<Event>,
) {}
}entities 옵션 — 두 가지 스타일
| 스타일 | 예 | 장단점 |
|---|---|---|
| 명시 배열 | entities: [User, Post, Comment] | 컴파일타임 검증 — IDE가 빠짐을 즉시 표시 |
| glob 패턴 | entities: [__dirname + '/**/*.entity{.ts,.js}'] | 새 entity 추가가 자동 — 하지만 번들러(webpack/esbuild)에서 깨지기 쉽다 |
공식 권장은 명시 배열. glob은 webpack 빌드나 서버리스 배포에서 실패하는 issue가 반복적으로 등장한다 (예:
nestjs/typeormissue #1129 등).
NestJS 라이프사이클과의 매핑
| Nest hook | @nestjs/typeorm이 하는 일 |
|---|---|
onModuleInit | DataSource.initialize() — 연결 풀 시작, migration 옵션에 따라 자동 실행 |
onApplicationShutdown | DataSource.destroy() — 풀 종료 |
onModuleDestroy | (테스트 환경) — DataSource 정리 |
이 두 hook이 DataSource lifecycle을 NestJS lifecycle에 묶어 주는 결정적 결합이다 — 단독 TypeORM에서는 직접 호출해야 한다.
What-if — 잘못 이해하면
1) “forFeature를 안 써도 Repository가 주입된다”고 믿으면
→ forRoot만 한 모듈에서 @InjectRepository(User)를 하면 NestJS가 토큰을 못 찾는다. 런타임에 “Nest can’t resolve dependencies” 에러.
대응: 해당 entity가 쓰이는 모든 모듈에서 TypeOrmModule.forFeature([Entity]) 추가. 또는 exports로 다른 모듈에 빌려 주기.
2) “synchronize: true는 NestJS가 안전하게 처리해 준다”고 믿으면
→ NestJS는 통과만 한다 — TypeORM 옵션이 그대로 적용된다. synchronize: true는 production DB의 컬럼을 자동으로 drop할 수 있다.
대응: synchronize: process.env.NODE_ENV !== 'production'도 위험하다. false 고정 + migration만 사용 (06장 참고).
3) “@InjectRepository는 그냥 syntactic sugar”라고 믿으면
→ DI 토큰을 컴파일타임에 박는 결정 — 즉 Repository<User> 타입만으로는 Nest가 인스턴스를 못 찾는다. 토큰이 필요하다.
대응: 항상 @InjectRepository(EntityClass)를 명시. 다른 ORM(예: Prisma)에서는 Prisma client 한 개가 DI되니까 토큰 개념이 다르다 — 마음의 모델을 섞지 말 것.
4) “동적 DataSource(멀티테넌트)도 같은 forFeature 패턴으로 된다”고 믿으면
→ forFeature는 singleton DataSource를 가정한다. 멀티테넌트에서는 런타임에 connection을 선택해야 하므로 Scope.REQUEST provider나 *직접 DataSource.getRepository*를 호출해야 한다 (02장 참고).
대응: 멀티테넌트는 @InjectDataSource()로 직접 DataSource를 받아 repository를 그때그때 꺼낸다. @nestjs/typeorm의 기본 패턴 밖이다.
Insight — 흥미로운 이야기
”Kamil Mysliwiec이 처음부터 TypeORM을 골랐다”
NestJS 창시자 Kamil Mysliwiec은 2017년 GitHub 초기 README부터 TypeORM을 첫 번째 추천 ORM으로 언급한다. Angular를 백엔드로 옮긴 그의 디자인 직관에서 — Angular DI가 데코레이터 + Reflect Metadata에 기반하니, ORM도 같은 가정을 공유하는 것이 자연스러웠다. 다른 ORM(Sequelize, Mongoose)은 나중에 같은 패턴으로 맞춰진 것이고, 시작점부터 데코레이터였던 ORM은 TypeORM이 거의 유일했다.
”forRoot/forFeature는 NestJS가 발명한 패턴이 아니다 — Angular의 forRoot다”
이 메서드 이름은 *Angular의 RouterModule.forRoot()/forChild()*에서 직접 가져왔다.
의도도 같다 — 루트 모듈에서 한 번만 설정, 피처 모듈에서는 그 설정을 재사용. Kamil이 Angular 백엔드를 만들면서 이름까지 그대로 옮긴 결과다. TypeORM과의 결합이 자연스럽게 느껴지는 진짜 이유는 이름과 패턴이 Angular에서 이미 검증되었기 때문이다.
”DI scope가 만든 멀티테넌트 함정”
NestJS의 기본 provider scope는 singleton이다. 즉 Repository<User>는 앱 전체에서 한 인스턴스. 멀티테넌트에서 요청마다 다른 DB를 쓰고 싶으면 Scope.REQUEST로 바꿔야 하는데 — 이 한 줄이 Repository를 받는 모든 provider를 request-scoped로 전파시킨다. NestJS 문서의 “performance” 절(docs.nestjs.com/fundamentals/injection-scopes)이 경고하는 그 함정이다.
”@nestjs/typeorm 자체는 매우 얇다”
저장소 nestjs/typeorm을 열어 보면 전체 코드가 ~2,000 LoC에 불과하다. 핵심 로직은 typeorm-core.module.ts(~300 LoC) 한 파일. 얇기 때문에 호환성 깨짐도 잦다 — TypeORM 0.2 → 0.3 시점에 @nestjs/typeorm도 major bump하면서 DataSource API 전체가 바뀌었다. NestJS 9·10 출시와 맞물려 production 코드의 대규모 업그레이드가 강제됐다.
요약 + 다이어그램
@nestjs/typeorm은 *forRoot(전역 DataSource)*와 *forFeature(모듈별 Repository)*라는 두 메서드로 NestJS DI에 TypeORM을 녹인다.@InjectRepository(Entity)한 줄로 어디서든 Repository를 받을 수 있는 경험은 TypeScript 백엔드 진영에서 사실상 표준 패턴이 되었다. 하지만 그 결합은 singleton 가정과 Angular식 모듈 시스템에 묶여 있다 — 멀티테넌트·동적 DataSource로 가는 순간 이 모델 밖으로 나가야 한다 (02장).
참고 자료
- NestJS 공식 문서 —
docs.nestjs.com/techniques/database @nestjs/typeorm저장소 —github.com/nestjs/typeorm- TypeORM 0.3 마이그레이션 가이드 —
typeorm.io/changelog typeorm-transactional(3rd-party) —github.com/Aliheym/typeorm-transactional- Kamil Mysliwiec, NestJS 초기 README —
github.com/nestjs/nest(커밋 히스토리) - NestJS 공식 문서, Injection scopes —
docs.nestjs.com/fundamentals/injection-scopes
다음 문서:
02-multi-tenant-patterns.mdx— 동적 DataSource로 들어가는 순간, forFeature 패턴 밖의 세계.