Skip to main content

Multi-tenancy with Nestjs

· 10 min read

Nest.js를 처음 사용하는 경우 탄력적인 웹 애플리케이션을 구축하기 위한 탁월한 프레임워크의 공식문서를 살펴보는 것이 좋습니다 .

다중 테넌트에 대해 이미 잘 알고 있고 Nest.js를 사용한 구현에 대해 코드로 바로 알고 싶다면 GitHub 저장소 에서 코드를 직접 검토해 보세요.

준비​

먼저 Nest.js CLI 프로그램을 아직 설치하지 않았다면 설치하세요.

Nestjs cli 설치: https://docs.nestjs.com/cli/overview

nest new multitenant 프로젝트를 발판으로 실행합니다 . 저는 패키지 관리자로 pnpm을 선택했습니다. 또는 pnpm을 사용하지 않으려는 경우 npm 또는 Yarn을 선택할 수 있습니다.

멀티 테넌시(Multi-tenancy)가 무엇인지 알아봅시다.​

멀티 테넌시는 SaaS(Software as a Service) 애플리케이션에 사용되는 널리 사용되는 아키텍처 접근 방식으로, 일반적으로 테넌트라고 하는 고객 간의 리소스 공유를 촉진합니다.

위키피디아 정의: https://en.wikipedia.org/wiki/Multitenancy

Multitenancy에서 가장 중요한 개념은 Host vs Tenant 입니다.

호스트는 SaaS 애플리케이션 시스템의 관리를 소유하고 감독할 책임이 있습니다.

테넌트는 서비스를 활용하는 SaaS 애플리케이션의 유료 고객을 의미합니다.

코드를 봅시다.​

IService먼저, 나중에 애플리케이션 서비스에서 사용할 일반 인터페이스를 만들어야 합니다 .

폴더 app.interface.ts안에 파일을 만듭니다 .src

export interface IService<T, C, U> {
get: (uuid: string, tenantId?: string) => T;
create: (data: C, tenantId?: string) => void;
update: (uuid: string, data: U, tenantId?: string) => void;
delete: (uuid: string, tenantId?: string) => void;
getAll: (tenantId?: string) => T[];
}

애플리케이션에서 T는 TodoModel 로 예시된 모델 엔터티를 나타냅니다 . C 와 U는 각각 CreateDto 와 UpdateDto를 나타냅니다 . 또한, 곧 명백해질 이유 때문에 중요한 의미를 지닌 선택적 테넌트 ID 속성이 있습니다 .

다음으로, 기능에 따라 애플리케이션의 폴더를 구성하기 위해 src 디렉터리 내에 테넌트 와 todo라는 두 개의 폴더를 생성해 보겠습니다. 테넌트 기능 은 우리의 주요 초점으로 작용하고, todo는 테넌트(고객)가 접근할 수 있는 서비스 역할을 하며 호스트도 이를 활용할 수 있습니다.

기본 설정이 완료되면 테넌트 기능을 완료해 보겠습니다. 테넌트 폴더 에는 모델, 컨트롤러, 서비스, 미들웨어 등을 포함한 해당 논리가 포함됩니다.

image

테넌트 논리 설정​

테넌트 기능에 대한 DTO, 모델 및 서비스를 만드는 것부터 시작해 보겠습니다. 모델 폴더 내에서 TenantModel.ts 라는 파일을 시작합니다 .

export class TenantModel {
id: string;
name: string;
subdomain?: string; // https://store.mysassapp.com
constructor(id: string, name: string, subdomain?: string) {
this.id = id;
this.name = name;
this.subdomain = subdomain || 'https://mysassapp.com';
}
}

테넌트 모델은 id 및 name 과 같은 필수 속성으로 구성되며 둘 다 필수입니다. 또한 도메인 또는 하위 도메인을 포함하여 고객이 도메인을 맞춤설정할 수 있는 옵션을 제공할 수 있습니다.

참고: 게시물에서 도메인/하위 도메인 부분을 다루지는 않겠습니다.

이제 dtos와 서비스를 만들어 보겠습니다.

src/tenant/dtos/CreateTenantDto.ts

export class CreateTenantDto {
name: string;
subdomain?: string;
constructor(name: string, subdomain?: string) {
this.name = name;
this.subdomain = subdomain;
}
}

