콘텐츠로 이동

Process Communication

Umbra 의 세 Go 프로세스(bot, api, worker)는 서로를 직접 호출하지 않습니다. 대신 같은 도메인 패키지를 import 해 각자의 진입점에서 필요한 유스케이스를 실행하고, 프로세스 경계를 넘는 조정이 필요한 경우에만 DB(Outbox, Temporal) 를 경유합니다. 이 문서는 그 통신 모델의 원칙과 패턴을 정리합니다.

Why this model

Umbra 의 통신 모델은 "분산된 모놀리스" 라고 부를 수 있습니다. 배포 단위는 분리되어 있지만 코드는 공유합니다. 이 선택에는 세 가지 이유가 있습니다.

첫째, Discord 의 3초 응답 제한 입니다. 슬래시 커맨드는 3초 내 ACK 해야 하므로 bot 에서 HTTP 로 api 를 호출하는 구조는 latency 부담이 큽니다. 도메인 로직을 bot 프로세스 안에서 직접 실행하면 지연이 0 입니다.

둘째, 마이크로서비스의 조기 비용 회피 입니다. MVP 규모에서 bot-api-worker 를 RPC 로 연결하면 네트워크 오버헤드, 에러 모델, 버전 호환성 관리 부담이 실익 대비 큽니다. 도메인 코드 공유는 이 비용을 0 으로 만들면서 프로세스 분리의 이점(장애 격리, 독립 스케일)만 챙깁니다.

셋째, 추후 분리 가능성 보존 입니다. 도메인이 Port/Adapter 로 캡슐화되어 있으므로 필요한 시점에 Adapter 를 HTTP/gRPC 구현으로 교체하면 진짜 마이크로서비스 전환이 가능합니다. 지금 하지 않을 뿐 길은 열려있습니다.

Communication matrix

프로세스 간 어떤 통신이 허용되고 금지되는지입니다.

From To umbra-bot umbra-api umbra-worker
umbra-bot 금지 (직접 호출) 금지 (직접 호출)
umbra-api 금지 금지
umbra-worker 금지 금지

모든 프로세스 간 직접 통신이 금지됩니다. 대신 다음 간접 통신만 허용됩니다.

Mechanism Direction Use case
Outbox event (via DB) any → any (subscriber) 도메인 상태 변경 알림
Temporal signal api / bot / worker → Temporal Workflow 진행 중 워크플로우에 외부 이벤트 전달
Redis pub/sub 특정 캐시 invalidation 세션 강제 로그아웃 같은 즉시성 필요 이벤트 (Phase 2)

MVP 는 Outbox 와 Temporal signal 만 사용합니다.

Shared domain code pattern

