요약
1. NestJS + PostgreSQL 환경에서 테넌트(Tenant)별로 스키마 단위 분리 전략을 적용하는 방법
2. 비즈니스 로직에서 Tenant 판별 로직을 제거하고, AOP 기반의 RequestScope Tenant Provider를 활용해 Clean Architecture를 구현하는 방법
이전에 CSAP 인증을 준비하면서 “사용자별 데이터를 논리적으로 분리해야 한다”는 요구 사항을 충족하기 위해 처음으로 테넌시 구조를 도입했다.
현재 게임 회사에서도 게임별로 데이터를 분리해야 하는 요구가 있어, 동일한 개념을 다시 활용하게 되었다.
데이터 분리 방법은 여러가지가 존재한다.
내가 선택한 방식은 스키마 단위 분리를 선택했다.
Tenant가 동적으로 변경될때마다 데이터베이스 인스턴스를 생성하는건 불필요한 낭비라고 생각했고, 테이블 단위로 관리를 하면 ORM의 장점을 활용하기 어렵다는 단점때문에 스키마 단위 관리를 선택하게 됐다.
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 방식으로 요청 단위에서 처리한다.

@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;
}
}
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],
}