LexQLexQ
패턴 목록으로

차단 규칙으로 KYC 등급별 이체 한도 집행하기

서비스마다 흩어진 KYC 등급별 이체 한도를 한곳의 차단 규칙으로 모으고, 막힌 이체마다 사유가 남게 만드는 패턴.

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

문제

송금이나 결제를 다루는 서비스는 대개 본인 인증 단계에 따라 이체 한도를 다르게 적용합니다. 인증을 거치지 않은 계좌는 소액까지만, 인증을 마친 계좌는 더 큰 금액까지, 강화 실사까지 끝낸 계좌는 가장 큰 금액까지 보낼 수 있습니다. 이 한도는 엔지니어링이 정하는 값이 아닙니다. 컴플라이언스팀이 정하고 검토하고, 자기 일정에 맞춰 바꾸는 정책입니다.

문제는 이 한도가 코드 안에 상수로 박힌다는 데서 시작합니다. 이체 서비스 어딘가에 한도 상수가 있고, 금액을 검사하는 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에서 등급별 한도 집행은 다음 세 조각으로 표현됩니다.

  • fact: 엔진이 이체 한 건을 판정하려고 받는 입력입니다. 계좌의 인증 등급 kycLevel과 이체 금액 transferAmountUsd가 여기 해당합니다.
  • 규칙: 인증 등급 하나에 규칙 하나가 대응합니다. 등급과 금액을 함께 보는 조건에, 어느 한도를 넘겼는지 적은 사유 문자열을 실은 차단(BLOCK) 액션이 붙습니다.
  • 기본값은 통과입니다. 차단 액션은 규칙 조건에 걸린 이체에만 실행됩니다. 어떤 규칙에도 걸리지 않은 이체는 그대로 진행됩니다. 차단이 예외이고, 그 예외만 기록으로 남습니다.

이 구성에는 상호 배타 그룹(Mutex Group)을 쓰지 않습니다. 계좌의 인증 등급은 하나뿐이라, 한 이체에 걸리는 한도 규칙은 많아야 하나입니다. 고를 후보가 둘 이상 생기지 않습니다. 규칙 여럿이 한 결정을 두고 경쟁하는 경우라면, 상호 배타 그룹이 하나를 고르고 나머지에 무슨 일이 있었는지 남깁니다. 그 동작은 VIP 등급 할인 중첩 해소가 따로 다룹니다.

{
  "name": "Block: UNVERIFIED over 1,000",
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      {
        "type": "SINGLE",
        "field": "kycLevel",
        "operator": "EQUALS",
        "value": "UNVERIFIED",
        "valueType": "STRING"
      },
      {
        "type": "SINGLE",
        "field": "transferAmountUsd",
        "operator": "GREATER_THAN",
        "value": 1000,
        "valueType": "NUMBER"
      }
    ]
  },
  "actions": [
    {
      "type": "BLOCK",
      "parameters": {
        "reason": "transferAmountUsd exceeds UNVERIFIED limit 1000"
      }
    }
  ],
  "isEnabled": true
}

VERIFIED와 ENHANCED 규칙도 모양은 같고 임계값만 다릅니다. 네 번째 규칙은 앞의 셋이 알지 못하는 입력을 맡습니다.

{
  "name": "Block: unknown KYC level (fail closed)",
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      {
        "type": "SINGLE",
        "field": "kycLevel",
        "operator": "NOT_IN",
        "value": ["UNVERIFIED", "VERIFIED", "ENHANCED"],
        "valueType": "LIST_STRING"
      }
    ]
  },
  "actions": [
    {
      "type": "BLOCK",
      "parameters": {
        "reason": "kycLevel outside known levels"
      }
    }
  ],
  "isEnabled": true
}

기본 동작이 통과이기 때문에 한도 안쪽 이체는 따로 규칙이 없어도 지나갑니다. 문제는 그 기본 동작이 처음 보는 kycLevel에도 똑같이 적용된다는 점입니다. 등급을 읽지 못한 이체가 한도 검사를 건너뛴 채 나갑니다. 이 규칙이 그 빈틈을 의도된 결정으로 바꿉니다. 모르는 등급은 사유와 함께 막힙니다. 단순한 접근의 switch는 아무도 의식하지 못한 사이에 정반대(통과)를 택하고 있던 셈입니다.

KYC 등급별 차단 규칙 3개와 미지 등급을 닫는 규칙 1개

이제 한도는 데이터입니다. 컴플라이언스팀이 한도를 내려도 고칠 곳은 초안 버전의 숫자 하나이고, "5월 14일에 적용되던 한도"는 그날 운영에 올라 있던 버전이 그대로 답입니다.

변경 영향 시뮬레이션 전략

