Rust로 RML 엔진 만들기 ep1

아키텍처15분English

English version: Building an RML Engine in Rust, Ep. 1

설계 의사결정 기록이다. PoC는 아직 실행 전이고, 이 글은 계획과 근거다. 직접 잰 값은 로컬 맥북(macOS, Apple Silicon) 기준이고, 다른 엔진의 성능 수치는 각 출처가 공개한 값이다.

배경 - SIEM 하나에 어댑터 하나

보안관제 자동화 제품을 만들고 있다. 여러 SIEM에서 탐지 이벤트를 받아 위협을 판정하고, 확실한 위협은 사람이 보기 전에 장비에서 먼저 차단(선차단)하는 게 목표다.

지금 구조에서는 SIEM을 하나 붙일 때마다 그 SIEM 전용 어댑터가 생긴다. 수집 코드, 필드 변환 규칙, 다른 데이터와 엮는 조인 코드가 SIEM마다 따로 있다.

같은 IP를 한 SIEM은 src_ip, 다른 SIEM은 sourceAddress, 또 다른 SIEM은 attacker_ip라고 부르니, 섞는 코드는 소스 조합만큼 늘어난다.

선차단처럼 판단을 기계에 맡길수록 “이 연결이 어디서 어떻게 이뤄졌나”가 한눈에 보이지 않는다는 것 자체가 위험이 된다.

그래서 어댑터 대신 온톨로지를 들이기로 했다.
목표는 새 SIEM을 붙일 때 어댑터 코드를 짜지 않고 정규화 규칙과 매핑 문서만 더하는 것, 그리고 어떤 필드가 어떤 판단에 쓰였는지 문서로 남는 것이다.

온톨로지 도입에는 개념 설계, 데이터 정규화와 관리, 버저닝, 거버넌스, 대응 자동화가 모두 들어가는데, 이 작업일지는 그중 데이터를 개념에 맞춰 들여오는 엔진을 다룬다.

목표

RML 매핑을 레코드 단위로, 가볍게, 명세 그대로 실행하는 엔진(RMLrs)을 Rust로 만든다. 기존 엔진에 없는 기능을 더하는 게 아니라, 같은 매핑을 같은 결과로 더 빠르고 가볍게 돌리는 것이 목표다.

축목표기준
동작 방식레코드 단위 스트리밍, 상주해도 가벼움아래 “벤치마크 목표”
명세 적합현존 최상위 엔진(CARML) 이상공식 RML 테스트 Core 76/76, IO ≥ 61/73, FNML ≥ 18/20, CC 35/35, LV 41/41, 그리고 CARML에 없는 SQL 77/77
쓰임새실행 파일 하나, Python, 브라우저에서 같은 결과Core 이후 바로 Python 휠·WASM
라이선스제품에 넣을 수 있게Apache-2.0
생태계표준 생태계에 등재공식 RML 구현 보고서, awesome-kgc-tools

벤치마크 목표 - 주류 엔진 이상

성능은 RML 커뮤니티가 공식 챌린지에서 쓰는 방법으로 잰다. 2026년 KGCW 챌린지 성능 트랙은 참가 엔진마다 같은 사양의 VM(Intel Xeon Gold 5220 2.2GHz, RAM 16GiB, 디스크 120GiB)을 주고, 공식 벤치마크 KROWN을 개조한 하네스로 엔진을 Docker 안에서 돌려 실행 시간, RAM, CPU를 일정 간격으로 잰다. 시나리오는 여섯 가지다.

KROWN 시나리오무엇을 바꿔 가며 재나
Data-size레코드 수, 레코드당 속성 수
Joins조인 카디널리티, 조인되는 데이터 비율
MappingsTriplesMap 수, TriplesMap당 술어·목적어 맵 수
Named Graphs그래프 맵 수와 위치(주어 맵·술어목적어 맵)
Duplicates-empty중복 비율, 빈 값 비율
Collections and ContainersRML-CC 집계

