[Nest.js] Multi-Tenancy Database Design (부제. NestJs + Multi-Tenancy+TypeORM)

resilient

·

2022. 11. 3. 23:45

728x90
반응형

 

이번 팀 sprint에서는 Firestore를 사용하고 있는 프로젝트를 GCP Cloud MySQL로 마이그레이션 하는 작업이 포함됐었습니다.

 

상황을 말씀드리자면 각 TF팀마다 각각 다른 프로젝트를 진행하고 있었고, Firestore에는 각 TF팀마다의 Collection 형식으로 구현한 뒤, Document로 쪼개서 데이터들을 관리하고 있었습니다. 저희 팀이 개인정보처리를 담당하는 팀이다 보니, 'Document형식의 DB가 아닌 RDB를 사용해서 개인정보를 더 안전하고 체계적으로 관리해보자'라는 생각과 함께 이 sprint가 시작이 되었죠.

 

여기서 고민은 시작되었습니다...

 

팀 리더와 제가 나눈 이야기를 바탕으로 정리한 요구사항은 세 가지였습니다.

 

  • 먼저 각 TF팀마다 schema가 생겼으면 좋겠다.
  • 애플리케이션 레벨에서 어떤 TF팀이 사용하는지에 대한 정보(어떤 schema를 사용할지에 대한 tenantId)는  header에 담아서 옮기면 좋겠다.
  • RDB로 변경하면서 이제 Firestore가 제공해주는 sdk를 사용하지 않으니, TypeORM을 도입해서 사용하자.

 

결과적으로 이번 글의 주제인 multi-tenancy architecture을 도입하기로 하였습니다. 제가 리서치하고 기획한 만큼 제대로 이해하고 넘어가기 위해서 정리를 해보려고 합니다.

 

0. Multi-tenancy Architecture란?

 

먼저 Multi-tenancy는 Tenants라는 여러 사용자가 하나의 서버나 데이터 베이스를 공유해서 사용하는 방법이라고 정리할 수 있습니다. 

이번에 진행한 sprint 같은 경우에는 하나의 데이터베이스를 가상으로 분할해서 각각의 schema를 만들고 각 TF팀들은 주어진 schema를 사용할 수 있는 것이죠.

 

1. 왜 Multi-tenancy Architecture를 도입하려고 했는지?

 

Multi-tenancy를 사용하면 데이터베이스는 하나로 유지한 채로, 각각의 schema 리소스를 활용할 수 있고 관리할 수 있기 때문에 굉장히 편리하다고 할 수 있습니다. 또한 나중에 TF팀들은 우후죽순으로 생겨나고 있기 때문에 확장성도 굉장히 좋다고 판단했습니다.

 

물론 더 좋은 방법도 많고 infra레벨에서 더 좋은 아키텍처가 있을 순 있겠지만 현재로서 가장 빠르고 저렴하게 운영하기 위해서는 Multi-tenancy Architecture가 제일 효율적이라고 생각을 했습니다.

 

2. TypeORM을 이용한 Multi-tenancy Architecture 구현

 

multi-tenancy 구현을 위한 dir 구조

 

2-1. Custom middleware 생성하기

 

첫 번째로 어떤 TF팀이 사용하는지에 대한 정보(어떤 schema를 사용할지에 대한 tenantId)는  header에 담아서 옮기면 좋겠다.라는 니즈에 맞게 구현하기 위해 middleware를 사용했습니다. NestJS에서 custom middleware를 사용해서 요청이 오고 갈 때 항상 tenantId를 받아서 Controller로 넘기도록 구현한 것입니다. 각 TF팀이 로그인을 하면 TF팀 정보를 알 수 있고, 그 데이터에서 header에 TF팀 projectName을 넘기도록 구현했습니다.

 

const DATABASE_TENANT_HEADER = 'x-projectname';

export function tenancyMiddleware(
  req: Request,
  _res: Response,
  next: NextFunction,
): void {
  const header = req.headers[DATABASE_TENANT_HEADER] as string;
  req.projectName = header?.toString() || null;
  next();
}

 

여기서 추가적인 구현이 필요했던 부분은 NestJS가 Express 기반의 프레임워크이다 보니, Requeset를 보낼 때 tenantId로 사용하는 projectName을 받아오려면 Request interface를 namespace로 설정해주는 과정이 필요했습니다. 따라서 아래와 같이 Express라는 namespace를 만들어서 구현했고 declare를 해주기 위해 index.d.ts파일을 하나 만들어서 넣어줬습니다.

 

declare namespace Express {
  interface Request {
    projectname?: string;
  }
}

 

2-2. TypeORM을 사용해서 header에 담긴 tenantId(projectName)에 맞는 schema를 connect 해주기.

 

middleware를 통해 header로 tenantId(projectName)을 보내기까지는 성공했습니다.

 

