차단 규칙으로 요금제 등급별 기능 권한 결정하기
코드 곳곳에 흩어진 요금제 등급 검사를 한곳의 차단 규칙으로 모으고, 막힌 기능 접근마다 사유가 남게 만드는 패턴.
문제
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에서는 이 권한이 세 가지 개념으로 나뉩니다.
- fact: 엔진이 읽는 입력값입니다.
planTier,featureKey. - 규칙: 기능마다 규칙 하나씩. 조건과, 그 기능이 요구하는 등급을 사유 문자열에 담은 차단(
BLOCK) 액션으로 이루어집니다. - 기본 동작은 통과입니다. 차단 액션은 규칙이 매칭됐을 때만 실행됩니다. 어떤 규칙에도 걸리지 않은 요청은 그대로 진행됩니다. 거부가 예외이고, 그 예외만 기록으로 남습니다.
기능 규칙끼리는 상호 배타 그룹(Mutex Group)으로 묶지 않습니다. 한 요청에는 기능 키 하나와 등급 하나만 담기므로, 걸릴 수 있는 기능 규칙도 많아야 하나입니다.
{
"name": "Block: export requires enterprise",
"condition": {
"type": "GROUP",
"operator": "AND",
"children": [
{
"type": "SINGLE",
"field": "featureKey",
"operator": "EQUALS",
"value": "export",
"valueType": "STRING"
},
{
"type": "SINGLE",
"field": "planTier",
"operator": "NOT_IN",
"value": ["enterprise"],
"valueType": "LIST_STRING"
}
]
},
"actions": [
{
"type": "BLOCK",
"parameters": {
"reason": "featureKey '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": "featureKey",
"operator": "NOT_IN",
"value": ["sso", "export", "api_access", "custom_roles"],
"valueType": "LIST_STRING"
}
]
},
"actions": [
{
"type": "BLOCK",
"parameters": {
"reason": "featureKey is not a known entitlement"
}
}
],
"isEnabled": true
}
기본 동작이 통과이기 때문에 권한 있는 요청은 따로 규칙이 없어도 지나갑니다. 그런데 같은 기본 동작 때문에, 알 수 없는 featureKey도 아무 검사 없이 통과해 버립니다. 권한 결정에서 이 기본값은 매출 누수입니다. 규칙이 만들어지기 전에 새 유료 기능을 배포하면, 그 기능이 모든 등급에서 공짜가 됩니다. 이 규칙이 그 허점을 의도한 결정으로 바꿔 놓습니다. 규칙으로 만들어 두지 않은 기능은 사유와 함께 거부됩니다. 알려진 기능 목록과 기능 규칙은 같은 초안 버전에서 함께 고치고 함께 검증하므로, 기능 하나를 추가하는 일은 다음 서비스로 상수를 복제하는 게 아니라 검토를 거친 변경 하나가 됩니다.
이제 권한은 데이터입니다. 권한을 바꾸는 일은 초안 버전에서 규칙 하나를 고치는 일이고, "5월 14일에 pro 등급이 포함한 기능"은 git blame이 아니라 버전 이력이 답합니다.
변경 영향 시뮬레이션 전략
제품이 내보내기 기능을 한 등급 내리기로 합니다. enterprise 전용에서 pro 이상으로 풀어, pro 등급의 전환을 높이려는 겁니다. 이 변경이 실제 요청에 닿기 전에, 미칠 파장을 미리 잴 수 있습니다. 거부되던 요청 중 새로 허용될 비율입니다. 운영 중인 버전을 복제해 그 규칙의 등급 하나만 고치면, 아직 트래픽을 받지 않는 대상 버전이 됩니다.
권한 판정에는 합산할 금액이 없습니다. 규칙마다 몇 건이 걸렸는지만 있으면 되므로 분석 대상 fact는 비워 두고 규칙별 통계 포함(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
}
}'
새 등급 표를 올려도 되는지는 두 항목이 정합니다. 첫째, 새로 허용되는 비율(내보내기 규칙의 비교 기준 매칭률에서 대상 버전 매칭률을 뺀 차이)이 제품이 감당하기로 한 선 안에 들어와야 합니다. 둘째, 나머지 기능의 매칭률은 조금도 움직여선 안 됩니다. 손댄 규칙은 내보내기 하나뿐이라고 했으니, 다른 기능의 숫자가 흔들렸다면 고친 자리가 하나가 아니었다는 뜻입니다. 이 비교는 지난달 요청 기록을 다시 읽을 뿐이라, 그동안 어떤 계정의 기능 잠금도 실제로 풀리지 않습니다.
요금제를 막 붙인 제품이라 쌓인 실행 이력이 없다면, 요청을 직접 지어 데이터셋으로 올립니다. 등급과 기능 키의 조합이 빠짐없이 들어가게 하고, catch-all을 건드릴 알 수 없는 키도 몇 개 섞으면 충분합니다. 대상 버전과 비교 기준 버전을 맞대는 메커니즘 자체는 변경 영향 시뮬레이션으로 배포 전 규칙 변경 테스트하기에서 자세히 다룹니다.
의사결정 트레이스 출력
앞서 지원팀이 올린 그 건을 드라이런으로 돌려 봅니다. 무료 등급 계정이 내보내기를 요청한 경우입니다.
{
"result": "SUCCESS",
"data": {
"inputFacts": {
"planTier": "free",
"featureKey": "export"
},
"mutatedFacts": {},
"generatedVariables": {
"isBlocked": true,
"blockReason": "featureKey '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에는 아무 항목도 없습니다. 차단은 어떤 fact도 바꾸지 않으니까요. 거부 결과는 generatedVariables에 담깁니다. isBlocked가 true이고, blockReason에는 걸린 기능 규칙이 적어 둔 사유 문자열이 들어 있습니다. 두 키 모두 시스템이 만들어 내며, 차단 액션이 실행됐을 때만 생깁니다. 아무것도 거부하지 않았다면 false가 아니라 키 자체가 없습니다. 게이트 쪽 코드가 따를 규약도 이 두 키에서 그대로 정해집니다. isBlocked가 true면 기능을 거절하고(상위 등급 안내와 함께 402를 주거나 버튼을 숨기고), blockReason을 보여 줍니다. 이 문자열에는 그 기능이 요구하는 등급이 이미 담겨 있습니다. 사유 문자열이 곧 업그레이드 안내입니다. 규칙이 어떤 조건식으로 따져졌는지는 위 드라이런 화면의 실행 트레이스 표에서 함께 볼 수 있습니다. 지원팀 문의는 blockReason 한 줄에서 끝납니다. 재무팀이 묻는 쪽은 트레이스입니다. 어느 기능 규칙이 어느 정책 버전에서 planTier와 featureKey를 받아 언제 그 계정을 막았는지가 실행마다 남습니다.
같은 내보내기 요청을 enterprise 계정으로 돌리면 규칙 목록 전체가 미매칭(NO_MATCH)에 조건 미충족(CONDITION_MISMATCH)으로 찍히고, mutatedFacts와 generatedVariables가 나란히 빈 객체로 돌아옵니다. 기능은 그대로 진행됩니다. isBlocked가 없다는 것 자체가 권한 승인입니다.
엣지 케이스
이 패턴의 핵심은 특정 등급 값이 아니라 권한 결정을 한곳으로 모은다는 데 있습니다. 권한 표의 가장자리에 걸리는 경우들은 규칙을 쓰기 전에 정해 둡니다.
- 규칙에 없는 등급. 새
trial등급이 들어오기 시작했다고 합시다. 기능 규칙은 저마다planTier가 자기 등급 집합에 없으면 막으므로, 목록에 없는trial은 모든 유료 기능에서 자동으로 거부됩니다. 닫힌 채로 두는 게 안전한 기본값입니다. 다만 그것도 엄연히 결정입니다.trial에 어떤 기능을 열어 주려면 그 기능의 등급 집합에trial을 넣으면 되고, 그렇게 바꿨을 때 영향받는 규칙들의 매칭률이 얼마나 달라지는지는 시뮬레이션이 보여 줍니다. - 빠진 fact.
planTier나featureKey가 없는 페이로드는 조용히 지나가지 않습니다. 엔진은 빠진 fact 이름을 담아 에러를 던지고, 임의의 기본값으로 메우지 않습니다. 호출하는 쪽은 에러로 끝난 실행을 권한 없음으로 처리해야 합니다. 엔진이 빠진 값을 짐작하지 않았으므로, 게이트도 짐작으로 기능을 열지 않습니다. - 등급과 무관한 부가 기능. 어떤 기능은 기본 등급과 상관없이 부가 상품으로 따로 팝니다. pro 등급이면서 SSO를 부가로 따로 구매한 고객이 그렇습니다. 이런 경우는 등급만으로 표현할 수 없습니다. 부가 기능은 그 자체를 fact로 만들어,
addonSso같은 boolean을 두고sso규칙을 등급이 아니라 그 fact에 겁니다. 모든 권한이 등급의 높낮이로 정해지는 건 아닙니다. - 알려진 기능 목록의 어긋남. catch-all의 알려진 기능 목록은 기능 규칙들과 늘 맞아야 합니다. 둘 다 한 버전 안에 있고 함께 검토되지만, 어긋나는 방향에 따라 결과가 반대로 갈립니다. 기능 규칙만 추가하고 목록에 키를 넣지 않으면 catch-all이 그 기능 요청을 전부 막습니다. 권한 있는 계정까지 거부됩니다. 반대로 목록에만 키를 넣고 규칙을 만들지 않으면 그 기능은 아무 검사 없이 지나갑니다. 시뮬레이션이 이걸 드러냅니다. 어떤 규칙도, 목록도 다루지 않는
featureKey가 운영에 들어오면 예상치 못한 catch-all 매칭으로 나타납니다. 배포 전에 실제 키로 시뮬레이션을 돌립니다. - 예/아니오가 아니라 수량. 이 패턴은 "등급이 이 기능을 포함하는가"에 답합니다. "등급에 좌석이 몇 개 들어 있나", "한 달에 내보내기를 몇 번까지 하나" 같은 질문은 한도를 정해 놓고 세는 문제입니다. 값을 누적하는 fact와 임계값이 필요하고, 실패하는 방식도 달라서 별도 패턴으로 다룹니다. 이건 권한이라기보다 사용량 한도에 가깝고, 차단 규칙으로 KYC 등급별 이체 한도 집행하기가 그 사례입니다.
운영 배포
앞의 두 확인을 통과한 등급 표를 배포하면, 그다음 요청부터 모든 게이트가 새 권한으로 답합니다. 올린 순간의 기능 규칙 묶음은 해시로 봉인되어 이후 손댄 곳이 없는지 검증할 수 있고, 어느 등급 표를 누가 언제 올렸는지는 배포 이력이 들고 있습니다.
권한 변경은 트래픽을 쪼개 조금씩 적용하지 않고, 한 번에 전체 트래픽으로 배포합니다. A/B 테스트로 트래픽을 나누면 일부 요청은 새 권한으로, 나머지는 이전 권한으로 판정됩니다. 같은 요금제를 쓰는 두 계정이 같은 기능을 요청해도 어느 쪽에 떨어졌는지만으로 답이 갈린다는 뜻입니다. 가격이라면 그런 차이를 실험으로 받아들일 수 있지만, 권한에서는 통하지 않습니다. 그 차이는 실험 결과가 아니라 지원 문의로 돌아옵니다. 그래서 배포해도 되는지는 단계적 배포로 가늠하지 않습니다. 변경 영향 시뮬레이션에서 답이 이미 나와 있어야 합니다. 배포한 뒤에 할 일은 규칙별 운영 차단율이 시뮬레이션에서 본 매칭률과 어긋나지 않는지 지켜보는 것뿐입니다.
기능 플래그와 기능 권한을 갈라놓은 설계가 여기서 효과를 냅니다. 릴리스 롤아웃은 새 코드를 5%, 그다음 25%, 그다음 전체로 내보내는 점진적 분할이고, 권한이 피해야 할 바로 그 방식입니다. 이건 배포 파이프라인에 남아 코드 준비 상태를 가르지, 요금제 접근을 가르지 않습니다. 둘은 같은 제어 지점을 절대 공유하지 않습니다.
다음 두 가지 중 하나라도 보이면 이전 등급 표로 되돌립니다.
- 바꾼 규칙의 운영 차단율이 시뮬레이션 매칭률에서 벗어납니다. 운영에 들어오는 요청의 분포가 시뮬레이션에 쓴 과거 구간과 달라졌다는 뜻이고, 승인받았던 파장 범위는 더는 들어맞지 않습니다.
- 시뮬레이션이 변동 없다고 본 기능에서 차단이 생깁니다. 운영에 들어오는
featureKey나planTier가 데이터셋에 담겼던 것과 다르다는 뜻입니다.
변경의 충격을 누그러뜨리는 일은 경우가 좀 다릅니다. 그동안 무료였던 기능을 등급 뒤로 옮기면, 몇 달째 잘 쓰던 계정이 갑자기 막힙니다. 한 번에 배포하면 그 단절이 더 급작스럽습니다. 그렇다고 비율로 조금씩 푸는 카나리가 답은 아닙니다. 카나리는 똑같은 계정에 그때그때 다른 답을 주니까요. 답은 규칙을 명시적으로 두는 것입니다. 기존 사용자에게 종전 권한을 남겨 주는 규칙을 두고 grandfathered 플래그나 signupBefore 같은 날짜 fact로 가르면, 모든 계정의 답이 한결같이 정해지고 트레이스에도 그대로 남습니다. 이렇게 하면 충격을 누그러뜨리는 것도 기록으로 남은 결정이지, 슬그머니 벌어진 일이 아닙니다.
되돌리면 게이트가 묻는 정책 그룹이 직전 등급 표를 다시 쓰고, 되돌렸다는 사실도 배포 이력의 한 줄로 남습니다. 새 등급 표가 전체 트래픽을 받고 나면, 재무팀이 다시 물어도 규칙별 통계가 그 자리에서 답합니다. 어떤 계정이 어떤 기능에 닿았는지는 규칙과 버전으로 범위를 좁혀 실행 이력을 조회하면 나옵니다. 흘러간 로그를 되짚을 일이 아닙니다. 문제 절에서 재무팀이 요구한 "5월 14일에 pro 등급이 포함한 기능"도 그날 배포돼 있던 버전을 열면 그대로 나옵니다. 기능이 늘어 규칙 수가 불고 게이트 응답이 굼떠지면, 그 시간을 어느 기능 규칙이 썼는지부터 봅니다. 규칙별 프로파일을 읽는 방법은 느려진 규칙 집어내기에 있습니다.