Split View: 타입 검사기를 통과한 두 서비스가 서로를 기다리며 멈출 때 — 코레오그래피라는 다른 접근
타입 검사기를 통과한 두 서비스가 서로를 기다리며 멈출 때 — 코레오그래피라는 다른 접근
- 두 서비스가 서로를 기다리며 멈춥니다
- 문제의 뿌리는 코드가 둘로 나뉘어 있다는 사실입니다
- 코레오그래피 — 시스템 전체를 한 프로그램으로 쓰기
- 엔드포인트 프로젝션이 하는 일
- 왜 데드락이 설계상 사라지는가
- Wyzer가 여기에 얹은 것
- 빌림 검사기 없이 제자리 갱신을 얻는 쪽
- 이 언어의 현재 상태를 있는 그대로 적으면
- 정리와 출처
두 서비스가 서로를 기다리며 멈춥니다
메모리 안전한 언어로 서비스를 두 개 만들었습니다. 양쪽 다 컴파일이 깔끔하게 통과합니다. 널 참조도, 데이터 경쟁도, 해제 후 사용도 없습니다.
그런데 배포하고 나면 가끔 둘이 같이 멈춥니다. 서버는 클라이언트가 보내기로 한 두 번째 메시지를 기다리고 있고, 클라이언트는 서버가 먼저 응답할 것이라고 알고 기다리고 있습니다. 프로토콜이 어긋난 겁니다.
이 버그의 특징은 어느 쪽 코드에도 잘못이 없다는 점입니다. 각각을 따로 읽으면 둘 다 합리적입니다. 잘못은 둘 사이에 있고, 어느 컴파일러도 그 사이를 보지 않습니다.
문제의 뿌리는 코드가 둘로 나뉘어 있다는 사실입니다
타입 시스템이 지켜 주는 경계가 어디까지인지 생각해 보면 이유가 분명합니다. 타입 검사기는 하나의 컴파일 단위 안에서 값의 흐름을 봅니다. 그 단위 바깥으로 나간 순간, 즉 바이트가 소켓에 실린 순간부터는 아무것도 보장하지 못합니다. 반대편에서 그 바이트를 어떤 순서로 기대하는지는 검사기가 알 수 없는 정보입니다.
그래서 우리는 그 사이를 사람의 규율로 메웁니다. 문서에 시퀀스 다이어그램을 그리고, 스키마를 공유하고, 통합 테스트를 씁니다. 셋 다 유용하지만 셋 다 검사가 아니라 관행입니다. 한쪽 코드를 고치면서 다이어그램을 같이 고치지 않아도 컴파일은 통과합니다.
이 지점에 다른 답을 내는 계열이 있습니다. 사이를 검사하지 말고, 애초에 둘로 나누지 말자는 것입니다.
코레오그래피 — 시스템 전체를 한 프로그램으로 쓰기
코레오그래피 프로그래밍은 분산 시스템을 프로세스별로 따로 작성하는 대신 상호작용 전체를 하나의 전역 기술로 씁니다. 클라이언트가 이걸 보내고, 서버가 저걸 계산해서 되돌려 주고, 클라이언트가 그것을 받는다는 흐름이 하나의 프로그램 안에서 순서대로 읽힙니다.
이 이름은 Fabrizio Montesi의 2013년 박사 논문에서 붙었고, 이후로 여러 언어와 라이브러리가 이 아이디어를 구현해 왔습니다. 개념의 핵심은 관점 전환입니다. 지금 우리가 쓰는 코드는 한 노드의 시점에서 쓰입니다. 코레오그래피는 시스템 바깥에서 내려다보는 시점에서 씁니다. 시퀀스 다이어그램이 실행 가능해진 것이라고 생각하면 대체로 맞습니다.
엔드포인트 프로젝션이 하는 일
전역 기술을 그대로 실행할 수는 없습니다. 실제로 도는 것은 각 노드의 프로세스이기 때문입니다. 그래서 컴파일 단계에 절차가 하나 추가됩니다. 엔드포인트 프로젝션입니다.
프로젝션은 하나의 코레오그래피에서 노드별 코드를 기계적으로 뽑아냅니다. 클라이언트용 바이너리에는 클라이언트가 해야 할 보내기와 받기와 계산만 남고, 서버용 바이너리에도 마찬가지입니다. 두 결과물은 서로를 알지 못하지만, 같은 원본에서 나왔기 때문에 순서가 어긋날 수 없습니다.
여기서 중요한 것은 우리가 늘 손으로 해 오던 일이 컴파일러의 일이 되었다는 점입니다. 지금까지 클라이언트 코드와 서버 코드를 맞추는 작업은 사람이 다이어그램을 보고 양쪽에 옮겨 적는 일이었습니다. 옮겨 적는 두 번의 작업이 하나의 자동 변환으로 바뀝니다.
왜 데드락이 설계상 사라지는가
여기서 오해하기 쉬운 부분을 짚겠습니다. 데드락이 사라지는 것은 컴파일러가 똑똑해서가 아닙니다. 그런 프로그램을 쓸 수가 없어서입니다.
전역 기술에서 통신은 하나의 구문으로 표현됩니다. 누가 무엇을 누구에게 보낸다는 한 줄이 보내기와 받기를 동시에 낳습니다. 보내기만 있고 받기가 없는 상황, 또는 양쪽이 서로 받기부터 하는 상황을 소스 코드로 적을 방법이 없습니다. 그러니 프로젝션 결과에도 그런 짝이 나올 수 없습니다.
이 성질은 Carbone과 Montesi의 2013년 작업 이후 설계상 데드락 없음이라는 표현으로 알려져 있습니다. 다만 범위를 정확히 알아 둘 필요가 있습니다. 이 보장은 프로토콜의 어긋남에서 오는 데드락에 관한 것입니다. 노드가 죽거나, 네트워크가 끊기거나, 무한 루프에 빠지거나, 외부 자원에서 잠금이 걸리는 상황은 별개의 문제입니다.
Wyzer가 여기에 얹은 것
최근 공개된 Wyzer는 이 아이디어를 시스템 프로그래밍 쪽으로 가져오려는 시도입니다. README는 이 언어를 정적 타입에 컴파일되는 자원 지향 언어로 소개하고, 코레오그래피와 Perceus 메모리 모델을 결합했다고 밝힙니다.
문법에서 눈에 띄는 것은 타입에 노드가 붙는다는 점입니다.
role @Client;
role @Server;
fn fetch_data(query: str@Client) -> str@Client {
// 클라이언트가 서버로 query 를 넘긴다
let server_query: str@Server = query;
// 서버가 처리한다
let server_result: str@Server = db_lookup(server_query);
// 서버가 결과를 클라이언트로 돌려준다
let client_result: str@Client = server_result;
return client_result;
}
여기서 네트워크 전송은 별도의 함수 호출이 아니라 다른 노드 타입으로의 대입입니다. 그리고 이 언어는 그 대입을 선형 이동으로 취급합니다. 즉 넘긴 값은 원래 자리에서 소멸합니다. README가 싣고 있는 오류 메시지가 그 결과입니다. 옮긴 뒤에 다시 쓰면 이동한 변수를 사용했다는 컴파일 오류가 납니다.
이 설계가 겨냥하는 지점이 분명합니다. 분산 시스템에서 흔한 버그 하나가 이미 보낸 데이터를 로컬에서도 계속 최신인 양 쓰는 것인데, 전송을 이동으로 만들면 그 실수가 타입 오류가 됩니다.
빌림 검사기 없이 제자리 갱신을 얻는 쪽
메모리 쪽 선택도 함께 볼 만합니다. Wyzer는 가비지 컬렉터도 빌림 검사기도 두지 않고 Perceus를 택했다고 밝힙니다.
Perceus는 2021년 PLDI에서 발표된 기법으로, 참조 카운트 증감 명령을 컴파일러가 정밀하게 삽입해 순환이 없는 프로그램에서 쓰레기가 남지 않게 합니다. 여기에 재사용 분석이 얹히면, 어떤 값의 참조가 하나뿐일 때 새로 할당하는 대신 기존 메모리를 그대로 고쳐 쓸 수 있습니다. 함수형 스타일로 쓴 코드가 제자리 갱신으로 컴파일되는 이 방식을 FBIP라고 부릅니다. Koka 언어에 구현되어 있습니다.
왜 이 조합이 자연스러운지도 짚어 둘 만합니다. 코레오그래피에서 값이 노드 사이를 이동한다는 것은 소유권이 옮겨진다는 뜻이고, 소유권을 세는 일은 참조 카운트가 이미 하고 있는 일입니다. 메모리와 네트워크에 같은 규칙을 쓴다는 README의 설계 원칙은 여기서 나옵니다. 다만 이것은 언어가 내세우는 설계 의도이고, 실제로 그 규칙이 두 영역 모두에서 잘 동작하는지는 구현이 나온 뒤에 판단할 문제입니다.
이 언어의 현재 상태를 있는 그대로 적으면
성숙도를 과장하지 않기 위해 확인한 것만 적습니다. 저장소는 Apache-2.0 라이선스이고 주 언어는 OCaml입니다. 확인한 시점에 릴리스가 하나도 올라와 있지 않습니다. 그리고 저자 본인이 공개 글에서 다섯 달의 연구와 몇 주의 개발을 거쳤으며 곧 0.1.0을 낼 예정이라고 밝혔습니다. 즉 첫 번째 버전이 아직 나오지 않은 상태입니다.
그러니 이 언어로 무언가를 만들 계획을 세울 단계가 아닙니다. 그럼에도 읽어 볼 값이 있다고 보는 이유는, 이 프로젝트가 왜 만들었는지를 문제로 서술하기 때문입니다. 저자는 Rust에 대한 불만에서 출발했다고 밝히면서, 타입 검사가 메모리는 지켜 주지만 분산 데드락과 서비스 간 프로토콜 불일치는 지켜 주지 못한다는 점을 이유로 듭니다. 이 진단은 언어의 완성도와 무관하게 맞습니다. 그리고 그 진단에 대한 답으로 십 년 넘게 쌓인 학술적 계보가 존재한다는 사실이, 이 프로젝트를 보고 얻을 수 있는 가장 실용적인 정보입니다.
정리와 출처
우리가 서비스 간 프로토콜을 문서와 테스트로 맞추고 있다면, 그것은 도구가 없어서가 아니라 우리가 쓰는 언어가 노드 하나만 보기 때문입니다. 코레오그래피는 그 시야를 바꾸는 접근이고, 시야를 바꾸면 일부 버그는 고칠 대상이 아니라 표현할 수 없는 것이 됩니다.
- Choreographic Programming — Fabrizio Montesi 본인의 정의. 용어가 2013년 박사 논문에서 나왔다는 점, 엔드포인트 프로젝션의 역할, 설계상 데드락 없음이 Carbone과 Montesi의 2013년 작업으로 알려졌다는 점을 이 문서에서 확인했습니다.
- Wyzer-Lang/wyzer 저장소 — 문법 예시, 설계 원칙, 오류 메시지 형태. 본문의 Wyzer 관련 서술과 코드는 이 저장소 README에서 가져온 것입니다.
- 저장소 메타데이터로 확인한 사항: 라이선스 Apache-2.0, 언어 OCaml, 릴리스 없음. 0.1.0을 아직 내지 않았다는 서술은 저자가 공개한 소개 글의 내용입니다.
- Perceus: Garbage Free Reference Counting with Reuse — Reinking, Xie, de Moura, Leijen, PLDI 2021. FBIP와 재사용 분석의 출처입니다.
- 저는 Wyzer 컴파일러를 빌드하거나 실행해 보지 않았습니다. 본문의 코드는 README에 실린 예시이며, 동작을 직접 확인한 것이 아닙니다.
When Two Services That Passed the Type Checker Halt Waiting for Each Other — Choreography as a Different Approach
- Two Services Halt Waiting for Each Other
- The Root of the Problem Is That the Code Is Split in Two
- Choreography — Writing the Whole System as One Program
- What Endpoint Projection Does
- Why Deadlock Disappears by Design
- What Wyzer Puts on Top
- Getting In-Place Update Without a Borrow Checker
- Writing Down the Current State of This Language as It Is
- Wrap-Up and Sources
Two Services Halt Waiting for Each Other
You built two services in a memory-safe language. Both compile cleanly. No null references, no data races, no use after free.
But once deployed, sometimes the two of them halt together. The server is waiting for the second message the client was supposed to send, and the client is waiting because it knows the server will respond first. The protocol is out of step.
The characteristic of this bug is that neither side's code is at fault. Read them separately and both are reasonable. The fault is between the two, and no compiler looks at what is between them.
The Root of the Problem Is That the Code Is Split in Two
Think about how far the boundary a type system protects extends and the reason becomes clear. A type checker sees the flow of values within one compilation unit. The moment something leaves that unit — the moment the bytes go onto a socket — it guarantees nothing. What order the other side expects those bytes in is information the checker cannot know.
So we fill that gap with human discipline. We draw sequence diagrams in documents, share schemas, and write integration tests. All three are useful and all three are convention rather than checking. You can change the code on one side without changing the diagram and compilation still passes.
There is a line of work that gives a different answer at this point. Do not check what is between them — do not split them in two in the first place.
Choreography — Writing the Whole System as One Program
Choreographic programming writes the whole interaction as a single global description instead of writing a distributed system process by process. The flow of the client sending this, the server computing that and sending it back, and the client receiving it reads in order inside one program.
The name came from Fabrizio Montesi's 2013 doctoral thesis, and since then a number of languages and libraries have implemented the idea. The core of the concept is a change of viewpoint. The code we write today is written from the viewpoint of one node. Choreography is written from a viewpoint that looks down from outside the system. Thinking of it as a sequence diagram made executable is broadly right.
What Endpoint Projection Does
You cannot execute the global description directly, because what actually runs is a process on each node. So one procedure gets added to the compilation stage: endpoint projection.
Projection mechanically extracts per-node code from a single choreography. The client binary keeps only the sends, receives, and computations the client has to do, and likewise for the server binary. The two artifacts do not know about each other, but because they came from the same original, their ordering cannot go out of step.
What matters here is that a job we have always done by hand has become the compiler's job. Until now, matching client code and server code was a person reading a diagram and transcribing it onto both sides. Two acts of transcription turn into one automatic transformation.
Why Deadlock Disappears by Design
Let me flag a part that is easy to misread. Deadlock disappears not because the compiler is clever but because you cannot write such a program.
In the global description, communication is expressed as a single construct. One line saying that someone sends something to someone else gives birth to a send and a receive at the same time. There is no way to write down in source a situation where there is a send with no receive, or where both sides start by receiving. So no such pairing can appear in the projection result either.
This property has been known as deadlock-freedom by design since the 2013 work of Carbone and Montesi. But the scope needs to be understood precisely. This guarantee is about deadlocks that come from protocol mismatch. A node dying, the network being cut, falling into an infinite loop, or a lock being taken on an external resource are separate problems.
What Wyzer Puts on Top
The recently published Wyzer is an attempt to bring this idea over into systems programming. The README introduces the language as a statically typed, compiled, resource-oriented language, and states that it combines choreography with the Perceus memory model.
What stands out in the syntax is that nodes attach to types.
role @Client;
role @Server;
fn fetch_data(query: str@Client) -> str@Client {
// the client hands query over to the server
let server_query: str@Server = query;
// the server processes it
let server_result: str@Server = db_lookup(server_query);
// the server returns the result to the client
let client_result: str@Client = server_result;
return client_result;
}
Here a network transfer is not a separate function call but an assignment into another node type. And this language treats that assignment as a linear move. That is, the value handed over is destroyed in its original place. The error message the README carries is the result: use it again after moving it and you get a compile error saying you used a moved variable.
What this design aims at is clear. One common bug in distributed systems is continuing to use data you have already sent as if the local copy were still current, and making transfer a move turns that mistake into a type error.
Getting In-Place Update Without a Borrow Checker
The memory choice is worth looking at too. Wyzer states that it takes Perceus rather than having either a garbage collector or a borrow checker.
Perceus is a technique presented at PLDI 2021 in which the compiler precisely inserts reference count increment and decrement instructions so that no garbage remains in programs without cycles. Add reuse analysis on top and, when a value has only one reference, you can rewrite the existing memory in place instead of allocating anew. This way of having code written in a functional style compile to in-place update is called FBIP. It is implemented in the Koka language.
It is worth noting why this combination is natural. In a choreography, a value moving between nodes means ownership is being transferred, and counting ownership is already what reference counting does. The README's design principle of using the same rule for memory and for the network comes from here. That said, this is the design intent the language puts forward, and whether that rule actually works well in both areas is a question to judge after an implementation exists.
Writing Down the Current State of This Language as It Is
To avoid overstating maturity I write only what I confirmed. The repository is Apache-2.0 licensed and the primary language is OCaml. At the time I checked, not a single release had been posted. And the author has stated in a public post that it went through five months of research and a few weeks of development and that 0.1.0 is coming soon. In other words, the first version has not shipped yet.
So this is not the stage at which to plan on building something with this language. The reason I still think it is worth reading is that this project describes why it was made as a problem. The author states that it started from dissatisfaction with Rust, giving as the reason that type checking protects memory but does not protect against distributed deadlock or protocol mismatch between services. That diagnosis is correct regardless of the language's completeness. And the fact that an academic lineage accumulated over more than ten years exists as an answer to that diagnosis is the most practical thing you can take from looking at this project.
Wrap-Up and Sources
If we are matching up protocols between services with documents and tests, it is not because the tools do not exist but because the languages we use look at only one node. Choreography is an approach that changes that field of view, and when you change the field of view, some bugs stop being things to fix and become things that cannot be expressed.
- Choreographic Programming — Fabrizio Montesi's own definition. I confirmed from this document that the term came from a 2013 doctoral thesis, the role of endpoint projection, and that deadlock-freedom by design is known from the 2013 work of Carbone and Montesi.
- The Wyzer-Lang/wyzer repository — the syntax example, the design principles, the shape of the error message. The description of Wyzer and the code in this post are taken from that repository README.
- Confirmed from repository metadata: license Apache-2.0, language OCaml, no releases. The statement that 0.1.0 has not shipped yet is from an introductory post the author published.
- Perceus: Garbage Free Reference Counting with Reuse — Reinking, Xie, de Moura, Leijen, PLDI 2021. The source for FBIP and reuse analysis.
- I did not build or run the Wyzer compiler. The code in this post is the example carried in the README, not behavior I verified myself.