LexQLexQ
패턴 목록으로
SaaS자격 검증운영신뢰초급

차단 규칙으로 요금제 등급별 기능 권한 결정하기

코드 곳곳에 흩어진 요금제 등급 검사를 한곳의 차단 규칙으로 모으고, 막힌 기능 접근마다 사유가 남게 만드는 패턴.

Sanghyun Park·2026년 6월 28일14분 읽기12 min

문제

SaaS 제품은 대개 같은 소프트웨어를 여러 요금제로 팝니다. 등급을 가르는 건 기능입니다. 무료 등급은 핵심 기능만 쓰고, 상위 등급으로 갈수록 SSO나 API 접근 같은 기능이 하나씩 열립니다. 어떤 기능을 어느 등급에 열지는 엔지니어링이 정할 일이 아닙니다. 제품과 세일즈가 정하고, 자기 일정에 맞춰 바꾸는 비즈니스 결정입니다.

문제는 이 결정이 코드 안에서 분기 하나로 시작한다는 데 있습니다. if (plan == PRO) 한 줄이 어딘가에 박히고, 곧 퍼집니다. API 게이트가 이 조건을 검사합니다. 웹 UI도 똑같은 조건으로 버튼을 숨기고, 결제 webhook은 구독이 바뀔 때마다 다시 검사합니다. 1년 뒤 새 기능 컴포넌트가 그 시점의 등급 값으로 같은 패턴을 복제합니다. 같은 권한이 이제 API에, 클라이언트에, webhook에, 그리고 여러 기능 컴포넌트에 흩어져 있고, 요금제 구성을 한 번 바꾸면 그 모두를 동시에 고쳐야 합니다.

하지만 그렇게 한꺼번에 바뀌지는 않습니다. 어느 날 무료 등급 계정이 enterprise 전용 내보내기 엔드포인트에 멀쩡히 접근하고 있는 게 드러납니다. 게이트 하나가 갱신되지 않았던 겁니다. 곧바로 두 곳에서 질문이 들어옵니다. 지원팀은 "이 고객이 요금제에 없는 기능에 왜 접근되느냐"고 묻고, 재무팀은 "5월 14일에 pro 등급이 어떤 기능을 포함했고, 포함하지 않은 기능을 쓴 계정을 전부 내놓으라"고 요구합니다. 코드는 둘 중 어느 쪽에도 답하지 못합니다.

이 패턴이 풀려는 문제가 바로 이것입니다. 기능마다 권한 결정을 하나로 두고 모든 게이트가 그것만 따르게 하면서, 몇 달이 지난 뒤에도 어느 등급이 어떤 기능을 포함했는지, 그리고 그 요청이 왜 허용되거나 거부됐는지까지 답할 수 있게 만드는 것.

단순한 접근

처음에는 권한을 검사 코드 바로 옆, 게이트 클래스의 상수로 둡니다.

public class FeatureGate {

    // 어떤 등급이 어떤 기능을 여는지. 제품과 세일즈가 관리합니다.
    private static final Set<Plan> SSO_PLANS    = Set.of(Plan.PRO, Plan.ENTERPRISE);
    private static final Set<Plan> EXPORT_PLANS  = Set.of(Plan.ENTERPRISE);
    // 웹 UI는 버튼을 보이거나 숨기려고 이 집합을 따로 복제해 둡니다.
    // 결제 webhook은 등급이 바뀔 때 다시 검사하고, 나중에 붙은
    // 분석 서비스는 그 시점의 등급 값으로 굳었습니다.

    public void requireFeature(String feature, Account account) {
        Plan plan = account.getPlan();

        switch (feature) {
            case "sso"    -> require(plan, SSO_PLANS);
            case "export" -> require(plan, EXPORT_PLANS);
            // default가 없습니다. 이 switch가 모르는 기능 키는
            // 그냥 빠져나가고, 호출한 쪽은 권한이 있는 것처럼 진행됩니다.
        }
    }

