LexQLexQ
패턴 목록으로
SaaS일반성능중급

규칙 성능 상세로 느려진 규칙 집어내기

규칙마다 걸린 시간을 캐시 상태별로 갈라 봅니다. 느려진 요청이 아니라 느려진 규칙의 이름을 얻는 패턴.

Sanghyun Park·2026년 8월 11일11분 읽기15 min

문제

API 쿼터 검사는 SaaS 제품이 가장 자주 실행하는 결정입니다. 요청이 들어올 때마다, 실제 일을 시작하기 전에 같은 것을 확인합니다. 이 테넌트가 아직 요금제의 월 한도 안에 있는지입니다. 요청 하나가 처리되는 동안 끝나야 하는 검사라, 시간을 넉넉히 쓸 수 없습니다.

이 검사는 비교 세 개로 시작하지만 거기 머물지 않습니다. 요금제 등급이 하나 늘고, 새 기능을 누구에게 열어 줄지 고르는 규칙이 붙고, 장애를 한 번 겪은 뒤 안전하게 막는 규칙이 들어옵니다. 하나하나는 작고 저마다 이유가 있습니다. 그러다 응답 시간이 길어지면, 어느 규칙 때문인지 아무도 짚지 못합니다.

느린 쪽 응답이 특히 길어집니다. 모니터링 도구는 정책 평가에 12ms를 썼다는 것까지만 알려 주고 거기서 멈춥니다. 규칙은 코드가 아니라 데이터라서, 코드를 들여다보는 도구로는 그 12ms 안을 쪼갤 수 없습니다. 평균을 봐도 가려집니다. 대부분의 요청에서는 빠르고 태그가 많이 달린 요청에서만 느려지는 규칙은 평균 안으로 묻힙니다.

필요한 것은 그 12ms를 규칙 단위로 갈라, 느려진 규칙의 이름을 얻는 일입니다. 조건을 다시 짤지는 그 이름을 받은 다음에 정합니다.

단순한 접근

처음에는 규칙을 코드에 그대로 두고, 평가 호출 앞뒤에서 시간을 잽니다.

public class QuotaGate {

    private static final Map<String, Long> QUOTAS =
            Map.of("free", 10_000L, "growth", 100_000L, "pro", 1_000_000L);

    public Decision check(Request req) {
        long t0 = System.nanoTime();
        Decision d = evaluate(req);
        long elapsed = System.nanoTime() - t0;

        // 게이트 전체를 하나의 숫자로 남깁니다.
        // 어느 분기가 그 시간을 썼는지는 남지 않습니다.
        log.info("quota_gate_ns={}", elapsed);
        return d;
    }

    private Decision evaluate(Request req) {
        Long quota = QUOTAS.get(req.plan());
        if (quota == null) {
            return Decision.deny("plan outside known plans");
        }
        if (req.callsThisMonth() > quota) {
            return Decision.deny("monthly quota exceeded");
        }
        // 새 기능을 일부에게만 열어 주려고 나중에 붙었습니다.
        // 요청마다 태그 목록을 훑습니다.
        if (matchesAnySegment(req.tags())) {
            return Decision.allowWithRoute(routeFor(req.tags()));
        }
        return Decision.allow();
    }
}

규모가 작을 때는 이대로도 잘 돌아가고, 시간 측정 한 줄을 넣은 것도 잘한 일입니다. 문제는 구조에 있고, 세 군데에서 드러납니다.

  • 남는 숫자가 하나뿐입니다. quota_gate_ns는 모든 분기를 합친 시간입니다. 분기별로 나누려면 if마다 타이머를 두르고, 그 타이머를 규칙과 함께 관리해야 합니다. 누군가 분기 순서를 바꾸면 측정과 로직이 곧바로 어긋납니다.
  • 로그 한 줄로는 분포를 못 봅니다. 실제로 남는 값은 평균이거나, 누군가 어림으로 정한 기준을 넘은 요청의 건수입니다. 정작 알아야 할 것은 이 분기가 다른 분기보다 얼마나 느린가입니다. 그러려면 분기마다 백분위를 내야 하는데, 로그 수집 도구는 분기가 저마다 자기 값을 따로 남길 때만 그걸 계산해 줍니다.
  • 첫 호출과 천 번째 호출이 한 평균에 섞입니다. 배포 직후 첫 요청은 규칙 묶음을 읽어 들이고 컴파일하는 값을 치르지만, 그다음 요청들은 치르지 않습니다. 둘을 한데 섞으면 이따금 응답이 튀는 것처럼 보입니다. 팀이 설명하려던 것이 바로 그 튀는 응답인데도 그렇습니다.

