LEVEL 55 QUEST
기능 제한 & 사용량 제어
Feature Flag 시스템과 사용량 카운터로 요금제별 기능 제한을 구현합니다.
Feature Flag 시스템 구현
Feature Flag란?
Feature Flag(기능 플래그)는 코드를 배포하지 않고도 특정 기능을 켜고 끌 수 있는 시스템입니다.
SaaS에서는 요금제에 따라 기능을 활성화/비활성화하는 데 핵심적으로 사용됩니다.
Free 사용자에게는 AI 에이전트 기능이 보이지 않고, Basic 사용자에게는 커스텀 도메인 설정이 비활성화되는 식입니다.
Free 사용자에게는 AI 에이전트 기능이 보이지 않고, Basic 사용자에게는 커스텀 도메인 설정이 비활성화되는 식입니다.
-- Feature Flags 테이블 CREATE TABLE feature_flags ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, feature_key VARCHAR(100) NOT NULL UNIQUE, -- 'ai_agent', 'custom_domain' 등 display_name VARCHAR(200) NOT NULL, description TEXT DEFAULT NULL, is_global TINYINT(1) DEFAULT 0, -- 전체 활성화 여부 created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 요금제-기능 매핑 테이블 CREATE TABLE plan_features ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, plan_id INT UNSIGNED NOT NULL, feature_id INT UNSIGNED NOT NULL, is_enabled TINYINT(1) DEFAULT 1, limit_value INT DEFAULT NULL, -- 기능별 한도 (NULL=무제한) UNIQUE KEY uq_plan_feature (plan_id, feature_id), FOREIGN KEY (plan_id) REFERENCES plans(id), FOREIGN KEY (feature_id) REFERENCES feature_flags(id) ); -- 테넌트별 기능 오버라이드 (특별 제공용) CREATE TABLE tenant_feature_overrides ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, tenant_id INT UNSIGNED NOT NULL, feature_id INT UNSIGNED NOT NULL, is_enabled TINYINT(1) DEFAULT 1, limit_value INT DEFAULT NULL, expires_at DATETIME DEFAULT NULL, -- 임시 제공 만료일 UNIQUE KEY uq_tenant_feature (tenant_id, feature_id), FOREIGN KEY (tenant_id) REFERENCES tenants(id), FOREIGN KEY (feature_id) REFERENCES feature_flags(id) );
// FeatureGate.php - 기능 접근 제어 클래스 class FeatureGate { private PDO $pdo; private array $cache = []; public function __construct(PDO $pdo) { $this->pdo = $pdo; } /** * 특정 기능이 현재 테넌트에서 사용 가능한지 확인 */ public function can(string $featureKey): bool { $tenantId = TenantManager::id(); $cacheKey = "{$tenantId}:{$featureKey}"; if (isset($this->cache[$cacheKey])) { return $this->cache[$cacheKey]; } // 1단계: 테넌트별 오버라이드 확인 $stmt = $this->pdo->prepare(" SELECT tfo.is_enabled FROM tenant_feature_overrides tfo JOIN feature_flags ff ON tfo.feature_id = ff.id WHERE tfo.tenant_id = :tid AND ff.feature_key = :fkey AND (tfo.expires_at IS NULL OR tfo.expires_at > NOW()) "); $stmt->execute([':tid' => $tenantId, ':fkey' => $featureKey]); $override = $stmt->fetch(PDO::FETCH_ASSOC); if ($override !== false) { $this->cache[$cacheKey] = (bool)$override['is_enabled']; return $this->cache[$cacheKey]; } // 2단계: 요금제 기반 확인 $stmt = $this->pdo->prepare(" SELECT pf.is_enabled FROM plan_features pf JOIN feature_flags ff ON pf.feature_id = ff.id JOIN tenants t ON t.plan_id = pf.plan_id WHERE t.id = :tid AND ff.feature_key = :fkey "); $stmt->execute([':tid' => $tenantId, ':fkey' => $featureKey]); $result = $stmt->fetch(PDO::FETCH_ASSOC); $this->cache[$cacheKey] = $result ? (bool)$result['is_enabled'] : false; return $this->cache[$cacheKey]; } /** * 기능 접근 차단 시 에러 표시 */ public function deny(string $featureKey): void { if (!$this->can($featureKey)) { http_response_code(403); echo json_encode([ 'error' => 'feature_not_available', 'message' => '이 기능은 현재 요금제에서 사용할 수 없습니다.', 'upgrade' => '/pricing', ]); exit; } } } // 사용 예시 $gate = new FeatureGate($pdo); // AI 에이전트 기능 체크 if ($gate->can('ai_agent')) { // AI 에이전트 UI 렌더링 } else { // 업그레이드 안내 표시 }
사용량 카운터 구현 (DB 기반)
Redis 없이 DB로 사용량 추적
Redis가 이상적이지만, 초기 SaaS에서는 MariaDB만으로도 충분합니다.
핵심은 사용량을 효율적으로 카운팅하고, 한도를 빠르게 체크하는 것입니다.
-- 사용량 추적 테이블 CREATE TABLE usage_counters ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, tenant_id INT UNSIGNED NOT NULL, metric_key VARCHAR(50) NOT NULL, -- 'api_calls', 'storage_mb' 등 period CHAR(7) NOT NULL, -- '2026-03' (연-월) used_count INT UNSIGNED NOT NULL DEFAULT 0, max_limit INT UNSIGNED NOT NULL DEFAULT 0, -- 0 = 무제한 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uq_tenant_metric_period (tenant_id, metric_key, period), INDEX idx_period (period) ); -- 상세 사용 로그 (분석용) CREATE TABLE usage_logs ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, tenant_id INT UNSIGNED NOT NULL, user_id INT UNSIGNED DEFAULT NULL, metric_key VARCHAR(50) NOT NULL, amount INT UNSIGNED DEFAULT 1, metadata JSON DEFAULT NULL, -- 추가 정보 (endpoint, 파라미터 등) created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_tenant_created (tenant_id, created_at) );
// UsageTracker.php - 사용량 추적 및 제한 클래스 class UsageTracker { private PDO $pdo; public function __construct(PDO $pdo) { $this->pdo = $pdo; } /** * 사용량 증가 (API 호출 시마다 실행) * INSERT ... ON DUPLICATE KEY로 원자적 증가 */ public function increment(string $metricKey, int $amount = 1): array { $tenantId = TenantManager::id(); $period = date('Y-m'); // 요금제에서 한도 조회 $maxLimit = $this->getLimit($tenantId, $metricKey); // 원자적 카운터 증가 (race condition 방지) $stmt = $this->pdo->prepare(" INSERT INTO usage_counters (tenant_id, metric_key, period, used_count, max_limit) VALUES (:tid, :key, :period, :amount, :max) ON DUPLICATE KEY UPDATE used_count = used_count + :amount2, max_limit = :max2 "); $stmt->execute([ ':tid' => $tenantId, ':key' => $metricKey, ':period' => $period, ':amount' => $amount, ':max' => $maxLimit, ':amount2' => $amount, ':max2' => $maxLimit, ]); // 사용 로그 기록 $stmt = $this->pdo->prepare(" INSERT INTO usage_logs (tenant_id, user_id, metric_key, amount) VALUES (:tid, :uid, :key, :amt) "); $stmt->execute([ ':tid' => $tenantId, ':uid' => $_SESSION['user_id'] ?? 0, ':key' => $metricKey, ':amt' => $amount, ]); return $this->getUsage($metricKey); } /** * 현재 사용량 조회 */ public function getUsage(string $metricKey): array { $tenantId = TenantManager::id(); $period = date('Y-m'); $stmt = $this->pdo->prepare(" SELECT used_count, max_limit FROM usage_counters WHERE tenant_id = :tid AND metric_key = :key AND period = :period "); $stmt->execute([':tid' => $tenantId, ':key' => $metricKey, ':period' => $period]); $row = $stmt->fetch(PDO::FETCH_ASSOC); $used = $row ? (int)$row['used_count'] : 0; $limit = $row ? (int)$row['max_limit'] : 0; return [ 'used' => $used, 'limit' => $limit, 'remaining' => $limit > 0 ? max(0, $limit - $used) : PHP_INT_MAX, 'percentage' => $limit > 0 ? round(($used / $limit) * 100, 1) : 0, 'unlimited' => ($limit === 0), ]; } /** * 한도 초과 여부 확인 */ public function hasQuota(string $metricKey): bool { $usage = $this->getUsage($metricKey); return $usage['unlimited'] || $usage['remaining'] > 0; } /** * 요금제 한도 조회 */ private function getLimit(int $tenantId, string $metricKey): int { $map = ['api_calls' => 'api_calls_limit', 'storage' => 'storage_mb']; $col = $map[$metricKey] ?? 'api_calls_limit'; $stmt = $this->pdo->prepare(" SELECT p.{$col} FROM tenants t JOIN plans p ON t.plan_id = p.id WHERE t.id = :tid "); $stmt->execute([':tid' => $tenantId]); return (int)$stmt->fetchColumn(); } }
🔒
여기까지는 미리보기입니다
기능 제한 & 사용량 제어
무료 가입하면 이어서 볼 수 있고, 강의를 완료할 때마다 XP와 레벨이 쌓입니다.
Google로 3초 만에 시작 →🧵 Threads로 시작무료 공개 강의 둘러보기 (Lv.1~3)무료 가입하면 이어서 볼 수 있고, 강의를 완료할 때마다 XP와 레벨이 쌓입니다.