LexQLexQ
패턴 목록으로
보험자격 검증운영신뢰초급

차단 규칙으로 연령·지역별 보험 가입 자격 검증하기

주 인가가 바뀔 때마다 코드 세 곳을 고치는 대신, 가입 자격 검증을 규칙 넷으로 옮겨 거절 사유를 신청 단위로 남깁니다.

Sanghyun Park·2026년 7월 20일13분 읽기15 min

문제

보험 상품은 인가받은 대로만 팝니다. 미국 시장의 정기 보험 하나를 놓고 보면, 어느 주(state)에서 팔 수 있는지는 주별 승인에 달려 있고, 최소·최대 가입 연령은 인가 서류의 일부이며, 고연령 신청자의 보장 한도는 언더라이팅(인수 심사) 지침에 담겨 있습니다. 전부 개발팀이 정하는 값이 아닙니다. 상품팀과 컴플라이언스팀이 정하고, 인가 일정에 맞춰 바꾸는 정책입니다. 주 승인이 새로 나고, 연령 구간이 넓어지고, 한도가 내려가는 식입니다.

문제는 이 값들이 코드 안에 상수로 박힌다는 데서 시작합니다. 신청 서비스 어딘가에 상수가 있고, 검사 분기가 그 옆에 붙습니다. 그리고 이 검사는 한 곳으로 끝나지 않습니다. 견적 페이지는 가입 가능 여부를 먼저 보여 주려고 승인 주 목록을 따로 복제합니다. 1년 뒤에는 설계사들이 신청을 넣는 판매 채널 API가 따로 붙고, 그 시점의 값들이 코드에 또 한 번 고정됩니다. 같은 검증이 이제 세 곳에 따로 존재하고, 인가가 한 번 바뀌면 세 곳을 동시에 고쳐야 합니다.

세 곳이 같은 날 함께 바뀌는 일은 없습니다. 승인받은 적 없는 주의 신청이 설계사 채널을 통과한 기록은 몇 달이 지난 점검에서야 나옵니다. 그 채널이 지난 분기의 목록을 들고 있었던 겁니다. 질문은 곧바로 양쪽에서 옵니다. 영업팀은 "이 신청자가 왜 거절됐느냐"고 묻고, 컴플라이언스팀은 "3월 3일에 적용되던 검증 기준이 무엇이었고, 그 기준에 거절된 신청을 전부 내놓으라"고 요구합니다. 상수와 로그 한 줄로는 어느 쪽에도 답이 나오지 않습니다.

이 패턴이 풀려는 문제가 바로 이것입니다. 가입 자격 검증을 모든 신청 경로에서 단 하나로 유지하고, 몇 달이 지난 뒤에도 어떤 신청이 어떤 검사에 왜 거절됐는지 답할 수 있게 만드는 것.

단순한 접근

처음에는 검증 기준값을 검사 코드 바로 옆, 신청 서비스의 상수로 둡니다.

public class ApplicationScreeningService {

    // 가입 자격 기준값. 상품팀과 컴플라이언스팀이 관리합니다.
    private static final int MIN_ISSUE_AGE = 19;
    private static final int MAX_ISSUE_AGE = 60;
    private static final Set<String> APPROVED_STATES =
            Set.of("CA", "TX", "NY", "WA", "IL");
    private static final int SENIOR_AGE_BAND = 50;
    private static final BigDecimal SENIOR_COVERAGE_CAP = new BigDecimal("500000");
    // 견적 페이지는 가입 가능 여부를 미리 보여 주려고 승인 주 목록을
    // 따로 복제해 둡니다. 설계사 채널 API는 나중에 붙으면서
    // 그 시점의 값들로 굳었습니다.

    public void screen(Application app) {
        if (app.age() < MIN_ISSUE_AGE) {
            throw new IneligibleException("최소 가입 연령 미만");
        }
        if (app.age() > MAX_ISSUE_AGE) {
            throw new IneligibleException("최대 가입 연령 초과");
        }
        if (!APPROVED_STATES.contains(app.residenceState())) {
            throw new IneligibleException("미승인 주");
        }
        if (app.age() >= SENIOR_AGE_BAND
                && app.requestedCoverage().compareTo(SENIOR_COVERAGE_CAP) > 0) {
            throw new IneligibleException("연령대 보장 한도 초과");
        }
        underwriting.enqueue(app);
    }
}

