콘텐츠로 이동

ADR-0019: Pragmatic Hexagonal Architecture

Umbra 는 Hexagonal Architecture 를 모든 도메인에 일률적으로 적용하지 않는다. Core 컨텍스트(Recovery, Licensing, Billing)에는 풀세트로 적용하고, Supporting/Generic 컨텍스트는 단순 구조로 실용적으로 적용한다.

Status

Accepted

  • Decided at — 2026-04-13
  • Decided by — Pablo

Context

Umbra 는 9개 Bounded Context 를 가진다 (ADR-0020). 이 전부를 완전한 Hexagonal Architecture (Port/Adapter 분리, 도메인 코어 순수 유지)로 구현하면 다음 비용이 발생한다.

  • 단순 CRUD 수준 도메인에도 Port 인터페이스 필요
  • Adapter 레이어의 보일러플레이트
  • 신규 팀원의 학습 부담 (모든 도메인이 3~4층 구조)

그러나 Hexagonal 의 이점(외부 의존 격리, 테스트 용이성, 미래 RPC 전환 가능성)은 비즈니스 복잡도가 높은 도메인에서는 명확하다.

"모든 도메인에 동일하게 적용" 은 이론적 완결성을 추구하는 오버엔지니어링 이고, "Hexagonal 을 전혀 쓰지 않음" 은 Core 도메인의 유지보수 가치를 버리는 선택 이다.

Decision

Bounded Context 의 분류에 따라 Hexagonal 적용 수준을 달리한다.

Core (full Hexagonal)

대상: Recovery, Licensing, Billing

  • domain/ — 순수 entity, value object, invariant
  • port/ — Inbound (driving) + Outbound (driven) 인터페이스
  • app/ — 유스케이스 구현, Port 기반으로 Service 작성
  • adapter/ — Port 의 실제 구현 (persistence, external, etc.)

도메인 코어는 외부를 모른다. 테스트는 Port 의 mock/stub 으로 독립 실행 가능.

Supporting (단순 구조)

대상: Identity, Guild, Member, Notification

  • repository + service 2층 구조
  • Port/Adapter 분리 없음
  • *Service 가 직접 DB pool, Redis client, Discord client 등 의존

Generic (단순 구조)

대상: Audit, Webhook

  • Supporting 과 동일한 2층 구조
  • Audit 은 대부분 INSERT only, Webhook 은 외부 진입점 adapter

분류 기준

"이 도메인이 Umbra 의 경쟁 우위에 직접 기여하는가?"

  • Core — Umbra 의 차별화 영역 (복구, 권한, 결제)
  • Supporting — Core 를 떠받치는 영역 (사용자, 길드, 멤버, 알림)
  • Generic — 일반 솔루션으로 충분한 영역 (감사 로그, 웹훅 수신)

선택 근거:

  • 비용-이점 균형 — 복잡도가 정당화되는 도메인에만 Hexagonal 적용
  • 도메인 중요도 반영 — Core 는 미래 Rust 재작성의 핵심이라 언어 무관 설계 필수
  • 학습 부담 분산 — 신규 팀원이 Supporting 부터 접근 후 Core 로 확장
  • 리팩토링 가능성 — Supporting 이 커지면 Core 로 승격하며 Hexagonal 적용

Directory structure

Core context 예: Billing

engine/billing/
├─ domain/          ← 순수 도메인 (외부 의존 0)
│  ├─ subscription.go
│  ├─ billing_key.go
│  └─ invariants.go
├─ port/            ← 인터페이스
│  ├─ subscription_repository.go
│  ├─ toss_client.go
│  └─ event_publisher.go
├─ app/             ← 유스케이스
│  └─ service.go
└─ adapter/         ← 구현
   ├─ persistence/  (sqlc 연결)
   ├─ toss/         (Toss API client)
   └─ event/        (Outbox 연결)

Supporting context 예: Member

engine/member/
├─ entity.go        ← Member, Guild 참조 등
├─ service.go       ← 유스케이스 (DB pool 직접 참조)
├─ repository.go    ← sqlc 쿼리 래핑
└─ events.go        ← 발행 이벤트 정의

Consequences