src/tenant/dtos/UpdateTenantDto.ts`

export class UpdateTenantDto {
id: string;
name: string;
subdomain?: string;
constructor(id: string, name: string, subdomain?: string) {
this.id = id;
this.name = name;
this.subdomain = subdomain;
}
}

src/tenant/services/TenantService.ts

import { randomUUID } from 'crypto';
import { Injectable, NotFoundException } from '@nestjs/common';
import { IService } from '../../app.interface';
import { TenantModel } from '../models/TenantModel';
import { CreateTenantDto } from '../dtos/CreateTenantDto';
import { UpdateTenantDto } from '../dtos/UpdateTenantDto';

@Injectable()
export class TenantService
implements IService<TenantModel, CreateTenantDto, UpdateTenantDto>
{
private readonly tenants: TenantModel[] = []; // Temp local database..

create(data: CreateTenantDto): void {
const uuid = randomUUID();
this.tenants.push(new TenantModel(uuid, data.name, data.subdomain));
}

delete(uuid: string): void {
const index = this.tenants.findIndex((tenant) => tenant.id === uuid);
if (index === -1) throw new NotFoundException('Tenant not found');
this.tenants.splice(index, 1);
}

get(uuid: string): TenantModel {
const todo = this.tenants.find((tenant) => tenant.id === uuid);
if (!todo) throw new NotFoundException('Tenant not found');
return todo;
}

update(uuid: string, data: UpdateTenantDto): void {
const tenant = this.tenants.find((tenant) => tenant.id === uuid);
if (!tenant) throw new NotFoundException('Tenant not found');
tenant.name = data.name;
tenant.subdomain = data.subdomain;
}

getAll(): TenantModel[] {
return this.tenants;
}
}

테넌트 서비스에서는 현재 테넌트 정보를 저장하기 위해 임시 배열을 활용하고 있습니다. 프로덕션 환경에서는 데이터 지속성을 위해 실제 데이터베이스를 통합하는 것이 필수적입니다. 또한 개별 테넌트에 대해 서로 다른 데이터베이스를 구성할 수 있는 옵션도 있습니다. (이 기능을 구현하는 방법을 배우고 싶다면 댓글로 문의해 주세요.)

앞으로 테넌트 컨트롤러를 만들어 보겠습니다.

src/tenant/controllers/tenant.controller.ts

import {
Body,
Controller,
Delete,
Get,
HttpStatus,
Param,
Post,
Put,
Req,
} from '@nestjs/common';
import { CreateTenantDto } from '../dtos/CreateTenantDto';
import { UpdateTenantDto } from '../dtos/UpdateTenantDto';
import { TenantService } from '../services/TenantService';

@Controller()
export class TenantController {
constructor(private readonly tenantService: TenantService) {}

@Get('/tenants')
getAll() {
return this.tenantService.getAll();
}

@Post('/tenants')
createTodo(@Req() req: Request, @Body() data: CreateTenantDto) {
this.tenantService.create(data);
return HttpStatus.CREATED;
}

@Get('/tenants/:uuid')
getTenant(@Req() req: Request, @Param('uuid') uuid: string) {
return this.tenantService.get(uuid);
}

@Put('/tenants/:uuid')
updateTenant(
@Req() req: Request,
@Param('uuid') uuid: string,
@Body() data: UpdateTenantDto,
) {
this.tenantService.update(uuid, data);
return HttpStatus.NO_CONTENT;
}

@Delete('/tenants/:uuid')
deleteTodo(@Req() req: Request, @Param('uuid') uuid: string) {
this.tenantService.delete(uuid);
return HttpStatus.ACCEPTED;
}
}

좋습니다. 테넌트 논리 구현을 완료했습니다. 다음으로app.module.ts에 컨트롤러와 서비스를 등록합니다. 그런 다음 Postman과 같은 선호하는 REST 클라이언트를 실행하고 엔드포인트를 테스트하세요.

image

image

좋습니다. 테넌트 API가 예상대로 작동합니다. 할 일 논리를 만들어 보겠습니다.

Todo 로직 설정​

먼저 todo의 dto, 모델 및 서비스를 계속 생성해 보겠습니다. 모델 폴더 내에 할 일 모델을 생성합니다.TodoModel.ts

export class TodoModel {
uuid: string;
title: string;
done: boolean;
tenantId?: string;

constructor(uuid: string, title: string, done: boolean) {
this.uuid = uuid;
this.title = title;
this.done = done;
}

setTenantId(tenantId: string) {
this.tenantId = tenantId;
}

우리의 할일 모델에서 tenantId 속성은 선택 사항입니다. 이는 각 할일 항목의 소유권 관리를 용이하게 하기 위해 설계되었습니다. 예를 들어, 호스트는 호스트의 고객(테넌트)에게 보이지 않는 상태로 유지되는 할 일 항목을 가질 수 있습니다.

참고: 실제 프로덕션 코드에서는 다대일 관계를 설정하는 todo 모델 엔터티를 소유해야 합니다.

나머지 todo 로직을 계속 진행해 보겠습니다.

src/todo/dtos/CreateTodoDto.ts

export class CreateTodoDto {
title: string;
done: boolean;
constructor(title: string, done: boolean) {
this.title = title;
this.done = done;
}
}

src/todo/dtos/UpdateTodoDto.ts

export class UpdateTodoDto {
id: string;
title: string;
done: boolean;
constructor(id: string, title: string, done: boolean) {
this.title = title;
this.done = done;
}
}

src/todo/services/TodoService.ts

import { randomUUID } from 'crypto';
import { Injectable, NotFoundException } from '@nestjs/common';
import { IService } from '../../app.interface';
import { TodoModel } from '../models/TodoModel';
import { CreateTodoDto } from '../dtos/CreateTodoDto';
import { UpdateTodoDto } from '../dtos/UpdateTodoDto';

@Injectable()
export class TodoService
implements IService<TodoModel, CreateTodoDto, UpdateTodoDto>
{
private readonly todos: TodoModel[] = []; // temp local databse to store all our todo items

create(data: CreateTodoDto, tenantId?: string): void {
const uuid = randomUUID();
const newTodo = new TodoModel(uuid, data.title, data.done);
if (tenantId) newTodo.setTenantId(tenantId);
this.todos.push(newTodo);
}

delete(uuid: string, tenantId?: string) {
const index = this.todos.findIndex((todo) => todo.uuid === uuid);
if (index === -1) throw new NotFoundException('Todo not found');
if (tenantId && this.todos[index].tenantId !== tenantId)
throw new NotFoundException('Todo not found');
this.todos.splice(index, 1);
}

get(uuid: string, tenantId?: string): TodoModel {
const todo = this.todos.find((todo) => todo.uuid === uuid);
if (!todo) throw new NotFoundException('Todo not found');
if (tenantId && todo.tenantId !== tenantId)
throw new NotFoundException('Todo not found');
return todo;
}

update(uuid: string, data: UpdateTodoDto, tenantId?: string): TodoModel {
const todo = this.todos.find((todo) => todo.uuid === uuid);
if (!todo) throw new NotFoundException('Todo not found');
if (tenantId && todo.tenantId !== tenantId)
throw new NotFoundException('Todo not found');
todo.title = data.title;
todo.done = data.done;
return todo;
}

getAll(tenantId?: string): TodoModel[] {
if (tenantId)
return this.todos.filter((todo) => todo.tenantId === tenantId);
return this.todos.filter((todo) => !todo.tenantId);
}
}

src/todo/controllers/todo.controller.ts`