    private void require(Plan plan, Set<Plan> entitled) {
        if (!entitled.contains(plan)) {
            throw new FeatureNotEntitledException("요금제에 없는 기능");
        }
    }
}

규모가 작을 때는 이대로도 잘 돌아갑니다. 복제된 값들도 처음 배포되던 날에는 다 맞았습니다. 문제는 구조에 있고, 세 군데에서 드러납니다.

  • 같은 권한이 여러 곳에 흩어져 있습니다. 서비스가 집합을 들고 있고, UI가 사본을, webhook이 또 하나를, 나중에 붙은 분석 서비스가 출시 시점 값을 들고 있습니다. 이제 요금제 구성을 한 번 바꾸려면 여러 코드베이스에 동시에 배포해야 하고, 한 곳을 빠뜨려도 아무 신호가 없습니다. 어디서도 에러가 나지 않고, 한 게이트만 조용히 틀린 값을 들고 있습니다.
  • 권한을 바꾸려면 배포를 해야 합니다. 제품은 시행일을 정하지만, 상수는 배포 일정에 실려 나갑니다. 고객이 실제로 적용받는 권한은 어느 배포가 먼저 나갔는지에 따라 갈리고, 누가 무엇을 받았는지의 이력은 저장소 여러 곳의 git 로그에 흩어집니다.
  • 거부가 나중에 찾아볼 기록을 남기지 않습니다. 예외 메시지는 로그 한 줄로 흘러갈 뿐입니다. "5월에 내보내기 기능에 접근한 무료 계정을 전부"는 로그를 파헤치는 작업이 되고, "5월 14일에 pro 등급이 포함한 기능"은 저장소 여러 곳을 git blame으로 뒤지는 일이 됩니다.

패턴 정의

해결의 핵심은 권한 결정을 한 번의 호출로 모으는 것입니다. API든 UI든 결제 webhook이든, 모든 게이트가 같은 정책 그룹에 묻고, 권한 표는 딱 한 곳에만 존재합니다.

먼저, 흩어진 코드가 뭉개 버리는 구분이 하나 있습니다. 기능 플래그(feature flag)와 기능 권한(entitlement)은 코드에서 똑같이 생겼습니다. 둘 다 분기 하나를 가르는 boolean이니까요. 하지만 둘은 답하는 질문도, 바뀌는 주기도 다릅니다. 기능 플래그는 릴리스를 제어합니다. 새로 만든 코드를 지금 켤지 말지를 정하는 스위치라, 배포 파이프라인 안에 있고 엔지니어링이 배포하면서 켜고 끕니다. 기능 권한은 비즈니스 결정입니다. 이 고객의 요금제로 이 기능을 써도 되는지를 정하는 것이라, 제품과 결제가 쥐고 있고 요금제 구성이 바뀔 때 바뀝니다. 이 패턴은 기능 권한을 코드 밖으로 꺼냅니다. 기능 플래그는 꺼내지 않습니다. 그건 배포 파이프라인에 그대로 둡니다.

LexQ에서는 이 권한이 세 가지 개념으로 나뉩니다.

  • 팩트: 엔진이 읽는 입력값입니다. plan_tier, feature_key.
  • 규칙: 기능마다 규칙 하나씩. 조건과, 그 기능이 요구하는 등급을 사유 문자열에 담은 차단(BLOCK) 액션으로 이루어집니다.
  • 기본 동작은 통과입니다. 차단 액션은 규칙이 매칭됐을 때만 실행됩니다. 어떤 규칙에도 걸리지 않은 요청은 그대로 진행됩니다. 거부가 예외이고, 그 예외만 기록으로 남습니다.

여기에는 상호 배타 그룹(Mutex Group)이 필요 없습니다. 한 요청에는 기능 하나와 등급 하나만 담기므로, 조건들이 입력을 알아서 갈라놓습니다. 한 요청에 걸릴 수 있는 기능 규칙은 많아야 하나입니다.