Positive

  • Core 는 테스트와 미래 재작성이 용이
  • Supporting 은 학습 부담 낮고 빠른 구현
  • 도메인별 구조가 "왜 이런지" 설명 가능 (Core = 복잡/중요, Supporting = 단순/보조)
  • 팀 성장에 따라 Supporting → Core 승격 경로 존재

Negative

  • 도메인마다 구조가 달라 신규 팀원이 두 패턴을 학습
  • 승격 시 리팩토링 필요 (Supporting → Core 전환 시)
  • "왜 A 는 Core 이고 B 는 Supporting 인가" 논쟁 가능 (분류 기준 문서화 필요)

Neutral

  • 분류는 architecture/context-map.md 에 공식 기록
  • 각 도메인의 Hexagonal 적용 수준은 도메인 문서에서 명시

Boundaries between core and supporting

Core 가 Supporting 을 호출하려면

Core 컨텍스트가 Supporting 데이터를 필요로 할 때, Core 안의 Port 로 정의하고 Adapter 가 Supporting 을 호출 한다.

예: Recovery 가 Member 데이터가 필요한 경우

// engine/recovery/port/member_reader.go
type MemberReader interface {
    GetMembers(ctx context.Context, guildID uuid.UUID) ([]Member, error)
}

// engine/recovery/adapter/member/reader.go
type memberReaderImpl struct {
    memberSvc *member.Service  // Supporting 의 단순 Service
}
func (r *memberReaderImpl) GetMembers(...) { ... }

이렇게 하면 Core 의 domain/app 은 Supporting 을 모르고, 경계가 유지된다.

Supporting 이 Supporting 을 호출

Supporting 간 호출은 자유. member.Serviceguild.Service 를 직접 import 해도 OK. 단, 순환 의존은 금지.

Supporting 이 Core 를 호출

일반적으로 권한 체크 목적. Licensing 은 Core 지만 Supporting 에서 직접 호출 가능 (Licensing 이 읽기 전용 쿼리만 제공하는 경우).

예: Member 의 Web Join 핸들러가 Licensing 에 "이 Guild 가 Pro 인가?" 질문.

Alternatives considered

Alternative 1: 모든 도메인에 Hexagonal 적용

Pros

  • 구조 일관성
  • 모든 도메인이 테스트 용이

Cons

  • Supporting 수준 도메인에 overkill
  • Audit 같은 단순 INSERT 에도 Port 필요
  • 보일러플레이트 폭증

Why rejected — 비용이 이점을 압도. 오버엔지니어링.

Alternative 2: 전혀 Hexagonal 미적용 (단순 2층)

Pros

  • 구조 단순, 빠른 구현

Cons

  • Billing 같은 복잡 도메인에서 외부 의존(Toss) 결합이 도메인에 섞임
  • 테스트 시 mock 복잡
  • 미래 RPC 전환/재작성 비용 ↑

Why rejected — Core 도메인의 유지보수성이 결정적.

Alternative 3: 모든 도메인 단일 Service + 필요 시 리팩토링

Pros

  • 시작 단순

Cons

  • Core 도메인이 복잡해진 후 Hexagonal 리팩토링은 큰 부담
  • 초기에 경계 설정 안 하면 도메인 간 결합 발생

Why rejected — Core 는 처음부터 Hexagonal 로 시작해야 경계가 제대로 형성됨.

Compliance

  • architecture/context-map.md 에 각 Context 의 분류(Core/Supporting/Generic) 기록
  • Core Context 는 domain/, port/, app/, adapter/ 4개 하위 패키지 강제
  • Supporting/Generic 은 자유 구조, 단 순환 의존 금지
  • Core 의 domain/app/ 는 Supporting 패키지를 직접 import 금지 (Port 경유)
  • 코드 리뷰에서 경계 위반 차단

Revisit triggers

  • Supporting Context 가 복잡해져서 Hexagonal 이 필요해지면 Core 로 승격 (ADR 로 기록)
  • 반대로 Core 의 특정 영역이 단순하게 유지되면 "실용주의" 대상에서 제외 검토
  • 팀 규모 확장으로 구조 일관성의 가치가 커지면 재평가

References

  • ADR-0018 — 도메인 코드 공유 (Hexagonal 의 Adapter 전략과 결합)
  • ADR-0020 — 도메인 경계 = DB 경계
  • ADR-0016 — 도메인 간 이벤트 전달