규모가 작을 때는 이대로도 잘 돌아갑니다. 복제본들도 처음 배포되던 날에는 다 맞았습니다. 무너지는 자리는 기준값이 아니라 기준값을 둔 위치이고, 인가가 한 번 바뀌는 순간 세 가지로 드러납니다.

  • 같은 검증이 여러 곳에 흩어져 있습니다. 서비스가 하나 들고 있고, 견적 페이지가 가입 가능 표시용으로 하나, 설계사 채널이 출시 시점 값으로 하나. 이제 인가 하나가 바뀌면 세 코드베이스에 동시에 배포해야 하고, 한 곳을 빠뜨려도 아무 신호가 없습니다. 한 경로는 팔면 안 되는 주에서 조용히 팔고, 다른 경로는 이제 팔아도 되는 주에서 조용히 거절합니다.
  • 자격 기준을 바꾸려면 배포를 해야 합니다. 규제 당국은 시행일이 박힌 인가를 내주지만, 상수는 배포 일정에 실려 나갑니다. 신청자가 실제로 적용받는 기준은 어느 배포가 먼저 나갔는지에 따라 갈리고, 기준이 어떻게 바뀌어 왔는지는 저장소 세 곳의 git 로그에 흩어집니다.
  • 거절이 나중에 찾아볼 기록을 남기지 않습니다. 예외 메시지는 로그 한 줄로 흘러갈 뿐입니다. "6월에 주 검사에 거절된 신청을 전부"는 로그를 파헤치는 작업이 되고, "3월 3일에 적용되던 기준"은 저장소 세 곳을 git blame으로 뒤지는 일이 됩니다.

패턴 정의

해결의 핵심은 자격 판정을 한 번의 호출로 모으는 것입니다. 신청 서비스든 견적 페이지의 가입 가능 확인이든 설계사 채널이든, 모든 경로가 같은 정책 그룹에 묻고, 검증 기준은 딱 한 곳에만 존재합니다. KYC 등급별 이체 한도와 같은 단일 집행 지점 구조를, 다른 규제 결정에 적용한 셈입니다.

LexQ에서 이 검증은 입력과 규칙, 그리고 어디에도 걸리지 않은 신청의 처리로 나뉩니다.

  • fact: 엔진이 읽는 입력값입니다. applicantAge, residenceState, requestedCoverageUsd.
  • 규칙: 자격 검사 하나가 규칙 하나입니다. 걸러 낼 조건과, 거절 사유 문자열을 실은 차단(BLOCK) 액션이 한 쌍을 이룹니다.
  • 걸리지 않으면 통과입니다. 차단 액션은 조건이 맞은 신청에서만 실행되고, 검사 넷을 모두 지나친 신청은 그대로 언더라이팅으로 넘어갑니다. 자격 거절이 예외이고, 사유 문자열이 붙는 것도 그 예외뿐입니다.

등급별 한도에서는 이체 한 건에 걸리는 규칙이 많아야 하나였지만, 여기서는 연령, 주, 보장 금액을 저마다 따로 봅니다. 그래서 한 신청이 두 검사에 동시에 걸릴 수 있고, 그게 기록에 어떤 의미인지는 엣지 케이스에서 다시 봅니다.

최소 연령 규칙이 이 패턴의 가장 작은 꼴입니다. 조건 하나, 액션 하나.

{
  "name": "Block: age below minimum issue age",
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      {
        "type": "SINGLE",
        "field": "applicantAge",
        "operator": "LESS_THAN",
        "value": 19,
        "valueType": "NUMBER"
      }
    ]
  },
  "actions": [
    {
      "type": "BLOCK",
      "parameters": {
        "reason": "applicantAge below minimum issue age 19"
      }
    }
  ],
  "isEnabled": true
}

최대 연령 규칙(Block: age above maximum issue age)은 같은 모양에 연산자만 GREATER_THAN, 값만 60입니다. 주 규칙은 인가 목록 자체를 담습니다.

{
  "name": "Block: state not approved",
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      {
        "type": "SINGLE",
        "field": "residenceState",
        "operator": "NOT_IN",
        "value": ["CA", "TX", "NY", "WA", "IL"],
        "valueType": "LIST_STRING"
      }
    ]
  },
  "actions": [
    {
      "type": "BLOCK",
      "parameters": {
        "reason": "residenceState outside approved states"
      }
    }
  ],
  "isEnabled": true
}

