데브포일 홈
DEV MODE - 실서버 영향 없음 📦 배포 관리
기능 제한 & 사용량 제어
+250 XP
LEVEL 55 QUEST

기능 제한 & 사용량 제어

Feature Flag 시스템과 사용량 카운터로 요금제별 기능 제한을 구현합니다.

Feature Flag 시스템 구현

Feature Flag란?

Feature Flag(기능 플래그)는 코드를 배포하지 않고도 특정 기능을 켜고 끌 수 있는 시스템입니다. SaaS에서는 요금제에 따라 기능을 활성화/비활성화하는 데 핵심적으로 사용됩니다.

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)