멀티 테넌트 분석

멀티 테넌트 분석 (Multi-Tenant Analytics)

공유 Pinot 클러스터에서 리소스·데이터 격리를 갖춘 여러 고객을 서빙하는 전체 가이드예요. 이 플레이북은 단일 Pinot 클러스터에서 여러 고객(테넌트)에게 분석을 제공하는 방법을 다뤄요. 이 패턴은 각 고객이 자신만의 대시보드를 갖지만 비용 효율을 위해 인프라를 공유하는 B2B SaaS 제품에서 흔해요. Pinot의 테넌트 태깅, 워크로드 격리, 애플리케이션 레벨 행 필터링을 조합해 고객별 데이터 격리와 공정한 리소스 배분을 실현해요.

출처: 문서

본문

이 패턴을 언제 쓸까 (When to use this pattern)

다음과 같은 경우 이 플레이북을 사용해요:

  • 각 고객이 자기 데이터만 볼 수 있어야 하는 SaaS 제품을 운영해요.
  • 고객별 클러스터 하나씩 두는 대신 단일 Pinot 클러스터(운영 오버헤드가 낮음)를 운영하고 싶어요.
  • 고객마다 쿼리 볼륨이 다르고, 특정 고객(heavy-hitter)이 다른 고객을 악화시키는 것을 막아야 해요.
  • 인프라 레벨의 리소스 격리와 함께 쿼리 레이어에서 데이터 격리(행 수준 보안)를 강제해야 해요.

아키텍처 스케치 (Architecture sketch)

Customer A ──▶ App backend ──▶ Broker pool A ──▶ Servers (tenant A)
Customer B ──▶ App backend ──▶ Broker pool B ──▶ Servers (shared)
Customer C ──▶ App backend ──▶ Broker pool B ──▶ Servers (shared)
                  │
          (injects tenant_id
           filter into every query)

두 가지 상호 보완적 격리 레이어가 있어요:

  1. Pinot 테넌트를 통한 인프라 격리 — 서버와 브로커를 명명된 테넌트에 할당해서 리소스 소모가 많은 고객에게 전용 컴퓨팅을 제공해요.
  2. 애플리케이션 레벨 행 필터링을 통한 데이터 격리 — 애플리케이션 레이어가 모든 쿼리에 WHERE tenant_id = '<customer>' 조건을 주입해요.

데이터 모델 (Data model)

단일 테이블 접근 (대부분의 경우 권장)

모든 테넌트의 데이터를 tenantId 차원이 있는 하나의 테이블에 저장해요:

{
  "schemaName": "saas_events",
  "dimensionFieldSpecs": [
    { "name": "tenantId",   "dataType": "STRING" },
    { "name": "eventType",  "dataType": "STRING" },
    { "name": "userId",     "dataType": "STRING" },
    { "name": "feature",    "dataType": "STRING" },
    { "name": "plan",       "dataType": "STRING" }
  ],
  "metricFieldSpecs": [
    { "name": "durationMs", "dataType": "LONG" },
    { "name": "count",      "dataType": "INT" }
  ],
  "dateTimeFieldSpecs": [
    {
      "name": "eventTimestamp",
      "dataType": "TIMESTAMP",
      "format": "1:MILLISECONDS:EPOCH",
      "granularity": "1:MILLISECONDS"
    }
  ]
}

테넌트별 테이블 접근 (극한 격리용)

엄격한 규정 준수 요구가 있는 고객은 테넌트별로 별도 테이블을 만들어요. 이 방식은 완전한 격리(별도 세그먼트, 서버, 보존)를 주지만 운영 복잡성이 커져요. 규제 요구가 강제할 때만 사용하세요.

테이블 구성 (Table configuration)

{
  "tableName": "saas_events",
  "tableType": "REALTIME",
  "segmentsConfig": {
    "timeColumnName": "eventTimestamp",
    "retentionTimeUnit": "DAYS",
    "retentionTimeValue": "90",
    "replication": "2",
    "segmentPushType": "APPEND"
  },
  "tableIndexConfig": {
    "loadMode": "MMAP",
    "sortedColumn": ["tenantId"],
    "invertedIndexColumns": ["eventType", "feature", "plan"],
    "rangeIndexColumns": ["eventTimestamp"],
    "noDictionaryColumns": ["userId"],
    "bloomFilterColumns": ["tenantId"],
    "streamConfigs": {
      "streamType": "kafka",
      "stream.kafka.topic.name": "saas-events",
      "stream.kafka.broker.list": "kafka:9092",
      "stream.kafka.consumer.factory.class.name": "org.apache.pinot.plugin.stream.kafka30.KafkaConsumerFactory",
      "stream.kafka.decoder.class.name": "org.apache.pinot.plugin.stream.kafka.KafkaJSONMessageDecoder",
      "realtime.segment.flush.threshold.rows": "500000",
      "realtime.segment.flush.threshold.time": "6h"
    }
  },
  "tenants": {
    "broker": "SharedTenant",
    "server": "SharedTenant"
  },
  "metadata": {}
}

