배경 / 문제 상황
SaaS 서비스를 운영하다 보면 테넌트마다 별도 데이터베이스를 써야 하는 경우가 있다. 우리 서비스는 각 고객사가 독립된 DB를 사용하는 구조인데, 처음에는 단순하게 접근했다.
// 초기 접근: 요청마다 DataSource 생성
async getRepository(tenantCode: string, entity: EntityTarget<T>) {
const config = await this.loadConfig(tenantCode);
const dataSource = new DataSource(config);
await dataSource.initialize();
return dataSource.getRepository(entity);
}
문제는 금방 드러났다:
- 커넥션 폭발: 요청마다 새 DataSource를 만들어서 DB 커넥션이 기하급수적으로 증가
- 초기화 중복: 동시 요청이 들어오면 같은 테넌트에 대해 여러 번 초기화
- 메모리 누수: 사용 끝난 DataSource를 정리하지 않아 메모리 점유 증가
- 장애 전파: 한 테넌트 DB가 느려지면 전체 서비스가 영향 받음
CTO 관점에서 되돌아보면, 초기에 "일단 돌아가게" 만든 코드가 프로덕션에서 얼마나 빠르게 시한폭탄이 되는지를 보여주는 전형적인 사례다. 테넌트가 3개일 때는 괜찮았지만, 10개를 넘기자마자 새벽에 알람이 울리기 시작했다.
접근 방법
핵심 전략은 세 가지:
- Map 캐싱: 테넌트별 DataSource를 Map에 저장하여 재사용
- Promise 캐싱: 초기화 중인 Promise를 캐싱하여 동시 요청의 중복 초기화 방지
- Lazy Loading: 시작 시 전체 초기화 + 미등록 테넌트는 런타임에 동적 생성
왜 이 조합인가?
| 대안 | 검토 결과 | 채택 여부 |
|---|---|---|
| Schema-based 멀티테넌시 | 고객사별 DB 격리 요구사항 충족 불가 | X |
| Connection Pool만 공유 | 테넌트별 DB 주소가 다르므로 불가 | X |
| Lazy Loading만 | 첫 요청 레이턴시 문제 | 부분 채택 |
| Eager Loading만 | 신규 테넌트 추가 시 재배포 필요 | 부분 채택 |
| Eager + Lazy 혼합 | 기존 테넌트는 즉시, 신규는 동적 | O |
설계 결정의 핵심은 "재배포 없이 테넌트를 추가할 수 있는가"였다. 병원이 새로 계약하면 Config DB에 레코드만 추가하면 되는 구조를 목표로 했다.
구현
핵심 구조
@Injectable()
export class MultiTenantDatabaseService implements OnModuleInit, OnModuleDestroy {
// 테넌트별 DataSource 캐시
private datasourcesMap = new Map<string, DataSource>();
// Config 조회용 DataSource (메타 정보 DB)
private configDataSource: DataSource | null = null;
// 초기화 상태 관리
private initialized = false;
private initializationPromise: Promise<void> | null = null;
}
네 가지 상태를 명확히 분리했다:
-
datasourcesMap: 실제 테넌트 DataSource 저장소 -
configDataSource: 테넌트 설정 정보를 조회하는 메타 DB -
initialized: 초기화 완료 플래그 -
initializationPromise: 동시성 제어의 핵심
Promise 캐싱으로 중복 초기화 방지
가장 까다로운 부분이 동시성 제어다. NestJS는 onModuleInit을 한 번만 호출하지만, 초기화가 완료되기 전에 요청이 들어올 수 있다.
async onModuleInit(): Promise<void> {
// 이미 초기화 중이면 같은 Promise를 반환
if (this.initializationPromise) {
return this.initializationPromise;
}
// 새 Promise 생성 및 캐싱
this.initializationPromise = this.initialize();
return this.initializationPromise;
}
이 패턴의 핵심은 같은 Promise 객체를 공유하는 것이다. 세 개의 요청이 동시에 들어와도 initialize()는 단 한 번만 실행되고, 나머지는 같은 Promise의 resolve를 기다린다.
트레이드오프: Mutex나 Semaphore를 쓰면 더 정교한 제어가 가능하지만, Node.js의 싱글 스레드 특성상 Promise 캐싱만으로 충분하다. 오히려 외부 라이브러리 의존성을 줄이는 것이 장기적으로 유리하다고 판단했다.
Lazy Loading: 런타임 동적 생성
서비스 시작 시 Config DB에서 모든 테넌트 설정을 읽어 DataSource를 미리 생성한다. 하지만 나중에 추가된 테넌트도 처리해야 한다:
async getDataSource(tenantCode: string): Promise<DataSource> {
// 초기화 보장
if (!this.initialized) {
await this.ensureInitialized();
}
let dataSource = this.datasourcesMap.get(tenantCode);
// Map에 없으면 Config DB에서 조회하여 동적 생성
if (!dataSource) {
const config = await this.getConfig(tenantCode);
await this.createTenantDataSource(config);
dataSource = this.datasourcesMap.get(tenantCode);
}
if (!dataSource || !dataSource.isInitialized) {
throw new Error(
`DataSource for ${tenantCode} not found. Available: ${Array.from(
this.datasourcesMap.keys()
).join(', ')}`
);
}
return dataSource;
}
에러 메시지에 현재 사용 가능한 테넌트 목록을 포함시킨 게 디버깅에 큰 도움이 된다. 프로덕션에서 "테넌트를 찾을 수 없다"는 에러가 뜨면, 어떤 테넌트들이 로드되어 있는지 바로 확인할 수 있다.
커넥션 풀 최적화
초기에는 테넌트당 커넥션을 1000개로 설정했다가 10개 테넌트만 연결해도 DB 서버가 비명을 질렀다. 실제 트래픽을 분석해서 적정값을 찾았다:
private async createTenantDataSource(config: ConfigEntity): Promise<void> {
const dataSource = new DataSource({
url: config.databaseUrl,
type: config.databaseType as any,
entities: SHARED_ENTITIES,
synchronize: false, // 스키마 동기화는 별도 모듈에서
pool: {
max: 20, // 테넌트당 최대 20개 (기존 1000)
min: 2, // 테넌트당 최소 2개 (기존 10)
idleTimeoutMillis: 30000, // 30초 idle timeout
acquireTimeoutMillis: 10000, // 10초 acquire timeout
},
extra: {
connectionTimeoutMillis: 5000, // 5초 connection timeout
},
});
await dataSource.initialize();
this.datasourcesMap.set(config.tenantCode, dataSource);
}
핵심 수치 변경:
| 설정 | Before | After | 근거 |
|---|---|---|---|
max |
1000 | 20 | 테넌트당 동시 쿼리 최대 15개 관측 |
min |
10 | 2 | 유휴 시간대 커넥션 낭비 방지 |
idleTimeoutMillis |
없음 | 30초 | 유휴 커넥션 자동 반환 |
acquireTimeoutMillis |
없음 | 10초 | 풀 고갈 시 빠른 실패 |
테넌트 10개 기준 총 200개로 충분했고, DB 서버의 max_connections 설정과도 균형을 맞출 수 있었다.
부분 실패 허용 (Promise.allSettled)
시작 시 모든 테넌트 DataSource를 초기화하는데, 한 테넌트가 실패해도 나머지는 정상 동작해야 한다:
private async initializeAllDataSources(
configs: ConfigEntity[]
): Promise<void> {
const results = await Promise.allSettled(
configs.map((config) => this.createTenantDataSource(config))
);
// 실패한 테넌트만 로깅 (서비스는 계속 동작)
results.forEach((result, index) => {
if (result.status === 'rejected') {
this.logger.error(
`Failed to initialize DataSource for ${configs[index].tenantCode}`,
result.reason
);
}
});
}
Promise.all 대신 Promise.allSettled를 쓴 이유가 여기 있다. 한 테넌트 DB가 점검 중이어도 나머지 테넌트는 정상 서비스된다.
실무에서 이 결정이 빛났던 순간: 특정 고객사가 DB 서버를 점검하느라 2시간 동안 연결이 불가능했다.
Promise.all이었다면 서비스 전체가 시작 실패했겠지만,Promise.allSettled덕분에 나머지 9개 고객사는 아무 영향 없이 사용할 수 있었다.
안전한 리소스 정리
async onModuleDestroy(): Promise<void> {
// 모든 테넌트 DataSource 정리 (개별 실패 허용)
const closePromises = Array.from(this.datasourcesMap.entries()).map(
async ([tenantCode, dataSource]) => {
try {
if (dataSource.isInitialized) {
await dataSource.destroy();
}
} catch (error) {
this.logger.error(`Failed to close DataSource for ${tenantCode}`, error);
}
}
);
await Promise.allSettled(closePromises);
// 완전 초기화
this.datasourcesMap.clear();
this.configDataSource = null;
this.initialized = false;
this.initializationPromise = null;
}
정리 시에도 try-catch로 감싸서 한 DataSource 정리 실패가 다른 정리를 막지 않도록 했다.
사용 패턴
호출하는 쪽은 이 복잡함을 전혀 모른다:
// Service/Usecase에서 사용
const repo = await this.databaseService.getRepository<OrderEntity>(
tenantCode,
OrderEntity
);
const orders = await repo.find({ where: { status: 'active' } });
테넌트 코드만 넘기면 적절한 DataSource에서 Repository를 반환한다. 이 단순한 인터페이스가 설계의 성공 기준이었다.
결과 / 배운 점
이 구조로 변경한 후:
| 지표 | Before | After | 개선율 |
|---|---|---|---|
| 테넌트당 DB 커넥션 | ~1000개 | ~20개 | 98% 감소 |
| 메모리 사용량 | 지속 증가 | 안정 | - |
| 장애 격리 | 전파 발생 | 테넌트별 격리 | - |
| 신규 테넌트 추가 | 재배포 필요 | Config DB 추가만 | - |
배운 점:
- Promise 캐싱은 동시성 제어의 가장 단순한 해법 — Mutex나 Semaphore 없이 Promise 객체 하나로 중복 초기화를 막을 수 있다
-
Promise.allSettled는 MSA의 필수 도구 — 부분 실패를 허용해야 전체 시스템의 가용성이 올라간다 - 커넥션 풀은 보수적으로 — max 1000은 "혹시 모르니까"의 함정. 실제 트래픽 기반으로 설정하자
아쉬웠던 점
솔직히 이 구현에는 몇 가지 알면서도 넘어간 부분이 있다.
Lazy Loading 시 per-tenant 락 미구현: 동시에 같은 신규 테넌트에 대한 요청이 들어오면,
getDataSource()에서 Config DB 조회와 DataSource 생성이 중복 실행될 수 있다. Promise 캐싱은onModuleInit레벨에서만 적용했고, 개별 테넌트의 Lazy Loading에는 적용하지 않았다. 현재까지 실제 문제가 된 적은 없지만, 테넌트 수가 더 늘어나면 race condition이 발생할 수 있다.DataSource 수 증가에 따른 메모리 모니터링 부재: 테넌트가 늘어날수록 Map에 쌓이는 DataSource가 많아지는데, 이에 대한 메트릭 수집이 없다. 현재 몇 개의 DataSource가 활성 상태인지, 각각의 커넥션 풀 사용률이 어떤지 대시보드에서 확인할 수 없다.
Config DB가 단일 장애점(SPOF): 모든 테넌트 설정을 하나의 Config DB에서 읽는다. 이 DB가 죽으면 Lazy Loading이 불가능해지고, 서비스 재시작 시 어떤 테넌트도 초기화할 수 없다. Eager Loading으로 이미 로드된 테넌트는 동작하지만, 신규 테넌트 추가나 설정 변경은 불가능하다.
향후 보완할 점
LRU 기반 DataSource 캐시 eviction: 장기간 사용되지 않는 테넌트의 DataSource를 자동으로 정리하는 메커니즘 도입.
Map대신 TTL이 있는 캐시를 사용하거나, 마지막 접근 시간을 기록해서 주기적으로 evict하는 방식을 검토 중이다.커넥션 풀 메트릭 수집 (Prometheus): 테넌트별 활성/유휴 커넥션 수, 풀 대기 시간, acquire timeout 횟수 등을 Prometheus로 수집하고 Grafana 대시보드로 시각화할 계획이다.
Config DB 이중화: PostgreSQL Streaming Replication 또는 읽기 전용 복제본을 두어, Primary 장애 시에도 테넌트 설정 조회가 가능하도록 구성. 더 나아가 Config 정보를 Redis에 캐싱하는 방안도 고려 중이다.
테넌트별 커넥션 풀 동적 조정: 현재는 모든 테넌트에 동일한
max: 20설정을 사용하지만, 트래픽이 많은 대형 고객사와 소형 고객사의 풀 사이즈를 다르게 가져가야 한다. Config DB에 테넌트별 풀 설정을 추가하고, 트래픽 패턴에 따라 런타임에 조정하는 것이 목표다.
AI 활용 포인트
Claude Code로 이 서비스를 리팩토링할 때, 기존 코드의 문제점(커넥션 폭발, 초기화 중복)을 분석하고 Promise 캐싱 패턴을 제안받았다. 특히 onModuleDestroy에서 Map 정리 순서와 Promise.allSettled 적용은 AI가 놓치기 쉬운 엣지 케이스를 짚어줬다.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.