K6로 멀티테넌트 격리 검증하기
배경
클라우드 서비스 인증 시험에서 멀티테넌트 환경의 데이터 격리를 증빙해야 했다. 우리 시스템은 DB-per-tenant 패턴을 사용하고 있어서, 동시 부하 상황에서도 테넌트 간 데이터가 섞이지 않는다는 것을 실제 테스트로 보여줘야 한다.
검증해야 할 항목은 세 가지다.
- 데이터 격리 — 각 테넌트의 API 응답에 다른 테넌트 데이터가 포함되지 않는가?
- 보안 경계 — JWT와 X-Tenant-Key가 불일치하면 요청이 차단되는가?
- 성능 공정성 — 특정 테넌트에 성능이 편향되지 않는가?
아키텍처
테넌트 격리의 전체 흐름은 이렇다.
Client → 서브도메인(dlight.qgx.co.kr) → Frontend
→ X-Tenant-Key 헤더 추출 → Backend(TenantContextFilter)
→ TenantContext → DynamicTenantRoutingDataSource
→ 테넌트별 물리 DB (DLIGHT DB / KUMJANG DB)
프론트엔드가 서브도메인에서 테넌트를 식별하고, X-Tenant-Key 헤더로 백엔드에 전달한다. 백엔드의 TenantContextFilter가 이 값을 받아 TenantContext에 설정하면, DynamicTenantRoutingDataSource가 해당 테넌트의 물리 DB로 라우팅한다.
테스트 설계
시나리오 구성
3개의 시나리오를 동시에 실행한다. 약 7분간 진행된다.
| 시나리오 | Executor | VU | 검증 내용 |
|---|---|---|---|
| dlight_data_isolation | ramping-vus | 0→5→20→0 | DLIGHT API 응답의 compUid 검증 |
| kumjang_data_isolation | ramping-vus | 0→5→20→0 | KUMJANG 동일 검증 (동시 실행) |
| security_boundary | constant-vus | 3 (5분) | JWT-헤더 mismatch → 400 검증 |
두 테넌트에 동시에 부하를 주면서 데이터가 섞이는지 확인하는 것이 핵심이다.
커스텀 메트릭
K6의 기본 메트릭만으로는 격리 검증이 안 되므로, 커스텀 메트릭을 정의했다.
import { Counter, Trend, Rate } from 'k6/metrics';
const crossTenantLeakage = new Counter('cross_tenant_leakage');
const securityBoundaryFailures = new Counter('security_boundary_failures');
const dlightResponseTime = new Trend('dlight_response_time');
const kumjangResponseTime = new Trend('kumjang_response_time');
const isolationChecksPassed = new Rate('isolation_checks_passed');
| 메트릭 | 타입 | 임계값 | 의미 |
|---|---|---|---|
| cross_tenant_leakage | Counter | count==0 | 교차 테넌트 데이터 누출 건수 |
| security_boundary_failures | Counter | count==0 | 보안 경계 위반 건수 |
| isolation_checks_passed | Rate | >99% | 격리 검증 통과율 |
| dlight_response_time | Trend | p95<3s | DLIGHT 응답시간 |
| kumjang_response_time | Trend | p95<3s | KUMJANG 응답시간 |
K6 옵션 설정
// config.js
tenantIsolation: {
scenarios: {
dlight_data_isolation: {
executor: 'ramping-vus',
exec: 'dlightDataIsolation',
stages: [
{ duration: '30s', target: 5 },
{ duration: '3m', target: 20 },
{ duration: '2m', target: 20 },
{ duration: '1m', target: 0 },
],
},
kumjang_data_isolation: {
executor: 'ramping-vus',
exec: 'kumjangDataIsolation',
stages: [
{ duration: '30s', target: 5 },
{ duration: '3m', target: 20 },
{ duration: '2m', target: 20 },
{ duration: '1m', target: 0 },
],
},
security_boundary: {
executor: 'constant-vus',
exec: 'securityBoundary',
vus: 3,
duration: '5m',
},
},
thresholds: {
cross_tenant_leakage: ['count==0'],
security_boundary_failures: ['count==0'],
isolation_checks_passed: ['rate>0.99'],
http_req_failed: ['rate<0.05'],
},
}
ramping-vus로 점진적 부하를 주고, security_boundary는 constant-vus로 일정하게 보안 경계를 두드린다.
테스트 코드
데이터 격리 검증
각 테넌트 시나리오에서 로그인 후 4개 API를 호출하고, 응답의 compUid가 해당 테넌트인지 검증한다.
function verifyDataIsolation(baseUrl, token, expectedCompUid, responseTrend) {
const endpoints = [
{ path: '/api/auth/me', name: 'me' },
{ path: '/api/factory/plants', name: 'plants' },
{ path: '/api/products', name: 'products' },
{ path: '/api/users', name: 'users' },
];
for (const ep of endpoints) {
const res = http.get(`${baseUrl}${ep.path}`, {
headers: authHeaders(token, expectedCompUid),
});
responseTrend.add(res.timings.duration);
if (res.status !== 200) continue;
if (ep.name === 'me') {
// /api/auth/me → response.compUid 직접 비교
const compUid = res.json('response.compUid');
const passed = compUid === expectedCompUid;
isolationChecksPassed.add(passed);
if (!passed) {
crossTenantLeakage.add(1);
console.error(`[LEAK] ${ep.name}: expected=${expectedCompUid} got=${compUid}`);
}
} else if (ep.name === 'plants') {
// /api/factory/plants → 배열의 각 항목 compUid 검증
const items = res.json('response');
if (Array.isArray(items)) {
for (const item of items) {
const passed = item.compUid === expectedCompUid;
isolationChecksPassed.add(passed);
if (!passed) crossTenantLeakage.add(1);
}
}
} else {
// DB-per-tenant이므로 200 응답 자체가 격리 확인
isolationChecksPassed.add(true);
}
}
}
핵심은 /api/auth/me와 /api/factory/plants에서 compUid를 직접 비교하는 것이다. DB-per-tenant 구조이므로 products, users는 200 응답 자체가 올바른 DB에서 데이터를 가져왔다는 증거가 된다.
보안 경계 검증
JWT 토큰의 테넌트와 X-Tenant-Key 헤더가 불일치하면 400을 반환해야 한다.
export function securityBoundary() {
const dlightUrl = buildBaseUrl('dlight');
const kumjangUrl = buildBaseUrl('kumjang');
const dlightToken = login(dlightUrl, DLIGHT_COMP_UID, config.auth.testUser);
const kumjangToken = login(kumjangUrl, KUMJANG_COMP_UID, config.auth.testUser);
// DLIGHT JWT + X-Tenant-Key: KUMJANG → 400 기대
if (dlightToken) {
const res = http.get(`${dlightUrl}/api/auth/me`, {
headers: {
Authorization: `Bearer ${dlightToken}`,
'X-Tenant-Key': KUMJANG_COMP_UID,
},
});
const passed = check(res, {
'DLIGHT JWT + KUMJANG header → 400': (r) => r.status === 400,
});
if (!passed) securityBoundaryFailures.add(1);
}
// KUMJANG JWT + X-Tenant-Key: DLIGHT → 400 기대
if (kumjangToken) {
const res = http.get(`${kumjangUrl}/api/auth/me`, {
headers: {
Authorization: `Bearer ${kumjangToken}`,
'X-Tenant-Key': DLIGHT_COMP_UID,
},
});
const passed = check(res, {
'KUMJANG JWT + DLIGHT header → 400': (r) => r.status === 400,
});
if (!passed) securityBoundaryFailures.add(1);
}
sleep(2);
}
추가로 크로스 서브도메인 접근도 테스트한다. DLIGHT JWT로 kumjang 서브도메인에 접근했을 때, 반환되는 데이터가 여전히 DLIGHT 것인지 확인한다.
테스트 결과
실행 환경
- 일시: 2026-02-23 11:14 KST
- 최대 VU: 43
- 총 요청: 43,653 (111.7 req/s)
- 테스트 시간: 약 6.5분
핵심 메트릭
| 메트릭 | 결과 | 임계값 | 판정 |
|---|---|---|---|
| cross_tenant_leakage | 0 | count==0 | PASS |
| security_boundary_failures | 4 | count==0 | FAIL |
| isolation_checks_passed | 100% (33,263/33,263) | >99% | PASS |
| http_req_failed | 2.97% | <5% | PASS |
성능 공정성
| 테넌트 | avg | med | p90 | p95 | max |
|---|---|---|---|---|---|
| DLIGHT | 25.2ms | 22.8ms | 37.5ms | 43.6ms | 570ms |
| KUMJANG | 24.3ms | 21.6ms | 36.1ms | 41.8ms | 680ms |
p95 차이 1.8ms. 특정 테넌트에 대한 성능 편향 없음.
체크 상세
| 체크 | passes | fails | 통과율 |
|---|---|---|---|
| login status 200 | 9,093 | 85 | 99.1% |
| DLIGHT JWT + KUMJANG header → 400 | 376 | 1 | 99.7% |
| KUMJANG JWT + DLIGHT header → 400 | 378 | 3 | 99.2% |
HTTP 요청 통계
| 항목 | 값 |
|---|---|
| 총 요청 수 | 43,653 |
| 요청 속도 | 111.7 req/s |
| 평균 응답시간 | 53.4ms |
| p95 응답시간 | 195.1ms |
| 최대 응답시간 | 1,003.6ms |
결과 분석
데이터 격리 — PASS
33,263건의 격리 검증에서 교차 테넌트 데이터 누출 0건. 두 테넌트에 동시 부하를 주는 상황에서도 compUid가 뒤섞이지 않았다. DB-per-tenant 패턴이 동시 부하 환경에서 정상 동작함을 확인했다.
보안 경계 — 4건 실패 (99.5%)
754건 중 4건 실패. 임계값(count==0) 기준으로는 FAIL이지만, 원인을 분석하면:
- 로그인 실패(85건 중 일부) 시 토큰이 발급되지 않음
- 토큰 없이 mismatch 검증 요청이 진행되면, 서버가 401(인증 실패)을 반환
- 401은 400이 아니므로 체크 실패로 집계됨
즉, 보안 경계 자체는 정상 동작하고 있다. 토큰 없는 요청이 통과된 게 아니라 인증 단계에서 이미 차단된 것이다. 테스트 스크립트에서 로그인 실패 시 검증을 스킵하도록 처리하면 해결된다.
성능 공정성 — PASS
양 테넌트 p95가 각각 43.6ms, 41.8ms로 차이가 1.8ms에 불과하다. DynamicTenantRoutingDataSource가 테넌트별로 공정하게 DB 커넥션을 라우팅하고 있다.
프로젝트 구조
qgx-load-test/
├── k6/
│ ├── config.js # 테스트 설정 (시나리오, 임계값)
│ └── scenarios/
│ └── tenant-isolation.js # 격리 검증 시나리오
├── scripts/
│ └── run-tenant-isolation.sh # 실행 스크립트
└── results/
└── tenant-isolation_YYYYMMDD_HHMMSS/
├── k6-results.json # 상세 결과
├── k6-summary.json # 요약 JSON
├── report.html # HTML 리포트
└── report.md # Markdown 리포트
실행 방법
cd qgx-load-test
./scripts/run-tenant-isolation.sh prod
실행하면 타임스탬프가 붙은 결과 디렉토리가 생성되고, JSON/HTML/Markdown 리포트가 자동으로 저장된다.
정리
| 검증 항목 | 결과 | 비고 |
|---|---|---|
| 데이터 격리 | 33,263건 누출 0건 | DB-per-tenant 정상 동작 |
| 보안 경계 | 99.5% (754건 중 4건 실패) | 로그인 실패 케이스, 실제 위반 아님 |
| 성능 공정성 | p95 차이 1.8ms | 테넌트 간 편향 없음 |
K6의 커스텀 메트릭(Counter, Rate, Trend)을 활용하면 단순 성능 테스트를 넘어서 비즈니스 로직 수준의 검증도 자동화할 수 있다. 특히 멀티테넌트 환경에서는 동시 부하 상황의 격리 검증이 필수인데, 이런 테스트를 CI/CD 파이프라인에 넣어두면 배포마다 자동으로 격리를 보장할 수 있다.