Config 파일, Feature Flag, 아니면 다른 무엇? 규모에 맞는 비즈니스 로직 관리
config 파일은 할인율을, feature flag는 할인받을 고객까지 담습니다. 규칙 전체를 담으려면 규칙 테이블이 필요하고, 규칙이 자주 바뀌고 이유를 묻는 사람이 생기면 버전, 지난 주문으로 돌려 보는 시뮬레이션, 결정 기록까지 직접 만들거나 규칙 서비스로 옮겨야 합니다.
config 파일은 할인율을 담고, feature flag(배포 없이 기능을 켜고 끄는 스위치)는 그 할인을 누가 받는지까지 담을 수 있습니다. 할인을 어떻게 계산하는지까지 포함해 규칙 전체를 담으려면 규칙 테이블이 필요하고, 규칙이 자주 바뀌고 주문이 왜 그 할인을 받았는지 묻는 사람이 생기면 테이블에는 없는 버전 관리, 지난 주문으로 미리 돌려 보는 시뮬레이션, 결정마다 남는 기록까지 갖춰야 해서, 이 셋을 직접 만들거나 규칙을 통째로 맡아 돌려 주는 서비스로 옮겨야 합니다.
한 이커머스 사이트에 할인 규칙이 둘 있습니다. VIP 고객은 $100 이상 주문하면 20%를 할인받습니다. 휴일 세일 기간에는 고객 등급과 상관없이 모든 주문이 15% 할인 대상입니다. 두 할인은 함께 받을 수 없고, 둘 다 해당하면 휴일 할인이 우선합니다.
지금은 두 할인율이 config 파일인 application.yml에 들어 있고, 둘 중 어느 할인을 줄지는 체크아웃 코드의 if가 정합니다. 마케팅팀은 배포를 기다리지 않고 이 규칙을 바꾸고 싶어 합니다. 고객 지원팀은 VIP 고객이 $150어치를 주문하고도 왜 20% 할인($30.00)이 아니라 15% 할인($22.50)을 받았는지 묻습니다.
할인 규칙을 어디에 둘지 고르는 표
| 고를 때 | 바꾼 규칙이 운영에 반영되는 방법 | 「이 주문은 왜 15%였나요?」에 답할 자료 | 팀이 따로 맡는 일 | |
|---|---|---|---|---|
| config 파일 | 할인율이 한 해에 몇 번 바뀌고 조건은 거의 그대로일 때 | 배포 (또는 설정 다시 읽기) | 그날 배포된 코드와 로그 | 새로 생기는 일 없음 |
| feature flag | 프로모션을 일부 고객에게 몇 주 동안 내보내고, 끝나면 플래그를 지울 때 | 플래그 변경. 일부 사용자에게 먼저 켜기도 함 | 플래그 변경 기록과 체크아웃 코드 | 끝난 플래그 지우기 |
| 직접 만든 규칙 테이블 | 마케팅팀이 조건을 바꾸지만 규칙이 몇 개뿐이고, 엔지니어 한 명이 맡고, 아직 이유를 묻는 사람이 없을 때 | 행 수정 | 코드가 남긴 로그만큼 | 저장한 조건을 읽어 계산하는 평가기와 관리 화면. 규칙이 자주 바뀌고 이유를 묻는 사람이 생기면 버전, 시뮬레이션, 결정 기록까지 |
| 규칙 서비스 (이 블로그를 내는 LexQ) | 마케팅팀이 조건을 자주 바꾸고, 프로모션이 겹치고, 고객 지원팀이나 재무팀이 이유를 물을 때 | 지난 주문에 시뮬레이션해 본 새 규칙 버전을 바로 또는 예약해서 배포. 애플리케이션 배포는 없음 | LexQ가 그 주문에 남긴 결정 기록 | 규칙, API 호출 하나, 주문마다 결정 기록 ID(traceId) 저장 |
아래 줄로 갈수록 코드 밖으로 꺼내는 것이 늘어납니다. 할인율, 받는 고객, 규칙 전체, 그리고 버전과 기록 순입니다. 「고를 때」가 지금 팀의 사정과 맞는 줄 가운데 가장 위의 줄을 고르고, 마지막 열은 그 줄을 고른 팀이 새로 맡게 되는 일로 읽으세요.
config 파일에 두는 것: 할인율
application.yml에 들어 있는 것은 0.20과 0.15, 숫자 두 개뿐입니다. 고객이 VIP인지, 주문이 $100 이상인지, 지금이 휴일 세일 기간인지는 그 숫자를 읽는 Java 코드가 따집니다.
// 할인율 둘은 application.yml에 있습니다. 어느 쪽을 쓸지는 여기서 정합니다.
double rate = 0;
if (campaigns.isActive("HOLIDAY")) {
rate = discounts.holidayRate(); // 0.15
} else if (customer.tier() == Tier.VIP && order.subtotal() >= 100) {
rate = discounts.vipRate(); // 0.20
}
할인율은 config 파일 한 줄만 고치면 되지만, 보통은 그것도 배포해야 체크아웃에 반영됩니다. Spring 앱이라면 할인율을 Spring Cloud Config 같은 설정 서버에 두어 배포를 건너뛸 수 있습니다. 앱의 refresh 엔드포인트를 부르면 @RefreshScope를 붙인 bean이 새 값을 다시 읽어 들입니다. 지금 order.subtotal() >= 100으로 코드에 박힌 VIP 기준 금액도 설정으로 빼면 같은 방법으로 바꿀 수 있습니다. 그래도 조건을 하나 더하거나, 두 할인이 겹칠 때 어느 쪽을 줄지 바꾸려면 Java 코드를 고쳐야 하고, 마케팅팀은 그 배포를 기다려야 합니다.
고객 지원팀의 질문에 답할 때도 코드를 열어야 합니다. 엔지니어는 그 주문이 들어온 날 배포돼 있던 코드에서 어느 if가 먼저였는지 확인하고, 그날 휴일 세일이 켜져 있었는지 로그에서 찾아 둘을 맞춰 봐야 합니다.
할인율은 가끔 바뀌고 조건은 거의 그대로인 팀이라면 여기서 멈춰도 됩니다. 마케팅팀이 캠페인마다 조건을 더하거나 두 할인의 우선순위를 바꾸고 싶어 한다면, 배포 없이 비즈니스 규칙을 바꾸는 방법과 그 비용을 먼저 견줘 보세요.
feature flag가 더하는 것: 할인을 받는 고객
feature flag도 체크아웃 코드 안에서는 if 하나입니다. 다른 점은 그 if가 받는 답을 플래그 도구가 준다는 것이고, 그 답은 배포 없이 도구에서 바꿀 수 있습니다. LaunchDarkly와 Unleash의 공식 문서를 보면, 그 답은 켜짐과 꺼짐 말고도 숫자나 JSON일 수 있습니다. LaunchDarkly에서는 플래그가 돌려주는 값을 variation이라고 부르고, 그 타입에 boolean 외에 string, number, JSON이 있습니다. Unleash는 플래그가 돌려주는 값을 variant라고 부르고, variant에 싣는 데이터(payload)로 string, json, csv, number를 쓸 수 있습니다. 값을 누구에게 줄지 정하는 조건에서도 숫자를 비교할 수 있습니다. LaunchDarkly의 targeting rule에서는 >, <, >=, <=를 쓰고, Unleash의 constraint에서는 NUM_GT와 NUM_GTE 같은 연산자를 씁니다. 그래서 체크아웃 코드가 고객 등급과 주문 금액을 넘겨 주면, 「VIP 고객이고 $100 이상이면 0.20」을 플래그 하나가 돌려줄 수 있습니다.
두 도구 모두 플래그를 일부 사용자에게 먼저 켜 볼 수 있습니다(LaunchDarkly의 percentage rollout, Unleash의 gradual rollout). 누가 언제 플래그를 바꿨는지도 두 도구에서 찾아볼 수 있습니다(LaunchDarkly의 change history, Unleash의 event log).
그래도 할인 계산은 체크아웃 코드에 남습니다. 플래그는 할인율을 돌려줄 뿐이고, 그 할인율을 주문 금액에 곱하는 일은 코드가 합니다. 휴일 할인을 따로 플래그로 두면 둘 중 무엇을 쓸지 체크아웃 코드가 고릅니다. 플래그 하나에 둘 다 담으면, LaunchDarkly는 규칙을 위에서부터 차례로 따지므로 휴일 규칙을 VIP 규칙 위에 두어 플래그가 고르게 할 수 있습니다. 어느 쪽이든 「이 주문은 왜 15%였나요?」에 답하려면 두 군데를 봐야 합니다. 그날 플래그가 어떤 상태였는지는 플래그 변경 기록에 있고, 할인을 어떻게 계산했는지(플래그가 둘이면 어느 쪽을 먼저 따졌는지도)는 그날 배포된 체크아웃 코드를 열어 봐야 압니다. LaunchDarkly SDK는 값과 함께 어느 규칙이 그 값을 냈는지 알려 주는 evaluation reason도 돌려주지만, 그것을 주문과 이어 두려면 체크아웃 코드가 주문과 함께 저장해야 합니다. 또 evaluation reason에는 값을 낸 규칙 하나만 적히고, 함께 조건이 맞았지만 순서에서 밀린 규칙은 적히지 않습니다.
피트 호지슨(Pete Hodgson)은 martinfowler.com에 실린 「Feature Toggles (aka Feature Flags)」(2017-10-09)에서 플래그를 쓰임새에 따라 나눕니다. 아직 끝나지 않은 코드를 꺼 둔 채 운영에 내보내는 플래그(release toggle)는 대개 한두 주보다 오래 두지 말아야 하고, 특정 사용자에게만 기능을 열어 주는 플래그는 아주 오래 남는다고 적습니다. 어느 쪽이든 노련한 팀은 플래그를 갖고 있는 동안 계속 비용이 드는 재고로 본다고 덧붙입니다.
VIP 할인처럼 끝나는 날이 없는 규칙을 플래그로 두면 지울 날이 오지 않는 재고를 계속 들고 있게 되고, 할인 계산은 그대로 체크아웃 코드에 남습니다. 플래그에 맞는 일은 휴일 세일 같은 프로모션 하나를 일부 고객에게 먼저 켜 보고 세일이 끝나면 플래그를 지우는 일입니다.
이 절에서 두 도구에 대해 쓴 내용은 2026년 10월 4~6일에 읽은 아래 문서에서 확인했습니다.
- LaunchDarkly: Creating flag variations, Targeting rules, Feature flag hierarchy, Percentage rollouts, Change history, Evaluation reasons
- Unleash: Strategy variants, Activation strategies, Events
직접 만든 규칙 테이블에 두는 것: 규칙 전체
규칙 전체를 코드 밖으로 꺼내려면 조건, 할인 방식, 우선순위를 열로 둔 rules 테이블에 규칙을 행으로 넣고 관리 화면을 붙입니다. 「VIP 고객, $100 이상, 20%」가 한 행이 되고 「휴일 세일 중, 15%」가 또 한 행이 됩니다. 마케팅팀은 관리 화면에서 행을 고쳐 규칙을 바꾸고, 그 변경은 배포 없이 운영에 반영됩니다.
행 두 개와 관리 화면은 금방 만들지만, 그 뒤로 다섯 가지가 따라옵니다.
- 행에 저장한 조건(JSON이든 식 문자열이든)을 안전하게 읽고 계산하는 평가기
- VIP 할인과 휴일 할인의 조건이 둘 다 맞을 때 어느 쪽이 이기는지, 둘을 같이 주면 안 되는지 정하는 우선순위 처리
- 누가 운영 중인 행을 잘못 고쳤을 때 이전 값과 고친 사람을 보여 주고 한 번에 되돌리는 버전 관리
- 바꾼 휴일 할인율이 지난달 주문에 무엇을 했을지 미리 보여 주는 배포 전 시뮬레이션
- 주문마다 어느 행이 맞았고 무엇이 이겼는지, 그때 어느 버전이 운영 중이었는지 남기는 결정 기록
저는 전 직장에서 2022년부터 2025년까지 이런 방식의 시스템을 직접 개발하고 유지보수했습니다. 행 하나하나는 혈압이나 혈당 측정값을 정상, 이상, 위험으로 가르는 건강 알림 규칙이었습니다. 평가기는 Java 라이브러리로 만들었습니다. 평가기 자체는 괜찮았고, 규칙은 우선순위 순서대로 돌았습니다. 하지만 두 규칙의 조건이 함께 맞을 때 그중 하나만 적용하는 방법은 없었습니다. 버전도, 배포 전 시뮬레이션도, 결정 기록도 없었습니다. 나중에 감사 기능 같은 것이 필요해져, 누가 언제 마지막으로 행을 고쳤는지 남기는 updated_at과 updated_by 같은 열을 넣었습니다. 다른 기능을 위한 것도 계속 더하다 보니 시스템이 커졌고, 다른 사람들은 손대기 어려워했습니다. 개발과 유지보수는 결국 저 혼자 맡게 됐습니다. 그만두면서 인수인계는 했지만, 남은 사람들은 쓰기 힘들어하는 눈치였습니다.
다섯을 다 갖추면 체크아웃 팀은 할인 두 개를 위한 작은 규칙 엔진을 직접 운영하게 됩니다. 어차피 엔진이 필요하다면 라이브러리를 써서 직접 만들지, 클라우드 서비스에 맡길지 정해야 합니다. 둘 사이에서 고르는 기준은 Drools 직접 운영과 클라우드 룰 엔진 비교에 정리돼 있습니다.
LexQ로 같은 주문 계산하기
표 마지막 줄의 LexQ는 이 두 할인 같은 비즈니스 규칙을 데이터로 저장해 실행하고, 주문마다 결정 기록을 남기는 의사결정 운영 플랫폼입니다. 이런 플랫폼이 무엇을 맡고 언제 필요해지는지는 의사결정 운영 플랫폼이 맡는 일과 필요해지는 때에서 따로 다룹니다.
규칙은 정책 그룹에 들어 있습니다. 정책 그룹은 체크아웃 할인처럼 한 가지 일을 맡는 LexQ의 규칙 묶음이고, 규칙을 바꿀 때마다 그룹에 새 버전이 생깁니다. 새 버전은 LexQ 콘솔이나 API에서 초안으로 고치고, 발행하면 잠깁니다. 발행한 버전을 배포하면 체크아웃의 다음 호출부터 그 버전이 쓰이고, 애플리케이션은 다시 배포하지 않습니다.
정책 그룹에는 버전을 둘 둡니다. 평소 버전에는 VIP 20% 규칙 하나만 있습니다. 휴일 버전은 평소 버전을 복제해 모든 주문에 맞는 Holiday 15% 규칙을 더한 새 버전입니다. 이 버전에서는 두 규칙을 같은 상호 배타 그룹(Mutex Group) one-discount에 넣습니다. 상호 배타 그룹에 든 규칙은 조건이 여럿 맞아도 그중 하나만 실행되고, 여기서는 우선순위가 높은 규칙이 이깁니다. 휴일 규칙의 우선순위를 1, VIP 규칙을 2로 줍니다. 우선순위는 숫자가 작을수록 높습니다.
마케팅팀은 휴일 버전의 유효 기간을 세일 첫날과 마지막 날로 정하고 예약 배포를 걸어 둡니다. 예약 시각은 따로 고르지 않습니다. LexQ가 유효 기간이 시작되는 시각을 예약 시각으로 씁니다. 그 시각에 배포를 실행하는 스케줄러는 1분마다 돌아서, 휴일 버전이 운영에 올라가기까지 최대 1분쯤 늦을 수 있습니다. 끝날 때는 기다리지 않습니다. LexQ는 호출마다 운영 중인 버전의 유효 기간을 확인하고, 기간이 지났으면 발행된 버전 가운데 아직 기간 안에 있는 가장 최근 버전, 여기서는 평소 버전으로 계산합니다. 시작에는 이 확인이 통하지 않습니다. LexQ가 이 확인으로 하는 일은 운영 중인 버전에서 물러서는 것뿐이고, 휴일 버전은 스케줄러가 배포하기 전까지 운영 중이 아니기 때문입니다. 휴일 버전을 만료로 바꾸고 평소 버전을 다시 배포하는 일은 다음 스케줄러 차례에 하고, 두 번 모두 배포 이력에 System이 한 배포로 남습니다.
그래서 체크아웃 코드는 할인율도 세일 여부도 고르지 않고 fact(정책 평가의 입력 변수) 두 개만 보냅니다. 고객 등급 loyaltyTier와 주문 금액 purchaseSubtotalUsd입니다. 주소의 {groupId}가 이 정책 그룹의 ID입니다.
curl -X POST https://api.lexq.io/api/v1/execution/groups/{groupId} \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"facts": {
"loyaltyTier": "VIP",
"purchaseSubtotalUsd": 150.00
}
}'
휴일 세일 중에 VIP 고객이 $150어치를 주문했을 때 돌아오는 응답입니다. ID는 직접 호출해 보면 다른 값이 나오므로 줄였습니다. 규칙마다 조건이 맞았는지를 적는 executionTraces는 고객 지원팀의 질문에 답하는 데 쓰지 않아서 [ ... ]로 접었습니다.
{
"result": "SUCCESS",
"data": {
"traceId": "...",
"inputFacts": {
"loyaltyTier": "VIP",
"purchaseSubtotalUsd": 150.00
},
"mutatedFacts": {
"purchaseSubtotalUsd": 127.50
},
"generatedVariables": {
"purchaseSubtotalUsd__delta": -22.50
},
"executionTraces": [ ... ],
"decisionTraces": [
{
"ruleId": "90cda54b-...",
"ruleName": "Holiday 15%",
"policyGroupId": "30f577e3-...",
"policyVersionId": "3a823789-...",
"status": "SELECTED",
"reasonCode": "FINAL_WINNER",
"reasonDetail": null
},
{
"ruleId": "944329a0-...",
"ruleName": "VIP 20%",
"policyGroupId": "30f577e3-...",
"policyVersionId": "3a823789-...",
"status": "BLOCKED",
"reasonCode": "MUTEX_PRIORITY_LOST",
"reasonDetail": "Winner=[Holiday 15%], Strategy=HIGHEST_PRIORITY"
}
]
}
}
이긴 규칙은 주문 금액 purchaseSubtotalUsd를 할인 뒤 금액으로 바꿔 돌려줍니다. mutatedFacts의 127.50이 그 값이고, generatedVariables의 -22.50은 할인액입니다. 이 __delta 값은 LexQ가 직접 붙입니다. 규칙이 바꾼 fact마다 바뀐 만큼을 부호와 함께 적으므로, 체크아웃은 할인액을 여기서 읽으면 됩니다. 고객 지원팀의 질문에 답할 자료는 규칙마다 적용 여부와 이유를 적은 의사결정 트레이스(decisionTraces)입니다.
두 번째 항목에서 VIP 20% 규칙은 조건이 맞았는데도 상태가 BLOCKED(경쟁 탈락)입니다. 사유 코드 MUTEX_PRIORITY_LOST는 같은 상호 배타 그룹 안에서 우선순위에 밀렸다는 뜻이고, reasonDetail에는 이긴 규칙의 이름 Holiday 15%까지 적혀 있습니다. 첫 번째 항목에서 휴일 규칙의 상태는 SELECTED(선택됨), 사유 코드는 FINAL_WINNER(최종 승자)입니다. 두 항목의 policyVersionId가 휴일 버전을 가리키므로, 고객 지원팀은 이 트레이스만 보고 그날 휴일 버전이 운영 중이었고 두 할인 가운데 우선순위가 높은 휴일 할인이 VIP 할인을 이겼다고 답할 수 있습니다. 세일이 끝난 뒤 같은 주문은 평소 버전에서 VIP 20%를 받고, 할인 뒤 금액은 $120.00, 할인액은 $30.00입니다.
나중에 이 기록을 찾으려면 체크아웃 코드가 주문과 함께 traceId를 저장해 두고, 고객 지원팀은 LexQ 콘솔의 실행 이력 화면에서 그 ID로 검색합니다. LexQ는 이 기록을 결정과 함께 저장합니다. 기록을 저장하지 못하면 결정을 돌려주는 대신 AN-029 오류로 호출을 실패시키고, 오류 문서는 이때 다시 호출하라고 안내합니다. 다시 호출해도 실패하면 할인 없이 주문을 받을지 멈출지는 체크아웃 쪽에서 정합니다. 결정 기록이 남은 버전은 지우려 하면 AN-028 오류가 나서, 기록이 가리키는 버전을 나중에도 열어 볼 수 있습니다.
세일이 진행되는 도중에 마케팅팀이 VIP 고객에게는 20%를 주기로 정했다면, 운영 중인 휴일 버전을 복제한 새 초안에서 VIP 규칙의 우선순위를 1로, 휴일 규칙을 2로 바꾸고, 같은 유효 기간을 다시 넣습니다. 복제는 유효 기간을 옮겨 오지 않습니다. 배포하기 전에는 새 버전을 대상 버전으로, 지금 운영 중인 버전을 비교 기준 버전으로 두고, LexQ가 이미 처리한 주문이나 CSV나 JSON 파일로 올린 지난 주문에 함께 돌려 보는 변경 영향 시뮬레이션(Impact Simulation)을 실행합니다. 분석 대상 fact는 시뮬레이션이 두 버전의 결과에서 맞대 볼 fact입니다. 규칙이 할인 뒤 금액으로 바꿔 둔 purchaseSubtotalUsd를 분석 대상 fact로 두면, 결과에는 할인 뒤 금액이 바뀐 주문 수와, 그중 오른 건수와 내린 건수, 가장 크게 움직인 한 건이 나옵니다. 이번 변경에서는 VIP 고객의 휴일 주문이 20% 할인을 받게 되므로 「내린 건수」로 잡힙니다.
결과를 확인한 뒤 콘솔에서 배포합니다. 바꾼 뒤 할인액이 예상보다 커지면 롤백 한 번(콘솔, 또는 LexQ CLI의 lexq deploy rollback)으로 휴일 할인이 이기던 버전으로 돌아가고, 롤백도 배포 이력에 한 건으로 쌓입니다. 시뮬레이션을 돌리고 결과를 읽는 순서는 변경 영향 시뮬레이션으로 배포 전 규칙 변경 테스트하기에 있습니다.
LexQ가 필요 없는 경우
VIP 할인율을 한 해에 두어 번 바꾸고 $100 기준과 휴일 할인이 우선하는 순서는 그대로 둔다면, config 파일과 평소의 배포로 충분하고, 표의 다른 어느 줄보다 비용이 적게 듭니다. 휴일 세일이 한 번 내보내고 1월에 지울 프로모션이라면 플래그가 맞습니다.
마케팅팀이 조건을 직접 바꿔야 하지만 할인이 VIP와 휴일 둘에서 더 늘지 않고, 두 할인 규칙을 엔지니어 한 명이 맡으며, 어느 주문이 왜 그 할인을 받았는지 고객 지원팀이 묻지 않는다면 규칙 테이블을 직접 만드세요. 행이 두 개라면 한 사람이 맡을 만한 크기로 남습니다.