Clean Architecture

이 문서는 source-repo에서 Clean Architecture를 적용하는 기준을 설명합니다.

1. 왜 적용하는가

Clean Architecture의 목적은 layer를 많이 만드는 것이 아닙니다. 바뀌어도 되는 부분은 느슨하게 만들고, 흔들리면 안 되는 제품 계약은 단단하게 만드는 것입니다. Hospital-at-Home Risk Monitor 예제에서는 생체신호 packet, preprocessing feature, risk assessment, alarm state, review queue처럼 팀 전체가 공유해야 하는 개념은 단단해야 합니다. 반대로 HTTP, MQTT, PostgreSQL, Redis, React, Zephyr, Kubernetes API 같은 기술 선택은 교체 가능해야 합니다.

이 구조가 필요한 이유는 세 가지입니다.

  • 제품 언어를 기술 프레임워크보다 오래 유지합니다.
  • framework, registry, database, runtime이 바뀌어도 domain rule과 usecase test를 보존합니다.
  • SaMD 성격의 예제에서 risk 근거, data quality, alarm state가 어디에서 결정되는지 추적 가능하게 만듭니다.

2. 핵심 원칙

느슨해야 하는 부분은 느슨하게 두고, 단단해야 하는 계약은 단단하게 고정합니다.

  • package는 제품 의미와 결정 contract를 소유합니다.
  • app은 그 contract를 특정 runtime에서 실행하고 side effect를 조율합니다.
  • domain은 framework와 runtime을 몰라야 합니다.
  • usecase는 port를 통해 외부 capability를 표현합니다.
  • adapter는 HTTP, browser, file, registry, firmware runtime 같은 외부 세계를 담당합니다.
  • app은 composition root로 dependency, transaction, persistence ordering, observability를 조립합니다.

2-1. 단단해야 하는 것

아래 항목은 app과 언어가 달라도 의미가 흔들리면 안 됩니다.

  • SignalPacket, SignalFrame, SignalFrameSet, PreprocessedSignal, RiskAssessment, MonitoringSnapshot, RiskMonitor contract
  • measurement quality와 clinical risk의 구분
  • risk evidence와 alarm state의 의미
  • port가 표현하는 외부 capability의 제품 의미
  • version, policy, model metadata, audit context

이 항목들은 문서, JSON Schema, shared fixture, package test로 고정합니다.

2-2. 느슨해야 하는 것

아래 항목은 adapter 또는 app runtime의 선택입니다.

  • HTTP와 MQTT ingestion protocol
  • Rust preprocessor와 Python package binding payload shape
  • in-memory, PostgreSQL, Redis repository adapter
  • React rendering 방식과 Storybook fixture
  • Zephyr native_sim, bare-metal app, 실제 board adapter
  • controller-runtime, fake client, local manifest reader

이 항목들은 port 뒤에 두고, domain/usecase가 직접 import하지 않게 합니다.

3. Layer 기준

3-1. Domain

Domain은 제품 규칙과 값 객체를 담습니다. Domain은 file system, network, database, framework, clock, random source를 직접 호출하지 않습니다.

예시는 아래와 같습니다.

  • C: PatientSignalSample, packet quality summary
  • Rust: SignalFrameSet, SignalProcessingRunResult, PreprocessedSignal
  • Python: RiskAssessment, AlarmState, policy rule
  • TypeScript: monitoring view model, alarm presentation state
  • Go: RiskMonitorSpec, reconcile decision

3-2. Usecase

Usecase는 제품 흐름을 표현합니다. 필요한 외부 기능은 port로 받습니다.

예시는 아래와 같습니다.

  • ingest_signal_packet
  • assess_patient_risk
  • build_monitoring_snapshot
  • reconcile_risk_monitor
  • firmware packet build usecase

Usecase는 FastAPI, Redis client, SQL driver, React hook, Kubernetes client, Zephyr scheduler를 몰라야 합니다.

3-3. Port

Port는 application workflow가 외부 세계에 기대하는 최소 capability입니다. 기술 이름보다 제품 의미를 기준으로 이름을 붙입니다. 저장, 조회, publish, notify, preprocessing 같은 side effect의 실제 실행 순서와 transaction boundary는 app이 결정합니다.

권장 이름은 아래처럼 둡니다.

  • app-owned preprocessing port
  • ReviewQueueRepository
  • MonitoringSnapshotRepository
  • AlarmStateRepository
  • ClinicalContextRepository
  • RuntimeStateProvider
  • firmware sensor/metadata port table