컴플라이언스팀이 VERIFIED 한도를 $10,000에서 $5,000으로 내린다고 합시다. 이 값이 실제 이체에 닿기 전에, 변경이 미칠 파장을 미리 잴 수 있습니다. 새 한도였다면 막혔을 이체가 전체에서 얼마나 되는지입니다. 운영 중인 버전을 복제해 임계값 하나만 고치면, 아직 트래픽을 받지 않는 대상 버전이 됩니다. 이 비교 자체를 정면으로 다루는 패턴은 배포 전 규칙 변경 검증이고, 여기서는 그보다 좁은 질문에 씁니다.

묻는 것은 막힌 금액의 합계가 아니라 막힌 이체의 건수입니다. 그래서 분석 대상 fact 항목은 비워 둡니다. 켜야 할 설정은 규칙별 통계 포함(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 매칭률에서 비교 기준 버전 값을 뺀 차이)이 운영팀이 감당하기로 한 선 안에 들어와야 합니다. 둘째, 나머지 등급의 매칭률은 조금도 움직여선 안 됩니다. 이번 수정은 임계값 하나만 건드렸다고 했고, 규칙별 통계가 그 말이 사실인지 확인해 줍니다. 이 비교는 지난달 이체 기록을 다시 판정해 볼 뿐, 원장에도 운영 중인 한도에도 손대지 않습니다.

5,000 대상 버전과 10,000 비교 기준 버전을 비교한 변경 영향 시뮬레이션 결과

운영 로그가 아직 쌓이지 않았다면 이체 표본을 데이터셋으로 올려 같은 비교를 돌립니다. 모든 등급이 들어가고 각 임계값 언저리에 금액이 몰린 이체로 구성하면 충분합니다.

의사결정 트레이스 출력

규제 준수 점검에서 나왔던 그 케이스, UNVERIFIED 계좌의 $4,800 이체를 드라이런으로 돌려 봅니다.

{
  "result": "SUCCESS",
  "data": {
    "inputFacts": {
      "kycLevel": "UNVERIFIED",
      "transferAmountUsd": 4800.00
    },
    "mutatedFacts": {},
    "generatedVariables": {
      "isBlocked": true,
      "blockReason": "transferAmountUsd 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
      }
    ]
  }
}
차단된 4,800 UNVERIFIED 이체의 드라이런 결과

mutatedFacts에는 아무것도 들어 있지 않습니다. 이체를 막는 일은 어떤 fact의 값도 건드리지 않습니다. 차단 결과는 generatedVariables에 담깁니다. isBlockedtrue이고, blockReason에 매칭된 규칙의 사유 문자열이 들어 있습니다. 이 두 키는 이체 서비스가 보낸 입력이 아니라 차단 액션이 실행된 순간 엔진이 붙인 값입니다. 아무것도 막지 않았다면 false가 아니라 키 자체가 없습니다. 이체 서비스가 따를 규약도 여기서 그대로 정해집니다. isBlockedtrue면 거절하고, blockReason(또는 거기에 대응시킨 메시지)을 보여 줍니다. 매칭된 규칙은 의사결정 트레이스에 선택됨(SELECTED) · 최종 승자(FINAL_WINNER)로 남고, 이 규칙이 어떤 조건식으로 평가됐는지는 위 드라이런 화면의 실행 트레이스 표에서 함께 볼 수 있습니다. 지원팀에는 사유 문자열이 답이고, 컴플라이언스팀에는 트레이스가 답입니다. 막은 규칙의 이름, 판정에 쓰인 정책 버전, 엔진이 받은 등급과 금액, 판정 시각이 실행 기록에 함께 남습니다.

VERIFIED 계좌의 $900 이체를 돌리면 모든 규칙이 미매칭(NO_MATCH) · 조건 미충족(CONDITION_MISMATCH)으로 남습니다. 두 맵 다 빈 객체로 돌아오고, 애플리케이션은 이체를 진행합니다. isBlocked가 없다는 것 자체가 승인입니다.

어느 규칙에도 매칭되지 않은 900 VERIFIED 이체의 드라이런 결과

엣지 케이스

