규칙 엔진은 60년간 같은 전제 위에 서 있었다. 검증 대상은 “사실(fact)“이라는 전제.

Drools는 Java 객체를 “fact"로 working memory에 넣는다. Rego는 input을 이미 참인 데이터로 취급한다. JSON Schema는 문서 구조가 주어진 것으로 간주한다. 전부 같은 가정이다 — 들어온 데이터는 사실이다.

그런데 규칙 엔진의 존재 이유가 뭔가? 데이터가 규칙을 충족하는지 검증하는 것이다. 검증이 필요한 대상을 “이미 참인 것"이라 부르는 건 모순이다.

사실이 아니라 주장이다

검증 대상은 fact가 아니라 **claim(주장)**이다. 참일 수도 거짓일 수도 있는 단언이다. 규칙에 의해 타당성이 판정되어야 한다.

JWT가 이미 이 원칙을 따르고 있다. sub, exp, iss를 “facts"가 아니라 “claims"라 부른다. 토큰 발급자의 주장이다. 서명을 검증하고, 만료를 확인하고, issuer를 대조해야 비로소 신뢰할 수 있다.

이것은 1958년에 이미 정립된 구조다.

툴민의 논증 모델

Stephen Toulmin은 1958년에 논증의 구조를 6요소로 분석했다:

  • Claim(주장): 판정 대상. 참인지 거짓인지 검증해야 하는 것.
  • Ground(근거): 판정에 사용되는 증거 데이터.
  • Warrant(보증): 근거가 주장을 뒷받침한다고 판단하는 규칙.
  • Backing(뒷받침): 규칙이 왜 유효한지에 대한 정당성.
  • Qualifier(한정): 판정의 확신도.
  • Rebuttal(반박): 주장이 성립하지 않는 예외 조건.

형식 논리학은 “전제가 참이면 결론도 참"이다. 툴민은 달랐다. “주장은 근거와 규칙에 의해 뒷받침되지만, 예외 조건이 있으면 뒤집힌다.” 모든 논증은 반박 가능하다.

규칙 엔진은 60년간 형식 논리학 쪽에 서 있었다. 입력은 fact, 결과는 allow/deny, 예외는 별도 메커니즘. 툴민은 반대편에 있었다. 입력은 claim, 결과는 정도(degree), 예외는 내장.

문제는 — 툴민의 책이 철학 서가에 꽂혀 있었다는 것이다. 규칙 엔진 서가에서는 보이지 않았다. 60년간의 미싱링크.

그래서 규칙 엔진을 만들었다

toulmin은 툴민의 논증 모델을 Go 규칙 엔진으로 구현한 것이다.

요구사항은 진화한다

if-else와 toulmin이 같은 진화에 어떻게 대응하는지 보자.

// 월요일: "인증된 사용자만 접근, IP 차단 적용, 내부망은 차단 면제"
g := toulmin.NewGraph("api:access")
auth    := g.Warrant(isAuthenticated, nil, 1.0)
blocked := g.Rebuttal(isIPBlocked, nil, 1.0)
exempt  := g.Defeater(isInternalIP, nil, 1.0)
g.Defeat(blocked, auth)
g.Defeat(exempt, blocked)

// 화요일: "Rate limiting 추가"
limited := g.Rebuttal(isRateLimited, nil, 1.0)
g.Defeat(limited, auth)

// 수요일: "프리미엄 사용자는 Rate limit 면제"
premium := g.Defeater(isPremiumUser, nil, 1.0)
g.Defeat(premium, limited)

// 목요일: "장애 대응 중에는 프리미엄도 제한"
incident := g.Rebuttal(isIncidentMode, nil, 1.0)
g.Defeat(incident, premium)

매일 2줄 추가, 기존 코드 변경 없음. 같은 진화를 if-else로:

// 월요일
if user != nil {
    if blockedIPs[ip] {
        if strings.HasPrefix(ip, "10.") {
            allow = true
        }
    } else {
        allow = true
    }
}

// 목요일 — 4중 중첩, 구조 파악 불가
if user != nil {
    if blockedIPs[ip] {
        if strings.HasPrefix(ip, "10.") {
            allow = true
        }
    } else if isRateLimited(ip) {
        if isPremium(user) {
            if !incidentMode {
                allow = true
            }
        }
    } else {
        allow = true
    }
}

toulmin: 요구사항당 2줄, 구조 불변. if-else: 매번 전체 구조를 뜯어고친다.

규칙은 Go 함수다