tenantId가 정렬 컬럼인 이유

tenantId가 정렬 컬럼이면 단일 테넌트의 모든 행이 각 세그먼트에서 물리적으로 인접해요. 그래서 WHERE tenantId = 'acme' 필터가 스캔 없이 전체 데이터 페이지를 건너뛰며 거의 즉각적인 세그먼트 프루닝(pruning)을 제공해요. 쿼리에 다른 컬럼이 더 나은 정렬 키라면 tenantId에 역인덱스를 대신 사용하세요.

Pinot 테넌트로 인프라 격리 (Infrastructure isolation with Pinot tenants)

서버를 테넌트에 할당하기

태그 없는 인스턴스에서 POST /tenants로 테넌트를 만들거나(서버 페이로드에는 offline/realtime 개수와 함께 numberOfInstances가 필요해요), 인스턴스에 직접 태그를 설정하세요:

# 태그 없는 인스턴스에서 서버 테넌트를 생성 (numberOfInstances 필수)
curl -i -X POST -H 'Content-Type: application/json' -d '{
  "tenantRole": "SERVER",
  "tenantName": "PremiumTenant",
  "numberOfInstances": 2,
  "offlineInstances": 2,
  "realtimeInstances": 2
}' http://localhost:9000/tenants

# 또는 특정 인스턴스를 재태깅 (서버는 여러 태그를 가질 수 있음)
curl -i -X PUT \
  "http://localhost:9000/instances/Server_host1_8098/updateTags?tags=PremiumTenant_OFFLINE,PremiumTenant_REALTIME"

인스턴스를 추가할 때 태그를 전달할 수도 있어요:

PUT /instances/Server_host1_8098
{
  "host": "host1",
  "port": "8098",
  "type": "SERVER",
  "tags": ["PremiumTenant_REALTIME", "PremiumTenant_OFFLINE"]
}

그런 다음 고가치 고객의 테이블을 PremiumTenant에 할당해요:

"tenants": {
  "broker": "PremiumTenant",
  "server": "PremiumTenant"
}

다른 고객은 SharedTenant 풀을 공유해요. 전체 POST /tenants 페이로드, CLI 플래그, 공동 배치(co-location) 규칙은 Tenant를 참고하세요.

워크로드 기반 쿼리 격리

공유 테넌트 내에서 더 세밀한 제어가 필요하면 워크로드 기반 쿼리 리소스 격리를 사용해 워크로드 클래스별로 CPU와 메모리를 제한해요:

{
  "workloadConfig": {
    "workloads": {
      "free_tier": {
        "maxQueriesPerSecond": 10,
        "maxServerThreads": 2
      },
      "enterprise": {
        "maxQueriesPerSecond": 100,
        "maxServerThreads": 8
      }
    }
  }
}

애플리케이션 백엔드가 쿼리 옵션에서 워크로드 클래스를 설정해요:

SET workload = 'free_tier';
SELECT ...

구성 상세는 Workload-Based Query Resource Isolation을 참고하세요.

브로커 레벨 쿼리 쿼터

특정 테넌트가 쿼리 리소스를 독점하지 못하도록 브로커에서 테이블별 쿼리 속도 제한을 적용해요:

{
  "quotas": {
    "maxQueriesPerSecond": 50
  }
}

Query Quotas를 참고하세요.

데이터 격리 (행 수준 보안) (Data isolation — row-level security)

Pinot은 브로커에 내장된 행 수준 보안(RLS)을 지원해요. 보안 주체별, 테이블별 필터 조건을 구성해서 Pinot이 실행 전에 들어오는 SQL 쿼리를 다시 쓰게 할 수 있어요. 테넌트 정책이 브로커 인증 보안 주체에 깔끔하게 매핑될 때 이 방식이 선호되는 강제 지점이에요.

구현 패턴 (Implementation pattern)

Pinot 관리 강제 방식이라면 브로커 접근 제어 구성에 테넌트 범위 RLS 필터를 정의해요:

pinot.broker.access.control.principals=tenant_app
pinot.broker.access.control.principals.tenant_app.password=verysecret
pinot.broker.access.control.principals.tenant_app.tables=events
pinot.broker.access.control.principals.tenant_app.events.rls=tenantId='tenant_123'

애플리케이션이 요청 시점에 필터를 동적으로 도출해야 한다면, 사용자와 Pinot 브로커 사이에 있는 애플리케이션 레이어에서 테넌트 필터링을 강제할 수 있어요. 이 모델에서 애플리케이션 백엔드는:

  1. 사용자를 인증하고 tenantId를 결정해요.
  2. Pinot 브로커로 보내기 전에 모든 SQL 쿼리에 AND tenantId = '<tenantId>'를 주입해요.
  3. Pinot 브로커를 최종 사용자에게 직접 노출하지 않아요.