2026년 성능 트랙에는 CARML(두 백엔드)과 SDM-RDFizer만 참가했다. 보고서가 밝힌 사실 중 기준으로 삼을 것은 이렇다.

  • CARML은 4개 시나리오 중 3개에서 시간·CPU가 SDM-RDFizer보다 좋았고, Named Graphs에서는 SDM-RDFizer가 가장 빨랐다.
  • CARML의 최대 상주 메모리는 입력 크기와 거의 무관하게 약 3.0~3.9GB였다(1,000만 행 Data-size 포함, JVM 힙 1GB 설정).
  • SDM-RDFizer는 만든 문장을 모두 기억해 중복을 지우기 때문에 입력이 클수록 메모리가 커진다.
  • 보고서는 결과를 엔진 간 상대 순위로만 공개했다. 절대 시간은 공개되지 않아, 비교하려면 같은 하네스에서 직접 돌려야 한다.

C++ 엔진 FlexRML은 같은 KROWN 시나리오와 GTFS-Madrid-Bench로 자체 측정한 절대 수치를 README에 공개했다(Ryzen 5 7500F 6코어, 3회 평균). 예: GTFS 100_csv 3,960만 문장을 5.7초, 최대 메모리 875MiB에 만들었다. 다만 공식 적합성 결과는 없다.

이를 바탕으로 RMLrs의 성능 목표를 정한다.

목표기준 엔진측정 방법
KROWN 6개 시나리오 모두에서 시간·CPU·RAM이 CARML·SDM-RDFizer보다 낫다공식 성능 트랙 참가 엔진챌린지와 같은 KROWN 하네스, 같은 사양 환경, 같은 반복 횟수
최대 메모리가 입력 크기에 비례해 커지지 않는다(1,000만 행에서도 일정)SDM-RDFizer(입력 비례), CARML(약 3~4GB 고정)KROWN Data-size, Duplicates-empty
Morph-KGC보다 빠르고 가볍다사용량 1위 Python 엔진KROWN에 Morph-KGC 실행 설정이 있어 같은 조건으로 비교
GTFS-Madrid-Bench에서 FlexRML 수치 이하가장 빠른 네이티브 엔진같은 머신에서 FlexRML과 나란히 재실행
상주 비용: 시작 즉시, 대기 메모리 수십 MB 이하Morph-KGC import 약 130MB, JVM 엔진같은 머신에서 시작 시간·대기 RSS 측정

이 수치들은 목표이고, 달성 여부는 성능 작업 단계에서 공개 측정으로 확인한다.

표준 준수 규칙 - 무엇을 통과해야 “준수”인가

  • 기준은 KG-Construct 커뮤니티 그룹의 공식 테스트 케이스다. 모듈별(RML-Core, IO, FNML, CC, LV, star) 테스트를 원본 그대로 돌린다. 매핑이나 기대 출력을 엔진에 맞게 고친 사본으로 돌리지 않는다.
  • 판정은 그래프 동형 비교다. 생성한 문장 집합이 기대 출력(N-Quads)과 같아야 한다. 오류를 기대하는 테스트는 그 오류가 나야 통과다. “미구현”으로 생긴 오류는 통과로 세지 않는다.
  • 결과는 공식 구현 보고서 형식(testcase_id,status,notes, 상태는 passed·failed·inapplicable)으로 낸다. 기대 출력 자체에 오류가 있다고 공식 검토 중인 케이스(예: RML-IO 일부)는 챌린지처럼 따로 표시한다.
  • 명세 표기를 그대로 받는다. 소스 경로(RelativePathSource), JSONPath(RFC 9535 표기 $.), 자연 데이터 타입처럼 공식 테스트가 2024~2025년에 바꾼 부분까지 따른다.

이번 편에서는 엔진을 만들기 전에, 이 엔진이 들어갈 구조가 맞는지부터 지금 있는 도구로 확인할 계획을 세운다.

고민했던 부분 - 1

RML 하나로 충분한가

처음에는 RML 하나로 다 하려 했다. RML은 “소스의 이 필드는 저 개념이다”를 매핑 문서로 선언하는 표준이고(조인 코드 없이 데이터를 섞는 법, RML), 같은 틀로 만든 이름표(IRI)는 같은 개체가 되므로 조인 코드 없이 소스가 섞인다.

그런데 RML만 쓰면 SIEM마다 매핑 카드가 하나씩 늘어난다. 어댑터 코드가 매핑 카드로 바뀌었을 뿐, “소스마다 하나”라는 구조는 그대로다.

그래서 로그 파이프라인 Vector의 코드를 읽어 봤다. Vector는 이벤트를 한 건씩 받아 VRL로 필드 이름과 값을 바꿔 DB나 Kafka로 넘기는 배관이다. 소스마다 다른 필드를 공통 필드로 맞추는 일을 하고, 서로 다른 소스의 레코드를 같은 개체로 잇는 일은 하지 않는다.