func(claim any, ground any, backing any) (bool, any)
  • ground = 요청마다 달라지는 판정 재료 (사용자, IP, 컨텍스트)
  • backing = 그래프 선언 시 고정되는 판정 기준 (임계값, 역할명, 설정)
  • 반환 = (판정 결과, 증거). 증거는 도메인별 자유 타입.
func CheckOneFileOneFunc(claim, ground, backing any) (bool, any) {
    g := ground.(*FileGround)
    if len(g.Funcs) > 1 {
        return true, &Evidence{Got: len(g.Funcs), Expected: 1}
    }
    return false, nil
}

Rego처럼 새 언어를 배울 필요 없다. Go 함수를 쓰면 된다.

backing — 같은 함수, 다른 판정 기준

backing은 규칙의 판정 기준을 런타임 값으로 전달한다. 같은 함수를 다른 backing으로 등록하면 별개의 규칙이 된다:

g := toulmin.NewGraph("access")
admin  := g.Warrant(isInRole, "admin", 1.0)
editor := g.Warrant(isInRole, "editor", 0.8)
g := toulmin.NewGraph("line-limit")
strict  := g.Warrant(CheckLineCount, &LineLimit{Max: 100}, 0.7)
relaxed := g.Warrant(CheckLineCount, &LineLimit{Max: 200}, 0.5)
g.Defeat(relaxed, strict)

backing이 nil이면 규칙에 판정 기준이 필요 없다는 뜻이다.

예외는 그래프로 선언한다

Graph Builder API로 규칙 간의 관계를 선언하면 엔진이 알아서 처리한다. 함수가 식별자다. 문자열 이름이 필요 없다.

g := toulmin.NewGraph("filefunc")
w := g.Warrant(CheckOneFileOneFunc, nil, 1.0)
d := g.Defeater(TestFileException, nil, 1.0)
g.Defeat(d, w)

results, _ := g.Evaluate(claim, ground)

같은 함수를 다른 그래프에서 다른 패배 관계로 재사용할 수 있다:

strictGraph := toulmin.NewGraph("strict")
strictGraph.Warrant(CheckOneFileOneFunc, nil, 1.0)
// 예외 없음 — 테스트 파일도 허용 안 함

lenientGraph := toulmin.NewGraph("lenient")
w := lenientGraph.Warrant(CheckOneFileOneFunc, nil, 1.0)
r1 := lenientGraph.Rebuttal(TestFileException, nil, 1.0)
r2 := lenientGraph.Rebuttal(GeneratedFileException, nil, 0.8)
lenientGraph.Defeat(r1, w)
lenientGraph.Defeat(r2, w)
// 테스트 + 생성 파일 둘 다 예외

판정 근거를 추적한다

EvaluateTrace는 verdict뿐 아니라 어떤 규칙이 활성화되고, 어떤 규칙이 어떤 규칙을 패배시켰는지 추적한다:

traced := g.EvaluateTrace(claim, ground)
// traced[0].Verdict: +0.6
// traced[0].Trace: [
//   {Name: "CheckOneFileOneFunc", Role: "warrant",  Activated: true,  Qualifier: 1.0},
//   {Name: "TestFileException",   Role: "rebuttal", Activated: true,  Qualifier: 1.0},
// ]

규칙이 수십 개일 때 “왜 이 verdict가 나왔는가"를 사람이 읽을 수 있다.

판정은 수식 하나로 계산된다

Amgoud의 h-Categoriser(2013)를 적용했다:

raw = w / (1 + Σ raw(attackers))
verdict = 2 × raw - 1
  • +1.0 — 위반 확정
  • 0.0 — 판정불가
  • -1.0 — 반박 확정

규칙이 발화하면 warrant가 된다. 예외도 발화하면 attacker가 된다. 수식이 둘의 세력을 계산해서 verdict를 산출한다. 예외의 예외가 있으면? attacker의 attacker가 되어 원래 규칙이 복원된다. 보상 원리(Compensation) — h-Categoriser만이 만족하는 성질이다.

규칙에는 세 강도가 있다

Nute(1994)의 분류를 적용했다:

강도의미예시
Strict절대 무력화 불가“인증 없이 admin API 접근 불가”
Defeasible예외에 의해 무력화 가능“파일당 함수 하나”
Defeater자신은 주장 없이 다른 규칙만 차단“테스트 파일은 예외”

Strict 규칙은 공격 간선을 거부한다. Defeater는 공격만 하고 자기 판정은 없다. 이것이 규칙의 강제 수준을 구조적으로 표현한다.

Rego와 뭐가 다른가