그렇다면 service 레이어에서 DB connect pool을 할 때 header에서 받은 tenantId에 맞는 schema를 connect 해줘야 하는데요. 그 방법은 아래 구현과 같습니다.

 

  constructor(
    @Inject(CONNECTION) connection: Connection,)
  {
    this.userRepository = connection.getRepository(User);
  }

 

사용하고자 하는 service 레이어에서 constructor안에 위와 같이 코드를 작성하면 connection안에 해당 tenantId가 적용된 connection pool을 사용할 수 있습니다. 

 

여기서 CONNECTION이 뭔데 Inject를 하고 어떻게 tenanId의 정보를 담아서 connection pool을 만든다는 거지?라는 궁금증이 드실 텐데요.

 

const CONNECTION = Symbol('CONNECTION');
//NestJs에서 제공해주는 useFactory를 사용해서 custom factory를 만듭니다.
const connectionFactory = {
  // 이 부분에서 provide에 CONNECTION이라는 값이 들어가게 됩니다.
  provide: CONNECTION,
  scope: Scope.REQUEST,
  useFactory: (request: ExpressRequest) => {
  	// header에 보냈던 projectName을 가져와서
    const projectname = request.projectName;
    // schema 이름과 같이 바꿔주고
    const tenantProjectName = projectname.split('-').join('_');
    // 만약에 schema이름이 있다면 
    if (tenantProjectName) {
    // 해당 스키마에 대한 connection pool을 가져와서 return 해줘. 라는 말이죠.
      return getTenantConnection(tenantProjectName);
    }
    return null;
  },
  inject: [REQUEST],
};


// Custom Module을 만들어서 서비스 전체에 적용시키기 위해
@Global()
@Module()
  // providers로는 위에서 만든 connectionFactory를 지정해줍니다.
  providers: [connectionFactory],
  exports: [CONNECTION],
})
export class TenancyModule {}

 

이제 위의 코드에 들어있는 getTenantConnection 함수를 자세히 들여다보겠습니다.

 

export function getTenantConnection(projectname: string): Promise<Connection> {
  const connectionName = `${projectname}`;
  const connectionManager = getConnectionManager();
  if (connectionManager.has(connectionName)) {
    const connection = connectionManager.get(connectionName);
    return Promise.resolve(
      connection.isConnected ? connection : connection.connect(),
    );
  } else {
    const tenantsOrmconfig = {
      type: 'mysql',
      host: process.env.DB_HOST,
      port: parseInt(process.env.DB_PORT, 10) || 3306,
      username: process.env.DB_USERNAME,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_DATABASE,
      logging: true,
      autoLoadEntities: true,
      entities: [join(__dirname, '/../../**/*.entity{.ts,.js}')],
    };
    console.log('tenantsOrmConfig', tenantsOrmconfig);
    return createConnection({
      ...(tenantsOrmconfig as MysqlConnectionOptions),
      database: connectionName,
      name: connectionName,
    });
  }
}

 

간단히 설명을 하자면 header에서 받은 projectName을 parameter로 받은 뒤, getConnectorManager안에 해당 projectName의 connection pool이 있는지를 확인합니다.

 

만약에 있다면 해당 connection pool을 가져와서 return 해주고, 없다면 연결된 DB의 TypeORM 설정을 적용해서 새로운 connection pool을 만들어서 return 해주는 방식이죠.

 

따라서 connection에는 요청을 보낸 projectName에 맞는 connection pool이 담기고, service레이어에서 사용할 수 있게 됩니다.

 

3. Service레이어에서 어떻게 쓰이는지 정리해봅시다.

export class UsersService {
  private readonly userRepository: Repository<User>;
  constructor(
    @Inject(CONNECTION) connection: Connection,
  ) {
    this.userRepository = connection.getRepository(User);
  }

  async getUser(uid: string) {
    const user = await this.userRepository.findOne({
      where: {
        gcipUid: uid,
      },
      relations: {
        publicProfile: true,
      },
    });
    return user;
  }
}

 

위 코드는 User라는 table에서 입력받은 uid값으로 해당 유저 정보를 뿌려주는 service 로직입니다.

 

정리를 해보면

  • header안에 x-projectname = A-TF라고 request를 요청하면 A-TF의 User table에서 해당 uid값을 읽어올 것이고
  • header안에 x-projectname = B-TF라고 request를 요청하면 같은 코드이지만 B-TF의 User table에서 해당 uid값을 읽어오는 것이죠.

 

4. 정리

 

Multi-tenancy Architecture을 적용하고, RDB로 mirgration을 무려 10일 만에 끝내야 하는 짧은 sprint 였지만, 하나하나 구현하는 과정에서 NestJS와 TypeORM을 조금 더 깊이 있게 공부를  할 수 있었습니다.

 

Multi-tenancy Architecture는 DB뿐만 아니라 애플리케이션 내에서도 다양하게 쓰일 수 있는데요. DB migration을 현재는 수동으로 하고 있지만 이 부분도 자동화를 시켜보려고 합니다.

 

긴 글 읽어주셔서 감사합니다.

반응형