[DevOps] K6로 멀티테넌트 격리 검증하기

K6로 멀티테넌트 격리 검증하기

배경

클라우드 서비스 인증 시험에서 멀티테넌트 환경의 데이터 격리를 증빙해야 했다. 우리 시스템은 DB-per-tenant 패턴을 사용하고 있어서, 동시 부하 상황에서도 테넌트 간 데이터가 섞이지 않는다는 것을 실제 테스트로 보여줘야 한다.

검증해야 할 항목은 세 가지다.

  1. 데이터 격리 — 각 테넌트의 API 응답에 다른 테넌트 데이터가 포함되지 않는가?
  2. 보안 경계 — JWT와 X-Tenant-Key가 불일치하면 요청이 차단되는가?
  3. 성능 공정성 — 특정 테넌트에 성능이 편향되지 않는가?

아키텍처

테넌트 격리의 전체 흐름은 이렇다.

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_boundaryconstant-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 파이프라인에 넣어두면 배포마다 자동으로 격리를 보장할 수 있다.