세 프로세스는 같은 Go module 안에서 engine/* 패키지를 공유합니다.

apps/
├─ bot/
│  └─ cmd/main.go          → imports engine/*, platform/*
├─ api/
│  └─ cmd/main.go          → imports engine/*, platform/*
└─ worker/
   └─ cmd/main.go          → imports engine/*, platform/*

engine/
├─ recovery/
├─ licensing/
├─ billing/
└─ ...

각 프로세스는 자신이 필요로 하는 도메인 서비스만 부트스트랩합니다. bot 은 모든 도메인을 다 초기화할 필요 없이 슬래시 커맨드가 호출할 도메인만 wiring 하면 됩니다.

Service instantiation per process

각 프로세스는 자기만의 서비스 인스턴스 를 만듭니다. 같은 도메인이라도 프로세스마다 인스턴스가 다릅니다.

// apps/bot/internal/bootstrap/wire.go
func Wire(ctx context.Context) (*BotApp, error) {
    db := platformdb.NewPool(ctx, cfg.DatabaseURL)
    rdb := platformredis.NewClient(cfg.RedisURL)

    // bot 이 필요로 하는 도메인 서비스만 초기화
    licensingSvc := licensing.NewService(db)
    recoverySvc := recovery.NewService(db, rdb, temporalClient)

    return &BotApp{
        licensing: licensingSvc,
        recovery:  recoverySvc,
    }, nil
}
// apps/api/internal/bootstrap/wire.go
func Wire(ctx context.Context) (*APIApp, error) {
    db := platformdb.NewPool(ctx, cfg.DatabaseURL)
    rdb := platformredis.NewClient(cfg.RedisURL)

    // api 는 모든 도메인 대상
    identitySvc := identity.NewService(db, rdb)
    billingSvc := billing.NewService(db, tossClient)
    licensingSvc := licensing.NewService(db)
    recoverySvc := recovery.NewService(db, rdb, temporalClient)

    return &APIApp{...}, nil
}

프로세스마다 DB pool, Redis client, Temporal client 가 독립적으로 생성됩니다. 연결 수는 늘어나지만 각 프로세스가 독립적으로 죽거나 재시작되어도 다른 프로세스에 영향이 없습니다.

Why not direct RPC between processes

bot 이 api 를 HTTP 로 호출하고 api 가 worker 에 작업을 던지는 마이크로서비스 패턴을 쓰지 않는 이유입니다.

Bot → API RPC 가 부적합한 이유

슬래시 커맨드 /restore 가 호출되면 bot 은 Licensing 권한 확인, 스냅샷 조회, Temporal workflow 시작 순서로 진행합니다. 이를 api 를 거쳐 처리하면:

  • bot → api HTTP 호출 (latency + 직렬화)
  • api → 도메인 호출 + Temporal 워크플로우 시작
  • api → bot 응답 (다시 latency + 직렬화)
  • bot → Discord 응답

한 요청에 네트워크 왕복이 추가되어 3초 제한에 위협이 됩니다. 직접 공유 코드 호출이면 이 왕복이 0 입니다.

API → Worker RPC 가 부적합한 이유

api 가 "백업 스냅샷 생성해줘" 같은 비동기 작업을 worker 에 맡겨야 할 때, HTTP 보다 asynq queue 가 적합합니다. 이유는:

  • 재시도 정책이 asynq 에 내장
  • worker 프로세스가 죽어도 queue 에 작업이 남아 살아있는 worker 가 처리
  • asynq 자체가 Redis backend 를 통한 분산 통신

MVP 에서 이미 asynq 를 쓰고 있으므로 HTTP RPC 는 중복 인프라입니다.

Communication patterns by scenario

실제 시나리오별로 통신이 어떻게 일어나는지입니다.

Scenario 1. 슬래시 커맨드 (bot 내부 완결)

/snapshot create 커맨드는 bot 프로세스 안에서 완결됩니다.

sequenceDiagram
    participant Discord
    participant Bot as umbra-bot
    participant Recovery as Recovery Service
(engine/recovery/) participant DB as Neon Discord->>Bot: Interaction
(/snapshot create) Bot->>Recovery: CreateSnapshot(guildID) Recovery->>DB: INSERT snapshot DB-->>Recovery: snapshot_id Recovery-->>Bot: SnapshotID Bot->>Discord: Interaction Response
"Snapshot created"

bot 프로세스가 Recovery 도메인을 직접 호출 합니다. api 나 worker 는 개입하지 않습니다.

Scenario 2. 복구 실행 (bot 시작, worker 완료)

/restore 커맨드는 bot 이 Temporal workflow 를 시작하고, worker 의 Temporal worker 가 실제 실행합니다.

sequenceDiagram
    participant Discord
    participant Bot as umbra-bot
    participant Restore as Restore Service
    participant TemporalSvc as Temporal Server
    participant Worker as umbra-worker
    participant DiscordAPI as Discord REST

    Discord->>Bot: /restore command
    Bot->>Restore: StartRestoreWorkflow(...)
    Restore->>TemporalSvc: ExecuteWorkflow
    TemporalSvc-->>Restore: WorkflowID
    Restore-->>Bot: WorkflowID
    Bot->>Discord: "Restore started,
workflow: xxx" Note over TemporalSvc,Worker: Later (async) TemporalSvc->>Worker: Deliver workflow
to worker pool Worker->>DiscordAPI: Restore actions
(roles, channels...) DiscordAPI-->>Worker: Results Worker->>TemporalSvc: Complete workflow

bot 은 workflow 를 시작하고 즉시 응답합니다. worker 가 실제 Discord API 호출을 수행합니다. 두 프로세스는 Temporal Server 를 경유 하여 조정되며 직접 통신하지 않습니다.

Scenario 3. 결제 완료 (worker 시작, 여러 도메인 영향)

매월 정기 결제가 worker 의 asynq cron 에서 발생합니다.

sequenceDiagram
    participant Cron as asynq scheduler
    participant Billing as Billing Service
    participant Toss as Toss Payments
    participant DB
    participant Outbox
    participant Poller as Outbox Poller
    participant Licensing as Licensing Subscriber

    Cron->>Billing: ProcessRecurring()
    Billing->>Toss: charge billing_key
    Toss-->>Billing: Payment success
    Billing->>DB: UPDATE subscription
INSERT payment_attempt Billing->>Outbox: INSERT event
PaymentSucceeded Note over DB,Outbox: Same transaction Note over Poller: 2s interval Poller->>Outbox: SELECT unpublished Outbox-->>Poller: PaymentSucceeded event Poller->>Licensing: OnPaymentSucceeded(event) Licensing->>DB: UPDATE license
extend expires_at Poller->>Outbox: Mark as published

Billing 과 Licensing 이 직접 서로를 호출하지 않고 Outbox 이벤트로 조정됩니다. 같은 worker 프로세스 안에서 일어나도 이 패턴이 유지됩니다. 도메인 간 결합도를 낮추는 것이 핵심이고, 프로세스 경계는 부수적입니다.

Scenario 4. Toss 웹훅 (api 수신, 여러 도메인 영향)

Toss 가 결제 상태 변경을 알리는 웹훅을 api 로 보냅니다.

sequenceDiagram
    participant Toss
    participant API as umbra-api
Webhook Handler participant Idem as Redis
Idempotency participant Billing participant DB participant Outbox Toss->>API: POST /webhooks/toss API->>Idem: SET NX event:id
(TTL 24h) alt Already processed Idem-->>API: 0 (exists) API->>Toss: 200 OK else New event Idem-->>API: 1 (new) API->>Billing: HandleWebhookEvent(event) Billing->>DB: UPDATE subscription status Billing->>Outbox: INSERT PaymentFailed
(or other event type) API->>Toss: 200 OK end

웹훅 처리는 api 안에서 완결됩니다. 후속 도메인 영향은 Outbox 이벤트로 전파됩니다.

Scenario 5. 대시보드 요청 (api 내부 완결)

사용자가 대시보드에서 "현재 구독 상태 조회" 를 요청하면 api 안에서 여러 도메인이 병렬 조회됩니다.

sequenceDiagram
    participant User
    participant Web
    participant API as umbra-api
Handler participant Identity participant Licensing participant Billing User->>Web: Visit dashboard Web->>API: GET /api/v1/dashboard API->>API: Parallel call
(errgroup) par API->>Identity: GetUser(sessionID) and API->>Licensing: GetLicenses(userID) and API->>Billing: GetSubscriptions(userID) end API-->>Web: Combined response Web-->>User: Render dashboard

여러 도메인 호출을 errgroup 으로 병렬 처리합니다. 프로세스 간 통신이 아니라 한 프로세스 안의 고루틴 병렬 실행 입니다.

Temporal signal pattern

진행 중인 Recovery workflow 에 외부에서 이벤트를 전달할 때 Temporal Signal 을 사용합니다.

sequenceDiagram
    participant AntiNuke as AntiNuke Workflow
    participant Restore as Restore Workflow
    participant Sync as Live Sync (asynq)

    Note over Restore: Running (10s elapsed)
    AntiNuke->>Restore: SignalWorkflow
("AdditionalSnapshotNeeded") Restore->>Restore: Handle signal
create mid-restore snapshot Restore->>Sync: SignalWorkflow
("PauseLiveSync") Sync->>Sync: Pause batch processing Note over Restore: Restore completes Restore->>Sync: SignalWorkflow
("ResumeLiveSync")

Signal 은 Temporal 이 제공하는 워크플로우 간 메시지 전달 메커니즘입니다. 직접 통신 없이 조정이 가능합니다.

Data access boundaries

프로세스마다 DB 접근 범위가 다릅니다.

Process Read Write
umbra-bot 대부분 도메인 제한적 (Identity, Member, Audit)
umbra-api 모든 도메인 모든 도메인
umbra-worker 모든 도메인 모든 도메인 + Outbox poller

bot 의 쓰기는 최소화됩니다. 슬래시 커맨드는 주로 조회이거나 Temporal workflow 를 트리거하는 역할이며, 실제 변경은 worker 가 수행합니다.

Graceful shutdown coordination

각 프로세스가 독립적으로 종료되지만 외부 상태(Redis, Temporal)를 공유하므로 순서가 중요합니다.

graph TB
    SIGTERM[SIGTERM received] --> Stop[Stop accepting new work]
    Stop --> Drain[Drain in-flight]
    Drain --> Close[Close connections]
    Close --> Exit[Exit 0]

Bot graceful shutdown

  1. Discord Gateway 연결 종료 시그널 전송
  2. 진행 중 interaction 응답 완료 대기 (최대 3초)
  3. DB/Redis 연결 종료
  4. 프로세스 종료

API graceful shutdown

  1. HTTP 서버 새 요청 거부
  2. 진행 중 HTTP 요청 완료 대기 (최대 30초)
  3. DB/Redis 연결 종료
  4. 프로세스 종료

Worker graceful shutdown

  1. asynq 워커 새 작업 수신 중단
  2. Temporal 워커 새 작업 수신 중단
  3. Outbox poller 중단
  4. 진행 중 작업 완료 대기 (최대 60초, Temporal 은 heartbeat 로 재개 가능)
  5. DB/Redis/Temporal 연결 종료
  6. 프로세스 종료

Fly.io 는 SIGTERM 후 기본 5분 유예를 제공합니다. 이 안에 완료되지 않으면 SIGKILL 이 강제됩니다.

Trade-offs

Benefit Cost
네트워크 호출 없어 latency 0 세 프로세스가 같은 코드를 빌드/실행 (이미지 3배 크기)
도메인 경계가 RPC 가 아니라 함수 호출 실제 마이크로서비스 전환 시 Adapter 구현 필요
디버깅 단순 (스택 트레이스 그대로) 프로세스별 메트릭 수집 복잡도 ↑
Outbox 로 결합도 낮춤 이벤트 polling 지연(최대 2초)

When to revisit

이 통신 모델을 재검토해야 하는 신호:

  • 팀 규모가 커져서 도메인별 소유 분리가 필요 — 같은 코드베이스가 오히려 병목이 되면 마이크로서비스 전환 고려
  • 특정 도메인의 배포 주기가 현저히 빨라짐 — 독립 배포 필요 시 서비스 분리
  • 다른 언어로 일부 도메인 재작성 필요 — Nabi 런타임 위 Rust 재작성이 이 시점과 맞물림
  • 프로세스 간 버전 호환성이 문제가 됨 — 같은 module 공유가 오히려 제약이 되면 분리

Umbra 의 Rust 재작성 계획(roadmap/rust-rewrite.md)은 이 통신 모델의 재검토 시점을 결정합니다.

Constraints

  • 모든 도메인 서비스는 stateless 여야 함 — 프로세스 간 인스턴스 차이가 동작 차이를 만들지 않아야 함
  • 프로세스 간 직접 HTTP/gRPC 호출 금지
  • Temporal signal 은 Temporal workflow 대상에만 사용, 일반 프로세스 호출에 사용 금지
  • 도메인 이벤트는 Outbox 경유 필수 — Redis pub/sub 등 다른 경로 금지 (캐시 invalidation 예외)

See also

  • architecture/overview.md — 시스템 전체
  • architecture/event-flow.md — Outbox 이벤트 흐름
  • architecture/context-map.md — 도메인 컨텍스트 관계
  • guides/outbox-pattern.md — Outbox publisher/subscriber 작성법
  • guides/temporal-workflow.md — Temporal Workflow 작성법
  • adr/0017-process-separation-bot-api-worker.md — 프로세스 분리 결정
  • adr/0018-domain-code-sharing.md — 도메인 코드 공유 결정