import {
Body,
Controller,
Delete,
Get,
HttpStatus,
Param,
Post,
Put,
Req,
} from '@nestjs/common';
import { CreateTodoDto } from '../dtos/CreateTodoDto';
import { UpdateTodoDto } from '../dtos/UpdateTodoDto';
import { TodoService } from '../services/TodoService';

@Controller()
export class TodoController {
constructor(private readonly todoService: TodoService) {}

@Get('/todos')
getTodos(@Req() req: Request) {
return this.todoService.getAll(req['tenantId']);
}

@Post('/todos')
createTodo(@Req() req: Request, @Body() data: CreateTodoDto) {
this.todoService.create(data, req['tenantId']);
return HttpStatus.CREATED;
}

@Get('/todos/:uuid')
getTodo(@Req() req: Request, @Param('uuid') uuid: string) {
return this.todoService.get(uuid, req['tenantId']);
}

@Put('/todos/:uuid')
updateTodo(
@Req() req: Request,
@Param('uuid') uuid: string,
@Body() data: UpdateTodoDto,
) {
this.todoService.update(uuid, data, req['tenantId']);
return HttpStatus.NO_CONTENT;
}

@Delete('/todos/:uuid')
deleteTodo(@Req() req: Request, @Param('uuid') uuid: string) {
this.todoService.delete(uuid, req['tenantId']);
return HttpStatus.ACCEPTED;
}
}

app.module.ts 에 todo 컨트롤러와 todo 서비스를 등록하여 진행해 보겠습니다 . 그런 다음 todo API를 테스트할 수 있습니다. 할 일 항목을 생성할 수 있어야 하지만 아직 테넌트 ID를 구성하지 않았다는 점에 유의하는 것이 중요합니다.

할 일 서비스에 대한 테넌트 ID를 구성해 보겠습니다.

미들웨어​

Nestjs는 expressjs 위에 구축되었으므로 미들웨어를 사용할 수 있습니다. 들어오는 HTTP 요청과 나가는 응답을 가로챌 수 있는 기능입니다.

미들웨어를 사용하여 테넌트 ID가 포함된 HTTP 수신 요청 헤더(예: x-tenant-id: f4d6f363-e4cf-4bda-af19-f0dc2feada81) 를 읽습니다.

테넌트 ID가 로컬 DB에 존재하는지 확인한 다음 요청 객체에 테넌트 ID를 속성으로 추가합니다. 그래서 우리는 그것을 todo 컨트롤러에서 사용할 수 있습니다.

