Nest.js를 처음 사용하는 경우 탄력적인 웹 애플리케이션을 구축하기 위한 탁월한 프레임워크의 공식문서를 살펴보는 것이 좋습니다 .
다중 테넌트에 대해 이미 잘 알고 있고 Nest.js를 사용한 구현에 대해 코드로 바로 알고 싶다면 GitHub 저장소 에서 코드를 직접 검토해 보세요.
먼저 Nest.js CLI 프로그램을 아직 설치하지 않았다면 설치하세요.
Nestjs cli 설치: https://docs.nestjs.com/cli/overview
nest new multitenant 프로젝트를 발판으로 실행합니다 . 저는 패키지 관리자로 pnpm을 선택했습니다. 또는 pnpm을 사용하지 않으려는 경우 npm 또는 Yarn을 선택할 수 있습니다.
멀티 테넌시는 SaaS(Software as a Service) 애플리케이션에 사용되는 널리 사용되는 아키텍처 접근 방식으로, 일반적으로 테넌트라고 하는 고객 간의 리소스 공유를 촉진합니다.
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는 테넌트(고객)가 접근할 수 있는 서비스 역할을 하며 호스트도 이를 활용할 수 있습니다.
기본 설정이 완료되면 테넌트 기능을 완료해 보겠습니다. 테넌트 폴더 에는 모델, 컨트롤러, 서비스, 미들웨어 등을 포함한 해당 논리가 포함됩니다.
테넌트 기능에 대한 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 클라이언트를 실행하고 엔드포인트를 테스트하세요.
좋습니다. 테넌트 API가 예상대로 작동합니다. 할 일 논리를 만들어 보겠습니다.
먼저 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. 각 테넌트에 맞게 여러 데이터베이스 연결을 구성합니다.
Konfig 소개: 스타트업 및 기업을 위해 사전 구성된 GitLab 및 Google Cloud (0) | 2024.04.19 |
---|---|
GCP에서 Serverless 구성하기(API Gateway + Cloud Run) (0) | 2024.04.19 |
Wagtail vs Wordpress - 프로젝트에 적합한 CMS를 선택하는 방법 (1) | 2024.04.18 |
Kubernetes의 데이터베이스는 좋은 생각인가요? (1) | 2024.04.18 |
Go의 이벤트 중심 아키텍처(golang) (0) | 2024.04.16 |
댓글 영역