삼체문제 — 발주 한 건이 세 회사의 장부 여섯 장을 채워야 한다
세 회사가 얽히는 순간 거래 장부는 여섯 장이 된다. 반쪽 삼각형이 만들던 정산 공백을, 불변식 하나와 세 시점 보급으로 막은 기록.
두 회사 사이의 거래는 단순하다. 내가 팔고 네가 산다 — 장부도 두 장이면 끝난다. 그런데 폐기물 물류에는 회사가 셋 얽힌다. 내놓는 회사, 옮기는 회사, 받아서 처리하는 회사. 셋이 되는 순간 거래의 궤도가 복잡해진다. 물리학이 두 물체까지는 깔끔하게 풀다가 세 물체 앞에서 일반해를 포기하는 것처럼 — 그래서 우리는 이 구조에 삼체문제라는 이름을 붙이고 한참을 고민했다.
한 줄 요약: 세 회사가 얽힌 거래에서 각 회사의 장부는 따로다. "발주 한 건이 풀 사이클을 돌면 세 회사 사이 계약 여섯 장 전부에 그 품목이 실린다"를 불변식으로 세우고, 여섯 장은 규칙을 아는 쪽이 세 시점에 나눠 채우게 했다. 모르는 단가는 지어내지 않고 0원으로 자리만 잡는다.
배경
우리 솔루션은 배출·운반·처리 사업자 사이의 주문, 배차, 계약, 정산을 다루는 B2B 물류 플랫폼이다. 폐기물 한 번 옮기는 데는 회사가 셋 등장한다 — 배출처(내놓는다), 운반사(옮긴다), 처리업체(받아서 처리한다). 발주는 배출처가 낼 수도 있고("우리 공장에서 실어 가 주세요"), 처리업체가 낼 수도 있다("저 공장에서 실어 와 주세요").
핵심 전제는 이것이다. 장부는 회사마다 따로다. 내가 너와 거래한다는 사실을 나는 내 장부에 적고, 너는 네 장부에 따로 적는다. 서로의 장부는 들여다볼 수 없다. 그래서 세 회사를 잇는 삼각형의 변은 셋이지만, 계약은 변마다 "내 장부 한 장 + 상대 장부 한 장" — 도합 여섯 장이 필요하다.
문제를 느낀 계기
여섯 장이 다 차 있지 않은 상태(반쪽 삼각형)는 관념적인 문제가 아니었다. 구체적인 것들이 깨졌다.
- 정산할 자리가 없다. 정산 라인은 계약의 사실에서만 태어난다는 원칙이라, 계약에 품목이 없으면 검수 화면에 처리비를 기입할 행 자체가 생기지 않는다.
- 상대가 나를 거래처로 보지 못한다. 시설 사본은 살아 있는 계약이 있는 상대의 것만 내 장부에 걸 수 있어서, 계약이 빠지면 상대 화면의 방문지와 계약 상세가 통째로 빈다.
- 착불을 표현할 수 없다. "운반비 부담자 = 발주자"가 상수로 박혀 있던 시절, 처리업체가 발주하고 배출처가 운반비를 내는 거래에서는 배출처와 운반사를 잇는 변이 양쪽 장부 어디에도 없었다. 청구서를 낼 상대가 시스템에 존재하지 않는 것이다.
그리고 상대 장부를 채우기 시작하자 더 아픈 문제를 만났다. 처음에는 발송하는 쪽이 상대 장부까지 직접 세우게 했는데, 발송자가 상대 회사의 규칙 — 품목은 어느 축으로 관리하는지, 시설은 어느 단위인지, 계약 방향은 어느 쪽인지 — 을 알 방법이 없어 자기 축의 참조를 그대로 꽂았다. 하루에 같은 유형의 어긋남이 여섯 번 난 날, 이 방식이 틀렸다는 걸 인정했다.
제약
- 회사별 장부 격리는 유지한다 — 남의 장부를 직접 읽고 쓰기 시작하면 테넌트 경계가 무너진다.
- 모르는 값을 지어내면 안 된다 — 나와 협상한 단가가 남의 계약 기본값으로 둔갑하는 것이 최악의 사고다.
- 같은 발주가 몇 번 반복돼도 장부가 불어나면 안 된다.
선택지
1. 세 회사의 거래를 한 장부로 통합한다 장점: 정합성 고민 자체가 사라진다. 단점: "장부는 각자"라는 도메인 현실과 충돌한다 — 각 사는 자기 단가, 자기 품목 축으로 자기 장부를 관리한다.
2. 발송자가 여섯 장을 전부 세운다 (기존 방식) 장점: 흐름이 한 곳에 모여 따라가기 쉽다. 단점: 발송자가 상대 규칙을 알아야 한다. 알 수 없으므로 어긋난다 — 실측 하루 여섯 번.
3. 불변식을 세우고, 각 장부는 규칙을 아는 쪽이 자기 시점에 채운다 장점: 경계가 지켜지고, 상대 규칙을 몰라 생기는 어긋남이 구조적으로 사라진다. 단점: 채움이 세 시점으로 갈라져 흐름 추적이 어렵다 — 불변식을 기계로 지키는 장치가 필요하다.
결정
3이다. 먼저 약속을 한 문장으로 박았다.
발주 한 건이 풀 사이클(등록 → 발송 → 운반사 수락)을 돌면, 세 회사 사이 계약 여섯 장 전부에 그 발주의 품목이 실려 있다.
그리고 여섯 장을 세 시점으로 나눴다. 원칙은 하나 — 각 회사의 장부는 그 회사 규칙을 아는 쪽이 세운다.
- 발송 순간: 발주자가 자기 장부 두 장(상대와의 역할 계약 + 운반사와의 운송계약)을 자기 주문 명세만 읽어 채운다.
- 운반사 수락 순간: 운반사가 자기 장부 두 장을 채운다. 물리 운행도 이때 딱 한 번 태어난다.
- 수락이 상대 관점을 부르는 순간: 상대 장부 두 장과, 상대 화면에 설 반대 관점의 주문. 배출처와 처리업체 사이에는 발주-수락 절차가 없으므로, 운반사의 수락을 물려받아 같은 트랜잭션에서 발송 명세(봉투)만 읽어 세운다.
단가 규칙이 이 구조의 나머지 절반이다.
- 협상 단가는 방향만 뒤집어 물려준다. 발주자의 지급 700원은 상대 장부에 수금 700원으로 — 같은 돈을 두 장부가 반대로 적는 것뿐이다.
- 운반비 단가는 부담자 장부에만 싣는다. 부담하지 않는 쪽의 운송계약에는 품목만 0원으로 싣는다. 남과 협상한 값이 내 계약의 기본값으로 둔갑하는 것을 막기 위해서다.
- 0원은 "미정"이라는 자리표시다. 모르는 단가는 지어내지 않고 자리만 잡는다. 실제 값은 그 회사가 계약 수정이나 검수에서 채운다.
멱등은 한 곳으로 수렴시켰다. 여섯 장의 어느 보장 경로든 같은 병합 규칙 하나를 지난다 — 유효 계약이 있으면 재사용하고, 살아 있는 품목 행은 건드리지 않고, 없는 것만 보탠다. 같은 품목을 열 번 발주해도 여섯 장 어디에도 행이 늘지 않는다.
// 계약 보장의 단일 수렴점 — 여섯 장 어디서 부르든 이 병합 규칙을 지난다.
// 유효 계약 재사용, 살아 있는 행 불변, 없는 것만 보탬. 반복 발주에 멱등.
public Contract ensureContractWith(PartyKey owner, PartyKey counterpart,
TradeType trade, List<ItemLine> lines) {
Contract c = findActiveContract(owner, counterpart, trade)
.orElseGet(() -> createSkeleton(owner, counterpart, trade));
for (ItemLine line : lines) {
if (c.hasLivingRowFor(line.item())) continue; // 협상된 행은 불변
c.addRow(line.item(), line.priceOrZero(), line.direction()); // 미정은 0원 자리표시
}
return c;
}
마지막으로, 불변식은 문서가 아니라 시나리오 테스트가 지킨다. 배출처 발주와 처리업체 발주 각각으로 풀 사이클을 돌리고 여섯 장을 전수 단정한다. 설명 문서와 구현이 어긋나면 테스트가 이긴다.
// 불변식의 집행 — "발주 1건 풀 사이클 = 여섯 장 전부에 그 품목"
fullCycle(order); // 등록 → 발송 → 운반사 수락 (비동기 보급 수렴 대기 포함)
for (Ledger ledger : sixLedgersOf(generator, transporter, processor)) {
assertThat(ledger.itemsOf(order)).isNotEmpty(); // 품목 없는 장이 하나라도 있으면 실패
}
결과와 한계
- 얻은 것: 반쪽 삼각형이 사라졌다. 착불도, 처리업체 발주도 여섯 장이 온전히 선다. 상대 규칙을 몰라 어긋나던 유형의 사고가 구조적으로 막혔다.
- 잃은 것: 채움이 세 시점과 이벤트로 갈라져 흐름을 한눈에 따라가기 어렵다. 개념 문서와 시나리오 테스트라는 유지 비용을 상시로 진다.
- 되돌릴 조건: 당사자가 넷 이상으로 늘어나는 거래(중개, 재위탁)가 정식 요구가 되면 "삼각형 여섯 장" 전제부터 다시 설계한다.
세 물체의 일반해가 없다는 것이 삼체문제의 결론이듯, 우리도 만능 공식 대신 불변식 하나를 골랐다. 모든 경우를 푸는 해법보다, 어떤 경우에도 깨지면 안 되는 문장 하나를 정하고 그것을 기계로 지키는 것 — 복잡한 도메인 앞에서 우리가 택한 방식이다.