패턴 정의

해결의 핵심은 결정을 직접 계측하지 않고 엔진이 재게 두는 것입니다. 규칙이 정책 버전 안의 데이터가 되면 평가가 이미 규칙과 단계로 쪼개져 있습니다. 아무도 타이머를 두르지 않아도 엔진이 시간을 어디에 썼는지 기록합니다.

이 기록을 떠받치는 LexQ 개념이 셋입니다. 평가에 들어가는 것, 평가하는 것, 그리고 타이머가 덮는 범위입니다.

  • fact: 미들웨어가 요청마다 엔진에 건네는 입력입니다. 테넌트의 요금제 tenantPlan, 이번 달 누적 호출 수 apiCallsThisMonth, 요청에 달린 태그 목록 userTags.
  • 규칙: 쿼터 결정마다 규칙 하나씩이고, 그동안 쌓인 규칙들이 같은 그룹에 함께 있습니다. 새 기능을 누구에게 열어 줄지 고르는 규칙과, 모르는 값을 막는 catch-all이 여기 들어갑니다.
  • 단계(phase): 규칙 하나를 두 단계로 나눠 잽니다. CONDITION은 조건 트리를 평가한 시간이고, ACTION은 걸린 규칙의 액션을 실행한 시간입니다. 둘은 서로 다른 질문에 답하므로 따로 보고됩니다.

쿼터 게이트에는 상호 배타 그룹(Mutex Group)을 두지 않습니다. 한 요청에는 요금제 하나만 담기므로, 거기 걸리는 쿼터 규칙은 많아야 하나입니다. 라우팅 규칙은 별도 fact를 쓰므로 서로 경쟁할 일이 없습니다.

{
  "name": "Block: free plan over monthly quota",
  "condition": {
    "type": "GROUP",
    "operator": "AND",
    "children": [
      {
        "type": "SINGLE",
        "field": "tenantPlan",
        "operator": "EQUALS",
        "value": "free",
        "valueType": "STRING"
      },
      {
        "type": "SINGLE",
        "field": "apiCallsThisMonth",
        "operator": "GREATER_THAN",
        "value": 10000,
        "valueType": "NUMBER"
      }
    ]
  },
  "actions": [
    {
      "type": "BLOCK",
      "parameters": {
        "reason": "apiCallsThisMonth exceeds the free plan quota 10000"
      }
    }
  ],
  "isEnabled": true
}

그동안 쌓인 규칙은 모양이 다릅니다. 조건이 AND 그룹 여덟 개를 OR로 묶은 형태이고, 각 그룹이 userTags 목록을 훑는 비교로 시작합니다. 그러면서도 쿼터 규칙과 똑같이 요청마다 평가됩니다.

쿼터 규칙 여섯 개. 그동안 쌓인 라우팅 규칙이 맨 뒤

엔진이 해 주지 않는 일이 하나 있습니다. 어떤 규칙이 절대적으로 느리다고 말해 주지 않습니다. 규칙에 표시가 붙는 기준은 그 규칙의 p50같은 그룹 규칙들의 p50 중앙값의 10배에 이르는 경우입니다. 같은 그룹에 있는 다른 규칙들과 견주는 상대 비교입니다. 절대 기준값은 일부러 지원하지 않습니다. 무엇을 느리다고 할지는 서버와 규칙 묶음과 트래픽에 달려 있는데, 문서에 박아 둔 기준값은 그 셋보다 오래 살아남기 때문입니다.

변경 영향 시뮬레이션 전략

규칙 성능 상세(Rule Performance)는 어느 규칙이 가장 비싼지 말해 줍니다. 그 규칙을 고쳐도 되는지는 말해 주지 않습니다. 여덟 갈래 조건을 여러 규칙으로 쪼개는 것도, 값싼 비교가 먼저 실행되도록 순서를 바꾸는 것도 정책을 고치는 일입니다. 그래서 내보내기 전에 결정이 그대로인지부터 확인합니다.

운영 중인 버전을 복제해 비싼 규칙을 다시 짜고, 현재 버전을 비교 기준 버전으로 두어 과거 실행 데이터에 돌립니다. 통과 조건은 보통의 시뮬레이션과 반대입니다. 대상 버전과 비교 기준 버전 사이에 결정 차이가 0이어야 합니다.

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

