요금제 기능과 한도를 하드코딩하지 마세요: Feature Flag와 Entitlement는 다릅니다
요금제 등급을 if (plan == PRO)로 코드에 박으면 가격 변경 하나하나가 배포가 됩니다. Feature Flag에 entitlement를 담으면 안 되는 이유와, 새 요금제 등급을 배포 없이 규칙 변경으로 다루는 방법을 정리했습니다.
가격 페이지에는 요금제가 세 줄로 정리되어 있습니다. 코드를 열어 보면 이야기가 다릅니다.
API 어딘가에 if (plan == PRO)가 있습니다. 프론트엔드에는 버튼을 보여줄지 말지 정하는 같은 검사의 복사본이 있고, 결제 웹훅은 구독이 바뀔 때 등급을 다시 확인하고, 백그라운드 워커는 언젠가 배포된 상수로 API 호출 한도를 집행합니다. 이 복사본들은 서로 따로 놉니다. 그리고 이 검사들은 전부 같은 질문에 답하고 있습니다. 이 고객이 돈을 내고 산 범위가 어디까지인가. 이 답을 entitlement라고 부릅니다. 고객이 요금제로 확보한 이용 범위, 곧 기능과 한도의 집합입니다. 지금은 그 답이 애플리케이션 코드에 박혀 있습니다.
그러다 프로덕트 팀이 당연한 요청을 합니다. Free와 Pro 사이에 Team 등급을 하나 넣자고요. 아니면 Pro의 API 한도를 올리자고요. 세일즈가 그 한도 때문에 계약을 계속 놓치고 있으니까요. 엔지니어링의 정직한 답은 "다음 스프린트요"입니다. 가격 모델이 어디에도 데이터로 존재하지 않고, 코드베이스 네 곳에 흩어져 있기 때문입니다. 이 글은 왜 이렇게 되는지, 왜 이때 팀들이 Feature Flag를 집어 드는지, 왜 그게 잘못된 도구인지, 그리고 entitlement를 결정으로 분리하면 무엇이 달라지는지를 다룹니다.
요금제 등급이 코드 곳곳에 흩어집니다
첫 버전은 늘 순진합니다. 서비스 하나에 관문 하나면 충분해 보입니다.
public class PlanService {
// 플랜 한도. 프로덕트 팀이 정하고, 엔지니어링이 배포합니다.
private static final int FREE_MAX_SEATS = 3;
private static final int PRO_MAX_SEATS = 25;
private static final int FREE_API_CALLS_MONTHLY = 1_000;
private static final int PRO_API_CALLS_MONTHLY = 50_000;
public boolean canAddSeat(Account account) {
int max = switch (account.plan()) {
case FREE -> FREE_MAX_SEATS;
case PRO -> PRO_MAX_SEATS;
case ENTERPRISE -> account.customSeatLimit(); // 계약별 협상 한도
};
return account.seatCount() < max;
}
public boolean allowSso(Account account) {
// SSO는 일단 Pro 이상만.
return account.plan() == Plan.PRO || account.plan() == Plan.ENTERPRISE;
}
}
상수 하나하나는 실제 사업 결정을 담고 있습니다. 문제는 어느 한 줄이 아니라, 이 결정들이 불어나고 흩어지는 방식입니다. 초대 버튼을 비활성화하려면 UI도 좌석 한도를 알아야 하니 같은 상수가 프론트엔드에 하나 더 생기고, 사용량 워커에는 API 한도 상수가 또 하나 생깁니다. 그다음에는 예외가 쌓이기 시작합니다. 실제 SaaS의 entitlement는 등급 N개로 끝나는 법이 없기 때문입니다.
- 2025년 가격 개편 전에 가입한 고객은 옛 한도를 유지하기로 했습니다. 등급 검사 옆에
signupDate검사가 하나 더 붙습니다. - 어느 엔터프라이즈 고객은 협상으로 Pro 가격에 좌석 40개를 받아냈습니다. 그 계정 ID 하나만 예외로 빼 주는 분기가 생깁니다. 다들 임시라고 했습니다.
- 마케팅이 연간 결제 Free 계정에 SSO를 1년간 열어 주는 프로모션을 돌렸습니다. 그 분기는 아직 코드에 있습니다. 지금도 필요한 분기인지는 아무도 기억하지 못합니다.
entitlement의 실제 크기는 등급 곱하기 예외 곱하기 시간입니다. 하드코딩된 상태에서 이 곱은 어디서도 한눈에 읽을 수 없는 결정 트리가 되고, 두 가지 질문에 답할 수 없게 됩니다. 지원팀은 이 Pro 계정이 왜 1,000건에서 막혔는지 묻습니다. (알고 보니 워커에 남은 낡은 상수였습니다.) 재무는 3월에 프로모션으로 SSO를 쓸 수 있었던 계정이 어디까지였는지 묻습니다. 두 질문 모두 정직한 답은 저장소 여러 개에 걸친 git blame입니다. 결국 아무도 모른다는 뜻입니다.
가격 실험 하나가 배포 하나가 됩니다
흩어짐에는 두 번째 비용이 있고, 이 비용은 성장률에 직접 반영됩니다. 바로 가격을 실험하는 속도입니다.
가격은 SaaS가 가진 레버 중 효과가 가장 큰 축에 들지만, 프로덕트 팀이 가장 덜 당기는 레버이기도 합니다. 아이디어가 없어서가 아닙니다. 아이디어 하나를 실행에 옮기려면 저장소 여러 개를 고쳐야 하기 때문입니다. Team 등급을 추가하려면 API 관문, UI 검사, 웹훅, 워커, 그리고 찾아낼 수 있는 모든 낡은 복사본을 고쳐야 합니다. Pro의 API 한도를 한 분기만 올려서 전환율 변화를 보고 싶어도 목록은 같습니다. 가격 개편에서 기존 고객을 보호하려면 그 목록 전체에 날짜 분기까지 더해집니다.
그래서 실험에는 견적이 붙고, 견적은 스프린트가 되고, 스프린트는 로드맵에 밀립니다. 가격 모델은 얼어붙습니다. 옳아서가 아니라 바꾸는 비용이 너무 커서요. 그 사이 "이걸 바꾸면 무슨 일이 생기는가"라는 질문은 물을 곳조차 없습니다. 한도가 워커 코드에 박힌 상수인 이상, 지난달 실제 사용량에 대고 한도 변경을 시뮬레이션할 방법이 없습니다. 일단 내보내고, 결과는 고객 문의로 확인하게 됩니다.
Feature Flag는 entitlement를 담을 그릇이 아닙니다
이 시점에서 많은 팀이 이미 쓰고 있는 Flag 도구를 떠올립니다. 대상 선정 규칙도 있고, 점진 배포도 되고, 변경 로그도 있고, 배포도 필요 없는데 등급을 여기 두면 안 될까요?
안 됩니다. entitlement와 Feature Flag를 가르는 기준은 도구가 무엇을 할 수 있느냐가 아닙니다. Flag 도구도 plan == "pro" 정도는 평가할 수 있습니다. 기준은 둘의 정체입니다. Feature Flag는 배포와 릴리스, 그러니까 코드를 서버에 올리는 일과 기능을 사용자에게 여는 일을 분리하려고 존재합니다. 코드를 꺼진 채로 내보내고, 켜고, 문제가 생기면 끄고, 다 끝나면 Flag를 지웁니다. LaunchDarkly와 Unleash는 그 일을 잘합니다. entitlement는 수명 주기가 완전히 다릅니다.
| Feature Flag | Entitlement | |
|---|---|---|
| 답하는 질문 | 이 코드 경로가 릴리스됐는가 | 이 고객의 요금제에 무엇이 포함되는가 |
| 수명 | 설계상 임시 (지우려고 만듦) | 영구 (버전으로 관리, 지우지 않음) |
| 주인 | 엔지니어링, 릴리스 일정 | 프로덕트 팀과 재무, 패키징 일정 |
| 모양 | boolean (때로 대상 선정 포함) | 요금제·사용량·날짜·계약 조건과 한도 값 |
| 틀렸을 때 | 릴리스를 롤백 | 결제 분쟁에 답변 |
| 필요한 기록 | Flag 변경 로그 | 결정 단위 트레이스 (어느 규칙, 어느 버전, 왜) |
등급을 Flag에 넣으면 위 표의 모든 행이 문제가 되어 돌아옵니다. 그 Flag들은 영영 지울 수 없습니다. 그래서 임시 스위치를 위해 만든 대시보드가 사실상의 가격 데이터베이스로 굳어 갑니다. 좌석 25개, 호출 50,000건 같은 한도 값은 Flag의 반환값 목록(variation)에 박히거나 결국 코드로 되돌아갑니다. Flag가 돌려주는 건 미리 정해 둔 값 중 하나이지, 계산된 결정이 아니기 때문입니다. 그리고 6주 전의 차단을 고객이 문제 삼으면, Flag 평가 로그에는 어느 값을 돌려줬는지까지만 남아 있습니다. 어느 규칙이 그 결정을 내렸는지, 그날 요금제에 무엇이 포함되어 있었는지는 어디에도 없습니다.
판별법은 간단합니다. Flag를 지우는 순간 결제가 깨진다면, 그건 처음부터 Flag가 아니었습니다. entitlement가 Flag 자리에 들어가 있었던 것뿐입니다.
이 구도에서도 Flag의 일은 그대로 남습니다. 기능을 만드는 동안 아직 준비되지 않은 코드 경로를 막아 두는 일입니다. Flag가 정하면 안 되는 것은 요금제 접근 권한입니다.
entitlement를 결정으로 분리하면
대안은 entitlement를 다른 핵심 상태와 똑같이 다루는 것입니다. 애플리케이션 코드 밖에서, 수명 주기를 가진 데이터로요. API, UI, 웹훅, 워커가 전부 같은 곳에 같은 질문을 던지고, 가격 모델은 정확히 한 곳에만 존재합니다.
LexQ에서 이 한 곳은 plan_tier와 feature_key를 팩트로 받고, 경계 하나마다 사유를 담은 BLOCK 규칙 하나를 두는 정책 그룹이 됩니다.
{
"name": "Block: sso requires pro",
"condition": {
"type": "GROUP",
"operator": "AND",
"children": [
{
"type": "SINGLE",
"field": "feature_key",
"operator": "EQUALS",
"value": "sso",
"valueType": "STRING"
},
{
"type": "SINGLE",
"field": "plan_tier",
"operator": "NOT_IN",
"value": ["pro", "enterprise"],
"valueType": "LIST_STRING"
}
]
},
"actions": [
{
"type": "BLOCK",
"parameters": {
"reason": "feature_key 'sso' requires the pro plan or above"
}
}
],
"isEnabled": true
}
이 그룹의 기본값은 허용입니다. 어느 규칙에도 걸리지 않은 요청은 그대로 통과하고, 거부만 기록으로 남습니다. 이제 아까 "스프린트 하나씩"이던 변경 목록을 다시 봅시다.
- Team 등급 추가: 포함시킬 규칙마다 허용 목록에
"team"을 넣는 일입니다. 초안 버전 하나에서, 변경 전후가 전부 보이는 리뷰 한 번으로 끝납니다. - 기존 고객 보호: 특수 분기가 아니라 팩트입니다. 입력에
signup_before날짜나grandfathered값을 싣고, 그걸 읽는 규칙 하나를 둡니다. 보호받는 계정이 받는 답은 언제나 같고, 트레이스에 그대로 남습니다. - 협상된 엔터프라이즈 예외: 워커에 묻힌 계정 ID가 아니라, 계약 분쟁에서 가리킬 수 있는 규칙이 됩니다.
좌석 수나 API 호출 한도처럼 숫자로 된 한도도 모양은 같습니다. 등급 목록 대신 숫자 팩트와 임계값을 쓰는 것뿐입니다. 아직 규칙이 없는 신규 기능이 전 요금제에 조용히 무료로 풀리는 사고를 막는 fail-closed 규칙까지 포함해서, 기능 쪽의 전체 청사진은 패턴에 정리해 두었습니다: 차단 규칙으로 요금제 등급별 기능 권한 결정하기.
요금제는 프로덕트 팀이 바꾸고, 파이프라인은 코드만 배포합니다
LexQ는 entitlement를 Flag가 아니라 결정으로 다룹니다. 그래서 새 요금제 등급은 애플리케이션 배포가 아니라 규칙 변경입니다. 감으로 하는 변경도 아닙니다. 결정으로 분리한 효과는 여기서 두 가지로 나타납니다.
먼저, 내보내기 전에 "이 변경이 무엇을 바꾸는가"를 숫자로 물을 수 있게 됩니다. 운영 중인 버전을 복제해 수정한 뒤, 지난달의 실제 요청에 대고 변경 영향 시뮬레이션(Impact Simulation)을 돌립니다. Team 등급이 생기면 거부에서 허용으로 뒤집히는 요청이 얼마나 되는지, 올린 Pro 한도를 새로 통과하게 되는 계정이 어디인지, 마지막으로 건드리지 않은 규칙의 매칭률이 조금도 움직이지 않았는지까지 확인합니다. 이 항목이 실수를 잡아 줍니다. 고객이 보기 전에 실제 트래픽으로 검증합니다.
다음으로, 내보낸 뒤에는 모든 답이 기록을 남깁니다. 거부는 로그 한 줄로 증발하지 않고, 사유와 함께 응답에 실려 돌아옵니다.
{
"result": "SUCCESS",
"data": {
"traceId": "7d92c4a1-...",
"inputFacts": {
"plan_tier": "free",
"feature_key": "sso"
},
"mutatedFacts": {},
"generatedVariables": {
"is_blocked": true,
"block_reason": "feature_key 'sso' requires the pro plan or above"
},
"executionTraces": [ ... ],
"decisionTraces": [
{
"ruleName": "Block: sso requires pro",
"status": "SELECTED",
"reasonCode": "FINAL_WINNER",
"reasonDetail": null
}
]
}
}
block_reason은 그대로 페이월의 업그레이드 안내 문구가 됩니다. 그리고 트레이스가 결제 분쟁의 답입니다. 어느 규칙이, 어느 버전에서, 어떤 입력으로, 언제 그 결정을 내렸는지가 결정이 내려진 그 순간에 기록됩니다. 6주 뒤에 재구성하는 게 아니라요.
변경 자체는 LexQ의 버전 수명 주기를 탑니다. 초안을 만들고, 시뮬레이션하고, 발행하고, 배포합니다. 그리고 전체 트래픽에 한 번에 적용됩니다. 같은 요금제의 두 계정이 점진 배포 때문에 서로 다른 답을 받아서는 안 되기 때문입니다. 그동안 애플리케이션 릴리스 일정은 이 일과 무관하게 돌아갑니다. 코드가 바뀌면 코드를 배포하고, 가격이 바뀌면 규칙을 배포합니다.
LexQ가 필요 없는 경우
정직하게 두 가지입니다.
요금제가 하나뿐이거나 유료 등급이 없는 무료 단독 제품이라면, entitlement 매트릭스 자체가 없습니다. 그걸 위한 장치를 세우는 건 순수한 비용이고, 상수가 정답입니다. 두 번째 등급이 생기면 그때 다시 생각해도 늦지 않습니다.
등급이 둘이고, 차이가 기능 boolean 몇 개뿐이고, 패키징이 1년에 한 번쯤 바뀐다면, 이미 쓰는 Flag 도구나 주인이 한 명인 작은 상수 파일이 딱 맞는 크기의 장치입니다. Flag의 본래 용도가 아니더라도요. 옮겨야 할 신호는 구체적입니다. 가격 실험이 배포 뒤에 줄을 서고, 고객 보호와 계약 예외가 관문마다 불어나고, 기록으로 답하지 못한 결제 분쟁이 한 번이라도 생겼을 때입니다. 이 중 둘이 겹치면 entitlement는 이미 코드라는 그릇을 넘어선 겁니다.
Flag는 릴리스의 스위치입니다. entitlement는 계약입니다. 계약에는 기록 시스템이 필요합니다.