NOT_IN이라서 이 규칙은 태생부터 닫혀 있습니다. 목록에 없는 주는, 새 지역 코드든 오타든 아무도 예상 못 한 값이든, 검사 없이 지나가는 대신 사유와 함께 막힙니다. 보장 한도 규칙(Block: coverage above senior cap)은 조건 둘을 AND로 묶습니다. applicantAgeGREATER_THAN_OR_EQUAL 50이고 requestedCoverageUsdGREATER_THAN 500000이면, 자기 사유 문자열로 차단합니다. 규칙 넷, 검증 정책 하나.

연령 상하한 규칙 2개, 승인 주 목록 규칙, 고연령 보장 한도 규칙

이제 검증 기준은 데이터입니다. 새 주 승인은 초안 버전에서 목록 하나를 고치는 일이고, "3월 3일에 적용되던 기준"은 git blame이 아니라 버전 이력이 답합니다.

변경 영향 시뮬레이션 전략

새 인가 두 건이 나옵니다. 이제 FL(플로리다)과 GA(조지아)에서도 팔 수 있습니다. 변경은 주 규칙의 목록에 코드 두 개를 더하는 것뿐입니다. 운영 중인 버전을 복제해 그 목록만 고치면, 아직 트래픽을 받지 않는 대상 버전이 됩니다.

넓어진 목록이 실제 신청에 닿기 전에, 이 변경이 미칠 파장을 미리 잴 수 있습니다. 재려는 것은 숫자 하나입니다. 지난달에는 주 검사에 막혔지만 새 목록에서는 막히지 않을 신청의 비율. 금액을 합치거나 평균 낼 필요 없이 건수만 세면 되는 질문이라, 분석 대상 fact는 비워 두고 규칙별 통계 포함(includeRuleStats)만 켜면 충분합니다. 읽는 법도 단순합니다. 주 규칙의 매칭률을 비교 기준 버전과 대상 버전에서 각각 보면, 둘의 차이가 곧 그 비율입니다. 이체 한도를 조이는 변경이라면 같은 시뮬레이션이 새로 막힐 이체를 재고, 인가 목록을 넓히는 변경에서는 새로 들어올 신청을 잽니다. 같은 도구를 반대 방향으로 읽는 셈입니다. 비교 기준 버전과 대상 버전을 맞대는 실행의 전체 절차는 배포 전 규칙 변경 테스트에서 다룹니다.

lexq analytics simulation start --json '{
  "policyVersionId": "<candidate-version-id>",
  "dataset": {
    "type": "HISTORICAL",
    "source": "EXECUTION_LOGS",
    "from": "2026-06-01",
    "to": "2026-06-30"
  },
  "options": {
    "baselinePolicyVersionId": "<baseline-version-id>",
    "includeRuleStats": true,
    "maxRecords": 50000
  }
}'

배포할지 말지는 두 가지로 판단합니다. 첫째, 주 규칙 매칭률의 감소 폭이 신청 유입 통계에서 이미 알고 있는 FL·GA 비중과 맞아야 합니다. 그보다 크게 줄었다면 이 수정이 목록 하나를 고치는 데서 그치지 않았다는 뜻입니다. 둘째, 연령·보장 규칙의 매칭률은 조금도 움직여선 안 됩니다. 목록 하나만 건드리는 변경이라고 했으니, 그 말이 사실인지 규칙별 통계로 확인합니다. 시뮬레이션은 저장된 실행 로그를 규칙에 한 번 더 통과시켜 볼 뿐입니다. 신청 상태도 언더라이팅 대기열도 이 실행으로는 움직이지 않습니다.

주 7개 대상 버전과 주 5개 비교 기준 버전을 비교한 변경 영향 시뮬레이션 결과

운영 기록이 아직 없다면 신청 표본을 직접 만들어 올리고, 같은 두 버전을 맞대면 됩니다. 검사 넷에 각각 걸리는 신청을 빠짐없이 넣고, 19세와 60세, 500,000달러 경계의 양옆에 놓이는 신청도 함께 담습니다.

의사결정 트레이스 출력

점검에서 나왔던 그 케이스, FL 거주 34세가 $250,000 보장을 신청한 건을, 새 인가 이전에 운영 중이던 버전으로 드라이런해 봅니다.