{
  "name": "Block: export requires enterprise",
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      {
        "type": "SINGLE",
        "field": "feature_key",
        "operator": "EQUALS",
        "value": "export",
        "valueType": "STRING"
      },
      {
        "type": "SINGLE",
        "field": "plan_tier",
        "operator": "NOT_IN",
        "value": ["enterprise"],
        "valueType": "LIST_STRING"
      }
    ]
  },
  "actions": [
    {
      "type": "BLOCK",
      "parameters": {
        "reason": "feature_key 'export' requires the enterprise plan"
      }
    }
  ],
  "isEnabled": true
}

방금 본 건 export 기능 하나에 대한 규칙입니다. sso, api_access처럼 기능마다 같은 모양의 규칙을 하나씩 두고, 규칙마다 자기 기능이 어느 등급부터 열리는지만 다르게 지정합니다. 그리고 규칙이 하나 더 필요합니다. 앞의 기능 규칙 어디에도 걸리지 않는, 즉 알 수 없는 기능 키를 막는 규칙입니다.

{
  "name": "Block: unknown feature (fail closed)",
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      {
        "type": "SINGLE",
        "field": "feature_key",
        "operator": "NOT_IN",
        "value": ["sso", "export", "api_access", "custom_roles"],
        "valueType": "LIST_STRING"
      }
    ]
  },
  "actions": [
    {
      "type": "BLOCK",
      "parameters": {
        "reason": "feature_key is not a known entitlement"
      }
    }
  ],
  "isEnabled": true
}

기본 동작이 통과이기 때문에 권한 있는 요청은 따로 규칙이 없어도 지나갑니다. 그런데 같은 기본 동작 때문에, 알 수 없는 feature_key도 아무 검사 없이 통과해 버립니다. 권한 결정에서 이 기본값은 매출 누수입니다. 규칙이 만들어지기 전에 새 유료 기능을 배포하면, 그 기능이 모든 등급에서 공짜가 됩니다. 이 규칙이 그 허점을 의도한 결정으로 바꿔 놓습니다. 규칙으로 만들어 두지 않은 기능은 사유와 함께 거부됩니다. 알려진 기능 목록과 기능 규칙은 같은 초안 버전에서 함께 고치고 함께 검증하므로, 기능 하나를 추가하는 일은 다음 서비스로 상수를 복제하는 게 아니라 검토를 거친 변경 하나가 됩니다.

기능별 차단 규칙과 미지 기능을 닫는 catch-all 규칙이 담긴 콘솔 규칙 목록

이제 권한은 데이터입니다. 권한을 바꾸는 일은 초안 버전에서 규칙 하나를 고치는 일이고, "5월 14일에 pro 등급이 포함한 기능"은 git blame이 아니라 버전 이력이 답합니다.

변경 영향 시뮬레이션 전략

제품이 내보내기 기능을 한 등급 내리기로 합니다. enterprise 전용에서 pro 이상으로 풀어, pro 등급의 전환을 높이려는 겁니다. 이 변경이 실제 요청에 닿기 전에, 미칠 파장을 미리 잴 수 있습니다. 거부되던 요청 중 새로 허용될 비율입니다. 운영 중인 버전을 복제해 그 규칙의 등급 하나만 고치면, 아직 트래픽을 받지 않는 대상 버전이 됩니다.

이건 금액이 아니라 건수를 묻는 질문이라, 분석 대상 팩트는 필요 없습니다. 그 항목은 비워 둬도 됩니다. 규칙별 통계 포함(includeRuleStats)만 켜면 충분합니다. 규칙마다 매칭률이 나오고, 비교 기준 버전 매칭률에서 대상 버전 매칭률을 뺀 차이가 곧 지난달 요청 중 새 권한이 새로 허용할 비율입니다. 그 숫자가 곧 이 변경으로 늘어날 지원 문의와 응대 부담이고, 그동안 enterprise만 쓰던 기능이 얼마나 많은 사용자에게 새로 열리는지입니다. 배포하기 전에 미리 가늠해 보는 값입니다.