배포할지 말지는 두 가지로 판단합니다. 첫째, 모든 규칙의 매칭률이 비교 기준 버전과 같아야 합니다. 다시 짠 조건이 예전과 다른 요청에 걸린다면 그건 성능 수정이 아니라 정책 변경입니다. 둘을 갈라 주는 것이 규칙별 통계입니다. 둘째, 결정 구성이 그대로여야 합니다. 막히는 건도, 통과하는 건도, 사유도 같아야 합니다. 이 비교 자체를 정면으로 다루는 패턴은 배포 전 규칙 변경 검증이고, 여기서는 변경의 크기를 재는 대신 변경이 무해하다는 것을 증명하는 데 씁니다.

다시 짠 규칙과 비교 기준 버전 사이에 결정 차이가 없다고 보고한 변경 영향 시뮬레이션

시뮬레이션에서 동작 차이가 없다고 확인한 뒤에 지연을 따집니다. 지연이 줄었는지는 대상 버전을 운영에 올린 뒤 프로파일에서 읽습니다.

의사결정 트레이스 출력

결정 자체는 다른 패턴과 똑같이 기록됩니다. 쿼터 규칙은 미매칭(NO_MATCH)이나 선택됨(SELECTED)으로 남고, 차단은 generatedVariablesisBlockedblockReason으로 담깁니다. 이 패턴이 더하는 것은 같은 실행에서 나오는 두 번째 출력, 프로파일입니다.

{
  "policyGroupId": "<group-id>",
  "policyVersionId": "<version-id>",
  "ruleCacheState": "HIT",
  "droppedRows": 0,
  "summary": [
    {
      "cacheState": "HIT",
      "total": { "n": 16199, "p50Nanos": 1490943, "p95Nanos": 3571711, "p99Nanos": 8519679 }
    },
    {
      "cacheState": "MISS",
      "total": { "n": 2, "p50Nanos": null, "p95Nanos": null, "p99Nanos": null,
                 "minNanos": 48234496, "maxNanos": 81264639 }
    }
  ],
  "baselines": [
    { "phase": "CONDITION", "baselineP50Nanos": 1535, "cohortSize": 6, "status": "OK" },
    { "phase": "ACTION", "baselineP50Nanos": null, "cohortSize": 1, "status": "INSUFFICIENT_COHORT" }
  ],
  "rules": [
    {
      "ruleId": "<segment-routing>",
      "phases": [
        { "phase": "CONDITION", "stats": { "n": 177, "p50Nanos": 4095, "p95Nanos": 9727, "p99Nanos": null },
          "baselineMultiple": 2.67, "flagged": false },
        { "phase": "ACTION", "stats": { "n": 168, "p50Nanos": 24063, "p95Nanos": 99839, "p99Nanos": null },
          "baselineMultiple": null, "flagged": false }
      ]
    }
  ]
}
캐시 상태로 갈린 그룹 전체 값. HIT과 MISS는 서로 다른 집단

summary는 그룹 전체 값입니다. 모든 호출에서 기록되고 캐시 상태로 갈립니다. baselines는 표시를 계산할 때 견주는 기준이고 단계마다 따로 나옵니다. 규칙마다 백분위 옆에 표본 수 n이 함께 붙고, baselineMultiple은 그 규칙이 같은 그룹의 다른 규칙들보다 몇 배 느린지를 보여 줍니다. 위에서는 그동안 쌓인 라우팅 규칙이 조건 평가에서 그룹 중앙값의 2.67배입니다. 여섯 규칙 중 가장 높은데도 표시 기준에는 못 미칩니다. 손댈 근거가 되는 것은 기준값이 아니라 이 순위입니다.

규칙별 표와 기준 대비 배수. 라우팅 규칙이 가장 높지만 표시는 붙지 않았다 라우팅 규칙의 구간별 시계열. 빠진 구간은 보간하지 않는다

엣지 케이스

