콘텐츠로 이동

System Architecture Overview

Umbra 는 네 개의 독립 프로세스와 두 개의 주요 외부 시스템으로 구성된 분산 SaaS 입니다. 각 프로세스는 명확한 책임을 갖고, 도메인 코어는 프로세스 간 공유되는 Go 패키지로 구현됩니다.

Why this structure

Umbra 가 단일 모놀리스가 아닌 네 개의 프로세스로 분리된 데는 세 가지 이유가 있습니다.

첫째, 책임 분리의 본질적 차이 입니다. Discord Gateway 에 상시 연결된 봇, HTTP 요청을 처리하는 API, 백그라운드 작업을 실행하는 워커, 사용자 UI 를 제공하는 웹은 서로 다른 lifecycle 과 스케일 요구사항을 가집니다. 같은 프로세스에 묶으면 한 쪽의 부하가 다른 쪽을 끌어내립니다.

둘째, 장애 격리 입니다. 결제 SaaS 에서는 한 영역의 장애가 다른 영역을 멈추지 않아야 합니다. Bot 이 죽어도 결제 cron 은 계속 돌아야 하고, API 가 죽어도 진행 중인 복구 워크플로우는 완주해야 합니다. 프로세스 분리가 이를 강제합니다.

셋째, 독립 스케일링 입니다. Fly.io 에서 프로세스별로 머신 수와 리소스를 독립 조정합니다. Recovery 워크플로우가 폭주하면 worker 머신만 늘리고, 대시보드 트래픽이 몰리면 api 머신만 늘립니다.

