콘텐츠로 이동

Hexagonal Pattern (Pragmatic)

Umbra 가 채택한 Hexagonal Architecture 의 실무 가이드. Core 도메인은 풀세트 Hexagonal, Supporting/Generic 은 단순 2층 구조를 쓴다 (ADR-0019). 이 문서는 각 도메인 타입별로 파일을 어디에 두고 어떻게 의존성을 관리하는지 설명한다.

Why Hexagonal

전통 Layered (Controller → Service → Repository) 는 의존성 방향이 단방향이지만 실무에서 몇 가지 문제가 생긴다.

  • Repository 인터페이스가 Service 에 끌려 감 — ORM 의 관심사가 도메인에 침투
  • 외부 시스템 (Toss, Discord, Temporal) 호출이 도메인 안에 박힘 — 테스트 불가, mock 지옥
  • 도메인이 인프라를 알아야 함 — 결제 도메인이 HTTP client 를 import

Hexagonal 은 이 문제를 해결한다. 도메인이 Port (인터페이스) 만 선언하고, Adapter 가 실제 구현을 제공. 도메인은 외부 세계를 모른 채 유스케이스만 수행.

Pragmatic 의 의미

순수 Hexagonal 은 모든 도메인에 풀세트 (Domain / Port / App / Adapter) 를 강요한다. 실무에서 Supporting 도메인 (Identity, Guild 같은 CRUD 성) 에 적용하면 코드가 부풀어 오른다.

Umbra 는 Core 만 풀세트, 나머지는 단순 구조.

Type Examples Structure
Core licensing, billing, recovery Domain + Port + App + Adapter (풀세트)
Supporting identity, guild, member, notification Service + Repository + Events (3-4 files)
Generic audit, webhook Service + Repository (2 files)

이 선택 근거는 adr/0019-hexagonal-pragmatic.md.

Core domain layout

engine/billing/
├─ domain/              # 순수 entity, invariant
│  ├─ subscription.go
│  ├─ billing_key.go
│  ├─ payment_attempt.go
│  ├─ events.go         # 도메인 이벤트 정의
│  └─ errors.go         # 도메인 에러 (sentinel)
├─ port/                # 인터페이스만
│  ├─ service.go        # inbound (driving) port
│  ├─ repository.go     # outbound (driven) port
│  ├─ toss.go           # 외부 시스템 port
│  ├─ crypto.go
│  ├─ event.go
│  └─ clock.go
├─ app/                 # 유스케이스 구현
│  ├─ service.go        # inbound port 구현 (Service)
│  ├─ start_subscription.go
│  ├─ process_recurring.go
│  ├─ retry_charge.go
│  ├─ cancel.go
│  └─ change_plan.go
└─ adapter/             # outbound port 구현
   ├─ persistence/
   │  └─ sqlc/
   │     ├─ subscription.go
   │     ├─ billing_key.go
   │     └─ payment_attempt.go
   ├─ toss/
   │  ├─ client.go
   │  └─ webhook_parser.go
   ├─ crypto/
   │  └─ aes_gcm.go
   ├─ event/
   │  └─ outbox.go
   └─ clock/
      └─ real.go

Domain layer

가장 안쪽. 외부 의존 0. import 가능한 패키지: 표준 라이브러리 + platform/uuid 같은 pure util.

// domain/subscription.go
package domain

import "time"

type Subscription struct {
    ID                 SubscriptionID
    LicenseID          LicenseID
    PayerUserID        UserID
    GuildID            GuildID
    BillingKeyID       BillingKeyID
    PlanID             PlanID
    Status             SubscriptionStatus
    CurrentPeriodStart time.Time
    CurrentPeriodEnd   time.Time
    NextBillingAt      time.Time
    CycleCount         int
    RetryCount         int
    CancelAtPeriodEnd  bool
    CanceledAt         *time.Time
    SuspendedAt        *time.Time
    SuspendedReason    string
}

// 도메인 메서드 (상태 전이, 규칙)
func (s *Subscription) AdvancePeriod(newEnd time.Time, nextBilling time.Time) {
    s.CurrentPeriodStart = s.CurrentPeriodEnd
    s.CurrentPeriodEnd = newEnd
    s.NextBillingAt = nextBilling
    s.CycleCount++
    s.RetryCount = 0
    s.Status = StatusActive
}

func (s *Subscription) MarkPastDue(nextRetry time.Time) {
    s.Status = StatusPastDue
    s.RetryCount++
    s.NextBillingAt = nextRetry
}
  • DB 의존 금지 (sql, pgx import 금지)
  • 외부 API 의존 금지
  • JSON tag 있으면 애매 — domain entity 는 순수, adapter 에서 DTO 로 변환

Port layer

인터페이스만. Adapter 가 구현.

// port/repository.go
package port

