📖 개요: MTX 없이 멀티테넌트를 만들면 벌어지는 일
SaaS 제품을 CAP(Cloud Application Programming Model)으로 만들 때 가장 먼저 부딪히는 벽이 멀티테넌시입니다. "테이블에 tenant_id 컬럼 하나 추가하면 되지 않나?"라는 접근은 초기에 빠르게 동작하는 것처럼 보이지만, 고객사가 늘어나는 순간 데이터 격리 사고·스키마 업그레이드 지옥·규제 감사 이슈로 되돌아옵니다. 이 글은 SAP BTP Cloud Foundry 환경에서 @sap/cds-mtxs 패키지를 활용해 테넌트 온보딩/오프보딩을 구현하는 패턴을 다룹니다.
- 직접 구현(discriminator column) 방식의 구조적 한계 이해
@sap/cds-mtxs의 온보딩 → HDI 컨테이너 프로비저닝 흐름 파악- MTX 사이드카 구성과 구독 훅 커스터마이징 실습
- 테넌트별 데이터 격리 검증과 CF 배포 시 주의점 정리
📚 시작 전에 갖춰야 할 배경
이 글은 advanced 난이도로, CAP Node.js 런타임으로 서비스(CDS 모델 + 커스텀 핸들러)를 만들어 본 경험, BTP Cloud Foundry에 MTA로 배포해 본 경험, XSUAA 기반 인증 개념을 전제로 합니다. HDI(HANA Deployment Infrastructure) 컨테이너가 "스키마 단위 격리 공간"이라는 개념 정도는 알고 있으면 좋습니다.
🔧 환경 / 버전 / 준비물
이 글의 예제는 다음 환경을 기준으로 작성했습니다.
- Node.js 20 LTS,
@sap/cds8.x,@sap/cds-mtxs1.x (일반적으로 최신 LTS 조합 권장) - SAP BTP Cloud Foundry 환경 + SAP HANA Cloud 인스턴스
- 필수 서비스 인스턴스: XSUAA(
tenant-mode: shared), SaaS Provisioning(saas-registry), Service Manager(containerplan) - CLI:
cfCLI 8+,@sap/cds-dk8.x,mbt(MTA Build Tool)
프로젝트에 멀티테넌시 골격을 추가하는 것은 한 줄로 시작합니다.
# 프로젝트 루트에서
cds add multitenancy --for production
# xsuaa, approuter까지 함께 갖추려면
cds add multitenancy,xsuaa,approuter --for production
💡 핵심 개념: cds-mtxs는 무엇을 대신 해주는가
직접 구현 방식의 문제부터 짚겠습니다. 모든 테이블에 테넌트 컬럼을 넣고 모든 쿼리에 WHERE tenant = ?를 강제하는 방식은 (1) 개발자가 조건 하나만 빠뜨려도 즉시 타사 데이터가 노출되는 휴먼 에러 의존적 격리이고, (2) 고객사별 백업/삭제/이관 요구(GDPR의 잊혀질 권리 등)에 대응하기 어렵고, (3) 인덱스와 통계가 전 테넌트에 섞여 노이지 네이버 문제가 생깁니다.
@sap/cds-mtxs는 이를 테넌트당 HDI 컨테이너 1개라는 물리적 격리 모델로 해결합니다. 비유하자면, 직접 구현이 "한 아파트에 커튼으로 방을 나눠 여러 세대를 받는 것"이라면, cds-mtxs는 "세대별로 현관문과 열쇠가 따로 있는 오피스텔을 자동 분양하는 관리사무소"입니다.
동작 흐름은 다음과 같습니다.
- 고객사가 BTP 코크핏에서 SaaS 앱을 구독(Subscribe)하면, saas-registry가 앱에 등록된 콜백 URL을 호출합니다.
- 이 콜백을 받는 것이 cds-mtxs가 제공하는
cds.xt.SaasProvisioningService입니다. 내부적으로cds.xt.DeploymentService에 위임합니다. - DeploymentService는 Service Manager API를 통해 해당 테넌트 전용 HDI 컨테이너를 생성하고, 컴파일된 CSN 모델을 배포(hdi-deploy)합니다.
- 런타임에서는 요청의 JWT에 담긴 테넌트 ID(
cds.context.tenant)를 보고 커넥션 풀을 해당 테넌트의 컨테이너로 라우팅합니다. 애플리케이션 코드는 테넌트를 의식할 필요가 없습니다.
운영에서는 이 프로비저닝 로직을 메인 앱과 분리한 MTX 사이드카(mtx/sidecar 폴더의 별도 Node 앱)로 구동하는 구성이 권장됩니다. 배포/업그레이드처럼 무겁고 드문 작업이 메인 서비스의 메모리와 이벤트 루프를 잠식하지 않도록 격벽을 두는 것입니다.
💻 실전 코드 3단계: 온보딩/오프보딩 구현 패턴
시나리오: 구매 요청 관리 SaaS "ProcureHub"에 고객사(테넌트)가 구독하면 전용 DB가 생기고, 초기 결재 정책 데이터가 시딩되어야 합니다.
1단계 — 기본 설정: 사이드카와 서비스 등록
루트 package.json에서 멀티테넌시를 켜고, 프로비저닝 계열 서비스를 사이드카로 위임합니다.
{
"cds": {
"requires": {
"multitenancy": true,
"auth": "xsuaa",
"db": { "kind": "hana" },
"cds.xt.SaasProvisioningService": true,
"cds.xt.DeploymentService": { "kind": "rest", "[production]": true }
},
"profiles": ["with-mtx-sidecar"]
}
}
사이드카(mtx/sidecar/package.json)는 반대로 배포 서비스를 자기 안에서 실행합니다.
{
"dependencies": { "@sap/cds": "^8", "@sap/cds-mtxs": "^1", "@sap/hdi-deploy": "^5" },
"cds": {
"profile": "mtx-sidecar",
"requires": { "cds.xt.DeploymentService": true }
}
}
도메인 모델은 평소처럼 작성합니다. 테넌트 컬럼이 전혀 없다는 점이 핵심입니다.
namespace procurehub;
using { cuid, managed } from '@sap/cds/common';
entity PurchaseRequisitions : cuid, managed {
description : String(200);
totalAmount : Decimal(15,2);
currency : String(3);
status : String enum { DRAFT; SUBMITTED; APPROVED; REJECTED };
approvalRule : Association to ApprovalRules;
}
entity ApprovalRules : cuid {
thresholdAmount : Decimal(15,2);
approverRole : String(50);
}
2단계 — 실무 시나리오: 온보딩 훅 + 초기 데이터 시딩 + 로깅
구독 이벤트를 가로채 고객사별 초기 결재 정책을 시딩하고, 실패 시 원인을 추적할 수 있게 구조화 로깅을 넣습니다. server.js(사이드카 또는 임베디드 구성의 루트)에 작성합니다.
const cds = require('@sap/cds')
const LOG = cds.log('procurehub-mtx')
cds.on('served', () => {
const { 'cds.xt.SaasProvisioningService': provisioning } = cds.services
if (!provisioning) return LOG.warn('provisioning service not mounted')
provisioning.prepend(srv => {
srv.on('UPDATE', 'tenant', async (req, next) => {
const tenantId = req.data.subscribedTenantId
const company = req.data.subscribedSubdomain
LOG.info('onboarding started', { tenantId, company })
await next()
try {
await cds.tx({ tenant: tenantId }, async tx => {
await tx.run(INSERT.into('procurehub.ApprovalRules').entries([
{ thresholdAmount: 1000, approverRole: 'TeamLead' },
{ thresholdAmount: 50000, approverRole: 'CFO' }
]))
})
LOG.info('seed completed', { tenantId })
} catch (e) {
LOG.error('seed failed', { tenantId, error: e.message })
throw e
}
})
srv.on('DELETE', 'tenant', async (req, next) => {
const tenantId = req.data.subscribedTenantId
LOG.info('offboarding', { tenantId })
await next()
LOG.info('tenant container dropped', { tenantId })
})
})
})
3단계 — 프로덕션: 비동기 프로비저닝, 격리 검증 테스트, mta.yaml
HDI 컨테이너 생성은 수십 초가 걸릴 수 있어 saas-registry 콜백이 타임아웃될 수 있습니다. 비동기 모드를 권장합니다.
{
"cds": {
"requires": {
"cds.xt.SaasProvisioningService": {
"jobs": { "queueSize": 5, "workerSize": 2 }
}
}
}
}
데이터 격리는 자동화 테스트로 고정합니다.
const cds = require('@sap/cds')
const { GET, POST } = cds.test(__dirname + '/..')
describe('tenant isolation', () => {
const acme = { auth: { username: 'buyer-acme' } }
const globex = { auth: { username: 'buyer-globex' } }
it('keeps requisitions invisible across tenants', async () => {
await POST('/odata/v4/procurement/PurchaseRequisitions',
{ description: 'Laptops x20', totalAmount: 30000, currency: 'EUR' }, acme)
const { data } = await GET('/odata/v4/procurement/PurchaseRequisitions', globex)
expect(data.value.length).toBe(0)
})
})
mta.yaml 핵심 발췌입니다.
modules:
- name: procurehub-srv
type: nodejs
requires: [ { name: procurehub-uaa }, { name: procurehub-registry },
{ name: procurehub-sm }, { name: mtx-api } ]
- name: procurehub-mtx
type: nodejs
path: gen/mtx/sidecar
parameters: { memory: 512M }
provides: [ { name: mtx-api, properties: { mtx-url: ~{default-url} } } ]
resources:
- name: procurehub-uaa
type: org.cloudfoundry.managed-service
parameters: { service: xsuaa, config: { tenant-mode: shared } }
- name: procurehub-sm
type: org.cloudfoundry.managed-service
parameters: { service: service-manager, service-plan: container }
- name: procurehub-registry
type: org.cloudfoundry.managed-service
parameters: { service: saas-registry, service-plan: application }
⚠️ 흔한 실수 / 트러블슈팅
Q1. 구독은 성공했는데 고객사 URL 접속 시 404가 뜹니다.
구독 시 콜백이 반환한 URL에 대한 CF route가 없는 경우입니다. cf map-route로 테넌트 서브도메인 라우트를 매핑하세요. 운영에서는 온보딩 훅에서 CF API를 호출해 라우트 매핑을 자동화하는 패턴이 일반적입니다.
Q2. 온보딩이 간헐적으로 실패하고 saas-registry에 타임아웃이 기록됩니다.
동기 프로비저닝 상태에서 HDI 생성 시간이 콜백 제한을 초과한 경우입니다. 비동기 잡 큐를 켜고, 사이드카 메모리를 512M 이상으로 확보하세요.
Q3. 로컬에서 cds watch로 멀티테넌시를 테스트할 수 있나요?
가능합니다. mocked auth 사용자에 서로 다른 tenant 속성을 부여하면 SQLite 기반으로 테넌트별 인메모리 DB가 분리 생성됩니다. cds subscribe t-acme --to http://localhost:4004 같은 CLI로 온보딩 흐름도 재현할 수 있습니다.
Q4. t0이라는 테넌트가 자동으로 생기는데 지워도 되나요?
안 됩니다. t0은 cds-mtxs가 테넌트 메타데이터(구독 목록, 잡 상태)를 저장하는 내부 관리용 컨테이너입니다. 삭제하면 전체 프로비저닝 상태가 유실됩니다. 백그라운드 작업은 반드시 cds.tx({ tenant })로 컨텍스트를 명시하세요.
🚀 확장 방향과 이어서 볼 주제
온보딩/오프보딩이 안정화되면 다음 주제로 확장할 수 있습니다. (1) cds.xt.ExtensibilityService로 테넌트별 커스텀 필드 확장 허용, (2) cds upgrade 기반 전 테넌트 롤링 스키마 업그레이드 전략, (3) Service Manager 라벨을 활용한 테넌트-컨테이너 매핑 감사, (4) 구독 UI를 제공하는 Subscription Management Dashboard 연동. 특히 스키마 업그레이드는 테넌트 수가 늘수록 배치 크기와 실패 재시도 설계가 중요해집니다.
📚 더 깊이 볼 자료
댓글 0
아직 댓글이 없습니다.