차단 규칙으로 KYC 등급별 이체 한도 집행하기
서비스마다 흩어진 KYC 등급별 이체 한도를 한곳의 차단 규칙으로 모으고, 막힌 이체마다 사유가 남게 만드는 패턴.
문제
송금이나 결제를 다루는 서비스는 대개 본인 인증 단계에 따라 이체 한도를 다르게 적용합니다. 인증을 거치지 않은 계좌는 소액까지만, 인증을 마친 계좌는 더 큰 금액까지, 강화 실사까지 끝낸 계좌는 가장 큰 금액까지 보낼 수 있습니다. 이 한도는 엔지니어링이 정하는 값이 아닙니다. 컴플라이언스가 정하고 검토하고, 자기 일정에 맞춰 바꾸는 정책입니다.
문제는 이 한도가 코드 안에 상수로 박힌다는 데서 시작합니다. 이체 서비스 어딘가에 한도 상수가 있고, 금액을 검사하는 if 분기가 그 옆에 붙습니다. 그리고 이 검사가 한 곳에 머물지 않습니다. 모바일 팀은 제출 전에 미리 막아주려고 클라이언트에 같은 숫자를 복제합니다. 1년 뒤 파트너 API가 붙을 때, 그 시점의 한도 값이 코드에 또 한 번 고정됩니다. 같은 한도가 이제 세 곳에 따로 존재하고, 컴플라이언스가 한도를 한 번 바꾸면 세 곳을 동시에 고쳐야 합니다.
그게 안 됩니다. 어느 날 점검에서, 인증을 거치지 않은 계좌가 한 번에 $4,800을 이체한 기록이 나옵니다. 세 경로 중 하나가 낡은 상수를 들고 있었던 겁니다. 곧바로 두 곳에서 질문이 들어옵니다. 지원팀은 "이 고객 이체가 왜 막혔느냐"고 묻고, 컴플라이언스는 "5월 14일에 적용되던 한도가 얼마였고, 그 한도에 막힌 이체를 전부 내놓으라"고 요구합니다. 코드는 둘 중 어느 쪽에도 답하지 못합니다.
이 패턴이 풀려는 문제가 바로 이것입니다. 인증 등급별 한도를 모든 이체 경로에서 단 하나로 유지하고, 몇 달이 지난 뒤에도 어떤 이체가 어떤 한도에 왜 막혔는지 답할 수 있게 만드는 것.
단순한 접근
처음에는 한도를 검사 코드 바로 옆, 이체 서비스의 상수로 둡니다.
public class TransferService {
// KYC 등급별 한도. 컴플라이언스가 관리합니다.
private static final BigDecimal UNVERIFIED_LIMIT = new BigDecimal("1000");
private static final BigDecimal VERIFIED_LIMIT = new BigDecimal("10000");
private static final BigDecimal ENHANCED_LIMIT = new BigDecimal("50000");
// 모바일 BFF는 제출 전에 미리 막아주려고 이 숫자들을
// 따로 복제해 둡니다. 파트너 API는 나중에 붙으면서
// 그 시점의 값으로 굳었습니다.
public void execute(TransferRequest request, Account account) {
BigDecimal amount = request.getAmount();
switch (account.getKycLevel()) {
case UNVERIFIED -> require(amount, UNVERIFIED_LIMIT);
case VERIFIED -> require(amount, VERIFIED_LIMIT);
case ENHANCED -> require(amount, ENHANCED_LIMIT);
// default가 없습니다. 이 switch가 모르는 등급은
// 그냥 빠져나가고, 이체는 검사 없이 진행됩니다.
}
ledger.post(request);
}
private void require(BigDecimal amount, BigDecimal limit) {
if (amount.compareTo(limit) > 0) {
throw new TransferLimitExceededException("이체 한도 초과");
}
}
}
규모가 작을 때는 이대로도 잘 돌아갑니다. 복제된 숫자들도 처음 배포되던 날에는 다 맞는 값이었습니다. 문제는 구조에 있고, 세 군데에서 드러납니다.
- 같은 한도가 여러 곳에 흩어져 있습니다. 서비스가 하나 들고 있고, 모바일 BFF가 사전 검증용으로 하나, 파트너 API가 출시 시점 값으로 하나. 이제 한도를 한 번 바꾸려면 세 코드베이스에 동시에 배포해야 하고, 한 곳을 빠뜨려도 아무 신호가 없습니다. 어디서도 에러가 나지 않고, 한 경로만 조용히 틀린 값을 들고 있습니다.
- 한도를 바꾸려면 배포를 해야 합니다. 컴플라이언스는 시행일을 정하지만, 상수는 배포 일정에 실려 나갑니다. 고객이 실제로 적용받는 한도는 어느 배포가 먼저 나갔는지에 따라 갈리고, 한도가 어떻게 바뀌어 왔는지는 저장소 세 곳의 git 로그에 흩어집니다.
- 차단이 나중에 찾아볼 기록을 남기지 않습니다. 예외 메시지는 로그 한 줄로 흘러갈 뿐입니다. "5월에 UNVERIFIED 한도에 막힌 이체를 전부"는 로그를 파헤치는 작업이 되고, "5월 14일에 적용되던 한도"는 저장소 세 곳을
git blame으로 뒤지는 일이 됩니다.
패턴 정의
해결의 핵심은 한도 판정을 한 번의 호출로 모으는 것입니다. 이체 서비스든 모바일 사전 검증이든 파트너 API든, 모든 경로가 같은 정책 그룹에 묻고, 한도는 딱 한 곳에만 존재합니다.
LexQ에서는 이 시나리오가 세 가지 개념으로 나뉩니다.
- 팩트: 엔진이 읽는 입력값입니다.
kyc_level,transfer_amount_usd. - 규칙: 인증 등급마다 규칙 하나씩. 조건과, 사유 문자열을 담은 차단(
BLOCK) 액션으로 이루어집니다. - 기본 동작은 통과입니다. 차단 액션은 규칙이 매칭됐을 때만 실행됩니다. 어떤 규칙에도 걸리지 않은 이체는 그대로 진행됩니다. 차단이 예외이고, 그 예외만 기록으로 남습니다.
여기에는 상호 배타 그룹(Mutex Group)이 필요 없습니다. 계좌의 인증 등급은 하나뿐이라, 조건들이 입력을 알아서 갈라놓기 때문입니다. 한 이체에 걸릴 수 있는 한도 규칙은 많아야 하나이고, 서로 경쟁할 일이 없습니다.
{
"name": "Block: UNVERIFIED over 1,000",
"condition": {
"type": "GROUP",
"operator": "AND",
"children": [
{
"type": "SINGLE",
"field": "kyc_level",
"operator": "EQUALS",
"value": "UNVERIFIED",
"valueType": "STRING"
},
{
"type": "SINGLE",
"field": "transfer_amount_usd",
"operator": "GREATER_THAN",
"value": 1000,
"valueType": "NUMBER"
}
]
},
"actions": [
{
"type": "BLOCK",
"parameters": {
"reason": "transfer_amount_usd exceeds UNVERIFIED limit 1000"
}
}
],
"isEnabled": true
}
VERIFIED와 ENHANCED 규칙도 모양은 같고 임계값만 다릅니다. 네 번째 규칙은 앞의 셋이 알지 못하는 입력을 맡습니다.
{
"name": "Block: unknown KYC level (fail closed)",
"condition": {
"type": "GROUP",
"operator": "AND",
"children": [
{
"type": "SINGLE",
"field": "kyc_level",
"operator": "NOT_IN",
"value": ["UNVERIFIED", "VERIFIED", "ENHANCED"],
"valueType": "LIST_STRING"
}
]
},
"actions": [
{
"type": "BLOCK",
"parameters": {
"reason": "kyc_level outside known levels"
}
}
],
"isEnabled": true
}
기본 동작이 통과이기 때문에 한도 안쪽 이체는 따로 규칙이 없어도 지나갑니다. 그런데 같은 기본 동작 때문에, 알 수 없는 kyc_level도 아무 검사 없이 통과해 버립니다. 이 규칙이 그 빈틈을 의도된 결정으로 바꿉니다. 모르는 등급은 사유와 함께 막힙니다. 단순한 접근의 switch는 아무도 의식하지 못한 사이에 정반대(통과)를 택하고 있던 셈입니다.
이제 한도는 데이터입니다. 한도를 바꾸는 일은 초안 버전에서 값 하나를 고치는 일이고, "5월 14일에 적용되던 한도"는 git blame이 아니라 버전 이력이 답합니다.
변경 영향 시뮬레이션 전략
컴플라이언스가 VERIFIED 한도를 $10,000에서 $5,000으로 내린다고 합시다. 이 값이 실제 이체에 닿기 전에, 변경이 미칠 파장을 미리 잴 수 있습니다. 새 한도였다면 막혔을 이체가 전체에서 얼마나 되는지입니다. 운영 중인 버전을 복제해 임계값 하나만 고치면, 아직 트래픽을 받지 않는 대상 버전이 됩니다.
이건 금액이 아니라 건수를 묻는 질문이라, 분석 대상 팩트는 필요 없습니다. 그 항목은 비워 둬도 됩니다. 규칙별 통계 포함(includeRuleStats)만 켜면 충분합니다. 규칙마다 매칭률이 나오고, 대상 버전에서 VERIFIED 규칙의 매칭률이 곧 지난달 이체 중 새 한도에 막혔을 비율입니다. 그 숫자가 배포하기 전에 미리 가늠해 보는 지원 문의량이자 고객 불편입니다.
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
}
}'
배포할지 말지는 두 가지로 판단합니다. 첫째, 새로 막히는 비율(대상 버전 VERIFIED 매칭률에서 비교 기준 버전 값을 뺀 차이)이 운영팀이 감당하기로 한 선 안에 들어와야 합니다. 둘째, 나머지 등급의 매칭률은 조금도 움직여선 안 됩니다. 이번 수정은 임계값 하나만 건드렸다고 했고, 규칙별 통계가 그 말이 사실인지 확인해 줍니다. 시뮬레이션은 외부 호출을 모킹으로 대체합니다. 과거 데이터를 읽기만 할 뿐 아무것도 쓰지 않습니다.
아직 운영 트래픽이 없다면, 대표 데이터셋을 올려 같은 비교를 돌리면 됩니다. 모든 등급이 들어가고 각 임계값 언저리에 금액이 몰린 이체로 구성하면 충분합니다.
의사결정 트레이스 출력
컴플라이언스 점검에서 나왔던 그 케이스, UNVERIFIED 계좌의 $4,800 이체를 드라이런으로 돌려 봅니다.
{
"result": "SUCCESS",
"data": {
"traceId": "c41a8f2e-...",
"inputFacts": {
"kyc_level": "UNVERIFIED",
"transfer_amount_usd": 4800.00
},
"mutatedFacts": {},
"generatedVariables": {
"is_blocked": true,
"block_reason": "transfer_amount_usd exceeds UNVERIFIED limit 1000"
},
"executionTraces": [ ... ],
"decisionTraces": [
{
"ruleName": "Block: UNVERIFIED over 1,000",
"status": "SELECTED",
"reasonCode": "FINAL_WINNER",
"reasonDetail": null
},
{
"ruleName": "Block: VERIFIED over 10,000",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "Block: ENHANCED over 50,000",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
},
{
"ruleName": "Block: unknown KYC level (fail closed)",
"status": "NO_MATCH",
"reasonCode": "CONDITION_MISMATCH",
"reasonDetail": null
}
]
}
}
mutatedFacts는 비어 있습니다. 차단은 어떤 팩트도 바꾸지 않으니까요. 차단 결과는 generatedVariables에 담깁니다. is_blocked가 true이고, block_reason에 매칭된 규칙의 사유 문자열이 들어 있습니다. 두 키 모두 시스템이 만들어 내며, 차단 액션이 실행됐을 때만 생깁니다. 아무것도 막지 않았다면 false가 아니라 키 자체가 없습니다. 애플리케이션이 지켜야 할 규약은 여기서 바로 나옵니다. is_blocked가 true면 거절하고, block_reason(또는 거기에 대응시킨 메시지)을 보여 줍니다. 매칭된 규칙은 의사결정 트레이스에 선택됨(SELECTED) · 최종 승자(FINAL_WINNER)로 남고, 이 규칙이 어떤 조건식으로 평가됐는지는 위 드라이런 화면의 실행 트레이스 표에서 함께 볼 수 있습니다. 지원팀에는 사유 문자열이 답이고, 컴플라이언스에는 트레이스가 답입니다. 어떤 규칙이, 어느 버전에서, 무슨 입력으로, 언제.
VERIFIED 계좌의 $900 이체를 돌리면 모든 규칙이 미매칭(NO_MATCH) · 조건 미충족(CONDITION_MISMATCH)으로 남습니다. 두 맵 다 빈 객체로 돌아오고, 애플리케이션은 이체를 진행합니다. is_blocked가 없다는 것 자체가 승인입니다.
엣지 케이스
이 패턴의 핵심은 특정 한도 값이 아니라 단일 집행 지점입니다. 그 언저리의 몇 가지 경우는 의식적인 선택을 요구합니다.
- 한도와 딱 같은 금액의 이체.
GREATER_THAN은 UNVERIFIED에서 $1,000.00 이체를 통과시킵니다. "한도"를 허용되는 최대 금액으로 본 것입니다. 정책이 "그 금액부터 막는다"라면 연산자는GREATER_THAN_OR_EQUAL입니다. 한 번 정해 규칙에 적어 두면, 기록된 조건식이 지금 어느 해석으로 돌고 있는지 보여 줍니다. - 알 수 없는 인증 등급. 어떤 앱 버전이
kyc_level을PENDING으로 보내기 시작합니다. 네 번째 규칙이 없으면 이 이체는 아무 데도 걸리지 않고 지나갑니다. 실수로 열린 통과입니다.NOT_IN규칙이 같은 입력을 기록 남는 차단으로 바꿉니다. 닫아 두는 것 자체가 하나의 정책 결정이고, 규칙은 그 결정을 암묵이 아니라 명시로 만듭니다. - 빠진 팩트.
kyc_level이 없는 페이로드는 조용히 지나가지 않습니다. 엔진은 빠진 팩트 이름을 담아 에러를 던지고, 임의의 기본값으로 메우지 않습니다. 호출하는 쪽은 에러로 끝난 실행을 미승인으로 처리해야 합니다. 엔진이 추측을 거부했으니, 애플리케이션이 대신 추측해서도 안 됩니다. - 누적 한도. 이 패턴은 한 건의 이체를 제한합니다. 하루 합계나 이동 합계를 보려면 누적 팩트(
INCREMENT_FACT)와 시간 창이 필요하고, 이건 실패하는 방식이 다른 별도 패턴입니다. 여기서는 다루지 않습니다. - 여러 통화. 규칙은 한 가지 단위로 표현된 숫자 팩트 하나만 비교합니다. 환산은 윗단에서 끝내고 한 통화로 보냅니다. 팩트 키의
_usd접미사가 바로 그 약속을 드러냅니다. 통화가 뒤섞인 팩트는 모든 비교를 무의미하게 만듭니다.
운영 배포
두 조건을 통과한 대상 버전은 배포해서 운영에 올립니다. 배포 순간의 규칙 스냅샷은 해시로 봉인되어 무결성이 검증되고, 누가 언제 어떤 버전을 올렸는지가 기록으로 남습니다.
한도 변경은 트래픽을 쪼개 조금씩 적용하지 않고, 한 번에 전체 트래픽으로 배포합니다. A/B 테스트로 트래픽을 나누면 일부 이체는 새 한도로, 나머지는 이전 한도로 판정됩니다. 같은 인증 등급에서 같은 금액을 보내는 두 계좌가 서로 다른 결과를 받는다는 뜻입니다. 부분 적용을 견디는 할인이라면 그 정도 절충은 괜찮지만, 컴플라이언스 한도에서는 들쭉날쭉한 집행 자체가 결함이지 안전장치가 아닙니다. 배포해도 된다는 확신은 트래픽 분할이 아니라 변경 영향 시뮬레이션에서 옵니다. 배포가 더해 주는 건 운영 데이터로 하는 확인뿐입니다. 규칙별 라이브 차단율을 시뮬레이션 매칭률과 맞대어 봅니다.
다음 두 신호 중 하나라도 보이면 곧바로 롤백합니다.
- 바꾼 규칙의 라이브 차단율이 시뮬레이션 매칭률에서 벗어날 때. 운영 트래픽이 시뮬레이션에 쓴 과거 구간과 다르다는 뜻이고, 승인했던 파장 범위는 더 이상 유효하지 않습니다.
- 시뮬레이션이 변동 없음이라고 본 등급에서 차단이 나타날 때. 운영에 들어오는
kyc_level팩트가 데이터셋에 담겼던 것과 다르다는 뜻입니다.
롤백하면 정책 그룹이 이전 버전으로 돌아가고, 롤백했다는 사실까지 배포 이력에 남습니다. 대상 버전이 전체 트래픽을 받기 시작하면, 규칙별 통계가 컴플라이언스 질문에 대한 상시 답이 됩니다. 등급별로 몇 건이 막혔는지는 규칙과 버전으로 범위를 좁힌 실행 이력 조회면 됩니다. 로그를 파헤칠 일이 아닙니다. 그리고 "5월 14일에 적용되던 한도"는 버전 이력과 배포 기록에 그대로 있습니다.
LexQ가 어떻게 동작하는지 playground에서 직접 확인해보세요.