ADR-0018: Domain Code Sharing over RPC¶
Umbra 의 세 Go 프로세스(bot, api, worker)는 서로를 RPC 로 호출하지 않는다. 대신 같은
engine/*도메인 패키지를 import 하여 각자의 진입점에서 유스케이스를 직접 실행한다. 프로세스 간 조정이 필요한 경우에만 Outbox 또는 Temporal 을 경유한다.
Status¶
Accepted
- Decided at — 2026-04-13
- Decided by — Pablo
Context¶
프로세스를 분리(ADR-0017)했을 때 자연스럽게 따라오는 질문: "프로세스들이 서로 어떻게 통신하는가?"
세 가지 옵션:
- Full microservices — 각 프로세스가 독립 서비스, REST/gRPC 로 통신
- 분산 모놀리스 — 같은 코드베이스 공유, 각자 도메인 직접 호출, 간접 통신만 DB/queue 경유
- 혼합 — 일부는 RPC, 일부는 공유
Umbra 의 특수 요구사항:
- Discord 슬래시 커맨드는 3초 내 응답 필수. Bot 이 API 를 RPC 로 호출하면 네트워크 왕복 부담
- MVP 규모에서 RPC 인프라(gRPC, protobuf, 버전 호환성) 관리 부담 과도
- 팀이 소규모 (Pablo + 진욱) 이라 단일 코드베이스 관리가 훨씬 쉬움
Decision¶
프로세스 간 직접 RPC 를 사용하지 않는다. 세 프로세스는 같은 engine/* 패키지를 공유하며, 각자의 진입점에서 도메인 서비스를 직접 호출한다.
Shared packages¶
engine/ ← 도메인 코어 (세 프로세스 공유)
├─ recovery/
├─ licensing/
├─ billing/
├─ identity/
├─ guild/
├─ member/
├─ notification/
├─ audit/
└─ webhook/
platform/ ← 인프라 유틸 (세 프로세스 공유)
├─ event/ (Outbox)
├─ db/
├─ redis/
├─ temporal/
├─ uuid/
├─ crypto/
└─ ...
apps/
├─ bot/ ← 독립 진입점, engine/platform import
├─ api/ ← 독립 진입점, engine/platform import
└─ worker/ ← 독립 진입점, engine/platform import
Inter-process coordination¶
직접 RPC 가 필요한 경우는 실제로 없음. 대신 다음 간접 경로 사용:
- 상태 변경 전파 — Outbox 이벤트 (DB 경유)
- 진행 중 워크플로우와 조정 — Temporal Signal
- (미래) 실시간 브로드캐스트 — Redis pub/sub (MVP 범위 외)
선택 근거:
- latency 0 — 네트워크 왕복 없이 함수 호출
- MVP 복잡도 최소화 — gRPC, protobuf, 버전 호환성 불필요
- 팀 규모 적합 — 2인 팀이 하나의 코드베이스 유지가 가장 효율적
- 추후 분리 가능성 보존 — 도메인이 Port/Adapter 로 캡슐화되어 있어 필요 시 Adapter 를 RPC 구현으로 교체 가능
이를 팀 내부에서는 "distributed monolith" (의도적 분산 모놀리스) 라고 부른다.
Consequences¶
Positive¶
- 슬래시 커맨드의 3초 제한이 편안함 (네트워크 왕복 0)
- MVP 인프라 복잡도 ↓
- 도메인 로직 중복 없음 (세 프로세스가 같은 구현 사용)
- 디버깅 단순 (스택 트레이스 그대로, RPC 경계 없음)
- 리팩토링 안전 (IDE 가 전체 코드베이스 인식)
Negative¶
- 세 프로세스의 Docker 이미지가 같은 코드를 포함 (이미지 크기 3배)
- 전체 레포가 단일 module 로 묶임 → 한 부분의 빌드 오류가 모두 영향
- "진짜 마이크로서비스 전환" 비용이 미래에 발생할 수 있음
Neutral¶
- 도메인 코드는 Hexagonal 로 캡슐화되어 언제든 RPC adapter 추가 가능
- Go workspace (
go.work) 또는 단일go.mod로 관리 (현재는 단일 module)
Guidance: when to cross process boundaries¶
세 프로세스가 언제 어떤 경계를 넘는가?
Within-process (direct call)¶
같은 프로세스 안에서는 도메인 서비스를 직접 호출. 예:
apps/bot/internal/handler/snapshot.go가engine/recovery/subdomain/snapshot/Service.Create()호출apps/api/internal/handler/billing.go가engine/billing/Service.StartSubscription()호출
Cross-process (indirect via DB or Temporal)¶
다른 프로세스에 작업을 넘기는 경우:
- Bot → Worker: "이 Restore 를 실행해줘" → Temporal workflow 시작
- Worker → Worker: "결제 재시도 24h 뒤" → asynq delayed task
- Billing(worker) → Licensing(worker): "결제 성공" → Outbox event
- API → Worker: "알림 발송" → asynq task enqueue
이 경계들은 네트워크 RPC 가 아니라 DB/queue 경유 이다.
Alternatives considered¶
Alternative 1: Full microservices (RPC)¶
Pros
- 도메인 독립 배포
- 각 서비스가 완전 독립
Cons
- 슬래시 커맨드의 3초 제한 부담 ↑
- RPC 스펙(proto) 관리 부담
- 버전 호환성 관리
- 팀 규모 대비 과한 복잡도
Why rejected — MVP 범위 초과. 팀 2인에게 불필요한 오버헤드.
Alternative 2: Full monolith (단일 프로세스)¶
Pros
- 가장 단순
Cons
- 장애 격리 불가 (ADR-0017 에서 거부)
Why rejected — ADR-0017 에서 프로세스 분리가 결정되었으므로 이 옵션은 불가.
Alternative 3: Monorepo + 서비스별 독립 module (RPC)¶
Pros
- 코드 공유와 RPC 동시
Cons
- 공유 module + RPC spec 관리 부담 이중 증가
- 이점 불명 (결국 RPC 를 쓰는 마이크로서비스와 유사)
Why rejected — 복잡도 대비 이점 부족.
Alternative 4: 공유 라이브러리를 별도 repo¶
Pros
- 진짜 마이크로서비스 분리
Cons
- 버전 관리 (공유 라이브러리 bump 마다 세 서비스 재빌드)
- PR 이 여러 repo 에 걸침
- MVP 에 과함
Why rejected — 2인 팀에게 관리 부담 과도.
Compliance¶
engine/*와platform/*는 여러 프로세스가 import 가능- 세 프로세스(
apps/bot,apps/api,apps/worker)는 서로 import 금지 (cyclic 방지) - 프로세스 간 상태 변경 전파는 Outbox event 로만
- 진행 중 워크플로우 조정은 Temporal Signal 로만
- "Bot 에서 API 를 HTTP 로 호출" 같은 패턴 금지 (코드 리뷰 차단)
Revisit triggers¶
- 팀 규모 10인 이상 — 도메인별 소유 분리가 필요해지면 마이크로서비스 전환 검토
- 도메인별 배포 주기 분리 필요 — 예: Recovery 만 빠르게 배포
- 한 언어로 모두 재작성 — Rust 재작성(ADR-0031) 시 재평가
- 성능 프로파일링 결과 — 이미지 크기나 빌드 시간이 심각해지면
진짜 마이크로서비스 전환이 필요해지면 Hexagonal 의 Port/Adapter 덕에 다음 방식이 가능:
- Billing 의
engine/billing/app/Service를engine/billing/adapter/grpc/Client로 교체 - 다른 프로세스는 Client 경유로 호출 (함수 시그니처 동일)
- 점진적 분리 가능
References¶
- Distributed Monolith - Martin Fowler (반대 개념으로 자주 인용되지만 의도적 선택 가능)
- Majestic Monolith - David Heinemeier Hansson