04-05 15:53
Notice
Recent Posts
관리 메뉴

독산구너

멀티테넌시 구조와 NestJS Typeorm 사용 구현 본문

NestJS

멀티테넌시 구조와 NestJS Typeorm 사용 구현

독산구너 2024. 11. 20. 23:59

 

 

목차

     

     

     

    글의 목적

    인턴과정에서 SaaS 형태, 멀티테넌시 구조의 서비스 개발 수행을 맡았습니다. 그때 고민하고 구현했던 것들에 대해 기록을 남기고자 합니다.

     

    멀티테넌시란?

    단일 인스턴스에서 여러 테넌트(사용자 또는 사용자 조직)가 수용되고, 각 테넌트의 데이터는 다른 테넌트와 격리되어 보이지 않아 모든 테넌트에 대한 데이터 보안 및 개인정보 보호가 보장되는 소프트웨어입니다.

    출처: https://www.ibm.com/kr-ko/topics/multi-tenant

     

    멀티 테넌트란? | IBM

    여러 사용자가 소프트웨어 애플리케이션 및 해당 리소스의 단일 인스턴스를 공유할 수 있도록 하는 멀티 테넌트 소프트웨어 아키텍처에 대해 자세히 알아보세요.

    www.ibm.com

     

    멀티테넌시 격리수준

    1. 공유 데이터베이스, 공유 스키마 (Shared Database, Shared Schemas)

    - 모든 테넌트가 하나의 데이터베이스와 스키마를 공유합니다.

    - 데이터는 테넌트 식별자(ex: tenant_id) 통해 구분됩니다.

    CREATE TABLE users (
        id INT AUTO_INCREMENT,
        tenant_id INT,
        name VARCHAR(255),
        email VARCHAR(255),
        PRIMARY KEY (id)
    );

     

    장점: 데이터베이스 인스턴스와 리소스를 공유하므로 운영 비용이 가장 낮다 / 유지보수가 용이하다

    단점: 가장 낮은 수준의 격리 수준으로, 보안상 가장 취약하다. / 하나의 데이터베이스를 여러 테넌트가 공유하므로, 트래픽 증가시 성능 문제가 발생한다. / 테넌트별 맞춤형 설정이 불가해 유연성이 떨어진다.

     

     

    2. 공유 데이터베이스, 분리된 스키마 (Shared Databse, Separate Schemas)

    - 모든 테넌트가 하나의 데이터베이스를 공유하지만, 테넌트별로 스키마를 구분합니다.

    -- 테넌트 1의 스키마
    CREATE TABLE tenant1.users (
        id INT AUTO_INCREMENT,
        name VARCHAR(255),
        email VARCHAR(255),
        PRIMARY KEY (id)
    );
    
    -- 테넌트 2의 스키마
    CREATE TABLE tenant2.users (
        id INT AUTO_INCREMENT,
        name VARCHAR(255),
        email VARCHAR(255),
        PRIMARY KEY (id)
    );

     

    장점: 테넌트별로 데이터가 스키마로 격리되므로 데이터 누수위험 줄어듭니다. / 테넌트마다 데이터 구조를 다르게 설계할 수 있습니다.

    단점: 테넌트 수가 늘어나면 스키마 관리가 어려워집니다. / 데이터베이스 자체는 여전히 공유되므로 트래픽 증가 병목이 발생할 있습니다.

     

    여기서, 시끄러운 이웃 효과를 알아봅시다.

    -> 시끄러운 이웃 효과(Noisy Neighbor Effect) 공유자원을 사용하는 환경에서 일어나는 성능문제로, 하나의 테넌트의 과도한 리소스 사용으로 다른 테넌트나 사용자의 성능에 영향을 미치는 것을 말합니다. 격리수준이 낮은 멀티테넌시일수록 시끄러운 이웃 효과가 커집니다.

     

    3. 분리된 데이터베이스 (Seperate Databases)

    - 각 테넌트가 별도의 데이터베이스를 사용합니다.

     

    장점: 각 테넌트의 데이터가 완전히 불리되므로 보안과 데이터 무결성이 가장 강력해 집니다. / 독립적인 데이터베이스를 사용하므로 테넌트 트래픽 증가에 따른 확장 가능합니다. / 테넌트별로 데이터베이스 설정, 구조, 인덱싱을 다르게 설정할 수 있습니다.

    단점: 격리수준은 높지만 관리해야 하는 데이터베이스가 늘어나므로 비용이 높아집니다. / 유지보수가 복잡해지고, 테넌트가 작은 규모인 서비스의 경우에는 알맞지 않습니다.

     

     

    어떤 격리수준을 선택해야 할까?

    1. 보안 요구사항이 높을수록 높은 격리 수준이 필요합니다.
    2. 테넌트별로 사용자가 많고 트래픽이 높은 경우, 분리된 데이터베이스 방식이 적합합니다.
    3. 비용을 먼저고려하고 규모가 작은 서비스라면 공유 데이터베이스 방식이 적절합니다.
    4. 테넌트별 커스터마이징이 필요하다면 격리수준이 높아야 합니다.

    NestJS, Typeorm 사용 멀티테넌시 구조 구현

    요구사항

    1. 워크스페이스 생성 새로운 데이터베이스를 생성해야 한다.
    2. 워크스페이스 접근 해당 데이터베이스의 datasource 획득하고, initialize 해야한다.
    3. 테넌트 DB 접근마다 initialize 하지않게, datasource 캐시해 connection pool 통해 성능을 보장할 있도록 한다.

     

    가장 높은 격리수준인 Seperate Database 방식으로 구현해보겠습니다. 가장 높은 격리수준으로 설계해보는 것이 멀티테넌시 구조의 이점과 단점을 가장 크게 체감할 있는 방법이라고 생각했습니다.

     

     

    먼저, Typeorm Connection Pool 대해서

    NestJS orm 하나인 Typeorm 데이터베이스 접근방식을 먼저 알아보겠습니다.

     

    어플리케이션이 시작되면,

    1. NestJS는 모든 모듈(@Module)을 분석하고 설정합니다.  과정에서 TypeOrmModule imports 배열에 포함되어 있으면, 이를 초기화합니다.
    2. TypeOrmModule.forRoot() 또는 TypeOrmModule.forRootAsync()로 전달된 데이터베이스 설정 정보를 기반으로, 데이베이스 설정을 로드합니다.
    3. 데이터베이스 드라이버 초기화, 엔티티와 db 매핑 확인, 옵션 활성화 시 스키마 동기화 과정을 거칩니다.
    4. connectionLimit 등 extra 옵션에 따라 Connection Pool을 구성합니다.
    5. Connection 객체는 NestJS 의존성 주입을 통해 사용 가능한 상태로 등록됩니다.
    6. @InjectRepository 를 통해 서비스에서 Connection 객체를 주입받아 사용할 수 있도록 합니다.

    이러한 NestJS와 TypeORM 의 생성 및 구동 주기와 다르게, 멀티테넌시 구조에서는 워크스페이스 생성과 함께 데이터베이스가 생성되어야 하고, 해당 테넌트에 접근할 때마다 connection을 연결해줘야 합니다.

     

    요구사항에 따라 워크스페이스 생성에 따른 테넌트DB 생성 로직을 구현합니다.

    async createTenant(
        queryRunnerManager: EntityManager,
        workspaceId: string,
        memberId: string,
        memberName: string,
        memberProfile: string
      ) {
        const databaseName = this.getDatabaseName(workspaceId);
        await queryRunnerManager.query(`CREATE DATABASE \`${databaseName}\``);
    
        const tenantDataSource = new DataSource(await this.getTenantDataSourceOptions(databaseName));
        await tenantDataSource.initialize();
    
        const queryRunner = tenantDataSource.createQueryRunner();
    
        await queryRunner.connect();
        await queryRunner.startTransaction();
        try {
          await tenantDataSource.manager.save(MemberEntity,
            MemberEntity.create(
              memberId,
              memberName,
              MemberLevel.LEVEL_1,
              memberProfile
            )
          );
    
          await tenantDataSource.destroy();
    
          this.logger.log(`새로운 DB테넌트 생성 | databaseName: ${databaseName}`);
    
          return databaseName;
        } catch (error) {
          await queryRunner.rollbackTransaction();
          this.logger.error(`테넌트 데이터베이스 생성 실패: ${error}`);
          throw new ServiceException(ExceptionCode.DATABASE_ERROR);
        } finally {
          await queryRunner.release();
        }
      }

    여기서 DataSource를 생성하고 initialize() 하는것을 볼 수 있습니다. 해당 메서드는 다음과 같이 설명됩니다

    Performs connection to the database. This method should be called once on application bootstrap. This method not necessarily creates database connection (depend on database type), but it also can setup a connection pool with database to use.

    이 메서드는 어플리케이션이 bootstrap 될 때 한번 호출되며, Connection Pool을 설정한다고 되어있습니다. 다음은 DataSource 클래스의 connect() 메서드에 대한 설명입니다.

    Creates/ uses database connection from the connection pool to perform further operations. Returns obtained database connection.

    커넥션 풀에서 커넥션을 가져와 사용하는 것을 알 수 있습니다.

     

    다음은 해당 테넌트 DB 접근 시 DataSource를 생성하고 connection을 취득, 트랜잭션을 시작하는 로직입니다.

    // DatabaseService class
    async createDataSource(databaseName: string): Promise<DataSource> {
        try {
          const newDataSource = new DataSource(await this.getTenantDataSourceOptions(databaseName));
          await newDataSource.initialize();
    
          return newDataSource;
        } catch (error) {
          console.error(error);
          throw new ServiceException(ExceptionCode.DATABASE_ERROR);
        }
      }
    // TenantTxInterceptor class
      private async init(workspaceId: string): Promise<QueryRunner> {
        const dataSource: DataSource = await this.databaseService.getTenantDataSource(workspaceId);
        const queryRunner = dataSource.createQueryRunner();
        await queryRunner.connect();
        await queryRunner.startTransaction();
    
        return queryRunner;
      }

    TenantTxInterceptor에서는 workspaceId를 확인하고 해당 테넌트 DB 커넥션을 시도, 트랜잭션을 시작합니다.

     

    하지만, 이렇게 테넌트db에 접근할 때마다 DataSource를 생성하고 Initialize()하여 Connection Pool을 생성하고, connect()를 통해 커넥션을 획득해 쿼리를 수행하는 작업은 굉장히 비효율적입니다. 어플리케이션이 시작할 때 모든 준비가 끝나는 기존 TypeORM 사용방식과 다른 점입니다. 캐시를 사용해야 합니다.

     

    테넌트 DB 접근마다 initialize 하지않게, datasource 캐시해 connection pool 통해 성능을 보장할  있도록 한다.

    매번 connection Pool을 생성하지 않도록, 어플리케이션이 시작한 이후 한번이라도 연결된 테넌트 DB의 Connection Pool이 보존되고 이후에 사용할 수 있도록 합니다. 여기서는 테넌트 DB 식별자인 workspaceId를 사용한 Map 자료구조를 사용했습니다.

     @Injectable()
    export class DatabaseService {
      private tenantDataSources: Map<string, DataSource> = new Map();
      
     // 로직 수행때마다 DataSource를 생성하는 것이 아닌 map 자료구조로부터 생성되어있는 dataSource를 조회하고 사용합니다.
     async getTenantDataSource(workspaceId: string): Promise<DataSource> {
        try {
          const databaseName = this.getDatabaseName(workspaceId);
          let dataSource: DataSource;
          if (this.tenantDataSources.has(databaseName)) {
            dataSource = this.tenantDataSources.get(databaseName);
          } else {
            dataSource = await this.createDataSource(databaseName);
            this.tenantDataSources.set(databaseName, dataSource);
          }
    
          return dataSource;
        } catch (error) {
          console.error(error);
          throw new ServiceException(ExceptionCode.NOT_FOUND_WORKSPACE);
        }
      }
    }

     

    다음과 같이 처음 DataSource를 생성하고 Connection Pool을 구성한 후에는 같은 작업을 반복하지 않고 로직을 수행하는 것을 볼 수 있습니다.

     

    // 첫번째 요청, 내 프로필 조회 기능
    
    [Nest] 12472  - 11/20/2024, 11:53:25 PM     LOG [Request] /workspace/get-my-info | memberId: member-90cd9162-8ed2-4845-b477-1d5754beddbb | body: {"memberId":"member-90cd9162-8ed2-4845-b477-1d5754beddbb","workspaceId":"workspace-059122f2-9006-4f52-8f73-83ee3b0ae9ee","level":1}
    Connection Pool Initialized:
    - Database: workspace_059122f2_9006_4f52_8f73_83ee3b0ae9ee
    - Host: localhost
    - Port: 3306
    - User: root
    - Max Connections: default (10)
    - Min Connections: default (not set)
    query: START TRANSACTION
    query: SELECT `MemberEntity`.`id` AS `MemberEntity_id`, `MemberEntity`.`uuid` AS `MemberEntity_uuid`, `MemberEntity`.`name` AS `MemberEntity_name`, `MemberEntity`.`access_level` AS `MemberEntity_access_level`, `MemberEntity`.`image_file` AS `MemberEntity_image_file` FROM `member` `MemberEntity` WHERE (`MemberEntity`.`uuid` = ?) LIMIT 1 -- PARAMETERS: ["member-90cd9162-8ed2-4845-b477-1d5754beddbb"]
    query: SELECT `MemberPublicEntity`.`id` AS `MemberPublicEntity_id`, `MemberPublicEntity`.`uuid` AS `MemberPublicEntity_uuid`, `MemberPublicEntity`.`email` AS `MemberPublicEntity_email`, `MemberPublicEntity`.`refresh_token` AS `MemberPublicEntity_refresh_token`, `MemberPublicEntity`.`service_agree` AS `MemberPublicEntity_service_agree`, `MemberPublicEntity`.`terms_agree` AS `MemberPublicEntity_terms_agree`, `MemberPublicEntity`.`member_info_agree` AS `MemberPublicEntity_member_info_agree`, `MemberPublicEntity`.`status` AS `MemberPublicEntity_status` FROM `member_public` `MemberPublicEntity` WHERE (`MemberPublicEntity`.`uuid` = ?) LIMIT 1 -- PARAMETERS: ["member-90cd9162-8ed2-4845-b477-1d5754beddbb"]
    [Nest] 12472  - 11/20/2024, 11:53:25 PM     LOG [Response] /workspace/get-my-info | memberId: member-90cd9162-8ed2-4845-b477-1d5754beddbb | duration: 71ms | body: {"error":0,"payload":{"name":"한나라","email":"dowonjeong@nineright.com","profile":"profile-48fd4268-c4d5-47f4-85ce-ec3065b1b413","id":"member-90cd9162-8ed2-4845-b477-1d5754beddbb"}}
    query: COMMIT
    
    
    // 같은 워크스페이스 내 두번째 요청, 모든 멤버정보 조회 기능
    [Nest] 12472  - 11/20/2024, 11:55:24 PM     LOG [Request] /workspace/get-members | memberId: member-90cd9162-8ed2-4845-b477-1d5754beddbb | body: {"memberId":"member-90cd9162-8ed2-4845-b477-1d5754beddbb","workspaceId":"workspace-059122f2-9006-4f52-8f73-83ee3b0ae9ee","level":1}
    query: START TRANSACTION
    query: SELECT `MemberEntity`.`id` AS `MemberEntity_id`, `MemberEntity`.`uuid` AS `MemberEntity_uuid`, `MemberEntity`.`name` AS `MemberEntity_name`, `MemberEntity`.`access_level` AS `MemberEntity_access_level`, `MemberEntity`.`image_file` AS `MemberEntity_image_file` FROM `member` `MemberEntity`
    query: SELECT `MemberEntity`.`id` AS `MemberEntity_id`, `MemberEntity`.`uuid` AS `MemberEntity_uuid`, `MemberEntity`.`name` AS `MemberEntity_name`, `MemberEntity`.`access_level` AS `MemberEntity_access_level`, `MemberEntity`.`image_file` AS `MemberEntity_image_file` FROM `member` `MemberEntity` WHERE (`MemberEntity`.`uuid` = ?) LIMIT 1 -- PARAMETERS: ["member-90cd9162-8ed2-4845-b477-1d5754beddbb"]
    query: SELECT `MemberPublicEntity`.`id` AS `MemberPublicEntity_id`, `MemberPublicEntity`.`uuid` AS `MemberPublicEntity_uuid`, `MemberPublicEntity`.`email` AS `MemberPublicEntity_email`, `MemberPublicEntity`.`refresh_token` AS `MemberPublicEntity_refresh_token`, `MemberPublicEntity`.`service_agree` AS `MemberPublicEntity_service_agree`, `MemberPublicEntity`.`terms_agree` AS `MemberPublicEntity_terms_agree`, `MemberPublicEntity`.`member_info_agree` AS `MemberPublicEntity_member_info_agree`, `MemberPublicEntity`.`status` AS `MemberPublicEntity_status` FROM `member_public` `MemberPublicEntity` WHERE (`MemberPublicEntity`.`uuid` = ?) LIMIT 1 -- PARAMETERS: ["member-90cd9162-8ed2-4845-b477-1d5754beddbb"]
    [Nest] 12472  - 11/20/2024, 11:55:24 PM     LOG [Response] /workspace/get-members | memberId: member-90cd9162-8ed2-4845-b477-1d5754beddbb | duration: 5ms | body: {"error":0,"payload":{"memberInfoList":[{"name":"한나라","email":"dowonjeong@nineright.com","profile":"profile-48fd4268-c4d5-47f4-85ce-ec3065b1b413","id":"member-90cd9162-8ed2-4845-b477-1d57eddbb"}]}}
    query: COMMIT
    // Connection Pool 초기화 없이 기존 DataSource 사용

     

     

    NestJS TypeORM 사용 멀티테넌시 구현 과정을 통해

    현재는 서비스 크기가 작기때문에 멀티테넌시 구조를 도입하는 과정 자체가 오버스펙이고 불필요한 리소스 사용이라고 생각합니다. 하지만 데이터 격리 및 테넌트별 커스터마이징이 가능하도록 지원하는 멀티테넌시 구조를 알아보고 가장 높은 격리수준을 구현하면서 db 접근에 대한 고민을 할 수 있는 시간이었습니다.

    'NestJS' 카테고리의 다른 글

    불필요한 기능 무효화로 Jest 테스트 최적화  (2) 2024.11.28
    NestJS Guard, JWT 기반 권한 레벨 관리  (18) 2024.11.24
    NestJS Library 사용  (4) 2024.11.19