PostgresReviewQueueRepositoryRedisMonitoringSnapshotRepository는 port가 아니라 outbound adapter 이름입니다.

ports/adapters/ 폴더를 미리 두지 않습니다. 실제 경계와 구현이 있을 때 추가해야 구조가 문서 장식이 되지 않습니다.

3-4. Adapter

Adapter는 외부 기술과 domain/usecase 사이를 변환합니다.

  • inbound adapter: HTTP route, MQTT subscriber, CLI, Zephyr thread, React browser event
  • outbound adapter: PostgreSQL repository, Redis projection, Rust Python binding, sensor driver, Kubernetes client

Adapter는 validation, protocol parsing, serialization, retry, credential, connection lifecycle을 소유합니다. Domain rule을 adapter에 숨기면 같은 규칙을 테스트하기 어려워지고, protocol이 늘어날 때 중복됩니다.

3-5. App

App은 composition root입니다. 어떤 adapter를 어떤 port에 연결할지 결정하고, runtime config를 읽고, process/container/firmware entrypoint를 제공합니다. Protocol handling, persistence ordering, transaction/outbox policy, readiness, observability는 app 책임입니다. App은 제품 규칙을 새로 만들지 않습니다.

4. DDD 적용 기준

DDD는 큰 enterprise model을 만들기 위한 장식이 아니라, 팀이 같은 말을 같은 의미로 쓰게 만드는 기준입니다.

4-1. Ubiquitous language

문서와 코드에서 같은 개념은 같은 이름을 사용합니다.

Concept Boundary
SignalPacket device/simulator에서 backend로 넘어가는 측정 단위
SignalFrame high-frequency waveform과 multi-channel sensor를 표현하는 frame
SignalFrameSet 같은 site/ward/window에서 함께 처리할 수 있는 여러 signal frame
SignalProcessingRunResult grouped signal processing의 전체 상태와 item별 결과
PreprocessedSignal Rust/Python preprocessing 이후 risk model이 소비하는 feature
RiskAssessment Python risk usecase 결과
MonitoringSnapshot frontend가 재계산 없이 표시하는 화면 계약
ReviewQueueItem 의료진 검토 대상
RiskMonitor Go operator가 감시하는 runtime desired state

4-2. Bounded context

이 예제는 하나의 SaMD product를 보여주지만, 모든 코드를 하나의 model로 합치지 않습니다.

  • Device signal context: sample, packet, quality, calibration
  • Preprocessing context: missingness, artifact, feature extraction
  • Risk assessment context: clinical context, policy, evidence, alarm
  • Monitoring UI context: view model, presentation state, clinician action
  • Runtime operations context: desired state, observed state, drift, condition

Context 사이에는 JSON Schema, package API, repository port 같은 명시적 contract를 둡니다.

4-3. Domain event와 audit 관점

1단계에서 별도 event bus를 만들지는 않습니다. 그래도 audit 가능한 상태 변화는 이름을 분명히 해야 합니다.

  • signal packet ingested
  • risk assessment created
  • review queue item updated
  • alarm acknowledged or muted
  • runtime drift detected

PostgreSQL/Redis를 도입할 때도 이 의미를 잃지 않아야 합니다. SQL table이나 Redis key가 domain language를 대체하면 안 됩니다.

5. TDD와 테스트 이점

Clean Architecture는 테스트를 쉽게 만들기 위한 구조이기도 합니다.

  • domain rule은 I/O 없이 unit test합니다.
  • usecase는 fake port로 integration test합니다.
  • adapter는 contract와 runtime behavior를 검증합니다.
  • app은 smoke test로 조립이 깨지지 않았는지 확인합니다.

5-1. 테스트 피라미드

Layer Test style Example
Domain unit risk policy, packet quality, feature calculation
Usecase usecase integration with fake port signal packet ingestion, monitoring snapshot build
Adapter contract/integration FastAPI route, PostgreSQL repository, Redis projection, PyO3 binding
App smoke/acceptance make demo/check, make demo/check/storage

5-2. TDD 순서

새 기능은 아래 순서를 기본으로 둡니다.

  1. shared contract 또는 domain example을 먼저 추가합니다.
  2. domain/usecase test로 원하는 의미를 고정합니다.
  3. port를 통해 외부 의존성을 표현합니다.
  4. adapter를 붙이고 contract/integration test를 추가합니다.
  5. app smoke test로 composition을 확인합니다.
  6. 문서를 같은 PR에서 업데이트합니다.