lexq analytics simulation start --json '{
  "policyVersionId": "<candidate-version-id>",
  "dataset": {
    "type": "HISTORICAL",
    "source": "EXECUTION_LOGS",
    "from": "2026-05-01",
    "to": "2026-05-31"
  },
  "options": {
    "baselinePolicyVersionId": "<baseline-version-id>",
    "includeRuleStats": true,
    "maxRecords": 50000
  }
}'

배포할지 말지는 두 가지로 판단합니다. 첫째, 새로 허용되는 비율(내보내기 규칙의 비교 기준 매칭률에서 대상 버전 매칭률을 뺀 차이)이 제품이 감당하기로 한 선 안에 들어와야 합니다. 둘째, 나머지 기능의 매칭률은 조금도 움직여선 안 됩니다. 이번 수정은 규칙 하나만 건드렸다고 했고, 규칙별 통계가 그 말이 사실인지 확인해 줍니다. 시뮬레이션은 외부 호출을 모킹으로 대체합니다. 과거 데이터를 읽기만 할 뿐 아무것도 쓰지 않습니다.

pro 이상 대상 버전과 enterprise 전용 비교 기준 버전을 비교한 변경 영향 시뮬레이션 결과

아직 운영 트래픽이 없다면, 대표 데이터셋을 올려 같은 비교를 돌리면 됩니다. 모든 등급과 모든 기능 키가 들어가고, catch-all을 건드릴 알 수 없는 키도 몇 개 섞은 요청으로 구성하면 충분합니다. 대상 버전과 비교 기준 버전을 맞대는 메커니즘 자체는 변경 영향 시뮬레이션으로 배포 전 규칙 변경 테스트하기에서 자세히 다룹니다.

의사결정 트레이스 출력

앞서 지원팀이 올린 그 건을 드라이런으로 돌려 봅니다. 무료 등급 계정이 내보내기를 요청한 경우입니다.

{
  "result": "SUCCESS",
  "data": {
    "traceId": "7d92c4a1-...",
    "inputFacts": {
      "plan_tier": "free",
      "feature_key": "export"
    },
    "mutatedFacts": {},
    "generatedVariables": {
      "is_blocked": true,
      "block_reason": "feature_key 'export' requires the enterprise plan"
    },
    "executionTraces": [ ... ],
    "decisionTraces": [
      {
        "ruleName": "Block: export requires enterprise",
        "status": "SELECTED",
        "reasonCode": "FINAL_WINNER",
        "reasonDetail": null
      },
      {
        "ruleName": "Block: sso requires pro",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      },
      {
        "ruleName": "Block: api_access requires pro",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      },
      {
        "ruleName": "Block: custom_roles requires enterprise",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      },
      {
        "ruleName": "Block: unknown feature (fail closed)",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      }
    ]
  }
}

거부된 무료 등급 내보내기 요청의 드라이런 결과

mutatedFacts는 비어 있습니다. 차단은 어떤 팩트도 바꾸지 않으니까요. 거부 결과는 generatedVariables에 담깁니다. is_blockedtrue이고, block_reason에 매칭된 규칙의 사유 문자열이 들어 있습니다. 두 키 모두 시스템이 만들어 내며, 차단 액션이 실행됐을 때만 생깁니다. 아무것도 거부하지 않았다면 false가 아니라 키 자체가 없습니다. 애플리케이션이 지켜야 할 규약은 여기서 바로 나옵니다. is_blockedtrue면 기능을 거절하고(상위 등급 안내와 함께 402를 주거나 버튼을 숨기고), block_reason을 보여 줍니다. 이 문자열에는 그 기능이 요구하는 등급이 이미 담겨 있습니다. 사유 문자열이 곧 업그레이드 안내입니다. 규칙이 어떤 조건식으로 따져졌는지는 위 드라이런 화면의 실행 트레이스 표에서 함께 볼 수 있습니다. 지원팀에 줄 답은 사유 문자열이고, 재무팀에 줄 답은 트레이스입니다. 어느 규칙이 어느 버전에서 무슨 입력을 받아 언제 그렇게 판정했는지가 트레이스에 모두 남아 있습니다.