백엔드 의사코드 예시:

def query_pinot(user_query: str, tenant_id: str) -> dict:
    # Parse and inject tenant filter
    safe_query = inject_tenant_filter(user_query, tenant_id)
    # Send to Pinot broker
    return pinot_client.execute(safe_query)

def inject_tenant_filter(query: str, tenant_id: str) -> str:
    # Use a SQL parser to safely inject the filter
    # Never string-concatenate user input directly
    parsed = parse_sql(query)
    parsed.add_where_clause(f"tenantId = '{escape(tenant_id)}'")
    return parsed.to_sql()

경고: 테넌트 필터를 주입할 때는 항상 SQL 파서를 사용하세요. 문자열 연결은 SQL 인젝션에 취약해요. 다시 쓰인 쿼리에도 여전히 테넌트 필터가 들어 있는지 실행 전에 검증하세요.

격리 검증 (Validating isolation)

다음을 검증하는 통합 테스트를 작성하세요:

  1. 테넌트 A의 필터로 쿼리해서 테넌트 B의 데이터가 반환되지 않는지 확인.
  2. 테넌트 필터 없이 쿼리를 시도해서 백엔드가 거부하는지 확인.
  3. 테넌트 ID 필드에 SQL 인젝션을 시도해서 차단되는지 확인.

쿼리 패턴 (Query patterns)

테넌트별 대시보드 집계

SELECT
  DATETRUNC('hour', eventTimestamp, 'MILLISECONDS') AS hour,
  eventType,
  COUNT(*) AS event_count,
  SUM(durationMs) AS total_duration
FROM saas_events
WHERE tenantId = 'acme'
  AND eventTimestamp > ago('PT24H')
GROUP BY hour, eventType
ORDER BY hour
LIMIT 1000

크로스 테넌트 관리자 쿼리 (내부 분석용)

자체 내부 대시보드의 경우 테넌트 필터 없이 쿼리해요:

SELECT tenantId, COUNT(*) AS events, SUM(count) AS total_actions
FROM saas_events
WHERE eventTimestamp > ago('PT1H')
GROUP BY tenantId
ORDER BY events DESC
LIMIT 100

이 쿼리 패턴에 대한 접근은 내부 관리자 사용자로만 제한하세요.

운영 체크리스트 (Operational checklist)

서비스 시작 전

  • tenantId가 모든 이벤트에 항상 존재하는지 확인하세요. null tenantId 값은 행 수준 필터링을 우회해요.
  • 애플리케이션 백엔드가 모든 쿼리 경로(REST API, WebSocket, 배치 내보내기)에 테넌트 필터를 주입하는지 확인하세요.
  • 현실적인 테넌트별 쿼리 속도로 부하 테스트를 하세요. 워크로드 격리 상한이 연쇄 지연(cascading slowdown)을 막는지 확인하세요.
  • 단일 테이블 모델을 쓴다면 EXPLAIN PLAN을 실행해 tenantId 정렬 컬럼이 좋은 프루닝을 제공하는지 검증하세요.

모니터링

  • 테넌트별 쿼리 속도: workload 쿼리 옵션별로 나눈 브로커 메트릭으로 추적하세요. 테넌트가 쿼터를 넘으면 알림을 보내세요.
  • 테넌트별 쿼리 지연: 전체적인 급증 없이 한 테넌트만 급증하면 그 테넌트의 쿼리 패턴이 바뀐 것(예: 시간 필터 누락)을 나타내요.
  • 테넌트별 세그먼트 크기: 한 테넌트가 데이터 볼륨을 지배하면 전용 테넌트/서버 풀로 옮기는 것을 고려하세요.

흔한 함정 (Common pitfalls)

함정 해결책
한 API 엔드포인트에서 테넌트 필터 누락 WHERE 절에 tenantId가 없는 쿼리를 거부하는 중앙 쿼리 미들웨어를 추가하세요
한 테넌트의 무거운 쿼리가 모두를 느리게 함 워크로드 격리와 테이블별 쿼리 쿼터를 사용하고, 무거운 테넌트를 전용 서버 테넌트로 옮기세요
tenantId 정렬 컬럼이 시간 범위 쿼리를 해침 tenantId에 역인덱스를 사용하고, 시간 범위 성능이 더 중요하면 시간을 정렬 컬럼으로 유지하세요
JOIN 쿼리를 통해 테넌트 데이터가 누출됨 JOIN이 있는 다단계 쿼리를 쓴다면 JOIN 양쪽에 테넌트 필터가 적용되도록 하세요

더 알아보기 (Learn more)