type SubscriptionRepository interface {
    Insert(ctx context.Context, sub domain.Subscription) error
    Update(ctx context.Context, sub domain.Subscription) error
    GetByID(ctx context.Context, id domain.SubscriptionID) (*domain.Subscription, error)
    GetActiveByGuild(ctx context.Context, guildID domain.GuildID) (*domain.Subscription, error)
    ListDueForCharge(ctx context.Context, before time.Time) ([]*domain.Subscription, error)
}

type TossClient interface {
    IssueBillingKey(ctx context.Context, customerKey, authKey string) (*IssueResult, error)
    Charge(ctx context.Context, input ChargeInput) (*ChargeResult, error)
    CancelBillingKey(ctx context.Context, billingKey string) error
}

type Crypto interface {
    Encrypt(ctx context.Context, plaintext, aad []byte) (ciphertext, nonce []byte, err error)
    Decrypt(ctx context.Context, ciphertext, nonce, aad []byte) ([]byte, error)
}

type EventPublisher interface {
    Publish(ctx context.Context, event domain.Event) error
}

type Clock interface {
    Now() time.Time
    NextBillingTimeWithJitter(base time.Time) time.Time
}
  • 이름은 역할 중심 (TossClient 보다 PaymentGateway 가 추상적이지만 MVP 는 Toss 전제라 직접 이름 사용 허용)
  • 하나의 port 는 작게 — 5개 이하 메서드 권장
  • Consumer side definition (Go 관행) — port 는 도메인이 정의

App layer

Inbound port 를 구현하는 서비스. 유스케이스 별 파일 분리 권장.

// app/service.go
package app

type Service struct {
    subs     port.SubscriptionRepository
    keys     port.BillingKeyRepository
    attempts port.PaymentAttemptRepository
    toss     port.TossClient
    crypto   port.Crypto
    events   port.EventPublisher
    clock    port.Clock
    tx       port.TxManager
    logger   *slog.Logger
}

func NewService(
    subs port.SubscriptionRepository,
    keys port.BillingKeyRepository,
    attempts port.PaymentAttemptRepository,
    toss port.TossClient,
    crypto port.Crypto,
    events port.EventPublisher,
    clock port.Clock,
    tx port.TxManager,
    logger *slog.Logger,
) *Service {
    return &Service{
        subs: subs, keys: keys, attempts: attempts,
        toss: toss, crypto: crypto, events: events,
        clock: clock, tx: tx, logger: logger,
    }
}

유스케이스별 파일:

// app/start_subscription.go
package app

func (s *Service) StartSubscription(ctx context.Context, input StartSubscriptionInput) (*domain.Subscription, error) {
    // 1. 빌링키 발급
    issueResult, err := s.toss.IssueBillingKey(ctx, input.CustomerKey, input.AuthKey)
    if err != nil { return nil, fmt.Errorf("issue billing key: %w", err) }

    // 2. 암호화 + 저장
    encrypted, nonce, err := s.crypto.Encrypt(ctx, []byte(issueResult.BillingKey), []byte(input.CustomerKey))
    if err != nil { return nil, fmt.Errorf("encrypt: %w", err) }

    var sub *domain.Subscription
    err = s.tx.WithTx(ctx, func(tx port.TxRepos) error {
        // INSERT billing_key, subscription
        // Outbox INSERT BillingKeyIssued
        // First charge 는 별도 메서드
        ...
    })
    if err != nil { return nil, err }

    // 3. First charge
    return s.executeFirstCharge(ctx, sub, input.PlanPrice)
}

35줄 제한 지키기 어려운 유스케이스는 private helper 함수로 분리.

Adapter layer

Outbound port 구현. 실제 외부 시스템 접근.

// adapter/persistence/sqlc/subscription.go
package sqlc

type subscriptionRepo struct {
    q *queries.Queries
}

func NewSubscriptionRepo(q *queries.Queries) port.SubscriptionRepository {
    return &subscriptionRepo{q: q}
}

func (r *subscriptionRepo) Insert(ctx context.Context, sub domain.Subscription) error {
    return r.q.InsertSubscription(ctx, queries.InsertSubscriptionParams{
        ID:        sub.ID.UUID(),
        LicenseID: sub.LicenseID.UUID(),
        // ...
    })
}

func (r *subscriptionRepo) GetByID(ctx context.Context, id domain.SubscriptionID) (*domain.Subscription, error) {
    row, err := r.q.GetSubscriptionByID(ctx, id.UUID())
    if errors.Is(err, pgx.ErrNoRows) {
        return nil, domain.ErrSubscriptionNotFound
    }
    if err != nil { return nil, err }
    return rowToDomain(row), nil
}

// DB row → domain entity 변환
func rowToDomain(row queries.Subscription) *domain.Subscription { ... }
  • sqlc generated 코드 (queries.*) 는 adapter 에만 노출
  • DB row → domain entity 변환 함수 (rowToDomain) 가 경계
  • pgx/postgresql 특정 에러는 domain sentinel 로 변환
