Nest.js Dynamic Multi Tenancy 구현하기

이재상·2025년 11월 30일
post-thumbnail

요약
1. NestJS + PostgreSQL 환경에서 테넌트(Tenant)별로 스키마 단위 분리 전략을 적용하는 방법
2. 비즈니스 로직에서 Tenant 판별 로직을 제거하고, AOP 기반의 RequestScope Tenant Provider를 활용해 Clean Architecture를 구현하는 방법

서론

이전에 CSAP 인증을 준비하면서 “사용자별 데이터를 논리적으로 분리해야 한다”는 요구 사항을 충족하기 위해 처음으로 테넌시 구조를 도입했다.
현재 게임 회사에서도 게임별로 데이터를 분리해야 하는 요구가 있어, 동일한 개념을 다시 활용하게 되었다.

설계

1. 데이터 분리

데이터 분리 방법은 여러가지가 존재한다.

A. 물리적 DB 인스턴스 분리

  • 테넌트마다 DB 인스턴스를 완전히 분리
  • 장점: 가장 강력한 격리 수준
  • 단점: 리소스 낭비가 크고, 테넌트 생성 시 인스턴스 생성 비용이 크다

B. Schema 단위 분리

  • 공용 스키마 (Main) + Tenant 스키마(tenant_1, tenant_2 …) 구성
  • 테넌트별로 동일한 테이블 구조 유지
  • 장점: 격리 수준 적절 + 성능 우수
  • 단점: Connection이 스키마 단위로 분리되므로 커넥션 관리가 필요

C. Table 단위 분리

  • user_1, user_2와 같이 테이블 단위로 분리
  • 단점: Schema Backup이나 Tenant 삭제시 리소스 낭비가 발생한다.

내가 선택한 방식은 스키마 단위 분리를 선택했다.
Tenant가 동적으로 변경될때마다 데이터베이스 인스턴스를 생성하는건 불필요한 낭비라고 생각했고, 테이블 단위로 관리를 하면 ORM의 장점을 활용하기 어렵다는 단점때문에 스키마 단위 관리를 선택하게 됐다.

2. API 요청 처리

Tenant 기반 서비스는 결국 모든 요청이 “어떤 테넌트의 스키마를 사용할지” 결정해야 한다.
이 로직을 Service/Repository 내부에 직접 전달하면 다음과 같은 문제가 발생한다.

비즈니스 로직에 tenantId가 침투하는 문제

// (문제점) tenantId를 모든 계층으로 계속 전달해야 한다
 async listUser(tenantId: number) {
   const result = await this.userRepo.setTenant(tenantId).findAll();
   return result;
 }

tenantId를 안넘기는 경우

 async listUser() {
   const result = await this.userRepo.findAll();
   return result;
 }

위 케이스는 간단한 예시기 때문에 별로 차이가 안날 수 있지만, 다른 Service나 계층을 내려가야할때 계속해서 tenantId를 넘겨줘야 하기 때문에 모든 로직이 지저분해지는 문제가 발생한다.
이를 해결하기 위한 아이디어로 Tenant를 비즈니스 로직에서 완전히 제거하고, AOP 방식으로 요청 단위에서 처리한다.

아키텍처

  • Controller / Service / Repository: 비즈니스 로직만 구현 (Tenant에 대해 전혀 신경 쓰지 않음)
  • TenantProvider: RequestScope에서 요청 헤더에 따라 Tenant를 식별
  • ConnectionStore: Tenant별 DB Connection 캐싱하여 제공

코드 구현

ConnectionStore

@Injectable()
export class ConnectionStore {
  private readonly connectionMap: Record<string, Connection> = {};
  
  public async getConnection(schema: string) {
    if (this.connectionMap[schema]) {
      return this.connectionMap[schema];
    }

    const connection = await createConnection({
      type: 'postgresql',
      // ...
      schema: schema,
    });

    this.connectionMap[schema] = connection;
    return connection;
  }
}

TenantProvider

export const TenantProvider: Provider = {
  provide: TENANT_PROVIDER,
  useFactory: async (request: Request, connectionStore: ConnectionStore) => {
    const tenantId = request.headers['tenant-id'].toString();
    if (!tenantId) {
      throw new Error('Tenant ID is required');
    }
    const schemaName = `tenant_${tenantId}`;
    const entityManager = await connectionStore.getEntityManager(schemaName);
    return { tenantId, entityManager };
  },
  inject: [REQUEST, ConnectionStore],
}

✔ 장점

  • 비즈니스 로직이 테넌트를 전혀 신경 쓰지 않고 있음
  • 요청 단위로 Tenant가 결정되므로 Connection 재사용 가능
  • 스키마 분리 방식의 장점(격리 + 성능 + ORM 활용)을 모두 가져갈 수 있음.

✘ 단점

  • Tenant 수가 많아질수록 Connection도 증가
    • 이는 주기적으로 비접근 Tenant Connection을 메모리에서 비우는 전략이나, 트래픽 제어를 통해 해소 가능
  • NestJS RequestScope 특성상 Provider 트리가 RequestScope로 전이되는 문제
    • 이는 추후 Node.js AsyncLocalStorage 기반 Context 관리 기법으로 해결하는 글 작성 예정
profile
문제를 코드로만 보지 않고 구조와 흐름으로 해결하는 백엔드 개발자

0개의 댓글