{
  "result": "SUCCESS",
  "data": {
    "inputFacts": {
      "applicantAge": 34,
      "residenceState": "FL",
      "requestedCoverageUsd": 250000
    },
    "mutatedFacts": {},
    "generatedVariables": {
      "isBlocked": true,
      "blockReason": "residenceState outside approved states"
    },
    "executionTraces": [ ... ],
    "decisionTraces": [
      {
        "ruleName": "Block: age below minimum issue age",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      },
      {
        "ruleName": "Block: age above maximum issue age",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      },
      {
        "ruleName": "Block: state not approved",
        "status": "SELECTED",
        "reasonCode": "FINAL_WINNER",
        "reasonDetail": null
      },
      {
        "ruleName": "Block: coverage above senior cap",
        "status": "NO_MATCH",
        "reasonCode": "CONDITION_MISMATCH",
        "reasonDetail": null
      }
    ]
  }
}
주 검사에 거절된 FL 신청의 드라이런 결과

mutatedFacts는 비어 있습니다. 차단은 어떤 fact도 바꾸지 않으니까요. 거절 결과는 generatedVariables에 담깁니다. isBlockedtrue이고, blockReason에 매칭된 검사의 사유 문자열이 들어 있습니다. 이 신청은 검사 하나에만 걸렸습니다. 여럿에 걸릴 때 어느 사유가 남는지는 아래 엣지 케이스에서 다룹니다. 두 키는 차단 액션이 실행됐을 때만 생깁니다. 어느 검사에도 걸리지 않은 신청에서는 false가 들어오는 대신 두 키가 아예 나타나지 않습니다. 애플리케이션이 지켜야 할 규약은 여기서 바로 정해집니다. isBlockedtrue면 거절 흐름으로 보내고(또는 사유에 대응시킨 가입 불가 안내를 보여 주고), 아니면 언더라이팅으로 넘깁니다. 영업팀에는 사유 문자열이 답이고, 컴플라이언스팀에는 트레이스가 답입니다. 어느 규칙이 어느 버전에서 무슨 입력을 받아 언제 그렇게 판정했는지가 트레이스에 그대로 남습니다.

같은 보장 금액으로 TX(텍사스) 거주 34세를 돌리면 모든 규칙이 미매칭(NO_MATCH) · 조건 미충족(CONDITION_MISMATCH)으로 남습니다. 두 맵 다 빈 객체로 돌아오고, 신청은 다음 단계로 넘어갑니다. isBlocked가 없다는 것 자체가 통과입니다.

어느 검사에도 걸리지 않은 TX 신청의 드라이런 결과

엣지 케이스

규칙에 적힌 기준값보다 중요한 것은 그 검증이 한 곳에서만 일어난다는 점입니다. 경계에 놓인 몇 가지는 상품팀과 컴플라이언스팀의 결정이 먼저 필요합니다.

  • 기준 나이에 딱 걸치는 신청. GREATER_THAN은 60세의 신청을 통과시킵니다. "최대 가입 연령"을 신청할 수 있는 가장 많은 나이로 본 것입니다. 인가 문구가 "60세 미만"이라면 연산자는 GREATER_THAN_OR_EQUAL입니다. 한 번 정해 규칙에 적어 두면, 기록된 조건식이 지금 어느 해석으로 돌고 있는지 보여 줍니다.
  • "나이"를 세는 기준. 가입 연령은 만 나이를 그대로 쓰기도 하고, 보험나이(상령일 기준)로 계산하기도 합니다. 같은 날 같은 신청자라도 어느 관례를 쓰느냐에 따라 한 살이 갈립니다. 엔진은 숫자 하나를 비교할 뿐이고, 그 숫자를 만든 관례는 호출하는 쪽의 약속입니다. 가입 연령을 윗단에서 계산해 숫자로 보냅니다. 생년월일을 보내서 규칙 안에 날짜 계산을 심지 않습니다.
  • 빠진 fact. residenceState가 없는 페이로드는 조용히 지나가지 않습니다. 엔진은 빠진 fact 이름을 담아 에러를 던지고, 임의의 기본값으로 메우지 않습니다. 호출하는 쪽은 에러로 끝난 실행을 미검증으로 처리해야 합니다. 엔진이 거주 주를 짐작하지 않고 멈췄는데 신청 서비스가 대신 짐작하면, 단일 검증 지점은 거기서 깨집니다.
  • 두 검사에 동시에 걸리는 신청. 미승인 주에 사는 63세는 검사 둘에 걸리고, 어느 쪽이든 결정은 차단입니다. 정해진 검사 순서(연령, 주, 보장 순)로 대표 사유 하나만 남겨야 하는 팀이라면, 그건 우선순위가 있는 상호 배타 그룹이고 신용 신청 판정 패턴이 그 구조를 다룹니다. 이 패턴은 검사들을 독립으로 두고, 검사별 결과는 어느 경우든 의사결정 트레이스에 전부 남습니다. 걸린 검사는 모두 SELECTED / FINAL_WINNER로 남고 액션도 전부 실행되지만, generatedVariables에 남는 blockReason은 하나뿐입니다. 우선순위에서 뒤에 오는 검사가 앞선 것을 덮어씁니다. 위의 63세는 연령 사유가 아니라 주 사유로 거절됩니다. 특정 사유를 꼭 남겨야 하는 팀이라면 맨 처음 걸린 검사가 이긴다고 넘겨짚지 말고, 그 사유가 뒤에 오도록 검사 순서를 잡아야 합니다.
  • 자격 검증은 언더라이팅이 아닙니다. 이 규칙들은 신청을 받을 수 있는가에만 답합니다. 위험을 평가하지 않고, 건강 고지 항목을 읽지 않고, 심사자에게 넘기지도 않습니다. 승인, 거절, 회부로 가르는 라우팅은 실패하는 방식이 다른 별도의 결정이고, 여기서는 다루지 않습니다.

