룰 엔진에서 느린 규칙 하나를 찾기가 어려운 이유
핫 패스 결정의 p99가 흔들려도 APM은 결정 호출 span 하나만 보여줍니다. 규칙은 코드가 아니라 데이터라서 CPU 프로파일러가 그 안을 쪼개지 못하고, 문서를 확인할 수 있었던 룰 엔진들은 분위수를 사용자가 직접 만들게 둡니다.
요청이 올 때마다 반드시 지나가는 자리, 이른바 핫 패스에 결정 호출이 하나 있습니다. 배포도 없었고 요청 수도 평소와 같습니다. 그런데 어느 날부터 p99가 9ms에서 26ms로 올라가 있습니다. 응답 시간을 빠른 순서로 100개 줄 세웠을 때 99번째에 있는 값이 p99입니다. 팀은 APM을 엽니다. 화면에는 결정 호출 span 하나가 있습니다. span은 그 호출이 언제 시작해 언제 끝났는지를 담은 한 토막입니다.
span은 정직합니다. 결정 API가 몇 ms 걸렸는지 정확히 알려 줍니다. 그런데 그 안에는 두 해에 걸쳐 늘어난 규칙 열네 개가 들어 있습니다. 달라진 것은 들어오는 요청의 모양이고, 그 열네 개 중 하나가 호출마다 전보다 더 많은 일을 하고 있습니다. span이 내놓는 값은 열네 개를 하나로 합친 시간이라, 어느 규칙인지는 그 값 안에 없습니다. 다음으로 가려면 span을 규칙 단위로 쪼개야 하는데, 그럴 도구가 없습니다. 조사가 여기서 멈춥니다.
저는 이런 일을 여러 번 겪었습니다. 처음에는 제가 찾는 요령이 부족한 줄 알았습니다. 한참 뒤에야 이유를 알았습니다. 규칙마다 시간을 재 주는 제품은 몇 개 있지만, 그 시간을 분위수로까지 바꿔 주는 제품은 없었습니다. p50은 절반이 그보다 빠른 지점이고, p95와 p99는 느린 쪽 5%와 1%가 시작되는 지점입니다. 규칙 열네 개를 이 값으로 줄 세우는 일은 결국 사람이 손으로 해야 했습니다.
프로파일러가 그 안을 못 보는 이유
CPU 프로파일러를 붙이면 무엇이 보일지 생각해 보면 답이 나옵니다. 프로파일러는 실행 중인 스택 프레임을 주기적으로 들여다봅니다. 어느 함수에 시간이 몰려 있는지를 이렇게 알아냅니다. 그런데 규칙은 스택 프레임을 만들지 않습니다. 규칙은 코드가 아니라 데이터고, 실제로 실행되는 것은 그 데이터를 읽는 평가기 한 덩어리입니다.
그래서 프로파일러가 알려 주는 것은 evaluate()라는 함수가 시간이 오래 걸린다는 사실뿐입니다. 그건 프로파일러를 붙이기 전에 이미 알고 있던 사실입니다.
더 좋은 프로파일러를 붙여도 마찬가지입니다. 규칙이 코드였다면 규칙마다 함수가 하나씩 있었을 테고, 프로파일러는 그 함수 이름으로 시간을 나눠 줬을 겁니다. 규칙을 코드 밖으로 꺼내면 그 이름들이 없어집니다. 남는 함수는 evaluate() 하나뿐이고 규칙 열네 개가 전부 그 안에서 돕니다. 배포 없이 규칙을 바꿀 수 있게 된 것과 규칙별로 시간을 잴 수 없게 된 것은 원인이 같습니다. 규칙이 코드가 아니게 됐기 때문입니다.
그러면 규칙을 코드에 두고 규칙마다 함수를 하나씩 만들면 되지 않느냐고 물을 수 있습니다. 절반은 맞는 말입니다. 그런데 안 되는 나머지가 정작 알고 싶은 쪽입니다. 조건 하나만 보는 짧은 함수는 JIT이 호출부에 합쳐 버립니다. 합쳐지고 나면 스택에 그 함수 이름이 아예 없어서, 프로파일러가 볼 것이 없습니다. 이름이 살아남아도 이번에는 횟수가 모자랍니다. 프로파일러는 1초에 백 번쯤 스택을 들여다보는데, 4마이크로초 만에 끝나는 규칙이 그 순간에 걸릴 일은 거의 없습니다. 몇 시간을 모으면 숫자가 나오기는 합니다. 다만 그 숫자는 CPU를 몇 퍼센트 썼는가이고, 알고 싶은 것은 이번 호출이 몇 ms 걸렸는가입니다. 프로파일러가 그려 주는 그림은 함수별로 CPU를 얼마나 썼는지를 막대 폭으로 쌓아 놓은 것이라, 대부분의 입력에서 빠르고 일부에서만 느린 규칙은 그 느린 일부가 전체 몫에 묻혀 얇은 막대로 남습니다.
제일 큰 문제는 따로 있습니다. 규칙을 데이터로 옮긴 이유가 배포 없이 바꾸기 위해서인데, 프로파일러를 얻자고 함수로 되돌리면 기준값 하나 바꾸는 일이 다시 PR이 됩니다.
프로파일러가 놓친 그 규칙을 평균이 한 번 더 덮습니다. 대부분의 입력에서는 조용하다가 특정 조건 조합에서만 무거워지는 규칙이 있습니다. 호출 수만 건을 평균 내면 그 규칙은 그냥 조금 느린 규칙으로 보입니다. 드물게 느린 쪽은 평균으로 못 찾는다는 것은 지연을 다뤄 본 사람이면 다 압니다. 다만 규칙 단위로 그 느린 쪽을 보여 주는 화면이 드뭅니다. 그래서 어느 규칙이 그런지를 짚어 말하기가 어렵습니다.
남는 선택지는 하나하나 직접 타이머를 심는 것입니다. 실제로 그렇게들 합니다. if-else로 만들어진 정책이라면 대개 이런 모양이 됩니다.
long t0 = System.nanoTime();
boolean tierOk = checkPlanTier(ctx);
metrics.timer("policy.tier").record(System.nanoTime() - t0, NANOSECONDS);
long t1 = System.nanoTime();
boolean quotaOk = tierOk && checkMonthlyQuota(ctx);
metrics.timer("policy.quota").record(System.nanoTime() - t1, NANOSECONDS);
여기에는 문제가 둘 있고, 둘 다 이 코드가 뱉는 숫자에는 나타나지 않습니다. 지표 이름은 누군가 분기 옆에 타이핑해 둔 문자열일 뿐입니다. 나중에 그 분기를 고치거나 순서를 바꿔도 문자열은 그대로 있습니다. 그때부터 policy.tier라는 이름으로 쌓이는 값은 더 이상 등급 검사의 시간이 아닙니다. 그리고 tierOk &&의 단축 평가 때문에 policy.quota는 등급 검사를 통과한 호출에서만 시간이 기록됩니다. 두 시계열의 분모가 다른데 대시보드는 그 사실을 말해 주지 않습니다.
이 방식은 성능이 아니라 유지에서 무너집니다. 규칙 목록은 운영에서 바뀌고 계측 코드는 배포에서 바뀝니다.
다른 엔진들이 실제로 주는 것
2026년 8월 19일에 룰 엔진 7종의 공개 문서를 뒤졌습니다. 각 제품이 무엇을 재고 무엇을 화면에 띄우는지만 봤습니다. 직접 써 보고 견준 결과가 아니라 문서를 읽은 결과입니다.
| 제품 | 문서에 있는 것 | 없는 것 |
|---|---|---|
| Decisions.com | Profiler가 규칙을 포함한 항목별로 내는 Total·Count·Min·Max·Avg 시간 | 분위수. 데이터를 메모리에만 두고 15분마다 버려 이력이 안 쌓임 |
| Drools (KIE Server) | rule_name 라벨이 붙은 Prometheus histogram drl_match_fired_nanosecond | 분위수, 그리고 조건 시간. 벤더 대시보드는 1분 평균이고, 재는 구간이 조건(when)이 아니라 액션(then) 실행 |
| DecisionRules.io | audit log마다 executionTime, 시계열 차트 (벤더가 rule이라 부르는 단위는 의사결정 테이블·트리 하나) | 요약 통계가 최소·최대·평균뿐. 분위수 집계 없음 |
| GoRules | decision graph의 node 단위 실행 시간, trace 옵션 (테이블 행 단위로는 매칭 여부까지만) | 규칙 단위 시간. 분포 자체. 프로덕션 관측은 OpenTelemetry로 내보내 외부 APM에서 구성 |
| SAS Intelligent Decisioning | 규칙 묶음인 rule set 노드 단위 실행 시간 (성능 로깅을 켜야 나오는 DEBUG 로그) | 규칙 단위 계측. 상시 지표는 모듈·요청 단위뿐 |
| OpenRules | 실행된 규칙과 결정 변수 값, Rule Solver의 Execution Profile (제약 탐색 통계) | 규칙 단위 지연. 실행 보고서 행에 시간 칸이 없고, 찍히는 소요 시간은 결정 한 번의 총계, Execution Profile 쪽은 solve 한 번의 총계 |
| AWS Verified Permissions / Cedar | 없음 | 엔진이 정책별 시간을 아예 계측하지 않음. 관측 문서는 CloudTrail 한 쪽 |
이 넷은 제품 이름으로 검색하면 p95·p99가 나옵니다. 표의 "분위수 없음"과 어긋나 보이지만, 넷 다 규칙 단위가 아닙니다. GoRules 것은 마케팅 페이지에 그려 넣은 목업 대시보드이고, DecisionRules.io 것은 FAQ에 실린 부하 테스트 수치입니다. 둘 다 요청 한 번에 걸린 시간입니다. Drools 것은 Kogito가 내보내는 분위수인데, 요청 처리 시간과 DMN 출력값에 붙습니다. cedar-benchmarking은 Cedar 프로젝트가 자기 성능이 나빠지지 않았는지 보려고 돌리는 도구이고, 정책을 하나씩 재지 않고 묶음째 잽니다.
7종 중 둘은 표 한 줄로 끝내기 아깝습니다.
Drools를 서버로 돌리면 KIE Server가 표에 적은 그 histogram을 내보냅니다. histogram은 값을 구간(bucket)별 개수로 쌓아 두는 형태라, 나중에 어느 구간에 몇 건이 있었는지로 분위수를 역산할 수 있습니다. 그 계산을 하는 PromQL 함수가 histogram_quantile입니다. 한 줄이면 규칙별 p99가 나옵니다. 데이터는 진짜 있습니다. 걸리는 것은 두 가지입니다.
- 벤더가 기본으로 주는 대시보드는 1분 평균을 그립니다. 1000번 중 990번은 0.2ms, 10번은 20ms 걸리는 규칙의 1분 평균은 0.4ms입니다. 찾으려는 것이 그 10번인데 평균이 그걸 지웁니다. 그 10번은 histogram의 구간에 그대로 남아 있습니다. 벤더가 주는 화면이 그 구간을 안 읽을 뿐입니다. 분위수를 보려면 사용자가
histogram_quantile쿼리를 직접 써야 합니다. - 재는 구간이 조건이 아니라 액션입니다. 규칙은 두 부분으로 나뉩니다.
when은 조건이고,then은 조건이 맞았을 때 할 일, 곧 consequence입니다. KIE Server는 규칙이 실제로 발화하기 직전부터 끝날 때까지를 잽니다. 그 사이에 도는 것은then뿐이고when평가는 그 전에 이미 끝나 있습니다. 그런데 규칙셋이 자라면서 무거워지는 쪽은 대개when입니다. 조건에 가지가 늘어 느려진 규칙은 이 지표에 잡히지 않습니다. 규칙별 시간이라는 이름을 달고, 규칙이 느려지는 가장 흔한 경로를 구조적으로 못 보는 지표입니다.
그러니까 Drools 사용자가 규칙별 p99를 보고 있다면 그 화면은 Drools가 준 것이 아닙니다. 그 사용자가 Grafana에서 만든 것입니다. 만들 수 있다는 것과 제품이 준다는 것은 운영에서 다른 이야기입니다. 직접 만들어야 하는 화면은 대개 안 만들고 넘어갑니다. 그리고 평균 문제는 쿼리로 메울 수 있지만 재는 구간 문제는 못 메웁니다. 안 잰 시간은 어떤 쿼리로도 나오지 않습니다.
Decisions.com은 한 걸음 더 나갑니다. 규칙을 포함한 항목별로 Total, Count, Min, Max, Avg를 내고, 벤더 예시 문서가 그 값으로 느린 항목을 직접 지목합니다. 분위수는 없습니다. 수집한 데이터는 메모리에만 두고 15분마다 버립니다. 이력이 안 쌓이니 분위수를 낼 원자료가 애초에 모이지 않고, 새벽에 지나간 일은 아침에 열어 보면 이미 없습니다. 벤더도 이 기능을 이슈가 확인된 직후에 정보를 모으는 용도로 설명합니다.
Decisions.com의 Champion Challenger 화면에 나오는 performance는 여기서 말하는 지연과 관계가 없습니다. 원래 쓰던 규칙과 바꿔 본 규칙의 결과를 견주는 값입니다.
7종 가운데 스스로 규칙 단위 분위수를 계산하고, 규칙을 묶어 두는 단위인 정책 그룹 안에서 규칙들끼리 견줘 순위까지 매겨 주는 곳은 한 곳도 없었습니다. 지연을 어떤 단위로든 재는 제품은 여럿입니다. 제품끼리 갈리는 지점은 그다음, 그러니까 분위수와 순위를 누가 만드느냐입니다. 그 일을 대신해 주는 제품은 문서에서 찾지 못했습니다. 원자료를 내보내는 제품이라면 사용자가 Grafana나 Datadog이나 스프레드시트에서 직접 만들고, 원자료조차 없는 제품에서는 만들 방법이 없습니다.
Drools가 액션만 재고 조건은 못 잰다는 그 구멍이, LexQ가 조건 단계(CONDITION)와 액션 단계(ACTION)를 갈라서 재는 이유입니다. 둘은 표본 크기부터 다릅니다. 조건은 호출마다 평가되지만 액션은 규칙이 걸렸을 때만 실행되기 때문입니다.
이 측정이 지켜야 하는 세 가지
LexQ가 2026년 7월 14일에 규칙 성능 상세(Per-Rule Latency Profiling)를 내놓을 때 먼저 정한 것이 셋 있습니다.
임계값은 그룹 안에서만 정합니다
이 글에서는 정책 그룹을 줄여서 그룹이라고 씁니다. 어떤 규칙에 느리다는 표식, 즉 flagged가 붙는 기준은 하나입니다. 그 규칙의 p50이 같은 그룹 안 규칙별 p50들의 중앙값의 10배 이상일 때입니다. 절대 임계값은 지원하지 않습니다. 설계에서 의도적으로 배제했습니다. 사기 탐지 그룹의 정상 범위와 배송비 계산 그룹의 정상 범위가 같을 이유가 없습니다. 절대값 하나를 고르는 순간, 그 숫자를 고른 사람이 모든 도메인을 대신 판단하게 됩니다. 그룹 안에서 견주면 각 도메인이 자기 정상 범위를 스스로 말합니다. 그리고 서버가 늘거나 트래픽이 바뀌어도 손볼 것이 없습니다.
평균 대신 중앙값을 쓰는 이유는 더 단순합니다. 느린 규칙 하나가 그룹 평균을 끌어올리면 그 규칙이 자기 임계값을 스스로 올려 버립니다. 찾으려는 대상이 분모를 오염시켜 찾기 어려워집니다. 그래서 여기서 평균은 못 씁니다.
HIT와 MISS를 한 분포에 섞지 않습니다
규칙 평가에는 캐시가 붙습니다. HIT는 캐시에 이미 올라와 있는 버전을 쓴 호출입니다. MISS는 그 버전을 처음 불러 컴파일까지 해야 했던 호출입니다. 그 비용은 새 버전에 처음 닿는 몇 건이 치릅니다. 두 종류를 한 분포에 넣으면 배포 직후마다 p99가 튑니다. 그러면 팀은 그 튐을 규칙이 느려진 것으로 오해하고 규칙을 뜯어보기 시작합니다. 구별해 두면 같은 값이 시작 비용으로 읽힙니다. 시작 비용은 원인이 분명해서 대응이 달라집니다.
같은 값이라도 HIT 쪽에 있으면 규칙이 느린 것이고, MISS 쪽에 있으면 캐시를 처음 채운 것입니다. 어느 쪽으로 세느냐가 그 값의 뜻을 정합니다.
표본이 모자라면 추정하지 않습니다
분위수는 호출이 충분히 쌓였을 때만 의미가 있습니다. p99를 말하려면 느린 쪽 끝에 실제로 몇 건이 들어와 있어야 합니다. 그 '몇 건'을 세 건으로 잡은 것이 n × (1 − q) ≥ 3입니다. 이 조건을 넘기지 못하면 LexQ는 분위수를 계산하지 않고 null로 둡니다. p50은 n이 6 이상, p95는 60 이상, p99는 300 이상일 때 나옵니다. 판정 게이트는 이와 별도로 n이 100 이상입니다. 자격을 갖춘 규칙이 세 개보다 적으면 그룹 비교 자체를 포기합니다. 이때 보고하는 상태가 INSUFFICIENT_COHORT입니다. 견줄 규칙이 모자란다는 뜻입니다.
그래서 화면에 p95는 찍혀 있는데 플래그가 안 붙은 규칙이 생깁니다. 그건 그 규칙이 괜찮다는 말이 아닙니다. 아직 판정할 만큼 호출이 쌓이지 않았다는 말입니다. 둘을 구별해야 합니다. 빈칸을 추정으로 채우는 편이 친절했을 겁니다. 그게 바로 반대하는 이유입니다. 네 건으로 만든 p99는 화면에서 사천 건으로 만든 p99와 똑같이 생겼습니다.
셋 아래에 공통으로 깔린 제약이 하나 더 있습니다. 측정 자체도 지연을 만듭니다. 그래서 규칙별 상세는 호출의 1%만 봅니다. 무작위로 고르지 않습니다. 어떤 호출이 표본에 들어갈지를 정해진 방식으로 고르기 때문에, 같은 구간을 다시 열어도 늘 같은 값이 나옵니다. 결정 호출 한 번을 통째로 잰 전체 실행 시간(TOTAL)은 표본을 쓰지 않고 모든 호출에서 기록합니다. 이 비대칭이 핵심입니다. 얼마나 느린가는 총계가 싸게 답합니다. 어느 규칙이 느린가는 표본을 쓸 값어치가 있는 질문입니다. 앞의 n 기준값이 까다로워 보이는 이유도 여기 있습니다. 그 문턱을 넘어야 하는 것이 트래픽 전체가 아니라 100분의 1이기 때문입니다.
시계열도 같습니다. 그래프에 빈 곳이 있으면 데이터가 빠진 게 아니라 그 시간에 호출이 없었다는 뜻입니다. LexQ는 그 빈 곳을 앞뒤 값으로 채워 선을 이어 붙이지 않습니다. 이어 붙인 선 위의 점은 잰 적이 없는 값이기 때문입니다.
이 측정이 필요 없는 경우
규칙별 분위수가 값을 하는 자리는 하나입니다. 핫 패스에 있고, 아무도 비용을 머리에 담아 둘 수 없을 만큼 규칙이 많은 결정입니다. 여기서 벗어나는 경우가 넷 있고, 넷 다 이 측정을 가지러 갈 자리가 아닙니다.
- 결정이 핫 패스에 없을 때. 밤에 한 번에 몰아 도는 일괄 처리나, 담당자가 화면에서 하나씩 검토하는 내부 심사처럼 응답 시간이 수백 ms 흔들려도 영향이 없는 곳이 있습니다. 거기서는 규칙별 분위수를 읽느라 드는 품이 그렇게 얻는 것보다 큽니다. 전체 실행 시간만 보고 넘어가면 됩니다.
- 규칙이 서넛뿐일 때. 손으로 타이머를 심는 편이 낫습니다. 앞에서 말한 계측과 규칙이 어긋나는 문제는 규칙 개수가 늘어야 생깁니다. 네 개짜리 그룹에서는 어긋나도 눈으로 잡힙니다.
- 호출이 너무 적을 때. 하루 몇백 건이면 1% 표본에서 규칙별 n이 한 자리에 머물고, 앞에서 말한 분위수 표시 조건이 표의 대부분을
null로 잡아 둡니다. 그 트래픽에서는 그게 정직한 답이지만, 정직한 답이라고 해서 손을 쓸 수 있는 답은 아닙니다. - p99가 흔들리는 원인이 평가에 없을 때. 규칙이 읽는 입력값인 fact를 다른 서비스에서 받아 오는 경우, 또는 그 값을 만드는 쿼리가 무거운 경우입니다. 시간은 결정을 부르기 전 단계에서 이미 다 쓰입니다. 그러면 규칙별 분위수는 어느 규칙을 봐도 비슷비슷합니다. 시간이 규칙에 있지 않다는 사실만 확인하는 데 하루를 씁니다. span 분해를 먼저 보세요. 평가 span이 유독 길 때 규칙 프로파일을 여세요.
그다음
실제 화면과 임계값을 읽는 순서는 패턴 쪽에 있습니다. lexq_profile_overview와 lexq_profile_rule로 느려진 규칙을 집어내는 절차도 거기 있습니다. SaaS API 쿼터 게이트 한 건을 처음부터 끝까지 따라갑니다. 실제 수치도 함께 실려 있습니다.
자체 호스팅과 관리형 사이에서 운영 비용을 저울질하는 중이라면 Drools vs Cloud 룰 엔진 쪽이 이어 읽기 좋습니다. 관측 화면과 쿼리를 누가 만들고 누가 유지하느냐가 그 글에서 다루는 자체 호스팅 비용에 그대로 얹힙니다.