Regotoulmin
규칙 작성Rego DSL 학습 필요Go 함수
예외 처리default/else 패턴 수동defeats 그래프 선언적
판정allow/deny 이진[-1, +1] 연속값
규칙 정당성# METADATA (엔진이 무시)backing (구조의 일부)
규칙 강도없음strict/defeasible/defeater
엔진 크기수만 줄수백 줄
속도인터프리터 (파싱→AST→평가)Go 함수 직접 호출

Rego는 넓다 — Kubernetes, Terraform, Envoy 통합 생태계가 있다. toulmin은 깊다 — Rego에 없는 것(defeasibility, qualifier, backing)이 있다.

Qualifier의 재배치

툴민의 원래 모델에서 Qualifier는 Claim에 부착된다. “아마도 이 환자에게 페니실린을 투여해야 한다” — 주장의 확신도를 표현하는 양상 한정사다.

toulmin 엔진은 Qualifier를 Claim에서 각 Rule로 재배치했다. 규칙 엔진에서 claim은 검증 대상일 뿐이다. “이 파일에 함수가 3개다” — 사실 확인이며, 확신도가 붙을 대상이 아니다. 판정의 질을 결정하는 것은 규칙의 확신도다:

  • “파일당 함수 하나” — qualifier 1.0 (확실한 규칙)
  • “권장 100줄 이하” — qualifier 0.7 (유연한 규칙)

각 Rule의 qualifier가 h-Categoriser의 초기 가중치 w(a)가 되고, 최종 verdict가 툴민 원래 모델에서 Qualifier가 담당하던 역할 — 판정의 확신도 — 을 대신한다.

실증: filefunc 22개 규칙의 툴민 변환

filefunc은 LLM 네이티브 Go 개발을 위한 코드 구조 컨벤션 도구다. 22개 규칙을 전부 툴민 warrant로 변환했다.

강도 분류

Strength비율예시
Strict1568%F1, F2, F3, F4, A1-A3, A6-A16
Defeasible418%Q1, Q2, Q3, C4
Defeater314%F5, F6, 테스트 파일 예외

대부분이 strict다 — 코드 구조 컨벤션은 본질적으로 예외를 최소화한다.

정량적 결과

프로젝트파일 수 (전→후)평균 LOC/파일 (전→후)SRP 위반 해소depth 위반 해소
filefunc— (처음부터 준수)25.100
fullend87→1,260244→25.466→0148→0
whyso12→99147.8→24.412→023→0

fullend는 87개 파일이 1,260개로 늘었다. 파일 수가 폭발했지만 평균 LOC가 244에서 25.4로 떨어졌다. SRP 위반 66건, depth 위반 148건이 전부 0이 되었다.

이론적 기반

독창적인 이론은 없다. 전부 기존 연구다:

요소원저
6요소 구조Toulmin (1958)
strict/defeasible/defeaterNute (1994)
h-CategoriserAmgoud & Ben-Naim (2013)

독창성은 이것들이 연결된다는 발견이다. 60년간 철학(Toulmin), 논리학(Nute), 논증 이론(Amgoud)에 각각 있었던 것들이, 소프트웨어 규칙 엔진이라는 한 점에서 만난다.

계약을 계산하다

법치주의가 작동하는 이유는 판사가 똑똑해서가 아니다. 구조가 판정을 강제하기 때문이다. 규칙이 있고, 예외가 선언되어 있고, 증거에 따라 판결이 산출된다.

toulmin은 이 구조를 코드로 옮겼다.

  • Warrant = 법률 조항
  • Backing = 입법 취지
  • Strength = 강행 규정 vs 임의 규정
  • Rebuttal = 예외 조항
  • Claim = 사건
  • Ground = 증거
  • h-Categoriser = 판결

계약(warrant)을 선언하고, 예외(rebuttal)를 선언하고, 증거(ground)를 대입하면, 판정(verdict)이 계산된다.

사람이 판단하는 게 아니다. 수식이 계산한다.

Acc(a) = w(a) / (1 + Σ Acc(attackers))

YAML로 그래프를 정의할 수 있다

Go 코드 없이 YAML로 그래프 구조를 선언하고 코드를 생성한다:

graph: filefunc
rules:
  - name: CheckOneFileOneFunc
    role: warrant
    qualifier: 1.0
  - name: TestFileException
    role: rebuttal
    qualifier: 1.0
defeats:
  - from: TestFileException
    to: CheckOneFileOneFunc
toulmin graph filefunc.yaml    # graph_gen.go 생성

규칙 함수만 Go로 짜면 된다. 그래프 구조는 YAML이 선언한다.

MIT License. github.com/park-jun-woo/toulmin