이 패턴의 핵심은 특정 한도 값이 아니라 단일 집행 지점입니다. 집행 지점을 하나로 모으고 나면 그 지점이 무엇을 어떻게 받아야 하는지가 남고, 아래 경우들은 정책이 직접 골라야 합니다.

  • 한도와 딱 같은 금액의 이체. GREATER_THAN은 UNVERIFIED에서 $1,000.00 이체를 통과시킵니다. "한도"를 허용되는 최대 금액으로 본 것입니다. 정책이 "그 금액부터 막는다"라면 연산자는 GREATER_THAN_OR_EQUAL입니다. 어느 쪽을 택하든 그 선택은 연산자로 규칙에 적히고, 조건식을 열어 보면 지금 한도 금액이 허용 쪽인지 차단 쪽인지 바로 확인됩니다.
  • 알 수 없는 인증 등급. 어떤 앱 버전이 kycLevelPENDING으로 보내기 시작합니다. 네 번째 규칙이 없으면 이 이체는 아무 데도 걸리지 않고 지나갑니다. 실수로 열린 통과입니다. NOT_IN 규칙이 같은 입력을 기록 남는 차단으로 바꿉니다. 닫아 두는 것 자체가 하나의 정책 결정이고, 규칙은 그 결정을 암묵이 아니라 명시로 만듭니다.
  • 빠진 fact. kycLevel이 없는 페이로드는 조용히 지나가지 않습니다. 엔진은 빠진 fact 이름을 에러에 담아 돌려주며, kycLevel을 가장 낮은 등급으로 가정하는 식의 보정은 하지 않습니다. 호출하는 쪽은 에러로 끝난 실행을 미승인으로 처리해야 합니다. 엔진이 비워 둔 자리를 이체 서비스가 짐작으로 채우면, 한곳으로 모은 집행 지점이 다시 갈라집니다.
  • 누적 한도. 이 패턴은 한 건의 이체를 제한합니다. 하루 합계나 이동 합계는 다른 문제입니다. 엔진은 건네받은 fact만 보고 한 건을 판정하며, 실행과 실행 사이에 아무 상태도 들고 있지 않습니다. 누적값은 집계 구간까지 호출하는 쪽에서 관리해 입력 fact로 넘겨야 합니다. 실패하는 방식이 다른 별도 패턴입니다. 여기서는 다루지 않습니다.
  • 여러 통화. 규칙은 한 가지 단위로 표현된 숫자 fact 하나만 비교합니다. 환산은 윗단에서 끝내고 한 통화로 보냅니다. fact 키 끝의 Usd가 바로 그 약속을 드러냅니다. 통화가 뒤섞인 fact는 모든 비교를 무의미하게 만듭니다.

운영 배포

매칭률 두 조건을 모두 통과한 대상 버전을 운영 정책 그룹에 올려, 이체 경로 전부가 그 한도를 보게 합니다. 올라간 시점의 등급별 임계값과 규칙 구성은 해시로 봉인되어 이후 무결성이 검증되고, 누가 언제 어떤 버전을 올렸는지가 배포 이력에 남습니다.

한도 변경은 트래픽을 쪼개 조금씩 적용하지 않고, 한 번에 전체 트래픽으로 배포합니다. A/B 테스트로 트래픽을 나누면 일부 이체는 새 한도로, 나머지는 이전 한도로 판정됩니다. 같은 인증 등급에서 같은 금액을 보내는 두 계좌가 서로 다른 결과를 받는다는 뜻입니다. 할인 정책이라면 일부 트래픽만 먼저 받아도 넘어갈 수 있지만, 규제 준수 한도에서는 들쭉날쭉한 집행 자체가 결함이지 안전장치가 아닙니다. 배포해도 되는지는 카나리 트래픽이 아니라 변경 영향 시뮬레이션 결과가 정합니다. 배포 이후 남는 일은 그 예측이 실제 이체 흐름에서 재현되는지 대조하는 것입니다. 규칙별 라이브 차단율을 시뮬레이션 매칭률과 맞대어 봅니다.

아래 두 신호 가운데 하나라도 나타나면 이전 한도 버전으로 되돌립니다.

  • 바꾼 규칙의 라이브 차단율이 시뮬레이션 매칭률에서 벗어날 때. 지금 들어오는 이체가 시뮬레이션에 쓴 지난달 구간과 다르다는 뜻이고, 그 결과로 승인했던 파장 범위는 더 이상 유효하지 않습니다.
  • 시뮬레이션이 변동 없음이라고 본 등급에서 차단이 나타날 때. 운영에 들어오는 kycLevel fact가 데이터셋에 담겼던 것과 다르다는 뜻입니다.
대상 버전 배포 상세

되돌리면 정책 그룹이 직전 한도 버전을 다시 집행하고, 되돌렸다는 사실 자체도 배포 이력에 한 줄로 기록됩니다. 새 한도가 전체 트래픽에 적용되고 나면, 규칙별 통계가 컴플라이언스팀 질문에 대한 상시 답이 됩니다. 등급별로 몇 건이 막혔는지는 규칙과 버전으로 범위를 좁혀 실행 이력을 조회하면 나옵니다. 애플리케이션 로그를 뒤질 일이 아닙니다. 그리고 "5월 14일에 적용되던 한도"는 버전 이력과 배포 기록에 그대로 있습니다. 이체 승인 응답이 눈에 띄게 늦어진다면 등급 규칙 가운데 어디에서 시간이 새는지가 다음 확인 대상입니다. 규칙별 소요 시간을 읽는 방법은 느려진 규칙 집어내기가 설명합니다.


LexQ가 어떻게 동작하는지 playground에서 직접 확인해보세요.

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

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

무료로 시작