둘은 목적이 다르다. Vector는 소스마다 다른 필드를 공통 필드로 맞추는 도구이고, RML은 레코드를 개념에 잇는 도구다. 각자의 일을 맡기면 된다.

SIEM A ─┐
SIEM B ─┼─▶ [Vector] 소스별 필드 → 공통 필드       ← 소스 차이는 여기서 전부 흡수
SIEM C ─┘     (VRL, 이벤트 한 건씩, 상태 없음)
              │ 공통 필드 레코드
              ▼
        [RML 엔진] 공통 필드 → 개체·관계 그래프   ← 매핑은 공통 스키마용 한 장
              │ (같은 IP·호스트를 이름표로 잇기, 회사·시간 같은 정체성 규칙도 여기)
              ▼
        그래프 저장소 → 판정 · 선차단

결정: 정규화는 Vector, 개체 연결은 RML. 이렇게 나누면 RML 매핑은 SIEM 수와 상관없이 공통 스키마 하나만 본다. 매핑이 작고 안정적이고, 정체성 규칙이 한 곳에 모인다. 소스가 늘면 Vector 쪽 VRL 규칙만 는다.

고민했던 부분 - 2

DataFrame을 상주시켜야 하나

Python RML 엔진 중 가장 많이 쓰이는 Morph-KGC는 pandas DataFrame 위에서 동작한다. 데이터를 모아 한꺼번에 처리하는 배치에 맞춘 설계다. 이 방식을 컨테이너로 상시 실행하는 서비스에 넣으면 자원이 얼마나 드는지 재 봤다.

측정 (로컬 맥북)메모리시간
빈 Python16MB0.01초
import morph_kgc약 130MB1.4초
100만 행 열 연산, pandas301MB0.8초
같은 작업, Polars184MB0.2초

Polars는 처리 메모리와 속도를 크게 줄이지만, Python에서 쓰는 한 상시 무게는 크게 줄지 않는다. 그런데 매핑을 쪼개 보면 필드 대응과 이름표 생성은 레코드 한 건 안에서 끝나는 상태 없는 작업이다. 상태가 필요한 건 조인과 중복 제거뿐이고, 이건 조회표나 DB로 처리할 수 있다. DataFrame은 필수가 아니라 엔진의 구현 선택이라 판단하여, 상시 실행 경로에서는 제외했다.

PoC 계획 - 지금 있는 도구로 먼저 확인한다

PoC 목표는 하나다. SIEM 두 개의 데이터가 공통 스키마용 매핑 한 장으로 같은 IP 노드에 모이는지 확인하는 것이다. 그 외 사안들은 고려하지 않는다.

구성

단계도구역할
1합성 데이터필드 이름이 서로 다른 SIEM 세 개. 두 개로 시작하고 세 번째는 “추가해도 매핑 불변” 확인용 (사내 데이터 쓰지 않음)
2VectorSIEM을 공통 필드로 바꿔 Postgres events 테이블에 적재
3매핑 한 장공통 스키마(events) → 탐지·IP·호스트 개념. 정체성 규칙(회사 구분)을 템플릿에 포함
4-AOntop (가상 그래프)Postgres 위에서 R2RML로 질의 시점에 그래프를 보여 줌. 복사본 없음
4-BMorph-KGC (배치 실체화)같은 매핑으로 그래프를 만들어 저장소에 적재. 배치 작업으로 분리해 상주 비용을 피함
5SPARQL 질의“서로 다른 SIEM이 같은 출발지 IP로 보고한 탐지 묶기”

성공 기준

  1. SIEM 두 개의 탐지가 같은 IP 노드에 모인다.
  2. SIEM을 하나 더 붙일 때 바뀌는 건 Vector 규칙뿐이고 매핑은 그대로다.
  3. 회사가 다른 같은 사내 IP는 다른 노드로 남는다(정체성 규칙 확인).
  4. 4-A와 4-B가 같은 질의에 같은 답을 낸다(같은 매핑을 두 엔진이 같은 뜻으로 해석하는지).

함께 잴 것: 상시 메모리, 처리 중 메모리, 시작 시간, 탐지 1건이 그래프에 반영되기까지 걸리는 시간(4-A는 질의 시점, 4-B는 배치 주기).

