NestJS 멀티테넌트 DB 커넥션 풀 동적 관리: Promise 캐싱과 Lazy Loading

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • MyrinNew
    Senior Member
    • Feb 2024
    • 5168

    #1

    NestJS 멀티테넌트 DB 커넥션 풀 동적 관리: Promise 캐싱과 Lazy Loading

    배경 / 문제 상황

    SaaS 서비스를 운영하다 보면 테넌트마다 별도 데이터베이스를 써야 하는 경우가 있다. 우리 서비스는 각 고객사가 독립된 DB를 사용하는 구조인데, 처음에는 단순하게 접근했다.






    // 초기 접근: 요청마다 DataSource 생성
    async getRepository(tenantCode: string, entity: EntityTargetT>) {
    const config = await this.loadConfig(tenantCode);
    const dataSource = new DataSource(config);
    await dataSource.initialize();
    return dataSource.getRepository(entity);
    }







    문제는 금방 드러났다:
    • 커넥션 폭발: 요청마다 새 DataSource를 만들어서 DB 커넥션이 기하급수적으로 증가
    • 초기화 중복: 동시 요청이 들어오면 같은 테넌트에 대해 여러 번 초기화
    • 메모리 누수: 사용 끝난 DataSource를 정리하지 않아 메모리 점유 증가
    • 장애 전파: 한 테넌트 DB가 느려지면 전체 서비스가 영향 받음


    접근 방법

    핵심 전략은 세 가지:

    1. Map 캐싱: 테넌트별 DataSource를 Map에 저장하여 재사용
    2. Promise 캐싱: 초기화 중인 Promise를 캐싱하여 동시 요청의 중복 초기화 방지
    3. Lazy Loading: 시작 시 전체 초기화 + 미등록 테넌트는 런타임에 동적 생성


    구현

    핵심 구조





    @Injectable()
    export class MultiTenantDatabaseService implements OnModuleInit, OnModuleDestroy {
    // 테넌트별 DataSource 캐시
    private datasourcesMap = new Mapstring, DataSource>();

    // Config 조회용 DataSource (메타 정보 DB)
    private configDataSource: DataSource | null = null;

    // 초기화 상태 관리
    private initialized = false;
    private initializationPromise: Promisevoid> | null = null;
    }







    네 가지 상태를 명확히 분리했다:
    • datasourcesMap: 실제 테넌트 DataSource 저장소
    • configDataSource: 테넌트 설정 정보를 조회하는 메타 DB
    • initialized: 초기화 완료 플래그
    • initializationPromise: 동시성 제어의 핵심


    Promise 캐싱으로 중복 초기화 방지

    가장 까다로운 부분이 동시성 제어다. NestJS는 onModuleInit을 한 번만 호출하지만, 초기화가 완료되기 전에 요청이 들어올 수 있다.






    async onModuleInit(): Promisevoid> {
    // 이미 초기화 중이면 같은 Promise를 반환
    if (this.initializationPromise) {
    return this.initializationPromise;
    }

    // 새 Promise 생성 및 캐싱
    this.initializationPromise = this.initialize();
    return this.initializationPromise;
    }







    이 패턴의 핵심은 같은 Promise 객체를 공유하는 것이다. 세 개의 요청이 동시에 들어와도 initialize()는 단 한 번만 실행되고, 나머지는 같은 Promise의 resolve를 기다린다.


    Lazy Loading: 런타임 동적 생성

    서비스 시작 시 Config DB에서 모든 테넌트 설정을 읽어 DataSource를 미리 생성한다. 하지만 나중에 추가된 테넌트도 처리해야 한다:






    async getDataSource(tenantCode: string): PromiseDataSource> {
    // 초기화 보장
    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): Promisevoid> {
    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);
    }







    핵심 수치 변경:
    • max: 1000 -> 20: 테넌트 10개 기준 총 200개로 충분
    • min: 10 -> 2: 유휴 커넥션 최소화
    • idleTimeoutMillis: 30000: 30초간 미사용 시 자동 반환


    부분 실패 허용 (Promise.allSettled)

    시작 시 모든 테넌트 DataSource를 초기화하는데, 한 테넌트가 실패해도 나머지는 정상 동작해야 한다:






    private async initializeAllDataSources(
    configs: ConfigEntity[]
    ): Promisevoid> {
    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가 점검 중이어도 나머지 테넌트는 정상 서비스된다.


    안전한 리소스 정리





    async onModuleDestroy(): Promisevoid> {
    // 모든 테넌트 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.getRepositoryOrderEntity>(
    tenantCode,
    OrderEntity
    );
    const orders = await repo.find({ where: { status: 'active' } });







    테넌트 코드만 넘기면 적절한 DataSource에서 Repository를 반환한다.


    결과 / 배운 점

    이 구조로 변경한 후:
    • DB 커넥션 수: 테넌트당 ~1000개 -> ~20개 (95% 감소)
    • 메모리 사용량: DataSource 재생성 없이 Map 캐싱으로 안정화
    • 장애 격리: 한 테넌트 DB 장애가 다른 테넌트에 전파되지 않음
    • 운영 편의성: Health Check API로 테넌트별 DB 상태 모니터링 가능


    배운 점:

    1. Promise 캐싱은 동시성 제어의 가장 단순한 해법 — Mutex나 Semaphore 없이 Promise 객체 하나로 중복 초기화를 막을 수 있다
    2. Promise.allSettled는 MSA의 필수 도구 — 부분 실패를 허용해야 전체 시스템의 가용성이 올라간다
    3. 커넥션 풀은 보수적으로 — max 1000은 "혹시 모르니까"의 함정. 실제 트래픽 기반으로 설정하자


    AI 활용 포인트

    Claude Code로 이 서비스를 리팩토링할 때, 기존 코드의 문제점(커넥션 폭발, 초기화 중복)을 분석하고 Promise 캐싱 패턴을 제안받았다. 특히 onModuleDestroy에서 Map 정리 순서와 Promise.allSettled 적용은 AI가 놓치기 쉬운 엣지 케이스를 짚어줬다.




    More...
Working...