운영 배포

주 규칙의 감소 폭과 나머지 규칙의 무변동, 둘 다 확인됐으면 대상 버전을 운영에 올립니다. 배포하는 순간 규칙 넷의 스냅샷에 해시가 걸려 뒤에 손대면 드러나고, FL·GA가 더해진 목록이 언제 어느 버전으로 켜졌는지가 배포 이력에 남습니다.

자격 기준 변경은 트래픽을 쪼개 조금씩 적용하지 않고, 한 번에 전체 트래픽으로 배포합니다. 트래픽을 나누면 일부 신청은 새 목록으로, 나머지는 이전 목록으로 판정됩니다. 새로 승인된 같은 주에서 같은 날 신청한 두 사람이 서로 다른 결과를 받는다는 뜻입니다. 부분 적용을 견디는 할인이라면 그 정도 절충은 괜찮지만, 인가받은 자격 기준에서는 들쭉날쭉한 검증 자체가 결함이지 안전장치가 아닙니다. 배포해도 된다는 확신은 변경 영향 시뮬레이션에서 옵니다. 배포가 더해 주는 건 운영 데이터로 하는 확인뿐입니다. 검사별 라이브 차단율을 시뮬레이션 매칭률과 맞대어 봅니다.

운영에서 아래 둘 중 하나가 나타나면 넓힌 목록을 즉시 거둡니다.

  • 주 규칙의 라이브 차단율이 시뮬레이션 매칭률에서 벗어날 때. 운영 트래픽이 시뮬레이션에 쓴 과거 구간과 다르다는 뜻이고, 승인했던 파장 범위는 더 이상 유효하지 않습니다.
  • 시뮬레이션이 변동 없음이라고 본 검사에서 차단율이 움직일 때. 운영에 들어오는 fact가 데이터셋에 담겼던 것과 다르다는 뜻입니다.
주 7개 대상 버전의 배포 상세

이전 버전으로 되돌리면 자격 검증 정책 그룹은 주 5개 목록을 다시 집행하고, 되돌렸다는 사실도 배포 이력에 한 줄로 남습니다. 대상 버전이 전체 트래픽을 받기 시작하면, 규칙별 통계가 컴플라이언스팀 질문에 대한 상시 답이 됩니다. 어느 검사가 몇 건을 거절했는지는 규칙과 버전으로 범위를 좁힌 실행 이력 조회면 됩니다. 로그를 파헤칠 일이 아닙니다. 그리고 "3월 3일에 적용되던 기준"은 버전 이력과 배포 기록에 그대로 있습니다. 배포 뒤 판정이 느려졌다면 다음으로 물을 것은 연령, 주, 보장 한도 중 어느 검사가 시간을 먹었는가입니다. 규칙 단위 소요 시간을 읽어 그 검사를 지목하는 절차는 느려진 규칙 집어내기에 있습니다.

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

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

무료로 시작