왜 Multiple DataSources가 필요한가
운영 환경이 성장하면 단일 데이터베이스 연결로는 한계에 부딪힙니다. 읽기 트래픽을 Read Replica로 분산하거나, 레거시 시스템의 별도 DB에 접근하거나, 분석 전용 데이터웨어하우스를 연결해야 하는 상황이 발생합니다. TypeORM 0.3.x 공식 문서(Multiple data sources, replication and databases 섹션)에서는 이를 위한 두 가지 메커니즘을 제공합니다: Replication(읽기/쓰기 분리)과 Multiple DataSource 인스턴스입니다.
방법 1: Replication — 내장 읽기/쓰기 자동 분리
TypeORM은 DataSource 설정에 replication 옵션을 제공합니다. 공식 문서에 따르면, master에는 쓰기 연결을, slaves에는 읽기 전용 연결을 배열로 지정합니다. TypeORM이 자동으로 쿼리 유형에 따라 연결을 분기합니다.
// data-source.ts
import { DataSource } from 'typeorm';
export const AppDataSource = new DataSource({
type: 'mysql',
replication: {
master: {
host: 'primary.db.internal',
port: 3306,
username: 'app_write',
password: process.env.DB_WRITE_PASS,
database: 'myapp',
},
slaves: [
{
host: 'replica-1.db.internal',
port: 3306,
username: 'app_read',
password: process.env.DB_READ_PASS,
database: 'myapp',
},
{
host: 'replica-2.db.internal',
port: 3306,
username: 'app_read',
password: process.env.DB_READ_PASS,
database: 'myapp',
},
],
},
entities: [/* ... */],
});
라우팅 규칙 (공식 문서 기반):
find(),findOne(),query()(SELECT) → slave (라운드로빈)save(),remove(),insert(),update(),delete()→ master- QueryBuilder:
.select()는 slave,.insert()/.update()/.delete()는 master QueryRunner를 직접 생성하면 master에 연결 (명시적으로createQueryRunner("slave")가능)
Replication의 핵심 함정
함정 1 — 쓰기 후 즉시 읽기 (Replication Lag):
가장 흔한 운영 문제입니다. save()로 데이터를 쓴 직후 findOne()을 호출하면, slave에서 읽기 때문에 아직 복제되지 않은 이전 데이터가 반환될 수 있습니다.
// ❌ replication lag로 방금 저장한 데이터가 안 보일 수 있음
await repo.save(entity);
const fresh = await repo.findOneBy({ id: entity.id }); // slave에서 읽음
// ✅ 해결: QueryRunner로 master에서 명시적으로 읽기
const qr = dataSource.createQueryRunner('master');
try {
const fresh = await qr.manager.findOneBy(Entity, { id: entity.id });
} finally {
await qr.release();
}
함정 2 — 트랜잭션 내부에서의 라우팅:
TypeORM 공식 문서에 따르면, 트랜잭션 내부의 모든 쿼리는 master를 사용합니다. 이것은 올바른 동작이지만, 트랜잭션 외부에서 같은 서비스 메서드를 재사용할 때 slave로 라우팅이 바뀌는 것을 인지해야 합니다.
// 트랜잭션 내부 — 모든 쿼리가 master로 라우팅 (정상)
await dataSource.transaction(async (manager) => {
const user = await manager.findOneBy(User, { id: 1 }); // master
user.name = 'Updated';
await manager.save(user); // master
});
함정 3 — slave 장애 시 동작:
모든 slave가 다운되면 TypeORM은 자동으로 master로 폴백하지 않습니다. 연결 에러가 발생합니다. 프로덕션에서는 DB 프록시(ProxySQL, PgBouncer 등)를 앞에 두거나, 헬스체크를 통해 slave 목록을 동적으로 관리해야 합니다.
방법 2: Multiple DataSource 인스턴스 — 완전히 다른 DB 연결
서로 다른 데이터베이스(예: 주 서비스 MySQL + 분석용 PostgreSQL)에 접근해야 하는 경우, 별도의 DataSource 인스턴스를 생성합니다.
NestJS에서 Named DataSource 등록
@nestjs/typeorm 공식 문서에 따르면, TypeOrmModule.forRoot()에 name 옵션을 지정하여 여러 DataSource를 등록할 수 있습니다.
// app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
// 기본(primary) DataSource
TypeOrmModule.forRoot({
type: 'mysql',
host: 'primary.db.internal',
database: 'myapp',
entities: [User, Article],
// name을 생략하면 'default'
}),
// 분석용 DataSource
TypeOrmModule.forRoot({
name: 'analytics',
type: 'postgres',
host: 'analytics.db.internal',
database: 'analytics',
entities: [PageView, EventLog],
}),
],
})
export class AppModule {}
Feature Module에서 Named DataSource 사용
TypeOrmModule.forFeature()의 두 번째 인자로 DataSource 이름을 전달합니다.
// analytics.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([PageView, EventLog], 'analytics'),
],
providers: [AnalyticsService],
})
export class AnalyticsModule {}
// analytics.service.ts
@Injectable()
export class AnalyticsService {
constructor(
@InjectRepository(PageView, 'analytics')
private readonly pageViewRepo: Repository<PageView>,
@InjectDataSource('analytics')
private readonly analyticsDs: DataSource,
) {}
async getTopPages(days: number): Promise<PageView[]> {
return this.pageViewRepo
.createQueryBuilder('pv')
.where('pv.createdAt > :since', {
since: new Date(Date.now() - days * 86400000),
})
.orderBy('pv.viewCount', 'DESC')
.limit(20)
.getMany();
}
}
핵심: @InjectRepository(Entity, 'connectionName')과 @InjectDataSource('connectionName')에서 이름을 정확히 일치시켜야 합니다. 이름이 다르면 NestJS 부트스트랩 시점에 DI 에러가 발생합니다.
Cross-DataSource 트랜잭션의 한계
서로 다른 DataSource 간의 트랜잭션은 TypeORM이 지원하지 않습니다. 각 DataSource는 독립된 커넥션 풀을 가지며, 하나의 트랜잭션으로 묶을 수 없습니다. 이것은 TypeORM의 제한이 아니라 분산 트랜잭션(2PC)의 본질적 복잡성 때문입니다.
| 시나리오 | 트랜잭션 보장 | 해결 방법 |
|---|---|---|
| 같은 DataSource 내 여러 엔티티 | ✅ 단일 트랜잭션 | DataSource.transaction() |
| Replication master/slave | ✅ 트랜잭션은 master만 사용 | 자동 라우팅 |
| 서로 다른 DataSource | ❌ 불가 | Saga 패턴, 보상 트랜잭션 |
// ❌ 이것은 두 개의 독립 트랜잭션 — 원자성 보장 안 됨
async transferData(userId: number) {
await this.defaultDs.transaction(async (em1) => {
await em1.update(User, userId, { exported: true });
});
// 여기서 실패하면 위의 변경은 이미 커밋됨
await this.analyticsDs.transaction(async (em2) => {
await em2.insert(ExportLog, { userId, exportedAt: new Date() });
});
}
// ✅ 보상 트랜잭션 패턴
async transferDataSafe(userId: number) {
await this.defaultDs.transaction(async (em1) => {
await em1.update(User, userId, { exported: true });
});
try {
await this.analyticsDs.transaction(async (em2) => {
await em2.insert(ExportLog, { userId, exportedAt: new Date() });
});
} catch (err) {
// 보상: 첫 번째 트랜잭션 롤백
await this.defaultDs.transaction(async (em1) => {
await em1.update(User, userId, { exported: false });
});
throw err;
}
}
Replication + forRootAsync 동적 설정 패턴
실무에서는 환경변수나 ConfigService로 DB 설정을 주입합니다. forRootAsync를 사용한 패턴입니다.
// app.module.ts
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'mysql' as const,
replication: {
master: {
host: config.get('DB_MASTER_HOST'),
port: config.get('DB_PORT', 3306),
username: config.get('DB_USER'),
password: config.get('DB_PASS'),
database: config.get('DB_NAME'),
},
slaves: config.get('DB_SLAVE_HOSTS', '')
.split(',')
.filter(Boolean)
.map((host: string) => ({
host: host.trim(),
port: config.get('DB_PORT', 3306),
username: config.get('DB_READ_USER', config.get('DB_USER')),
password: config.get('DB_READ_PASS', config.get('DB_PASS')),
database: config.get('DB_NAME'),
})),
},
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: false, // 프로덕션에서 절대 true 금지
}),
}),
운영 팁: DB_SLAVE_HOSTS가 빈 문자열이면 slaves 배열이 비어있게 되어 TypeORM이 에러를 발생시킵니다. replication을 사용할 때는 최소 1개의 slave가 필요합니다. slave가 없는 환경(개발 등)에서는 replication 설정 자체를 조건부로 적용하세요.
// slave가 없으면 replication 비활성화
const slaveHosts = config.get('DB_SLAVE_HOSTS', '').split(',').filter(Boolean);
const baseConfig = {
type: 'mysql' as const,
database: config.get('DB_NAME'),
entities: [__dirname + '/**/*.entity{.ts,.js}'],
};
return slaveHosts.length > 0
? { ...baseConfig, replication: { master: { /* ... */ }, slaves: slaveHosts.map(/* ... */) } }
: { ...baseConfig, host: config.get('DB_MASTER_HOST'), /* 단일 연결 */ };
헬스체크와 커넥션 풀 모니터링
Multiple DataSource를 사용하면 커넥션 풀 관리가 중요해집니다. 각 DataSource별로 독립된 풀이 생성되므로 전체 커넥션 수가 배로 늘어납니다.
// health.controller.ts (NestJS Terminus 활용)
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: TypeOrmHealthIndicator,
@InjectDataSource('analytics')
private analyticsDs: DataSource,
) {}
@Get()
@HealthCheck()
check() {
return this.health.check([
// default DataSource 체크
() => this.db.pingCheck('default-db'),
// named DataSource는 직접 ping
async () => {
await this.analyticsDs.query('SELECT 1');
return { 'analytics-db': { status: 'up' } };
},
]);
}
}
흔한 실수와 운영 체크리스트
실수 1: 엔티티를 잘못된 DataSource에 등록
엔티티는 해당 테이블이 존재하는 DataSource의 entities에만 등록해야 합니다. 양쪽 DataSource에 같은 엔티티를 등록하면, synchronize 시 존재하지 않는 테이블을 생성하려는 에러가 발생합니다.
실수 2: Migration 대상 DataSource 혼동
CLI에서 migration을 실행할 때 -d 옵션으로 DataSource 파일을 지정합니다. Multiple DataSource가 있으면 각 DataSource별로 별도의 migration 설정 파일과 migration 디렉터리를 관리해야 합니다.
# default DataSource migration
npx typeorm migration:run -d ./data-source.ts
# analytics DataSource migration — 별도 설정 파일
npx typeorm migration:run -d ./analytics-data-source.ts
실수 3: 테스트 환경에서 DataSource 정리 누락
e2e 테스트에서 afterAll에 모든 DataSource의 destroy()를 호출하지 않으면 jest가 종료되지 않습니다 (open handles 경고).
정리: Replication vs Multiple DataSource 선택 기준
| 요구사항 | 권장 방식 | 이유 |
|---|---|---|
| 같은 스키마의 읽기 부하 분산 | Replication | 자동 라우팅, 설정 간단 |
| 다른 DB 엔진/스키마 접근 | Multiple DataSource | 독립 연결, 독립 엔티티 |
| 읽기 후 즉시 쓰기 결과 필요 | Replication + QueryRunner(‘master’) | lag 회피 |
| Cross-DB 원자적 트랜잭션 | Saga/보상 패턴 | TypeORM 미지원, 설계로 해결 |
| 개발/스테이징(replica 없음) | 조건부 Replication 비활성화 | 환경별 유연한 설정 |
TypeORM의 Multiple DataSource는 Replication으로 읽기 부하를 분산하고, Named DataSource로 이종 DB에 접근하는 두 축으로 구성됩니다. Replication에서는 쓰기 후 즉시 읽기(lag)가 가장 빈번한 운영 함정이며, Multiple DataSource에서는 Cross-DB 트랜잭션 불가를 설계 시점에 인지해야 합니다. NestJS에서는 forRoot({ name })과 @InjectRepository(Entity, name)의 이름 일치만 지키면 깔끔하게 통합됩니다.
참고: TypeORM 공식 문서 — Multiple Data Sources, Replication; NestJS 공식 문서 — Multiple Databases