이 패턴의 요지는 측정이지 규칙 묶음이 아닙니다. 이 숫자로 어디까지 말할 수 있는지는 아래 몇 가지에 달려 있습니다.

  • 규칙 상세는 표본이고 그룹 전체는 아닙니다. 모든 호출이 summary에 들어가지만, 규칙별 표는 그중 정해진 방식으로 골라낸 1%만 씁니다. 수천 건을 처리한 그룹도 규칙별 n은 수백 대에 머물기 때문에, 규칙별 표는 전체 값만큼 믿을 수 없습니다. 조회 구간을 그만큼 넉넉히 잡고, 갓 배포한 뒤 몇 분치를 규칙 단위 신호로 읽지 않습니다.
  • 기준이 둘이고 서로 다릅니다. n × (1 − q) ≥ 3을 만족하지 못하면 엔진은 백분위를 추정하지 않고 null로 비웁니다. p50n ≥ 6, p95n ≥ 60, p99n ≥ 300이 필요합니다. 판정에는 n ≥ 100이라는 별도 기준이 있고, 자격을 갖춘 규칙이 셋에 못 미치면 baselinesINSUFFICIENT_COHORT를 냅니다. 그래서 표에 백분위는 나오는데 견줄 배수가 계산되지 않은 상태가 생깁니다. 그건 규칙이 느리다는 뜻이 아니라 표본이 모자란다는 뜻입니다.
  • CONDITIONACTION은 표본 크기를 공유하지 않습니다. 조건은 엔진이 호출마다 평가하므로 모든 규칙의 CONDITIONn이 같습니다. 액션은 규칙이 걸렸을 때만 실행되므로, 드물게 걸리는 규칙은 CONDITION이 판정 기준을 여유 있게 넘는 동안 ACTION에서는 영영 넘지 못할 수 있습니다. 단계를 갈라 보고하는 이유가 이 비대칭입니다.
  • 캐시 상태가 다르면 아예 다른 집단입니다. MISS는 규칙을 읽어 들이고 컴파일하는 값까지 포함하며, 어떤 버전에 처음 닿는 호출들이 그 값을 치릅니다. 한 분포에 섞으면 그냥 느린 응답으로 보이지만, 갈라 두면 원인이 분명한 시작 비용입니다. 평소 속도는 HIT으로 읽고, 배포 직후 첫 요청들이 치르는 값은 MISS로 읽습니다.
  • 시계열은 다듬지 않습니다. 구간 단위 점은 그 구간의 값을 그대로 싣고 표시 기준을 적용하지 않습니다. 그래서 호출이 한 건뿐인 구간은 p50p95p99가 모두 같은 값으로 나옵니다. 빠진 구간은 실제로 트래픽이 없던 때이며 결코 보간하지 않습니다. 모양은 시계열에서 읽고 크기는 병합된 분포에서 읽습니다.

운영 배포

매칭률과 결정 구성, 두 조건을 통과한 대상 버전을 배포합니다. 올리는 순간의 규칙 스냅샷이 해시로 봉인되어 나중에 무결성을 검증할 수 있고, 어떤 버전을 누가 언제 올렸는지가 배포 기록에 남습니다.

다시 짠 규칙은 한 번에 전체 트래픽으로 배포해도 됩니다. 어떤 결정도 바뀌지 않는다는 것을 시뮬레이션이 이미 확인해 두었기 때문입니다. 배포한 뒤에야 얻는 것이 있습니다. 시뮬레이션으로는 만들 수 없는 값, 곧 실제 트래픽에서 잰 새 버전의 프로파일입니다. 변경 전 프로파일을 비교 대상으로 삼고, 같은 길이의 구간으로 다시 읽습니다.

새 프로파일이 아래 두 가지 중 하나를 보이면 곧바로 롤백합니다.

  • 다시 짠 규칙의 baselineMultiple그대로이거나 오히려 올라갈 때. 프로파일이 짚은 비용이 아니라 다른 비용을 겨냥해 조건을 고쳤다는 뜻이고, 이번 변경으로 얻은 것이 없습니다.
  • 배포가 한참 지났는데도 그룹의 MISS 분포가 계속 채워질 때. 배포 횟수로 설명되는 것보다 자주 규칙을 새로 읽어 들이고 있다는 뜻이고, 지금 재고 있는 값은 평가가 아니라 컴파일입니다.
다시 짠 버전의 배포 상세

롤백은 조건을 손대기 이전의 규칙 묶음으로 정책 그룹을 되돌리고, 되돌렸다는 사실도 배포 이력에 함께 남습니다. 대상 버전이 전체 트래픽을 받기 시작하면, 요청마다 거치는 결정이 어느 규칙에서 시간을 쓰는지에는 규칙별 표가 상시 답이 됩니다. 누군가 잊지 않고 넣어 둔 타이머가 아니라, 그룹과 버전과 구간으로 범위를 좁힌 조회면 됩니다.


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

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

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

무료로 시작