이 순서를 따르면 PostgreSQL, Redis, MQTT, controller-runtime 같은 기술을 붙일 때도 제품 규칙이 adapter에 섞이는 것을 줄일 수 있습니다.

5-3. Storage 도입 시 적용

PostgreSQL과 Redis는 domain이 아닙니다. Python core는 clinical workflow와 result contract를 만들고, 저장 순서와 transaction boundary는 app이 소유합니다. python-persistence는 특정 제품 저장소가 아니라 app adapter가 사용할 generic foundation입니다.

patient-risk-python-core
  -> risk/alarm/review/monitoring result builder
  -> clinical context, preprocessing, model policy ports
  -> no SQL table, no Redis key, no transaction policy

patient-risk-python-api
  -> app persistence ports
  -> app outbound persistence mappers/repositories/projections
  -> transaction, outbox, idempotency, projection ordering

python-persistence
  -> SqlPersistence, KeyValueStore, ObjectStore
  -> MessageStream, EventLog
  -> IdempotencyStore, LeaseManager, OutboxStore
  -> HealthCheck, lifecycle support
  -> SQLAlchemy/Redis adapters

저장소가 추가되어도 risk score, alarm severity, data quality 판단이 repository adapter 안으로 이동하면 안 됩니다. Adapter는 저장과 조회를 맡고, 판단은 domain/usecase에 남깁니다. 반대로 SQL table, ORM model, Redis key, stream group, payload version은 core package로 올리지 않습니다. 이들은 app outbound adapter가 python-persistence foundation 위에서 소유합니다.

테스트도 이 경계를 따라 나눕니다. Pure workflow는 core unit test로 고정하고, ORM mapping과 repository behavior는 app adapter test에서 SQLite 또는 PostgreSQL integration으로 확인합니다. Redis key/value, stream, projection semantics는 app contract/integration test에서 확인하고, python-persistence는 vendor-neutral port contract와 SQLAlchemy/Redis adapter capability를 따로 검증합니다.

6. PR 리뷰 체크리스트

Clean Architecture, DDD, TDD는 문서에만 있으면 효과가 없습니다. PR 리뷰에서는 아래 질문을 기준으로 경계가 유지되는지 확인합니다.

6-1. Clean Architecture 체크

  • 새 판단 규칙이 domain/usecase에 있는가, 아니면 HTTP route, SQL adapter, React component, controller adapter에 숨어 있는가?
  • usecase가 외부 의존성을 concrete client가 아니라 port로 받는가?
  • 실제 구현 없이 빈 ports/, adapters/, bootstrap scaffold만 추가하지 않았는가?
  • app은 runtime config와 composition만 담당하고, 제품 규칙을 새로 만들지 않는가?
  • adapter가 protocol parsing, serialization, retry, connection lifecycle을 담당하되 clinical rule을 소유하지 않는가?
  • PostgreSQL, Redis, MQTT, Kubernetes, Zephyr 같은 기술 이름이 domain type이나 usecase 이름을 밀어내고 있지 않은가?

6-2. DDD 체크

  • 변경된 이름이 bounded context의 언어와 맞는가?
  • C/Firmware는 measurement integrity를 말하고, Python은 risk workflow를 말하고, TypeScript는 monitoring presentation을 말하고, Go는 runtime operations를 말하는가?
  • SignalPacket, PreprocessedSignal, RiskAssessment, MonitoringSnapshot, AlarmAuditEvent, RiskMonitor의 의미가 contract와 문서에서 같은가?
  • 다른 context의 결론을 중복 계산하지 않는가? 예를 들어 frontend가 risk level을 다시 만들거나 firmware가 clinical alarm을 만들면 경계가 깨진 것입니다.
  • audit 가능한 상태 변화가 event/action 이름으로 남는가, 아니면 단순 flag update로 묻히는가?

6-3. TDD 체크

  • contract, fixture, domain example 중 하나가 먼저 고정되었는가?
  • domain/usecase test가 실패 원인을 I/O 없이 설명할 수 있는가?
  • adapter test가 실제 protocol/storage/runtime behavior를 검증하는가?
  • app smoke가 composition 확인에 집중하고, business rule 검증을 대신하지 않는가?
  • 문서와 README가 같은 PR에서 boundary, command, verification evidence를 갱신하는가?

이 체크리스트를 통과하지 못하는 변경은 동작하더라도 유지보수 관점에서 미완성입니다. 특히 SaMD 예제에서는 어디서 판단했고, 어떤 evidence로 판단했는지가 코드와 테스트에서 추적 가능해야 합니다.

7. 관련 문서