도메인 로직 자체는 세 Go 프로세스가 공유합니다. Bot, API, Worker 는 모두 같은 engine/* 패키지를 import 하며, 각자의 진입점에서 필요한 유스케이스를 호출합니다. 이는 마이크로서비스가 아닌 "분산된 모놀리스" 이며, 추후 진짜 분리가 필요해지면 같은 인터페이스를 RPC 로 감싸 전환 가능한 구조입니다.

System view

graph TB
    subgraph External
        Discord[Discord API]
        Toss[Toss Payments]
        User[End User]
    end

    subgraph Edge
        Vercel[Vercel CDN]
    end

    subgraph Fly.io
        Bot[umbra-bot
disgo gateway] API[umbra-api
Echo HTTP] Worker[umbra-worker
asynq + Temporal + Outbox] end subgraph Data Neon[Neon Postgres] Redis[Redis] TemporalSvc[Temporal Server] end User -->|HTTPS| Vercel Vercel -->|HTTPS| API Discord -.->|Gateway WS| Bot Bot -->|REST| Discord API -->|REST| Discord Worker -->|REST| Discord API -->|REST| Toss Worker -->|REST| Toss Toss -.->|Webhook| API Bot --> Neon API --> Neon Worker --> Neon Bot --> Redis API --> Redis Worker --> Redis Worker --> TemporalSvc API --> TemporalSvc

실선은 요청-응답 흐름, 점선은 비동기 콜백 또는 WebSocket 구독을 나타냅니다.

Processes

Umbra 는 네 개의 프로세스로 구성됩니다. 셋은 Fly.io, 하나는 Vercel 에 배포됩니다.

umbra-bot

Runtime — Go 프로세스, apps/bot/ Deployment — Fly.io, 상시 1대 (게이트웨이 샤딩은 후속)

Discord Gateway 에 WebSocket 으로 상시 연결되어 길드 이벤트를 수신하고 슬래시 커맨드를 처리하는 프로세스입니다. 인게이지먼트의 진입점이며, disgo 라이브러리로 Discord API 와 통신합니다.

Bot 프로세스의 내부 코드는 apps/bot/internal/ 에 위치하며, 도메인 로직 호출을 위해 engine/* 패키지를 import 합니다. 슬래시 커맨드 핸들러는 도메인 서비스를 직접 호출하여 응답을 Discord 의 3초 제한 내에 반환하며, 장기 작업이 필요한 경우 Temporal Workflow 나 asynq 작업을 트리거합니다.

umbra-api

Runtime — Go 프로세스, apps/api/ Deployment — Fly.io, 트래픽 기반 auto-scale

HTTP API 를 제공하는 Echo 프로세스입니다. 세 가지 책임을 가집니다.

  • 사용자 대시보드 APIapp.umbra.ink 에서 호출되는 인증된 요청 처리
  • 웹 조인 플로우join.umbra.ink/{slug} 의 OAuth2 콜백과 멤버 생성
  • Toss 웹훅 수신 — 결제 상태 변경 알림 처리

인증은 Discord OAuth2 + 세션 쿠키 기반이며 세션은 Redis 에 저장됩니다. CSRF, Rate Limit, 보안 헤더는 Echo 미들웨어로 표준화됩니다.

umbra-worker

Runtime — Go 프로세스, apps/worker/ Deployment — Fly.io, 작업량 기반 auto-scale

백그라운드 작업을 실행하는 프로세스입니다. 단일 프로세스 안에서 세 종류의 워커를 고루틴으로 병렬 실행합니다.

  • asynq 워커 — 결제 정기 cron, 결제 재시도, Live Sync 배치, 알림 발송
  • Temporal 워커 — Recovery 워크플로우, AntiNuke 워크플로우
  • Outbox pollerplatform/event/ 의 outbox 테이블을 폴링하여 구독자에 이벤트 전달

이 프로세스는 Discord Gateway 에 연결되지 않습니다. Discord API 는 REST 로만 호출하며, 주로 복구 실행 시 사용됩니다.

umbra-web

Runtime — Bun + Vite + React SPA, apps/web/ Deployment — Vercel, 정적 호스팅

사용자 대시보드의 프론트엔드입니다. Bun 으로 빌드된 정적 자산이 Vercel CDN 에서 서빙되며, 런타임에는 umbra-api 를 호출합니다. 결제 위젯은 @tosspayments/payment-sdk 로 클라이언트에서 직접 Toss 와 통신합니다.

Core principles

아키텍처 전반을 관통하는 네 가지 원칙입니다.

Pragmatic Hexagonal

Core 도메인(Recovery, Licensing, Billing)은 풀세트 Hexagonal Architecture 를 적용합니다. Port 와 Adapter 를 명확히 분리하고, 도메인 코어는 외부 의존성을 모릅니다. 반면 Supporting 도메인(Identity, Guild, Member, Notification)은 단순 구조로 실용적 적용합니다. 이론적 완결성이 아닌 유지보수 가치 기준입니다.

상세는 adr/0019-hexagonal-pragmatic.md 를 참조하세요.

Domain code sharing, not service splitting

Bot, API, Worker 는 서로 직접 통신하지 않습니다. 대신 같은 도메인 패키지(engine/*)를 import 하여 필요한 기능을 호출합니다. 이는 마이크로서비스의 네트워크 부담과 복잡도를 피하면서 프로세스 분리의 이점을 챙기는 선택입니다.

프로세스 간 통신이 필요한 경우는 도메인 이벤트뿐이며, 이는 Outbox 패턴으로 DB 를 경유합니다.

상세는 adr/0018-domain-code-sharing.md, architecture/process-communication.md 를 참조하세요.

Outbox for cross-domain events

도메인 간 결합도를 낮추기 위해 도메인 이벤트는 Outbox 패턴으로 전달됩니다. Billing 이 결제 완료를 알리면 Licensing 이 기간을 연장하는 흐름은, Billing 이 Licensing 을 직접 호출하지 않고 platform/event/ 의 outbox 테이블에 이벤트를 기록합니다. Worker 의 poller 가 이를 Licensing 구독자에게 전달합니다.

이 패턴은 트랜잭션 일관성(결제 업데이트와 이벤트 발행이 같은 트랜잭션)과 at-least-once 전달(프로세스 장애 시에도 이벤트 손실 없음)을 보장합니다.

상세는 adr/0016-outbox-pattern.md, architecture/event-flow.md 를 참조하세요.

Specialized workflow engine for recovery

Recovery 와 AntiNuke 워크플로우만 Temporal 을 사용합니다. 나머지 비동기 작업(결제 cron, 재시도, 알림, Live Sync)은 asynq 로 처리합니다. 두 도구는 책임 영역이 명확히 분리됩니다.

  • Temporal — 장기 실행, 장애 복원, 시그널 기반 조정이 필요한 워크플로우
  • asynq — 짧은 비동기 작업, 정기 cron, 단순 재시도

상세는 adr/0014-recovery-temporal-workflow.md, adr/0015-asynq-vs-temporal-split.md 를 참조하세요.

Data layer

Neon Serverless PostgreSQL

모든 영속 데이터의 source of truth 입니다. 도메인별로 schema 가 분리되어(identity, guild, billing, recovery 등) DB 레벨에서도 도메인 경계가 강제됩니다. 빌링키는 AES-256-GCM 으로 암호화되어 저장됩니다.

sqlc 로 타입 안전한 Go 코드를 생성하며, Atlas 로 선언형 스키마 마이그레이션을 관리합니다.

Redis

세 가지 용도로 사용됩니다.

  • 세션 저장소 — Discord OAuth2 세션
  • asynq 백엔드 — 작업 큐
  • idempotency 체크 — Toss 웹훅 중복 처리 방지

Temporal Server

Recovery 와 AntiNuke 워크플로우의 상태를 관리합니다. MVP 는 self-hosted 로 시작하며 Neon Postgres 를 persistence backend 로 공유합니다. 운영 부담이 커지면 Temporal Cloud 로 전환을 검토합니다.

Deployment topology

Fly.io

  • umbra-bot — asia-northeast1 리전, 1대
  • umbra-api — asia-northeast1 리전, auto-scale
  • umbra-worker — asia-northeast1 리전, auto-scale

세 머신은 Fly.io 의 internal 6PN 네트워크로 통신하지만, Umbra 는 프로세스 간 직접 통신을 하지 않으므로 실제 사용은 외부 공개 엔드포인트(Neon, Redis, Temporal)뿐입니다.

Vercel

umbra-web 은 Vercel 의 전역 CDN 에 정적 자산으로 배포됩니다. Preview deployment 는 PR 별로 자동 생성되어 리뷰에 활용됩니다.

Cloudflare

umbra.ink 도메인의 DNS 관리와 edge 보호를 담당합니다.

Trade-offs

Benefit Cost
프로세스 장애 격리 단일 모놀리스 대비 배포 단위 3개로 증가
독립 스케일링 디버깅 시 프로세스 경계 인지 필요
도메인 코드 공유로 네트워크 오버헤드 0 세 프로세스가 같은 코드를 빌드해서 실행 (이미지 3개)
Temporal 로 복구 워크플로우 장애 복원 Temporal 인프라 운영 부담 추가
Outbox 로 도메인 간 결합도 ↓ 이벤트 처리에 polling 지연(최대 2초) 발생

Constraints

이 아키텍처가 전제하는 제약 조건입니다. 이 전제가 바뀌면 아키텍처 재검토가 필요합니다.

  • 단일 리전 운영 — asia-northeast1 전용. 글로벌 확장 시 멀티 리전 설계 필요.
  • Discord Gateway 단일 연결 — 길드 수 2,500 개를 넘으면 Discord 가 샤딩을 요구하며, umbra-bot 구조 재설계 필요.
  • Neon 단일 인스턴스 — 수평 분산이 필요한 규모는 별도 설계.
  • 세 프로세스의 Go 버전 통일 — 같은 go.mod 를 공유하므로 Go 버전 업그레이드는 동시에 진행.

Alternatives considered

Single monolith

단일 Go 프로세스에 Bot + API + Worker 를 통합. 초기에는 단순하지만 Discord Gateway 가 장기 실행을 요구하는 반면 API 는 stateless 한 특성 차이로 장애 전파와 스케일링 충돌이 발생합니다. 거부.

Full microservices

도메인별 독립 서비스로 분리. 학습 부담, 네트워크 오버헤드, 운영 복잡도가 MVP 스코프에 과도합니다. Umbra 는 "분산된 모놀리스" 로 시작하고 필요 시 점진 분리하는 경로를 택했습니다. 거부.

BFF layer with Hono

프론트엔드와 API 사이에 Hono(Bun) BFF 를 두는 구조. 세션 토큰 격리 등의 이점이 있지만 Echo 와 책임이 겹치고 MVP 에 과도합니다. 거부.

See also

  • architecture/tech-stack.md — 기술 스택 전체와 선택 이유
  • architecture/deployment.md — Fly.io / Vercel 배포 상세
  • architecture/context-map.md — Bounded Context 관계도
  • architecture/process-communication.md — 프로세스 간 통신 패턴
  • architecture/event-flow.md — Outbox 이벤트 흐름
  • adr/0017-process-separation-bot-api-worker.md — 프로세스 분리 결정
  • adr/0018-domain-code-sharing.md — 도메인 코드 공유 결정