RMLrs를 만드는 이유

솔직히 말하면, 이 구조는 기존 RML 엔진으로도 된다. PoC가 성공하면 CARML이나 SDM-RDFizer처럼 공식 테스트를 모두 통과하는 엔진을 그대로 써도 제품은 굴러간다.

그런데도 RML을 Rust로 직접 구현하는 RMLrs를 만드는 건 개인적인 욕심이다. 두 가지를 제대로 익히고 싶다.

  • RML을 몸으로 익히고 싶다. 매핑을 쓰는 것과, 매핑이 어떻게 문장이 되는지 끝까지 구현해 보는 것은 다르다. 이름표 생성, 값이 없을 때의 처리, 자연 데이터 타입, 조인까지 명세를 한 줄씩 코드로 옮기면서 온톨로지의 데이터 정규화를 바닥부터 이해하려 한다.
  • Rust를 배우고 싶다. 파서, 스트리밍 처리, 메모리를 아끼는 조인처럼 Rust가 잘하는 문제를 실제 크기의 프로젝트로 다뤄 보고 싶다.

욕심으로 시작하지만 결과물은 쓸모가 있게 만든다. 엔진들을 재 보는 과정에서 빈자리도 보였다. 실시간 선차단까지 가려면 탐지가 들어오는 대로 그래프에 반영돼야 하는데, 지금 있는 엔진들은 각자 걸리는 점이 있다.

Vector ─(Kafka · HTTP, 공통 필드 JSON)─▶ [레코드 단위 RML 엔진] ─▶ 그래프 증분 갱신 ─▶ 판정 · 선차단
                                           가볍게 상주, 같은 매핑 한 장
바라는 것왜지금 엔진들
레코드 단위 스트리밍탐지가 바로 그래프에 들어가야 선차단이 빨라진다Morph-KGC·SDM-RDFizer는 배치형, RMLStreamer(Flink)는 2024년 이후 정체
가벼운 상주컨테이너 상시 서비스Python 엔진은 import만 약 130MB, JVM 엔진(CARML·RMLMapper·BURP)도 무겁다
명세 적합매핑 문서를 엔진과 상관없이 쓰려고공식 RML-Core 출력 테스트 61개 중 Morph-KGC 2.10.0은 1개 통과(명세 표기 그대로, 직접 측정). 완전 적합은 SDM-RDFizer·CARML(미출시 브랜치)·BURP-Error
상용 라이선스제품에 넣으려면FlexRML(C++)은 가볍지만 AGPL-3.0, rml-mapper-lib(Rust)는 라이선스 미표기·적합성 결과 없음

네 가지를 모두 갖춘 엔진은 아직 없다. 그래서 RMLrs는 새 기능이 아니라 이 네 가지를 목표로 삼는다. 원칙은 이렇다.

  • 퓨어함: 엔진은 매핑 + 레코드 → 문장만 한다. 수집·저장·판정은 앞뒤 단계의 일이다.
  • 명세 우선: 공식 테스트를 원본 그대로 통과한다.
  • Apache-2.0: 제품에 넣을 수 있어야 한다.
  • 측정으로 증명: 공식 적합성 테스트와 벤치마크 수치로 말한다.

매핑 문서가 표준이므로, PoC에서 쓴 매핑 한 장은 엔진만 RMLrs로 바꿔도 그대로 쓴다.
기존 엔진으로 구조를 먼저 증명하고, 같은 매핑으로 내 엔진을 검증한다.

정리

  1. 정규화(Vector)와 개체 연결(RML)을 나눈다. 매핑은 공통 스키마용 한 장이다.
  2. PoC는 Vector → Postgres → 매핑 한 장 → Ontop(가상) / Morph-KGC(배치)로 구성한다.
  3. 성공 기준은 같은 IP 노드로 모이기, SIEM 추가 시 매핑 불변, 정체성 규칙 동작, 두 엔진의 같은 답이다.
  4. 구조는 기존 엔진으로도 되지만, RML을 체득하고 Rust를 배우려고 RMLrs를 만든다. 목표는 레코드 단위·가벼움·명세 적합·상용 라이선스를 모두 갖추는 것이다.
  5. PoC의 매핑은 엔진을 바꿔도 그대로 쓴다. 그게 RML을 고른 이유다.

참고