차단 규칙으로 연령·지역별 보험 가입 자격 검증하기
서비스마다 흩어진 연령·지역·보장 한도 검사를 한곳의 차단 규칙으로 모으고, 거절된 신청마다 사유가 남게 만드는 패턴.
문제
보험 상품은 인가받은 대로만 팝니다. 미국 시장의 정기 보험 하나를 놓고 보면, 어느 주(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에서는 이 시나리오가 세 가지 개념으로 나뉩니다.
- 팩트: 엔진이 읽는 입력값입니다.
applicant_age,residence_state,requested_coverage_usd. - 규칙: 검사 하나마다 규칙 하나씩. 조건과, 사유 문자열을 담은 차단(
BLOCK) 액션으로 이루어집니다. - 기본 동작은 통과입니다. 차단 액션은 규칙이 매칭됐을 때만 실행됩니다. 어떤 검사에도 걸리지 않은 신청은 언더라이팅으로 넘어갑니다. 차단이 예외이고, 그 예외만 기록으로 남습니다.
등급별 한도는 조건이 입력을 서로 겹치지 않게 갈라 두었지만, 여기서는 연령, 주, 보장 금액을 저마다 따로 봅니다. 그래서 한 신청이 두 검사에 동시에 걸릴 수 있고, 그게 기록에 어떤 의미인지는 엣지 케이스에서 다시 봅니다.
최소 연령 규칙이 이 패턴의 가장 작은 꼴입니다. 조건 하나, 액션 하나.
{
"name": "Block: age below minimum issue age",
"condition": {
"type": "GROUP",
"operator": "AND",
"children": [
{
"type": "SINGLE",
"field": "applicant_age",
"operator": "LESS_THAN",
"value": 19,
"valueType": "NUMBER"
}
]
},
"actions": [
{
"type": "BLOCK",
"parameters": {
"reason": "applicant_age 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": "residence_state",
"operator": "NOT_IN",
"value": ["CA", "TX", "NY", "WA", "IL"],
"valueType": "LIST_STRING"
}
]
},
"actions": [
{
"type": "BLOCK",
"parameters": {
"reason": "residence_state outside approved states"
}
}
],
"isEnabled": true
}
NOT_IN이라서 이 규칙은 태생부터 닫혀 있습니다. 목록에 없는 주는, 새 지역 코드든 오타든 아무도 예상 못 한 값이든, 검사 없이 지나가는 대신 사유와 함께 막힙니다. 보장 한도 규칙(Block: coverage above senior cap)은 조건 둘을 AND로 묶습니다. applicant_age가 GREATER_THAN_OR_EQUAL 50이고 requested_coverage_usd가 GREATER_THAN 500000이면, 자기 사유 문자열로 차단합니다. 규칙 넷, 검증 정책 하나.
이제 검증 기준은 데이터입니다. 새 주 승인은 초안 버전에서 목록 하나를 고치는 일이고, "3월 3일에 적용되던 기준"은 git blame이 아니라 버전 이력이 답합니다.
변경 영향 시뮬레이션 전략
새 인가 두 건이 나옵니다. 이제 FL(플로리다)과 GA(조지아)에서도 팔 수 있습니다. 변경은 주 규칙의 목록에 코드 두 개를 더하는 것뿐입니다. 운영 중인 버전을 복제해 그 목록만 고치면, 아직 트래픽을 받지 않는 대상 버전이 됩니다.
넓어진 목록이 실제 신청에 닿기 전에, 이 변경이 미칠 파장을 미리 잴 수 있습니다. 재려는 것은 숫자 하나입니다. 지난달에는 주 검사에 막혔지만 새 목록에서는 막히지 않을 신청의 비율. 금액을 합치거나 평균 낼 필요 없이 건수만 세면 되는 질문이라, 분석 대상 팩트는 비워 두고 규칙별 통계 포함(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 비중과 맞아야 합니다. 그보다 크게 줄었다면 이 수정이 목록 하나를 고치는 데서 그치지 않았다는 뜻입니다. 둘째, 연령·보장 규칙의 매칭률은 조금도 움직여선 안 됩니다. 목록 하나만 건드리는 변경이라고 했으니, 그 말이 사실인지 규칙별 통계로 확인합니다. 시뮬레이션은 외부 호출을 모킹으로 대체합니다. 과거 데이터를 읽기만 할 뿐 아무것도 쓰지 않습니다.
아직 운영 트래픽이 없다면, 대표 데이터셋을 올려 같은 비교를 돌리면 됩니다. 모든 검사에 걸리는 케이스를 빠짐없이 넣고, 연령과 보장 금액이 각 기준값 언저리에 몰리도록 구성하면 충분합니다.
의사결정 트레이스 출력
점검에서 나왔던 그 케이스, FL 거주 34세가 $250,000 보장을 신청한 건을, 새 인가 이전에 운영 중이던 버전으로 드라이런해 봅니다.
{
"result": "SUCCESS",
"data": {
"traceId": "7d2f91c4-...",
"inputFacts": {
"applicant_age": 34,
"residence_state": "FL",
"requested_coverage_usd": 250000
},
"mutatedFacts": {},
"generatedVariables": {
"is_blocked": true,
"block_reason": "residence_state 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
}
]
}
}
mutatedFacts는 비어 있습니다. 차단은 어떤 팩트도 바꾸지 않으니까요. 거절 결과는 generatedVariables에 담깁니다. is_blocked가 true이고, block_reason에 매칭된 검사의 사유 문자열이 들어 있습니다. 두 키는 차단 액션이 실행됐을 때만 생깁니다. 아무것도 막지 않았다면 false가 아니라 키 자체가 없습니다. 애플리케이션이 지켜야 할 규약은 여기서 바로 정해집니다. is_blocked가 true면 거절 흐름으로 보내고(또는 사유에 대응시킨 가입 불가 안내를 보여 주고), 아니면 언더라이팅으로 넘깁니다. 영업팀에는 사유 문자열이 답이고, 컴플라이언스팀에는 트레이스가 답입니다. 어떤 규칙이, 어느 버전에서, 무슨 입력으로, 언제.
같은 보장 금액으로 TX(텍사스) 거주 34세를 돌리면 모든 규칙이 미매칭(NO_MATCH) · 조건 미충족(CONDITION_MISMATCH)으로 남습니다. 두 맵 다 빈 객체로 돌아오고, 신청은 다음 단계로 넘어갑니다. is_blocked가 없다는 것 자체가 통과입니다.
엣지 케이스
이 패턴의 핵심은 특정 기준값이 아니라 단일 검증 지점입니다. 그 언저리의 몇 가지 경우는 의식적인 선택을 요구합니다.
- 기준 나이에 딱 걸치는 신청.
GREATER_THAN은 60세의 신청을 통과시킵니다. "최대 가입 연령"을 신청할 수 있는 가장 많은 나이로 본 것입니다. 인가 문구가 "60세 미만"이라면 연산자는GREATER_THAN_OR_EQUAL입니다. 한 번 정해 규칙에 적어 두면, 기록된 조건식이 지금 어느 해석으로 돌고 있는지 보여 줍니다. - "나이"가 무엇인가. 가입 연령은 만 나이를 그대로 쓰기도 하고, 보험나이(상령일 기준)로 계산하기도 합니다. 같은 날 같은 신청자라도 어느 관례를 쓰느냐에 따라 한 살이 갈립니다. 엔진은 숫자 하나를 비교할 뿐이고, 그 숫자를 만든 관례는 호출하는 쪽의 약속입니다. 가입 연령을 윗단에서 계산해 숫자로 보냅니다. 생년월일을 보내서 규칙 안에 날짜 계산을 심지 않습니다.
- 빠진 팩트.
residence_state가 없는 페이로드는 조용히 지나가지 않습니다. 엔진은 빠진 팩트 이름을 담아 에러를 던지고, 임의의 기본값으로 메우지 않습니다. 호출하는 쪽은 에러로 끝난 실행을 미검증으로 처리해야 합니다. 엔진이 추측을 거부했으니, 애플리케이션이 대신 추측해서도 안 됩니다. - 두 검사에 동시에 걸리는 신청. 미승인 주에 사는 63세는 검사 둘에 걸리고, 어느 쪽이든 결정은 차단입니다. 정해진 검사 순서(연령, 주, 보장 순)로 대표 사유 하나만 남겨야 하는 팀이라면, 그건 우선순위가 있는 상호 배타 그룹이고 신용 신청 판정 패턴이 그 구조를 다룹니다. 이 패턴은 검사들을 독립으로 두고, 검사별 결과는 어느 경우든 의사결정 트레이스에 전부 남습니다.
- 자격 검증은 언더라이팅이 아닙니다. 이 규칙들은 신청을 받을 수 있는가에만 답합니다. 위험을 평가하지 않고, 건강 고지 항목을 읽지 않고, 심사자에게 넘기지도 않습니다. 승인, 거절, 회부로 가르는 라우팅은 실패하는 방식이 다른 별도의 결정이고, 여기서는 다루지 않습니다.
운영 배포
두 조건을 통과한 대상 버전은 배포해서 운영에 올립니다. 배포 순간의 규칙 스냅샷은 해시로 봉인되어 무결성이 검증되고, 누가 언제 어떤 버전을 올렸는지가 기록으로 남습니다.
자격 기준 변경은 트래픽을 쪼개 조금씩 적용하지 않고, 한 번에 전체 트래픽으로 배포합니다. 트래픽을 나누면 일부 신청은 새 목록으로, 나머지는 이전 목록으로 판정됩니다. 새로 승인된 같은 주에서 같은 날 신청한 두 사람이 서로 다른 결과를 받는다는 뜻입니다. 부분 적용을 견디는 할인이라면 그 정도 절충은 괜찮지만, 인가받은 자격 기준에서는 들쭉날쭉한 검증 자체가 결함이지 안전장치가 아닙니다. 배포해도 된다는 확신은 변경 영향 시뮬레이션에서 옵니다. 배포가 더해 주는 건 운영 데이터로 하는 확인뿐입니다. 검사별 라이브 차단율을 시뮬레이션 매칭률과 맞대어 봅니다.
다음 두 신호 중 하나라도 보이면 곧바로 롤백합니다.
- 주 규칙의 라이브 차단율이 시뮬레이션 매칭률에서 벗어날 때. 운영 트래픽이 시뮬레이션에 쓴 과거 구간과 다르다는 뜻이고, 승인했던 파장 범위는 더 이상 유효하지 않습니다.
- 시뮬레이션이 변동 없음이라고 본 검사에서 차단율이 움직일 때. 운영에 들어오는 팩트가 데이터셋에 담겼던 것과 다르다는 뜻입니다.
롤백하면 정책 그룹이 이전 버전으로 돌아가고, 롤백했다는 사실까지 배포 이력에 남습니다. 대상 버전이 전체 트래픽을 받기 시작하면, 규칙별 통계가 컴플라이언스팀 질문에 대한 상시 답이 됩니다. 어느 검사가 몇 건을 거절했는지는 규칙과 버전으로 범위를 좁힌 실행 이력 조회면 됩니다. 로그를 파헤칠 일이 아닙니다. 그리고 "3월 3일에 적용되던 기준"은 버전 이력과 배포 기록에 그대로 있습니다.