// adapter/toss/client.go
package toss

type client struct {
    http       *http.Client
    secretKey  string
    webhookKey string
}

func NewClient(httpc *http.Client, secretKey, webhookKey string) port.TossClient {
    return &client{http: httpc, secretKey: secretKey, webhookKey: webhookKey}
}

func (c *client) Charge(ctx context.Context, input port.ChargeInput) (*port.ChargeResult, error) {
    req := buildChargeRequest(input)
    resp, err := c.http.Do(req)
    if err != nil { return nil, fmt.Errorf("toss charge: %w", err) }
    defer resp.Body.Close()

    if resp.StatusCode >= 400 {
        return nil, parseTossError(resp)
    }
    return parseChargeResult(resp), nil
}

Supporting domain layout

풀세트 Hexagonal 없음. 3~4 파일 단순 구조.

engine/identity/
├─ entity.go       # User, Session 타입
├─ service.go      # Service 구조체 + 메서드
├─ repository.go   # Repository 인터페이스 + sqlc 구현
├─ events.go       # 도메인 이벤트
└─ adapter/
   ├─ discord/     # Discord OAuth2 client
   │  └─ oauth2.go
   └─ session/     # Redis session store
      └─ redis.go

Service

// engine/identity/service.go
package identity

type Service struct {
    repo       Repository
    sessions   SessionStore
    discord    DiscordOAuth2Client
    events     EventPublisher
    clock      Clock
    logger     *slog.Logger
}

func (s *Service) AuthenticateWithDiscord(ctx context.Context, authCode string) (*User, *Session, error) {
    // OAuth2 token exchange
    token, err := s.discord.ExchangeCode(ctx, authCode)
    if err != nil { return nil, nil, err }

    // User info fetch
    info, err := s.discord.FetchUserInfo(ctx, token.AccessToken)
    if err != nil { return nil, nil, err }

    // Upsert user
    user, err := s.repo.UpsertUser(ctx, UpsertInput{ ... })
    if err != nil { return nil, nil, err }

    // Create session
    session, err := s.createSession(ctx, user.ID, token)
    if err != nil { return nil, nil, err }

    return user, session, nil
}

Port/App/Adapter 디렉토리 나누지 않음. 대신 interface 는 service.go 또는 repository.go 에 선언.

Why simpler

Supporting domain 은:

  • 외부 시스템 integration 수가 적음
  • 유스케이스가 단순 (CRUD + 몇 가지 액션)
  • 상태 머신이 단순

풀세트 구조의 간접 비용이 이득을 초과한다.

Generic domain layout

Audit, Webhook. 단순 2-file 구조.

engine/audit/
├─ service.go
├─ repository.go
└─ consumer.go     # 이벤트 구독 구현

Audit 은 거의 "insert-only" 라 entity 메서드도 최소.

Dependency injection wiring

Per-process

apps/{bot,api,worker}/cmd/main.go 에서 명시적 wiring.

// apps/api/cmd/main.go
func main() {
    ctx := context.Background()
    cfg := config.Load()

    // Infra
    pool := platform.NewPgxPool(cfg.DatabaseURL)
    defer pool.Close()

    redisClient := platform.NewRedisClient(cfg.RedisURL)
    defer redisClient.Close()

    logger := platform.NewLogger(cfg.LogLevel)

    // Queries
    queries := sqlcQueries.New(pool)

    // Core: Billing
    billingSvc := billingApp.NewService(
        billingSqlc.NewSubscriptionRepo(queries),
        billingSqlc.NewBillingKeyRepo(queries),
        billingSqlc.NewPaymentAttemptRepo(queries),
        billingToss.NewClient(httpClient, cfg.TossSecretKey, cfg.TossWebhookKey),
        billingCrypto.NewAESGCM(cfg.EncryptionKey),
        platform.NewOutboxPublisher(pool),
        platform.NewRealClock(),
        platform.NewTxManager(pool),
        logger,
    )

    // Core: Licensing
    licensingSvc := licensingApp.NewService(...)

    // Supporting: Identity
    identitySvc := identity.NewService(
        identity.NewRepo(queries),
        identity.NewRedisSessionStore(redisClient),
        identity.NewDiscordOAuth2Client(cfg),
        platform.NewOutboxPublisher(pool),
        platform.NewRealClock(),
        logger,
    )

    // Handlers
    h := handler.NewHandler(billingSvc, licensingSvc, identitySvc, ...)

    // Run
    server := newEchoServer(h)
    server.Start(cfg.Port)
}
  • DI container (uber-fx 등) 도입은 Phase 2 에서 검토
  • 수동 wiring 은 파일 당 ~100줄 수준, 명확함

Cross-process shared

