손님이 한 명일 때도 장사는 해야 한다
고객이 적은 스타트업에서 거래의 세 당사자가 모두 우리 고객일 리는 없다. 한 테넌트만 있어도 업무가 닫히고, 다 모이면 자동으로 이어지고, 언제든 떼어낼 수 있게 운송 플랫폼에 시설 도메인을 이벤트로 이어 붙인 설계를 적었다.
물류 거래에는 세 당사자가 있다. 내놓는 쪽, 나르는 쪽, 받아서 처리하는 쪽. 고객이 적은 스타트업에서 이 셋이 동시에 우리 고객일 확률은 낮다. 배출처 하나가 가입했는데 그 배출처의 운송사와 처리장은 우리 플랫폼을 모르는 상황이 기본값이다. 그래서 운송만 다루던 플랫폼에 배출자와 처리자를 얹기로 했을 때 첫 요구는 분명했다. 상대가 없어도 자기 일은 끝낼 수 있어야 한다. 두 번째 요구는 그 반대편이다. 상대가 들어오는 순간 손으로 하던 연결이 저절로 이어져야 한다. 여기에 내가 조건 하나를 더 얹었다. 돌아가는 운송을 건드리지 않고, 언제든 별도 서비스로 떼어낼 수 있어야 한다. 두 서비스를 하나로 합친 글을 쓴 지 얼마 되지 않은 때였다. 이 셋을 한 번에 만족하는 답은 하나뿐이었고, 이 글은 왜 그 길밖에 없었는지를 적는다.
한 줄 요약: 배출처와 처리장의 발주, 입출고, 검수, 재고를 다루는 시설 도메인을 같은 프로세스 안의 별도 모듈로 넣고, 운송과의 연결은 이벤트 한 방향과 식별자 하나로만 했다. 이벤트는 소비자가 없어도 성립하므로 한 테넌트만 있어도 업무가 닫히고, 상대가 들어오면 소비자가 붙어 자동으로 이어진다. 운송 코드는 어댑터 몇 파일 밖에 손대지 않았고, 떼어낼 때 바꿀 것은 리스너의 전송 수단이다. 대가는 최종 일관성과 유실 위험이다.
배경
우리 솔루션은 주문을 받아 기사와 차량에 배차하고, 운행 결과를 정리해 운임을 계산하는 B2B 물류 플랫폼이다. 사용자는 운송사였다. 새 요구는 그 운송의 양 끝에 있는 사람들에게서 왔다. 폐기물을 내놓는 배출처와 받아서 처리하는 처리장이 직접 발주를 내고, 들어오고 나간 것을 검수하고, 재고를 관리하고 싶어 했다.
이들은 운송사와 다른 종류의 테넌트다. 화면이 다르고, 로그인이 다르고, 사업 규칙이 다르다. 운송은 이들의 업무 사이에 끼어 있는 한 단계일 뿐이다. 그리고 그 운송을 우리 플랫폼의 운송사가 나를 수도 있고, 플랫폼 밖의 업체가 나를 수도 있다. 발주에서 검수와 재고까지의 사이클은 우리 운송이 있든 없든 돌아야 했다. 앞 글에서 경계가 번역기가 되는 비용을 따졌다면, 이번에는 경계를 어디에 어떻게 그을지부터가 문제였다.
문제를 느낀 계기
- 상대가 없는 발주가 기본값이었다. 배출처가 발주를 내는데 운송사는 우리 고객이 아니다. 이 발주는 성립해야 하고, 운송은 전화로 잡고, 결과는 손으로 입력하게 된다. 반대로 운송사만 고객인 경우 기사의 상하차는 지금처럼 돌아야 하고, 시설 쪽에는 아무 일도 일어나지 않아야 한다. 어느 한 종류만 있어도 그 종류의 업무는 완결돼야 했다. 그런데 셋이 다 모인 고객사에서는 발주가 운송 주문이 되고 하차가 입출고 결과가 되는 연결이 저절로 일어나야 했다. 손으로 하던 일이 자동으로 바뀌는 것이 이 플랫폼에 다 모여야 하는 이유였기 때문이다.
- 별도 리포로 먼저 시작했다. 시설 도메인을 새 서비스로 세우려고 기존 백엔드를 복제해 리포를 하나 만들었다. 계획은 완벽했다. 열흘 만에 접었다. 복제한 212개 파일 중 도메인 코드는 하나도 없었고 전부 공통과 인프라였다. 장소, 품목, 계정, 이벤트 인프라, 코딩 규율, 배포 파이프라인이 두 벌 생길 각이었다. 앞 글에서 걷어낸 번역기를 손으로 다시 만드는 셈이었다.
- 운송 주문에 컬럼을 얹는 길도 있었다. 발주를 운송 주문의 확장으로 보고 발주자 정보를 운송 주문 엔티티에 붙이면 당장은 빠르다. 하지만 그 순간 운송 코드 전체가 시설 도메인을 알게 되고, 운송 없는 발주는 성립하지 않는다. 첫 번째 요구를 시작부터 어기는 길이다.
- 같은 장소를 세 테넌트가 각자 등록한다. 한 사업장을 배출처는 자기 시설로, 처리장은 거래처로, 운송사는 상하차지로 본다. 남의 행 id를 내 장부에 적기 시작하면 관점이 어긋나고, 상대가 아직 가입하지 않은 경우에는 적을 행조차 없다.
- 운송은 매일 돌고 있었다. 기사의 상차와 하차는 이 플랫폼에서 돈이 움직이는 순간이다. 새 도메인의 버그가 그 트랜잭션을 되돌리거나 운송 릴리즈를 막는 일은 허용할 수 없었다.
제약
- 어느 한 종류의 테넌트만 있어도 그 테넌트의 업무는 완결된다. 상대의 가입을 전제하지 않는다.
- 상대가 가입하는 순간 추가 작업 없이 연결된다. 손으로 하던 연결이 자동으로 바뀌는 것이 가치다.
- 운송 도메인의 코드와 트랜잭션에 영향을 주지 않는다. 운송 팀은 계속 운송 기능을 낸다.
- 언젠가 별도 서비스로 배포될 가능성이 실재한다. 뺄 수 있어야 한다.
- 그러나 지금은 같은 데이터베이스, 같은 코딩 규율, 같은 인프라를 써서 속도를 내야 한다.
- 백엔드 서너 명이 같은 리포에서 동시에 작업한다. 파일이 겹치면 안 된다.
선택지
1. 처음부터 별도 서비스. 실제로 시도한 길이다. 장점: 격리가 완전하다. 단점: 공유 자산이 두 벌이 되고, 두 서비스 사이에 번역 계층이 다시 자란다. 속도가 나지 않는다.
2. 운송 도메인 확장. 운송 주문과 결과에 발주 속성을 붙인다. 장점: 가장 빠르다. 단점: 운송이 시설 도메인을 알게 되고, 운송 없는 발주가 성립하지 않는다. 되돌릴 수 없다.
3. 같은 프로세스 안 별도 모듈, 연결은 이벤트 한 방향. 장점: 공유 자산은 그대로 쓰고, 경계는 처음부터 분리 가능한 모양으로 긋는다. 단점: 최종 일관성을 받아들여야 하고, 이벤트가 유실될 수 있다.
3을 택했다. 그런데 3 안에서도 두 도메인을 어떻게 잇느냐는 별개의 결정이었고, 여기서 선택지가 하나로 좁혀졌다.
결정
이벤트로 갈 수밖에 없었던 이유
두 도메인이 만나는 지점은 둘이다. 발주가 발송되면 우리 운송사인 경우 운송 주문이 생겨야 하고, 기사가 상차나 하차를 마치면 시설 쪽에 입출고 결과가 생겨야 한다. 이 두 지점을 잇는 방법은 세 가지다. 직접 호출, 테이블 공유, 이벤트.
직접 호출은 첫 두 요구에서 깨진다. 발주 발송 시점에 시설 서비스가 운송 서비스를 부르면, 운송사가 우리 고객인지 아닌지를 발주 코드가 분기해야 한다. 상대의 존재를 전제하지 않겠다던 요구가 if 문 하나로 무너진다. 반대로 하차 시점에 운송 서비스가 시설 서비스를 부르면 운송이 시설 도메인을 알게 되고, 시설 고객이 없는 운송사에서도 그 호출은 매번 실행된다. 어느 방향이든 같은 트랜잭션에서 부르면 한쪽의 예외가 다른 쪽을 되돌린다. 기사의 하차 저장이 시설 도메인 버그로 실패하는 그림이다.
테이블 공유는 분리에서 깨진다. 시설 도메인이 운송 테이블을 직접 읽어 상태 변화를 감지하면 운송 코드는 건드리지 않는다. 대신 운송 스키마가 곧 계약이 된다. 컬럼 하나 바뀌면 시설 도메인이 깨지고, 서비스를 나누는 날에는 데이터베이스를 나눌 수 없게 된다. 폴링 부하는 그다음 문제다.
이벤트는 소비자가 없어도 성립한다. 이 성질이 첫 두 요구의 코드 형태였다. 발주는 발송됐다는 사실만 발표한다. 운송사가 우리 고객이면 운송 쪽 어댑터가 그 사실을 듣고 운송 주문을 만든다. 외부 운송사면 아무도 듣지 않고, 발주자는 전화로 운송을 잡고 결과를 손으로 넣는다. 발주 코드는 두 경우를 구분하지 않는다. 반대 방향도 같다. 운송은 상차가 끝났다, 하차가 끝났다는 사실을 발표하고 누가 듣는지 모른다. 시설 고객이 있으면 리스너가 결과를 만들고, 없으면 그 사실은 알림과 로그에만 쓰인다. 어느 쪽이든 발행하는 코드는 같다. 상대가 가입하는 날 바뀌는 것은 소비자가 하나 늘어나는 것뿐이고, 그것이 곧 "다 모이면 자동으로 이어진다"의 구현이다.
나머지 요구도 같은 성질에서 따라온다. 리스너는 커밋 뒤에 별도 트랜잭션에서 돌기 때문에 시설 도메인의 예외가 운송 트랜잭션을 건드리지 못한다. 운송 팀은 발행 코드를, 시설 팀은 리스너를 각자의 패키지에서 만진다. 그리고 서비스를 나누는 날에는 프로세스 안 이벤트를 메시지로 바꾸면 된다. 이벤트의 이름과 네 필드가 계약이고, 그 계약은 프로세스 경계를 넘어도 같다.
결정적이었던 것은 이 이벤트들이 이미 있었다는 점이다. 상차와 하차, 배차, 검수 같은 운송의 사실은 알림톡과 웹소켓, 이벤트 로그를 위해 이미 발행되고 있었다. 카탈로그에 36종이 있었다. 시설 도메인은 그중 두 개를 구독하는 소비자 하나를 더 붙인 것이고, 운송 쪽에서는 발행 코드를 한 줄도 바꾸지 않았다.
// 운송이 발표한 사실을 시설 도메인이 듣는다. 운송은 이 리스너의 존재를 모른다.
// 발주가 없는 운행이면 조용히 지나간다. 시설 고객이 없는 운송사에서는 늘 이 경로다.
// 커밋 뒤 별도 트랜잭션이라, 여기서 실패해도 기사의 하차 저장은 되돌아가지 않는다.
@AsyncBusinessListener
public void onUnloaded(UnloadCompletedEvent event) {
unitRepository.findById(event.entityId())
.filter(unit -> unit.getTransportOrder() != null)
.flatMap(unit -> orderRepository.findByTransportOrderId(unit.getTransportOrder().getId()))
.ifPresent(order -> resultService.recordFromTransport(order, Movement.DELIVERY));
}
경계를 먼저 긋는다
이벤트가 연결을 맡는다면, 나머지는 경계를 선명하게 긋는 일이었다.
- 자기 패키지, 자기 API, 자기 인증. 시설 도메인은 별도 패키지에 있고, API 경로 접두와 인증 인터셉터, 문서 그룹을 따로 가진다. 화면이 부르는 주소부터 다르다.
- 자기 테이블. 발주, 입출고 결과, 검수, 재고 원장, 계약, 거래처 시설은 전부 시설 도메인 소유 테이블이다. 운송 테이블에는 컬럼 하나 추가하지 않았다. 발주가 운송 주문의 id를 nullable 컬럼 하나로 들고 있는 것이 두 도메인 사이의 유일한 데이터 참조다. 방향은 시설에서 운송으로, 그것도 선택적으로. 외부 운송이면 그 칸은 비어 있고 사이클은 그대로 돈다.
- 검수는 두 번, 따로. 운송의 검수는 운임을 확정하고, 시설의 검수는 입출고 정합성과 재고를 확정한다. 같은 하차를 두 번 검수하는 것처럼 보이지만 묻는 질문이 다르다. 하나로 합치면 두 도메인이 한 엔티티를 놓고 싸운다.
TMI지만, 운송 쪽에서 시설 도메인을 아는 파일은 여섯이다. 발송 이벤트를 운송 주문으로 바꾸는 어댑터와 매퍼, 운송사 화면에 발주 정보를 보여 주는 조회 둘, 열거형 둘. 이 여섯은 서비스를 나누는 날 운송 쪽에 남는 클라이언트 코드로 취급한다. 원칙은 운송이 시설을 모르는 것이지만, 어댑터는 원칙의 예외가 아니라 원칙이 예정한 자리다.
상대의 부재를 데이터 모델이 전제한다
세 테넌트가 같은 장소를 본다는 문제, 그리고 상대가 아직 없다는 문제는 둘 다 참조를 끊는 것으로 풀었다. 거래처 시설은 각 테넌트가 자기 관점으로 따로 등록한다. 처리장이 등록한 배출처 시설과 배출처가 등록한 자기 시설은 같은 주소라도 다른 행이다. 남의 행 id는 내 장부에 적지 않는다.
계약도 같은 원리다. 누가 누구에게 어떤 역할로 거래하는지를 담는 독립 엔티티로 두고, 같은 두 당사자 사이에 활성 계약은 한 건만 허용했다. 상대가 아직 가입하지 않았으면 사업자번호만 가진 가상의 상대를 세우고 그와 계약을 맺는다. 상대가 실제로 가입하면 사업자번호로 매칭해 그 계약을 실제 상대에게 이어 붙인다. 가입 전에 등록해 둔 시설과 계약이 가입 뒤에도 그대로 살아 있으니, 가입은 이미 있던 연결에 소비자를 붙이는 일이 된다. 그리고 서비스를 나누는 날 데이터를 테넌트 단위로 잘라도 참조가 끊어지지 않는다.
떼어낼 때 바뀌는 것과 바뀌지 않는 것
분리 시나리오를 미리 적어 두었다. 바뀌는 것은 셋이다. 리스너의 전송 수단이 프로세스 안 이벤트에서 메시지로, 어댑터의 호출이 메서드에서 HTTP로, 장소와 품목과 계정 같은 공유 커널이 코드 참조에서 라이브러리나 API로. 바뀌지 않는 것은 이벤트 카탈로그와 테이블 소유권과 API 경로다. 계약은 그대로고 운반 수단만 바뀐다.
규모가 커질 때의 그림도 같은 자리에서 나온다. 운송 인스턴스가 여러 대가 되고 시설 인스턴스도 여러 대가 되면 메모리 안의 이벤트 버스는 프로세스 사이를 넘지 못한다. 그때 버스를 Redis Streams나 Kafka 같은 스트림으로 바꾼다. 발행하는 코드와 듣는 코드는 그대로고, 컨슈머 그룹이 같은 이벤트를 인스턴스 하나에서만 처리하게 해 준다. 파티션 키로 쓸 테넌트 키는 이미 모든 이벤트에 실려 있어서 테넌트 안의 순서도 지킬 수 있다. 분리와 확장이 같은 한 수인 셈이다.
[지금] 프로세스 하나, 이벤트 버스는 메모리 안
운송 도메인 ──(사실 발표)──▶ 이벤트 버스 ──(구독)──▶ 시설 도메인
시설 도메인 ──(발주 발송)──▶ 이벤트 버스 ──(구독)──▶ 운송 어댑터
[나중] 인스턴스 여러 대, 버스는 스트림(Redis Streams / Kafka)
운송 #1..#N ──(발표)──▶ 스트림 ──(컨슈머 그룹, 한 번씩)──▶ 시설 #1..#M
시설 #1..#M ──(발표)──▶ 스트림 ──(컨슈머 그룹, 한 번씩)──▶ 운송 어댑터 #1..#N
파티션 키 = 테넌트 키. 이벤트 이름과 네 필드는 그대로.
앞 글에서 네트워크 경계를 허용하는 조건을 하나 적었다. 경계를 넘는 대화가 한 방향이고, 상대의 식별자가 내 도메인에 들어오지 않을 때. 네, 그 기준 맞다. 두 달 전에는 그 조건을 만족하지 못하는 경계를 걷어냈고, 이번에는 그 조건을 처음부터 만족하는 경계를 그렸다. 지금 안에 두는 것은 아직 나눌 이유가 없기 때문이다.
결과와 한계
- 얻은 것: 세 종류의 테넌트 중 하나만 있어도 그 업무가 닫히고, 둘 이상이 모이면 발주가 운송 주문으로, 하차가 입출고 결과로 자동으로 이어진다. 열 주 동안 시설 도메인 커밋 190건 중 운송 파일을 건드린 것은 32건이었고, 그마저 대부분 어댑터와 조회 노출이었다. 시설 도메인은 172개 파일, 엔티티 17개 규모로 섰고, 두 도메인을 잇는 코드는 리스너 하나와 어댑터 하나다. 운송 팀과 시설 팀은 같은 리포에서 다른 패키지를 만졌다.
- 잃은 것: 최종 일관성이다. 하차가 저장된 순간과 시설 쪽 결과가 생기는 순간 사이에 시차가 있고, 그 사이의 조회는 아직 없는 결과를 본다. 더 큰 문제는 유실이다. 커밋 뒤 비동기 리스너는 실행기 큐가 넘치거나 배포로 프로세스가 내려가면 이벤트를 흔적 없이 잃는다. 지금은 운영자가 결과를 직접 입력하는 수동 경로가 있어 메꿀 수 있지만, 원장 기반 재전달은 다음 숙제로 남겼다.
- 다시 계산할 조건: 시설 도메인의 배포 주기나 트래픽 특성이 운송과 뚜렷하게 갈라질 때, 또는 네 번째 종류의 테넌트가 붙을 때. 그때 첫 작업은 리스너를 메시지 소비자로 바꾸는 것이고, 그 작업이 하루를 넘기면 이 설계는 실패다.
이쯤 되면 감이 오겠지만, 앞 글과 이 글은 같은 기준에서 나온 두 결정이다. 경계를 걷어낸 것도, 새로 그은 것도 그렇다. 경계는 번역 비용이 한 방향으로만 흐르고 상대의 부재를 견딜 수 있을 때 유지할 가치가 있다. 그 조건은 나중에 맞추는 것보다 처음에 맞춰 두는 것이 훨씬 싸다.
팀에 남긴 규칙은 이렇다. 새 도메인은 기존 도메인의 사실을 듣기만 하고, 기존 도메인은 새 도메인의 존재를 모르며, 어떤 테넌트도 상대의 가입을 전제하지 않는다. 이 결정을 다시 봐야 할 조건은, 두 도메인 사이의 대화가 어느 순간 양방향이 되기 시작할 때다.