같은 내보내기 요청을 enterprise 계정으로 돌리면 모든 규칙이 미매칭(NO_MATCH) · 조건 미충족(CONDITION_MISMATCH)으로 남습니다. 두 맵 다 빈 객체로 돌아오고, 기능은 그대로 진행됩니다. is_blocked가 없다는 것 자체가 권한 승인입니다.

어느 규칙에도 매칭되지 않은 enterprise 내보내기 요청의 드라이런 결과

엣지 케이스

이 패턴의 핵심은 특정 등급 값이 아니라 권한 결정을 한곳으로 모은다는 데 있습니다. 그 언저리에서 몇 가지는 따로 정하고 넘어가야 합니다.

  • 규칙에 없는 등급.trial 등급이 들어오기 시작했다고 합시다. 기능 규칙은 저마다 plan_tier가 자기 등급 집합에 없으면 막으므로, 목록에 없는 trial은 모든 유료 기능에서 자동으로 거부됩니다. 닫힌 채로 두는 게 안전한 기본값입니다. 다만 그것도 엄연히 결정입니다. trial에 어떤 기능을 열어 주려면 그 기능의 등급 집합에 trial을 넣으면 되고, 그렇게 바꿨을 때 영향받는 규칙들의 매칭률이 얼마나 달라지는지는 시뮬레이션이 보여 줍니다.
  • 빠진 팩트. plan_tierfeature_key가 없는 페이로드는 조용히 지나가지 않습니다. 엔진은 빠진 팩트 이름을 담아 에러를 던지고, 임의의 기본값으로 메우지 않습니다. 호출하는 쪽은 에러로 끝난 실행을 권한 없음으로 처리해야 합니다. 엔진이 추측을 거부했으니, 애플리케이션이 대신 추측해서도 안 됩니다.
  • 등급과 무관한 부가 기능. 어떤 기능은 기본 등급과 상관없이 부가 상품으로 따로 팝니다. pro 등급이면서 SSO를 부가로 따로 구매한 고객이 그렇습니다. 이런 경우는 등급만으로 표현할 수 없습니다. 부가 기능은 그 자체를 팩트로 만들어, addon_sso 같은 boolean을 두고 sso 규칙을 등급이 아니라 그 팩트에 겁니다. 모든 권한이 등급의 높낮이로 정해지는 건 아닙니다.
  • 알려진 기능 목록의 어긋남. catch-all의 알려진 기능 목록은 기능 규칙들과 늘 맞아야 합니다. 둘 다 한 버전 안에 있고 함께 검토되지만, 기능 규칙만 추가하고 목록을 빠뜨리면 그 기능 요청이 catch-all을 그냥 통과합니다. 시뮬레이션이 이걸 드러냅니다. 어떤 규칙도, 목록도 다루지 않는 feature_key가 운영에 들어오면 예상치 못한 catch-all 매칭으로 나타납니다. 배포 전에 실제 키로 시뮬레이션을 돌립니다.
  • 예/아니오가 아니라 수량. 이 패턴은 "등급이 이 기능을 포함하는가"에 답합니다. "등급에 좌석이 몇 개 들어 있나", "한 달에 내보내기를 몇 번까지 하나" 같은 질문은 한도를 정해 놓고 세는 문제입니다. 값을 누적하는 팩트와 임계값이 필요하고, 실패하는 방식도 달라서 별도 패턴으로 다룹니다. 이건 권한이라기보다 사용량 한도에 가깝고, 차단 규칙으로 KYC 등급별 이체 한도 집행하기가 그 사례입니다.

운영 배포