그렇지 않으면 테넌트 ID가 null이 됩니다. 이는 할 일 항목이 호스트에 속함을 의미합니다.

마지막 단계는 todoService가 될 서비스에 미들웨어를 구현하는 것입니다. 엔드포인트를 호출하여 할일 서비스에 액세스할 수 있습니다 /todos.

미들웨어를 만들어 보겠습니다.

` 안에 폴더를 만들고 ` src/tenant/middlewares파일을 만듭니다 .TenantMiddleware.ts

import {
HttpException,
HttpStatus,
Injectable,
NestMiddleware,
Logger,
} from '@nestjs/common';
import { NextFunction } from 'express';
import { TenantService } from '../services/TenantService';

@Injectable()
export class TenantMiddleware implements NestMiddleware {

private readonly logger = new Logger(TenantMiddleware.name);
constructor(private readonly tenantService: TenantService) {}

async use(req: Request, res: Response, next: NextFunction) {
const { headers } = req;

const tenantId = headers['X-TENANT-ID'] || headers['x-tenant-id'];

if (!tenantId) {
this.logger.warn('`X-TENANT-ID` not provided');
req['tenantId'] = null;
return next();
}
const tenant = this.tenantService.get(tenantId);
req['tenantId'] = tenant.id;
next();
}
}

app.module.ts 에 미들웨어를 등록 하고 NestModule 인터페이스를 구현하여 구성해 보겠습니다.

@Module({
imports: [],
controllers: [AppController, TodoController, TenantController],
providers: [TodoService, TenantService, TenantMiddleware],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(TenantMiddleware).forRoutes('/todos');
}
}

curl을 사용하여 2개의 테넌트를 생성해 보겠습니다.

curl -X POST --location "http://localhost:3000/api/tenants" \ -H "Content-Type: application/json" \ -d '{ "name": "Tenant 1" }'
curl -X POST --location "http://localhost:3000/api/tenants" \ -H "Content-Type: application/json" \ -d '{ "name": "Tenant 2" }'

모든 임차인을 가져옵니다

curl -X GET --location "http://localhost:3000/api/tenants" \ -H "Accept: application/json"
[
{
"id": "f4d6f363-e4cf-4bda-af19-f0dc2feada81",
"name": "Tenant 1",
"subdomain": "https://mysassapp.com"
},
{
"id": "df19344b-1063-4699-809a-e596138b2194",
"name": "Tenant 2",
"subdomain": "https://mysassapp.com"
},
]

이제 호스트, 테넌트 1, 테넌트 2에 대한 할 일 항목을 만들어 보겠습니다.

curl -X POST --location "http://localhost:3000/api/todos" \ -H "Content-Type: application/json" \ -d '{ "title": "Belongs to host", "done": false }'
curl -X POST --location "http://localhost:3000/api/todos" \ -H "Content-Type: application/json" \ -H "X-Tenant-ID: f4d6f363-e4cf-4bda-af19-f0dc2feada81" \ -d '{ "title": "Belongs to tenant 1", "done": false }'
curl -X POST --location "http://localhost:3000/api/todos" \ -H "Content-Type: application/json" \ -H "X-Tenant-ID: df19344b-1063-4699-809a-e596138b2194" \ -d '{ "title": "Belongs to tenant 2", "done": false }'

이제 요청 헤더에tenantId를 전달하여 할일 항목을 가져옵니다.

curl -X GET --location "http://localhost:3000/api/todos" \ -H "Accept: application/json" \ -H "X-Tenant-ID: f4d6f363-e4cf-4bda-af19-f0dc2feada81"
[
{
"uuid": "0438dcc5-3061-4bf6-824d-7857885501ca",
"title": "Belongs to tenant 1",
"done": false,
"tenantId": "f4d6f363-e4cf-4bda-af19-f0dc2feada81"
}
]

요청 헤더에 테넌트 ID를 전달하지 않으면 호스트에 속한 할 일 항목을 가져옵니다.

curl -X GET --location "http://localhost:3000/api/todos" \ -H "Accept: application/json"
[
{
"uuid": "12922bd3-2d2b-4dff-9130-32adbaee0432",
"title": "Belongs to host",
"done": false
}
]

결론​

우리는 Nest.js를 사용하여 다중 테넌트의 기능적 예를 성공적으로 만들었습니다. 그럼에도 불구하고 실제 시나리오를 위한 다중 테넌트 애플리케이션을 개발할 때는 다음과 같은 몇 가지 요소를 고려하는 것이 중요합니다.

1. Redis와 같은 캐싱 메커니즘으로 보완된 MongoDB 또는 PostgreSQL과 같은 강력한 다중 클러스터 데이터베이스를 활용합니다.
2. 각 테넌트에 맞게 여러 데이터베이스 연결을 구성합니다.