이벤트 기반 시스템에서 “더 많은 기능”이 아니라 “제약”을 통해 예측 가능성과 신뢰성을 얻는 방법을 소개하고, Rust로 작성된 워크플로 엔진 Emergent가 세 가지 역할(Source/Handler/Sink) 제약으로부터 어떤 성질들이 자연스럽게 나타나는지 설명한다.
이벤트 기반 시스템에서의 통념은 더 많은 기능이 더 큰 힘을 의미한다는 것이다. 모든 컴포넌트에 게시, 구독, 변환, 라우팅 능력을 부여하면 개발자는 무엇이든 만들 수 있다. 이는 사실이다. 동시에 이것이 이벤트 기반 아키텍처에서 대부분의 복잡성이 생겨나는 원천이기도 하다. 모든 것이 모든 것을 할 수 있으면, 구조만으로는 시스템에 대해 아무것도 예측할 수 없다. 토폴로지를 이해하려면 모든 컴포넌트의 구현을 읽어야 한다. 설정만으로는 무엇이 무엇과 연결되는지 알 수 없기 때문에, 런타임에서 메시지 흐름을 추적해야 한다.
Rust 개발자는 이미 이 문제를 이해하고 있다. 언어 자체가 제약이 어떻게 힘을 만들어내는지에 대한 사례 연구다. borrow checker는 메모리 접근을 제약하고 데이터 레이스를 제거한다. Option은 null 처리 방식을 제약하고 null 포인터 예외를 제거한다. Result는 오류 처리 방식을 제약하고 처리되지 않은 예외를 제거한다. 각각의 경우, 제약은 처음에는 제한적으로 느껴지지만 곧 언어의 신뢰성 보장의 근원임이 드러난다.
같은 원리가 이벤트 기반 시스템에도 적용된다면 어떨까? 더 많은 기능이 아니라 더 적은 기능. 각 컴포넌트를 세 가지 역할 중 하나로 제약하고, 시스템 경계에서 그 제약을 강제한 다음 무슨 일이 벌어지는지 보자.
이것이 Rust로 작성된 이벤트 기반 워크플로 엔진 Emergent 뒤에 있는 가설이다. 이름은 의도적이다.
Emergent 파이프라인의 모든 컴포넌트는 정확히 세 가지 타입 중 하나다.
| Primitive | Can Publish | Can Subscribe | Role |
|---|---|---|---|
| Source | Yes | No | 데이터를 시스템으로 가져옴 |
| Handler | Yes | Yes | 데이터를 변환함 |
| Sink | No | Yes | 데이터를 시스템 밖으로 보냄 |
Source는 메시지를 받을 수 없다. Sink는 메시지를 게시할 수 없다. Handler는 둘 다 한다. 이것이 설계의 전부다.
이는 제한적으로 보이며, 실제로 그렇다. Source는 눈이 멀어 있다. 누가 받는지 모른 채 이벤트를 내보낸다. Sink는 종단점이다. 이벤트를 소비하지만 새로운 이벤트를 만들지 않는다. 오직 Handler만이 중간에 위치해 구독하고 게시한다. 하위 스트림 이벤트를 내보낼지 여부를 결정하는 조건 로직이 필요하다면 그 로직은 Handler에 있다. 데이터베이스에 써야 한다면 그것은 Sink다. 웹훅에서 수집해야 한다면 그것은 Source다.
Rust SDK는 별도의 타입을 통해 이 제약을 강제한다. EmergentSource에는 publish 메서드가 있지만 subscribe 메서드는 없다. EmergentSink에는 subscribe 메서드가 있지만 publish 메서드는 없다. EmergentHandler에는 둘 다 있다. 이것들은 동일한 제네릭 타입의 서로 다른 설정이 아니다. 서로 다른 역량을 가진 서로 다른 struct이며, Rust 컴파일러가 경계를 강제한다. 게시하려는 Sink를 작성하면 컴파일되지 않는다. 이 제약은 README에 문서화된 관례가 아니다. 타입 시스템의 성질이다.
이는 Rust 개발자가 어디에서나 사용하는 동일한 패턴이다. 유효하지 않은 상태를 표현 불가능하게 만들기. 유효한 UTF-8이 아닌 String을 만들 수 없다. MutexGuard를 drop한 뒤에는 사용할 수 없다. Sink에서 게시할 수 없다. 컴파일러가 코드가 실행되기 전에 실수를 잡아낸다.
이 제약은 문제를 일으키는 선택지를 제거한다. Source는 다른 컴포넌트의 출력에 의존하지 않는다. Sink는 예상치 못한 하위 스트림 동작을 촉발하는 이벤트를 만들지 않는다. 시스템이 오작동할 때 세 가지 primitive 제약은 탐색 공간을 좁혀준다. 데이터는 Source로 들어오고, Handler에서 변환되며, Sink로 나간다. 뭔가가 잘못되면 primitive 타입이 어디를 봐야 하는지 알려준다.
시스템의 유용한 성질은 설계된 기능이 아니다. 제약으로부터 자연스럽게 나타나는 결과다.
설정이 아키텍처가 된다. 각 컴포넌트가 게시만 하거나, 구독만 하거나, 둘 다만 할 수 있다면, 이 관계를 선언하는 설정 파일은 완전한 토폴로지가 된다. 여기 파이프라인이 있다. 타이머가 tick을 내보내고, 필터가 다섯 번째 tick마다 통과시키며, 콘솔이 결과를 출력한다.
[[sources]]
name = "timer"
path = "./target/release/timer"
publishes = ["timer.tick"]
[[handlers]]
name = "filter"
path = "./target/release/filter"
subscribes = ["timer.tick"]
publishes = ["timer.filtered"]
[[sinks]]
name = "console"
path = "./target/release/console"
subscribes = ["timer.filtered"]
이는 문서화 산출물이 아니다. 실행 가능한 명세다. 엔진은 이 TOML을 파싱하고 각 프로세스를 생성하며, 런타임에서 선언된 계약을 강제한다. Source는 구독할 수 없고, Sink는 게시할 수 없으며, 메시지는 선언된 구독으로만 라우팅된다.
의미는 단순히 “설정 파일이 읽기 쉽다”를 넘어선다. 전통적인 이벤트 기반 시스템에서는 아키텍처가 코드에 산다. 설정 파일은 어떤 프로세스를 실행할지 알려주지만, 각 프로세스의 소스를 읽어야 무엇을 게시하고 무엇을 구독하는지, 다른 프로세스와 어떻게 연결되는지 이해할 수 있다. 아키텍처는 N개의 코드베이스에 분산되어 있고, 어떤 컴포넌트든 설정에 변화가 반영되지 않은 채로 동작을 바꿀 수 있다.
컴포넌트가 세 가지 primitive로 제약되면, 설정은 각 컴포넌트가 할 수 있는 일이 제한되기 때문에 전체 토폴로지를 포착한다. publishes = ["timer.tick"]로 나열된 Source는 게시만 할 수 있다. 실제로 다른 것을 구독하지 않는지 확인하려고 소스 코드를 열 필요가 없다. TOML 파일은 아키텍처의 근사치가 아니다. 그것이 아키텍처다.
이는 팀의 일하는 방식을 바꾼다. 새 팀원이 파일 하나를 읽고 시스템 전체의 데이터 흐름을 이해한다. 아키텍처 리뷰는 코드베이스 투어가 아니라 설정 파일을 놓고 이루어진다. 파이프라인 리팩터링은 TOML을 편집하고 바이너리를 교체하는 일이다. 두 팀이 통합해야 하면 TOML 스니펫과 바이너리를 공유하면 되고, 토폴로지는 명시적이다. 컴포넌트가 세 가지 중 하나만 할 수 있다는 제약이 이를 가능하게 한다. 제약이 없다면 설정 파일은 생략을 통해 거짓말을 하게 된다.
라이프사이클 순서가 제약으로부터 따라 나온다. 세 역할에는 자연스러운 데이터 흐름 방향( Source는 생성, Handler는 변환, Sink는 소비 )이 있으므로, 엔진은 올바른 시작/종료 순서를 자동으로 도출할 수 있다. Sink는 Handler가 내보내기 전에 준비되어야 하고, Handler는 Source가 생산하기 전에 준비되어야 한다. 엔진은 Sink를 먼저 시작하고, 그 다음 Handler, 마지막으로 Source를 시작한다. 종료는 역순이다. Source는 생산을 멈추고, Handler는 비행 중 메시지를 비우며, Sink는 소비를 마친다.
이는 잘못된 라이프사이클 순서가 이벤트 기반 시스템에서 가장 흔한 버그 원인 중 하나이기 때문에 중요하다. 하위 Sink가 준비되기 전에 Source가 시작되면 메시지가 드롭되거나 무한 버퍼에 큐잉된다. 상위 Handler가 드레인하기 전에 Sink가 종료되면 이벤트가 사라진다. 전통적인 시스템에서는 이를 명시적 헬스 체크, readiness probe, 또는 시작 순서 설정으로 해결한다. 이는 누군가가 올바르게 맞추고 토폴로지가 진화해도 계속 맞게 유지해야 하는 수동 조정이다.
Emergent의 3단계 라이프사이클은 설정이 필요 없다. 엔진은 각 primitive의 역할을 검사하고, TOML 파일의 publish/subscribe 관계로부터 의존성 그래프를 구성한 뒤, 계층 간 짧은 안정화 지연을 두고 올바른 순서로 컴포넌트를 시작한다. ProcessManager는 Sink를 먼저 시작하고, 그 다음 Handler, 그 다음 Source를 시작한다. 종료는 역순으로 graceful_shutdown을 실행한다. SIGTERM으로 Source를 멈추고, system.shutdown을 Handler에 브로드캐스트한 뒤 드레인을 기다리고, 그 다음 Sink에 브로드캐스트하고 완료를 기다린다. 3단계 라이프사이클은 세 역할의 직접적인 결과다. 설정하지 않는다. 설정할 것이 없기 때문이다. 제약이 순서를 결정한다.
프로세스 격리로부터 언어 독립성이 따라 나온다. 각 primitive가 Unix 소켓으로 통신하는 독립 프로세스이기 때문에, 엔진은 어떤 언어로 작성되었는지 신경 쓰지 않는다. Rust Source가 Python Handler에 공급하고, 그것이 TypeScript Sink로 게시할 수 있다. 언어 선택은 컴포넌트별 결정이 된다. pandas가 중요한 곳은 Python을, 웹 통합은 TypeScript를, 성능이 중요한 경로는 Rust를 쓴다.
Emergent 예제 파이프라인의 실제 설정 파일이 이를 보여준다. Rust 타이머 Source, Rust 필터 Handler, Deno 기반 컬러 콘솔 Sink, Python 웹훅 Sink가 동일한 파이프라인에서 함께 실행된다. 각 primitive는 엔진이 생성하는 별도 프로세스이며, MessagePack 직렬화를 사용해 동일한 Unix 소켓으로 연결된다. 세-primitive 제약 덕분에 엔진이 필요로 하는 분류는 “Source, Handler, Sink”뿐이므로, 엔진은 이들을 모두 동일하게 관리한다.
이 다언어(polyglot) 능력은 설계 목표가 아니었다. 제약, 즉 primitive가 제약된 인터페이스를 가진 격리 프로세스라는 점에서 자연스럽게 나온 결과다.
중앙 라우팅으로부터 이벤트 소싱이 따라 나온다. 모든 메시지는 라우팅을 위해 엔진을 통과한다. 메시지가 중앙 지점을 통과하기 시작하면, 이를 영속화하는 것은 사소하다. 엔진은 개발자가 어떤 설정도 하지 않아도 자동으로 JSON 로그 파일과 SQLite 데이터베이스 둘 다에 append한다. 완전한 이벤트 히스토리, 인과(causation) 체인, 리플레이 능력까지.
이벤트 스토어가 실제로 제공하는 것은 단순한 “로그”보다 더 흥미롭다. Emergent의 모든 메시지에는 고유한 MessageId가 있다( msg_ 접두사를 가진 TypeID이며 UUIDv7에 기반하므로 ID는 기본적으로 시간 기준 정렬이 가능하다). Handler가 출력 메시지를 만들 때 with_causation_from_message를 사용해 그 출력을 원인이 된 입력과 연결할 수 있다. 엔진은 모든 메시지에 대해 인과 ID와 선택적인 correlation ID를 영속화한다.
이는 SQLite 이벤트 스토어를 질의해 어떤 이벤트의 전체 처리 히스토리를 재구성할 수 있음을 의미한다. “이 알림은 시간 T에 handler X가 만들었고, source Z의 메시지 Y에 의해 촉발되었다.” 시간 범위로 질의해 특정 구간에 일어난 모든 일을 볼 수 있다. correlation ID로 질의해 여러 handler에 걸친 전체 요청-응답 체인을 추적할 수 있다. 메시지 타입으로 질의해 특정 이벤트의 모든 발생을 볼 수 있다.
// Querying causation chains from the event store
let events = store.query_by_correlation(&correlation_id)?;
let timeline = store.query_by_time_range(start_ms, end_ms)?;
let ticks = store.query_by_type("timer.tick")?;
이벤트 스토어는 라우팅 아키텍처의 공짜 결과이며, 그 라우팅 아키텍처 자체가 primitive가 직접 통신할 수 없다는 제약에서 따라 나온다. primitive가 직접 채널로 서로 대화할 수 있다면 엔진은 메시지를 보지 못하고, 관측 가능성을 위해 각 컴포넌트를 개별적으로 계측해야 한다. 모든 통신이 엔진을 통해 라우팅된다는 제약이 이벤트 소싱을 자동으로 만든다.
세-primitive 제약은 눈에 보이는 설계다. 그 아래에는 한 층 더 내려가 동일한 철학을 적용한 내가 만든 Rust 액터 프레임워크 acton-reactive가 있다. 런타임을 제약하면 신뢰성이 나타난다.
각 primitive는 자체의 제한된(inbox가 bounded인) 메일박스를 가진 격리된 액터로 실행된다. Handler가 panic하더라도 acton-reactive가 실패를 포착해 시스템의 다른 액터에 영향을 주지 않는다. 연쇄(cascade)가 없다. 엔진은 Erlang 스타일 감독(supervision) 전략(실패한 primitive만 재시작, 모든 primitive 재시작, 또는 실패 지점의 하위 전체 재시작)을 적용하고, 재시작 폭풍을 막기 위해 지수 백오프를 사용한다. Sink를 작성하는 개발자는 이 중 어떤 것도 설정하지 않는다. 제약(primitive 하나 = 액터 하나 = 프로세스 하나)이 격리와 감독을 자동으로 만든다.
이것이 “크래시를 처리한다”를 넘어 왜 중요할까? 감독은 실패 모델을 바꾸기 때문이다. 감독이 없으면, 실패한 컴포넌트는 실패한 파이프라인을 의미한다. 운영자가 호출되고, 누군가 수동으로 프로세스를 재시작하거나 재시작 로직이 있는 systemd 유닛을 추가한다. 액터 감독이 있으면, 실패한 primitive는 일시적 이벤트다. 엔진은 실패를 감지하고(자식 모니터링 태스크가 ChildExited 메시지를 액터에 보냄), 설정된 재시작 정책을 적용해 primitive를 되살린다. 실패가 지속되면 지수 백오프가 재시작 폭풍을 방지한다. 실패가 영구적이면 primitive는 Failed 상태로 들어가고 나머지 파이프라인은 계속 실행된다. 시스템은 원자적으로 실패하는 대신 우아하게 열화된다.
백프레셔(backpressure)도 같은 아키텍처에서 따라 나온다. 모든 액터의 inbox는 bounded다. 느린 Sink가 빠른 Source를 따라가지 못할 때, bounded 채널이 메시지를 조용히 드롭하는 대신 자연스러운 흐름 제어를 적용한다. IPC 레이어는 연결별 레이트 리미팅을 그 위에 추가한다. 이 또한 설정이 필요 없다. 모든 메시지가 bounded inbox를 가진 액터를 통해 라우팅된다는 제약이 백프레셔를 선택 기능이 아니라 구조적 성질로 만든다.
엔진 자체도 acton-reactive의 type-state 패턴을 사용하며, 여기서 설계는 깊이 Rust스럽다. Idle 상태의 액터는 메시지 핸들러를 등록할 수 있다. Started 상태의 액터는 메시지를 처리할 수 있다. 이는 런타임 플래그가 아니다. 제네릭 타입 파라미터다.
// ManagedActor<Idle, State> has register methods
let mut actor = runtime.new_actor::<PrimitiveActorState>(name);
actor.mutate_on::<ChildSpawned>(|actor, envelope| { /* ... */ });
// .start() consumes the Idle actor, returns a Started handle
let handle = actor.start().await;
// actor.mutate_on(...) would not compile here - actor was moved
Rust 컴파일러는 start()가 move로 self를 소비하기 때문에 액터가 시작된 뒤 핸들러 등록을 막는다. Idle 타입은 사라진다. 시작된 핸들에서 등록 메서드를 호출하려 하면 런타임 panic이 아니라 컴파일 에러가 난다. 이는 Rust 개발자가 builder, 연결 상태, 프로토콜 시퀀스에 사용하는 type-state 패턴이다. acton-reactive에서는 액터 라이프사이클을 컴파일 타임에 강제한다.
전체 프레임워크는 unsafe 코드 없이 구축되었다. Rust 개발자에게 이는 의미 있는 보장이다. 액터 격리, 메시지 패싱, 라이프사이클 관리가 모두 안전한 Rust의 소유권 및 빌림 규칙 위에 구축되었음을 의미한다. 안전 보장이 사라지는 FFI 경계도 없고, 메모리 모델을 무효화할 수 있는 raw pointer 조작도 없다. primitive 런타임 인프라의 안전성은 primitive 비즈니스 로직을 검사하는 동일한 컴파일러에 의해 검증된다.
헬퍼 패턴은 각 primitive를 본질적인 형태로 줄여준다.
run_sink(
Some("console"),
&["timer.filtered"],
|msg| async move {
println!("{}", msg.payload());
Ok(())
}
).await?;
이것이 완전한 Sink다. 연결, 백프레셔, 결함 격리, 시그널 처리, 우아한 종료, IPC는 프레임워크가 처리한다. run_sink의 타입 시그니처를 보자.
pub async fn run_sink<F, Fut>(
name: Option<&str>,
subscriptions: &[&str],
consume_fn: F,
) -> HelperResult<()>
where
F: Fn(EmergentMessage) -> Fut + Send + Sync,
Fut: Future<Output = Result<(), String>> + Send,
consume_fn은 EmergentMessage를 받아 Result로 완료되는 future를 반환한다. Fn 트레이트 바운드(FnOnce가 아님)는 클로저가 각 메시지마다 반복 호출됨을 의미한다. Send + Sync 바운드는 Tokio 런타임이 요구하는 것처럼 스레드 경계를 안전하게 넘을 수 있음을 의미한다. Future의 출력이 Send이므로 어떤 워커 스레드에서든 poll될 수 있다. 이 시그니처의 모든 제약은 컴파일러가 강제하며, 함께 합쳐져 동시성 멀티스레드 async 런타임에서 Sink 콜백을 안전하게 실행할 수 있음을 보장한다.
TypeScript와 Python SDK도 동일한 패턴을 노출한다.
await runSink("console", ["timer.filtered"], async (msg) => {
console.log(msg.payloadAs<{ sequence: number }>());
});
Sink는 구독만 할 수 있기 때문에, 헬퍼는 무엇을 설정해야 하는지 정확히 안다. 연결하고, 선언된 타입을 구독하고, 각 메시지에 대해 콜백을 실행하고, 종료 시 연결을 끊는다. 설정 매트릭스도 없고, 모드 선택도 없다. primitive 타입이 모든 것을 결정한다.
Handler 헬퍼도 게시 기능이 하나 추가된 것만 빼면 같은 패턴이다. 또한 게시한다.
run_handler(
Some("filter"),
&["timer.tick"],
|msg, handler| async move {
let output = EmergentMessage::new("timer.filtered")
.with_causation_from_message(msg.id())
.with_payload(json!({"filtered": true}));
handler.publish(output).await.map_err(|e| e.to_string())
}
).await?;
with_causation_from_message 호출은 출력을 입력과 연결해 추적 가능한 이벤트 체인을 만든다. 모든 메시지는 TypeID(자기 기술적이며 시간 정렬 가능한 식별자)를 가지며, 파생 메시지는 부모를 가리킨다. 인과 ID는 stringly-typed 필드가 아니다. 타입 시스템이 MessageId, CorrelationId와 구분하는 CausationId newtype이다. 이를 통해 실수로 잘못 사용하는 것을 방지한다. CausationId가 기대되는 곳에 CorrelationId를 전달할 수 없다. 이는 물리 계산에서 Meters와 Feet를 분리했을 때 얻는 것과 같은 보호를 이벤트 트레이싱에 적용한 Rust의 newtype 패턴이다.
이벤트 스토어로부터 어떤 이벤트의 전체 처리 히스토리를 재구성할 수 있다. 이 추적 가능성은 기능으로 설계된 것이 아니라, 모든 메시지가 엔진을 통해 라우팅된다는 제약에서 따라 나온다.
Emergent는 단일 노드에서 실행된다. 여러 머신에 걸친 분산 이벤트 처리가 필요하다면 Kafka나 NATS가 필요하다. 세-primitive 모델은 프로세스 조정을 설명하지 네트워크 조정을 설명하지 않는다.
컴포넌트 사이의 결합도가 매우 높아 한 영역의 변화가 다른 영역을 무효화하는 문제에서는, 제약된 primitive로 단순 합성하는 것만으로는 충분하지 않다. 명시적 조정이 필요하다.
세-primitive 모델은 데이터가 파이프라인을 따라 흐르는 광범위한 문제 클래스에서 동작한다. 수집, 변환, 출력. 이 클래스는 대부분의 사람이 생각하는 것보다 크다. 로그 집계는 수집-변환-출력이다. 웹훅 처리는 수집-변환-출력이다. 센서 데이터 수집, ETL 파이프라인, 알림 팬아웃, CI/CD 이벤트 라우팅, 모니터링과 알림. 이들 모두는 생산하는 Source, 결정하는 Handler, 행동하는 Sink로 자연스럽게 분해된다. 패턴을 보기 시작하면, 팀이 메시지 브로커나 수제 채널 조정으로 해결하는 대부분의 이벤트 기반 문제가 사실 파이프라인 형태임이 드러난다. 진정으로 제약 없는 토폴로지가 필요한 문제도 존재하지만, 툴링 지형이 암시하는 것보다 더 드물다.
에이전틱 AI 시스템은 제약 없는 토폴로지가 필요한 것처럼 보인다. 에이전트는 추론하고, 계획하고, 도구를 사용하고, 다른 에이전트에게 위임하고, 만족할 때까지 루프를 돈다. 지배적인 패턴은 오케스트레이터, 즉 관리자 에이전트가 작업을 전문 에이전트에게 할당하고 출력물을 조정하는 방식이다.
하지만 각 에이전트가 실제로 하는 일을 보자. 컨텍스트를 받고, 그에 대해 추론하고, 결정 또는 행동 요청을 만들어낸다. 이는 Handler다. 계획 에이전트는 작업을 구독하고 하위 작업을 게시한다. 도구 호출 에이전트는 행동 요청을 구독하고 결과를 게시한다. 라우팅 에이전트는 사용자 입력을 구독하고 올바른 전문 에이전트로 게시한다. 각 에이전트는 소비하고 생산한다. 이것이 Handler 제약이다.
추론 체인을 시작하는 트리거(사용자 프롬프트, 스케줄된 작업, 웹훅 이벤트)는 Source다. 세계에 대한 효과(이메일 전송, 데이터베이스 업데이트, 사용자 응답)는 Sink다. 에이전트는 그 사이의 Handler다.
다음은 다중 에이전트 코드 리뷰 파이프라인이 Emergent 설정으로 어떻게 보이는지다.
# Code review agentic pipeline
[[sources]]
name = "github-webhook"
path = "uv"
args = ["run", "--project", "./agents/webhook", "python", "main.py"]
publishes = ["pr.opened"]
[[handlers]]
name = "code-analyzer"
path = "./target/release/code-analyzer"
subscribes = ["pr.opened"]
publishes = ["analysis.complete"]
[[handlers]]
name = "review-agent"
path = "uv"
args = ["run", "--project", "./agents/reviewer", "python", "main.py"]
subscribes = ["analysis.complete"]
publishes = ["review.ready"]
[[handlers]]
name = "approval-gate"
path = "./target/release/approval-gate"
subscribes = ["review.ready"]
publishes = ["review.approved", "review.changes-requested"]
[[sinks]]
name = "github-commenter"
path = "uv"
args = ["run", "--project", "./agents/commenter", "python", "main.py"]
subscribes = ["review.approved", "review.changes-requested"]
[[sinks]]
name = "slack-notifier"
path = "deno"
args = ["run", "--allow-env", "--allow-net", "./agents/notifier/main.ts"]
subscribes = ["review.approved"]
GitHub 웹훅 Source가 pull request 이벤트를 내보낸다. Rust code-analyzer Handler가 빠른 정적 분석을 수행한다. Python review-agent Handler가 LLM 추론을 실행한다(ML 생태계가 있는 곳이기 때문이다). Rust approval-gate Handler가 결정론적 정책 규칙을 적용한다. Python Sink가 GitHub에 코멘트를 게시한다. Deno Sink가 Slack에 알린다. 각 에이전트는 격리된 프로세스다. 각 에이전트는 자신의 작업에 합리적인 언어로 작성될 수 있다. 전체 파이프라인의 데이터 흐름은 설정에 드러난다.
여기서 제약은 특히 강력한데, 에이전틱 시스템은 관측 가능성이 가장 중요한 곳이기 때문이다. AI 에이전트가 결정을 내릴 때는 왜 그런 결정을 했는지 추적해야 한다. 어떤 입력이 촉발했는지, 어떤 컨텍스트를 가졌는지, 무엇을 결정했는지. 인과 체인이 이를 제공한다. 모든 에이전트의 결정은 이벤트 스토어를 통해 그 결정을 유발한 이벤트로 거슬러 올라간다. review-agent가 review.ready를 게시하면, 그 메시지는 이를 촉발한 analysis.complete 메시지를 가리키는 인과 ID를 가지며, 이는 다시 웹훅의 pr.opened 이벤트를 가리킨다. Slack 알림에서 시작해 모든 것의 시작이 된 pull request까지 체인을 따라갈 수 있다.
다언어 관점도 여기서 성립한다. 추론 Handler는 ML 생태계가 있는 Python에서 실행된다. 라우팅 로직은 빠르게 동작해야 하므로 Rust에서 실행된다. Slack 알림 Sink는 TypeScript로 실행된다. 각 에이전트는 격리된 프로세스다. 환각하거나 실패하는 에이전트는 감독에 의해 포착되어 재시작되며, 파이프라인의 나머지로 연쇄되지 않는다.
오케스트레이터의 대안은 개미 군집이 작동하는 원리와 같은 원리다. 각 에이전트를 지역적 결정으로 제약하고(이해하는 것을 구독하고, 생산하는 것을 게시), 토폴로지로부터 조정이 나타나게 하라. 관리자 에이전트는 없다. 설정 파일이 조정이다.
내가 잘 작동했던 시스템을 만들 때마다 공통 속성이 있었다. 올바른 행동은 저렴했고, 잘못된 행동은 비싸거나 불가능했다. 문서로가 아니라. 코드 리뷰로가 아니라. 시스템의 형태가 그 정합성을 강제하도록 만드는 구조적 제약을 통해서였다.
Rust 개발자는 이미 이 철학 속에 산다. borrow checker는 데이터 레이스를 불가능하게 만든다. Option은 null 역참조를 불가능하게 만든다. Result는 오류 무시를 불가능하게 만든다. Emergent는 같은 원리를 한 층 위에 적용한다. 세-primitive 제약은 유효하지 않은 토폴로지를 불가능하게 만든다. Sink는 게시할 수 없기 때문에 피드백 루프를 만들 수 없다. Source는 구독할 수 없기 때문에 숨겨진 의존성을 만들 수 없다. 설정 파일은 타입 시스템과 엔진이 둘 다 이를 강제하므로 이러한 불변식을 위반하는 시스템을 기술할 수 없다.
이벤트 기반 시스템에서 중요한 질문은 “내 컴포넌트에 어떤 능력을 줄까?”가 아니다. “어떤 제약이 올바른 행동이 나타나도록 만들까?”다.
세 가지 primitive. TOML 파일 하나. 바이너리 하나. 단순한 합성에서 나오는 복잡한 동작. 코드는 github.com/Govcraft/emergent에 있다.