두 조건을 통과한 대상 버전은 배포해서 운영에 올립니다. 배포 순간의 규칙 스냅샷은 해시로 봉인되어 무결성이 검증되고, 누가 언제 어떤 버전을 올렸는지가 기록으로 남습니다.

권한 변경은 카나리처럼 단계적으로 배포하면 안 됩니다. 한 번에 전체 트래픽에 적용합니다. 트래픽을 나눠 일부에만 새 권한을 적용하면, 같은 요금제를 쓰는 두 계정이 같은 기능을 요청해도 어느 그룹에 들어갔느냐에 따라 답이 갈립니다. 가격이라면 그런 차이를 실험으로 받아들일 수 있지만, 권한에서는 통하지 않습니다. 옆자리 동료는 막히는 기능을 나만 쓸 수 있다면, 그건 안전장치가 못 됩니다. 문의가 쌓이고 유료 모델에 금이 갈 뿐입니다. 그래서 배포해도 되는지는 단계적 배포로 가늠하지 않습니다. 변경 영향 시뮬레이션에서 답이 이미 나와 있어야 합니다. 배포한 뒤에 할 일은 규칙별 운영 차단율이 시뮬레이션에서 본 매칭률과 어긋나지 않는지 지켜보는 것뿐입니다.

기능 플래그와 기능 권한을 갈라놓은 설계가 여기서 효과를 냅니다. 릴리스 롤아웃은 새 코드를 5%, 그다음 25%, 그다음 전체로 내보내는, 바로 그 점진적 분할입니다. 권한이 피해야 할 바로 그 방식입니다. 이건 배포 파이프라인에 남아 코드 준비 상태를 가르지, 요금제 접근을 가르지 않습니다. 둘은 같은 제어 지점을 절대 공유하지 않습니다.

다음 두 신호 중 하나라도 보이면 곧바로 롤백합니다.

  • 바꾼 규칙의 운영 차단율이 시뮬레이션 매칭률에서 벗어납니다. 운영에 들어오는 요청의 분포가 시뮬레이션에 쓴 과거 구간과 달라졌다는 뜻이고, 승인받았던 파장 범위는 더는 들어맞지 않습니다.
  • 시뮬레이션이 변동 없다고 본 기능에서 차단이 생깁니다. 운영에 들어오는 feature_keyplan_tier가 데이터셋에 담겼던 것과 다르다는 뜻입니다.
대상 버전 배포 상세

완화하는 변경은 경우가 좀 다릅니다. 그동안 무료였던 기능을 등급 뒤로 옮기면, 몇 달째 잘 쓰던 계정이 갑자기 막힙니다. 한 번에 배포하면 그 단절이 더 급작스럽습니다. 그렇다고 비율로 조금씩 푸는 카나리가 답은 아닙니다. 카나리는 똑같은 계정에 그때그때 다른 답을 주니까요. 답은 규칙을 명시적으로 두는 것입니다. 기존 사용자에게 종전 권한을 남겨 주는 규칙을 두고 grandfathered 플래그나 signup_before 같은 날짜 팩트로 가르면, 모든 계정의 답이 한결같이 정해지고 트레이스에도 그대로 남습니다. 이렇게 하면 완화도 기록으로 남은 결정이지, 슬그머니 벌어진 일이 아닙니다.

롤백하면 정책 그룹이 이전 버전으로 돌아가고, 롤백했다는 사실까지 배포 이력에 남습니다. 대상 버전이 전체 트래픽을 받기 시작하면, 규칙별 통계가 재무팀 질문에 대한 상시 답이 됩니다. 어떤 계정이 어떤 기능에 닿았는지는 규칙과 버전으로 범위를 좁힌 실행 이력 조회면 됩니다. 로그를 파헤칠 일이 아닙니다. 그리고 "5월 14일에 pro 등급이 포함한 기능"은 버전 이력과 배포 기록에 그대로 있습니다.

결정을 배포 파이프라인 밖으로 꺼낼 준비가 되셨나요?

LexQ를 무료로 체험하세요 — 신용카드 불필요.

무료로 시작