ADR-0002: Outbox 플러그인 아키텍처 — 추상화와 구현체 분리¶
- 상태: Accepted
- 날짜: 2026-03-10
- 관련: ADR-0001 (Outbox Seam 정의)
맥락 (Context)¶
ADR-0001에서 정의한 Outbox Seam을 기반으로 spakky-outbox 플러그인을 설계해야 한다.
초기 설계(GitHub Issue #4)는 spakky-outbox가 spakky-sqlalchemy에 직접 의존하는 구조였다:
flowchart LR
outbox[spakky-outbox] -->|하드 의존| sqlalchemy[spakky-sqlalchemy]
이 설계는 SQLAlchemy 외의 영속화 기술(MongoDB, DynamoDB 등)을 지원하지 못한다는 구조적 한계가 있다.
확장 요구사항¶
- SQLAlchemy: PostgreSQL, MySQL 등 관계형 DB (트랜잭션 원자성)
- MongoDB: 문서 DB (트랜잭션 지원, v4.0+)
- DynamoDB: AWS 서버리스 (TransactWriteItems API)
- 향후: Redis Streams, Firestore 등
결정 동인 (Decision Drivers)¶
- 단일 책임: Outbox 코어 로직과 영속화 구현을 분리
- 확장성: 새 DB 지원 시 core 변경 없이 구현체만 추가
- Opt-in 원칙: Outbox 자체가 선택적 기능
- 코어 체인 보존:
spakky → spakky-domain → spakky-data → spakky-event체인에 인프라 의존 추가 금지 - Zero User Code: 플러그인 설치만으로 동작
고려한 대안 (Considered Options)¶
대안 A: 추상화 + 구현체 분리 (채택)¶
spakky-outbox는 인터페이스와 코어 로직만 제공하고, spakky-outbox-sqlalchemy 등 구현체를 별도 패키지로 분리:
| 패키지 | 역할 |
|---|---|
spakky-outbox |
추상화 (IOutboxStorage, Bus, Relay, Config) |
spakky-outbox-sqlalchemy |
SQLAlchemy 구현체 |
spakky-outbox-mongodb |
MongoDB 구현체 (향후) |
spakky-outbox-dynamodb |
DynamoDB 구현체 (향후) |
- 장점: 단일 책임, 명확한 확장 포인트, 새 DB는 구현체만 추가
- 단점: 패키지 수 증가 (2개 이상 설치 필요)
대안 B: 단일 플러그인 + 선택적 의존¶
spakky-outbox 하나가 모든 DB 구현을 포함하고, extras_require로 선택적 의존성 관리:
# pyproject.toml
[project.optional-dependencies]
sqlalchemy = ["spakky-sqlalchemy>=0.0.1"]
mongodb = ["motor>=3.0"]
dynamodb = ["boto3>=1.28"]
설치: pip install spakky-outbox[sqlalchemy]
- 장점: 단일 패키지, 설치 명령 단순
- 단점: 코드 복잡도 증가, 불필요한 DB 코드 포함, 의존성 충돌 가능
대안 C: 각 인프라 플러그인에 Outbox 통합¶
spakky-sqlalchemy에 Outbox 기능을 직접 추가:
| 패키지 | 추가되는 Outbox 기능 |
|---|---|
spakky-sqlalchemy |
OutboxStorage, OutboxMessageTable |
spakky-mongodb |
OutboxStorage (향후) |
- 장점: 기존 플러그인 재사용
- 단점: 책임 분리 위반 (SQLAlchemy 플러그인이 Event 시스템 알아야 함),
spakky-event의존 추가로 순환 가능성
결정 (Decision)¶
대안 A를 채택한다. 추상화와 구현체를 분리하여 확장성을 확보한다.
패키지 구조¶
| 패키지 | 경로 | 책임 |
|---|---|---|
spakky-outbox |
src/spakky/plugins/outbox/__init__.py |
패키지 루트 |
spakky-outbox |
src/spakky/plugins/outbox/error.py |
Outbox 에러 |
spakky-outbox |
src/spakky/plugins/outbox/main.py |
initialize(app) |
spakky-outbox |
src/spakky/plugins/outbox/common/config.py |
OutboxConfig |
spakky-outbox |
src/spakky/plugins/outbox/common/message.py |
OutboxMessage |
spakky-outbox |
src/spakky/plugins/outbox/ports/storage.py |
IOutboxStorage, IAsyncOutboxStorage |
spakky-outbox |
src/spakky/plugins/outbox/bus/outbox_event_bus.py |
AsyncOutboxEventBus |
spakky-outbox |
src/spakky/plugins/outbox/relay/relay.py |
OutboxRelay |
spakky-outbox-sqlalchemy |
src/spakky/plugins/outbox_sqlalchemy/main.py |
initialize(app) |
spakky-outbox-sqlalchemy |
src/spakky/plugins/outbox_sqlalchemy/persistency/table.py |
OutboxBase, OutboxMessageTable |
spakky-outbox-sqlalchemy |
src/spakky/plugins/outbox_sqlalchemy/adapters/storage.py |
SqlAlchemyOutboxStorage |
핵심 인터페이스¶
# spakky-outbox: common/message.py
@dataclass(frozen=True)
class OutboxMessage:
"""영속화 무관한 Outbox 메시지 모델."""
id: UUID
event_name: str # topic/queue 라우팅 키
payload: bytes # JSON 직렬화된 이벤트
created_at: datetime
published_at: datetime | None = None
retry_count: int = 0
claimed_at: datetime | None = None # atomic claim용
# spakky-outbox: ports/storage.py
class IOutboxStorage(ABC):
"""동기 Outbox 메시지 저장소 추상화."""
@abstractmethod
def save(self, message: OutboxMessage) -> None: ...
@abstractmethod
def fetch_pending(self, limit: int, max_retry: int) -> list[OutboxMessage]: ...
@abstractmethod
def mark_published(self, message_id: UUID) -> None: ...
@abstractmethod
def increment_retry(self, message_id: UUID) -> None: ...
class IAsyncOutboxStorage(ABC):
"""비동기 Outbox 메시지 저장소 추상화."""
@abstractmethod
async def save(self, message: OutboxMessage) -> None: ...
@abstractmethod
async def fetch_pending(self, limit: int, max_retry: int) -> list[OutboxMessage]: ...
@abstractmethod
async def mark_published(self, message_id: UUID) -> None: ...
@abstractmethod
async def increment_retry(self, message_id: UUID) -> None: ...
의존성 그래프¶
flowchart TD
event[spakky-event] --> outbox[spakky-outbox]
outbox --> outboxSqlalchemy[spakky-outbox-sqlalchemy]
outbox --> outboxMongo[spakky-outbox-mongodb]
outbox --> outboxDynamo[spakky-outbox-dynamodb]
outboxSqlalchemy --> sqlalchemy[spakky-sqlalchemy]
outboxMongo --> motor[motor MongoDB]
outboxDynamo --> boto[boto3 AWS]
이벤트 흐름¶
flowchart TD
subgraph StorePath[Store Path: 같은 트랜잭션]
UseCase --> Publisher[AsyncEventPublisher]
Publisher --> Bus[AsyncOutboxEventBus]
Bus --> Save[IOutboxStorage.save]
Save --> Commit[비즈니스 데이터와 원자적 커밋]
end
subgraph RelayPath[Relay Path: 독립 세션]
Relay[OutboxRelay BackgroundService] --> Fetch[IOutboxStorage.fetch_pending]
Fetch --> Send[IAsyncEventTransport.send]
Send --> Mark[IOutboxStorage.mark_published]
end
플러그인 위치: plugins/¶
Outbox는 opt-in 기능이며, 코어 체인(spakky → spakky-domain → spakky-data → spakky-event)에 속하지 않는다. IAsyncEventBus를 @Primary로 교체하는 확장이므로 plugins/ 디렉토리가 적합하다.
결과 (Consequences)¶
긍정적¶
- 확장성: 새 DB 지원 시
spakky-outbox-*구현체만 추가 - 단일 책임: Outbox 코어 로직과 영속화 구현 완전 분리
- Zero Config: 플러그인 2개 설치만으로 Outbox 활성화
- 테스트 용이:
IOutboxStoragemock으로 Bus/Relay 단위 테스트 가능
부정적¶
- 패키지 수 증가: 사용자가 2개 이상 패키지 설치 필요 (
spakky-outbox+spakky-outbox-sqlalchemy) - 버전 호환성 관리: 추상화와 구현체 간 버전 호환성 유지 필요
중립적¶
- 첫 버전 범위:
spakky-outbox-sqlalchemy만 구현, MongoDB/DynamoDB는 향후 별도 이슈