engine/, platform/ 은 bot / api / worker 3개 프로세스가 공유한다. 각 process 의 main.go 에서 필요한 서비스만 wiring.

예: bot 은 billing 의 ProcessRecurringCharge 는 호출하지 않으므로 worker 에만 wire 하면 됨.

Testing Hexagonal

Unit test 에서 port mock

func TestService_StartSubscription(t *testing.T) {
    mockSubs := &mockSubRepo{}
    mockToss := &mockTossClient{
        chargeResult: &port.ChargeResult{PaymentKey: "test", ApprovedAt: time.Now()},
    }

    svc := app.NewService(mockSubs, ..., mockToss, ...)

    sub, err := svc.StartSubscription(ctx, input)

    assert.NoError(t, err)
    assert.Equal(t, "active", string(sub.Status))
    assert.Equal(t, 1, sub.CycleCount)
    assert.Len(t, mockSubs.inserted, 1)
    assert.Len(t, mockToss.chargeCalls, 1)
}

type mockSubRepo struct {
    inserted []domain.Subscription
}

func (m *mockSubRepo) Insert(ctx context.Context, sub domain.Subscription) error {
    m.inserted = append(m.inserted, sub)
    return nil
}
// ... 다른 메서드

Integration test 에서 실제 adapter

func TestService_Integration(t *testing.T) {
    if testing.Short() { t.Skip() }

    // Docker Postgres
    pool := testsupport.NewPgxPool(t)
    queries := sqlcQueries.New(pool)

    svc := app.NewService(
        sqlc.NewSubscriptionRepo(queries),
        ..., // 실제 repo
        &mockTossClient{ ... }, // 외부 API 만 mock
        ...,
    )

    sub, err := svc.StartSubscription(ctx, input)
    require.NoError(t, err)

    // DB 에서 실제로 읽어서 검증
    got, _ := queries.GetSubscriptionByID(ctx, sub.ID.UUID())
    assert.Equal(t, "active", got.Status)
}

자세한 건 testing-strategy.md.

Cross-domain communication

Same process read (Port 경유)

다른 도메인 데이터를 읽어야 할 때, 해당 도메인의 Service 를 port interface 로 의존:

// engine/recovery/subdomain/restore/port/licensing.go
type LicensingReader interface {
    Can(ctx context.Context, guildID uuid.UUID, feature string) (bool, error)
    GetActiveLicense(ctx context.Context, guildID uuid.UUID) (*License, *Plan, error)
}

Wiring 시 licensing service 를 adapter 로 wrap:

// apps/api/cmd/main.go
restoreSvc := restoreApp.NewService(
    ...,
    adapter.NewLicensingReaderFromService(licensingSvc),
    ...,
)

이 방식이 직접 licensing.Service 를 의존하는 것보다 테스트 용이.

Cross-domain write 금지

다른 도메인의 데이터를 직접 쓰지 않는다. 대신:

  • 이벤트 발행 — outbox 로 이벤트, 구독자가 자기 도메인 업데이트
  • 요청 기반 API 호출 — 드물지만 필요 시 (보통 이벤트로 해결)

이벤트 구독

// engine/licensing/subscriber.go
type subscriber struct {
    svc *app.Service
}

func (s *subscriber) OnPaymentSucceeded(ctx context.Context, event billingDomain.PaymentSucceeded) error {
    return s.svc.ExtendExpiresAt(ctx, event.LicenseID, event.NewPeriodEnd)
}

func (s *subscriber) OnPaymentFailedFinal(ctx context.Context, event billingDomain.PaymentFailedFinal) error {
    return s.svc.Downgrade(ctx, DowngradeInput{ LicenseID: event.LicenseID, ... })
}

자세한 건 outbox-pattern.md.

Do / Don't

Do

  • ✅ Core 는 풀세트 Hexagonal (Domain / Port / App / Adapter)
  • ✅ Supporting / Generic 은 단순 구조
  • ✅ Port interface 는 consumer 측 정의
  • ✅ Domain layer 는 외부 의존 0
  • ✅ Adapter 에서 domain 변환
  • ✅ Cross-domain read 는 port 경유
  • ✅ Cross-domain write 는 이벤트로

Don't

  • ❌ Supporting 에 불필요한 Hexagonal 구조
  • ❌ Domain 이 sqlc 나 pgx import
  • ❌ Adapter 가 다른 adapter 호출 (port 경유 안 하고)
  • ❌ Cross-domain 직접 write
  • ❌ god interface (20+ 메서드)
  • ❌ Port 와 Adapter 의 1:1 강요 (하나의 port 에 여러 adapter 구현 가능)

See also

  • project-structure.md
  • backend-conventions.md
  • database-conventions.md
  • outbox-pattern.md
  • testing-strategy.md
  • ../adr/0019-hexagonal-pragmatic.md
  • ../adr/0018-domain-code-sharing.md