App / Package Boundary
이 문서는 app과 package를 나누는 기준입니다.
1. 왜 나누는가
App과 package를 나누는 이유는 폴더를 보기 좋게 만들기 위해서가 아닙니다. 바뀌어도 되는 runtime 조립과 오래 유지되어야 하는 제품 계약을 분리하기 위해서입니다.
Hospital-at-Home Risk Monitor 예제에서는 Python API, React app, firmware app, Go operator가 모두 실행 단위입니다. 반면 signal packet, preprocessing feature, risk assessment, monitoring view model, reconcile decision은 여러 app에서 재사용되거나 테스트되어야 하는 package 계약입니다.
1-1. 핵심 원칙
이 repo의 app/package 경계는 아래 문장으로 시작합니다.
Package는 제품 의미와 결정 contract를 소유한다.
App은 그 contract를 특정 runtime에서 실행하고 side effect를 조율한다.
다른 말로 표현하면 package는 “무엇이 맞는가”를 정의하고, app은 “어디서, 어떻게 실행하고 남길 것인가”를 결정합니다.
Package는 domain language, deterministic decision, workflow result, reusable port contract처럼 runtime이 바뀌어도 보존해야 하는 의미를 담습니다. App은 HTTP, browser, firmware scheduler, Kubernetes controller, persistence transaction, observability, deployment lifecycle처럼 실행 환경에 묶이는 결정을 담당합니다.
| Area | Package owns | App owns |
|---|---|---|
| Python risk | risk workflow result, alarm/review decision, external capability ports | FastAPI, runtime config, transaction, persistence ordering, readiness |
| TypeScript UI | monitoring view model, alarm presentation state, component contract | browser runtime, API polling, user action wiring, static asset serving |
| Rust signal | preprocessing feature contract, deterministic signal quality calculation, SignalFrameSet processing result |
Python runtime이 Rust wheel을 dependency로 선택하고 호출 |
| C/Firmware | packet quality rule, signal packet contract | board, driver, queue/thread, RTOS or bare-metal lifecycle |
| Go operator | reconcile decision, desired/current state comparison | Kubernetes client, status update, controller lifecycle |
1-2. Package가 단단해야 하는 이유
Package는 제품 언어와 테스트 가능한 규칙을 담습니다.
- domain model
- usecase
- port interface
- framework-independent view model
- reusable clinical component
- package registry publish 대상
Package는 가능하면 runtime config, process lifecycle, container image, deployment manifest를 몰라야 합니다.
1-3. App이 얇아야 하는 이유
App은 실행 가능한 배포 단위입니다.
- runtime config
- dependency composition
- HTTP server, browser, firmware, operator entrypoint
- container image
- deployment 대상
App은 제품 규칙을 새로 만들지 않고 package를 사용합니다. 다만 app이 단순 wiring만 한다는 뜻은 아닙니다. HTTP status code, request/response DTO, runtime config, transaction boundary, persistence ordering, readiness, auth context, request log, metrics/tracing middleware처럼 process, protocol, side effect에 속한 책임은 app에 둡니다. App이 두꺼워져 문제가 되는 경우는 이런 runtime 책임이 아니라 domain rule과 clinical decision이 app 안으로 들어오는 경우입니다.
2. 경계 판단 기준
2-1. Package로 둔다
아래 질문에 yes라면 package에 둡니다.
- 다른 app이나 언어 boundary에서 재사용할 수 있는가?
- framework 없이 unit test할 수 있어야 하는가?
- package registry에 publish할 의미가 있는가?
- domain language를 보존해야 하는가?
- CI에서 app/container build와 분리해 검증해야 하는가?
예시는 아래와 같습니다.
patient-risk-python-corepatient-signal-rust-corepatient-signal-c-corepatient-monitor-ui-corepatient-monitor-react-componentspatient-risk-go-core
2-2. App으로 둔다
아래 질문에 yes라면 app에 둡니다.
- process나 browser entrypoint인가?
- 환경변수와 credential을 읽는가?
- port에 실제 adapter를 연결하는 composition root인가?
- container image 또는 firmware artifact를 만드는가?
- runtime health/readiness를 노출하는가?
예시는 아래와 같습니다.
patient-risk-python-apipatient-risk-websignal-device-simulatorwearable-firmwarewearable-zephyr-firmwarerisk-monitor-operator
3. 예시
| Area | Package | App |
|---|---|---|
| TypeScript | patient-monitor-ui-core, patient-monitor-react-components |
patient-risk-web |
| Python | python-persistence, patient-risk-python-core |
patient-risk-python-api |
| Rust | patient-signal-rust-core, patient-signal-python-rust |
Python API가 Rust Python wheel을 runtime dependency로 선택 |
| C/Firmware | patient-signal-c-core |
wearable-firmware, wearable-zephyr-firmware |
| Go | patient-risk-go-core |
risk-monitor-operator |
이 경계를 지키면 package는 독립적으로 test/publish할 수 있고, app은 배포와 runtime 조립에 집중할 수 있습니다.
4. Clean Architecture 관점
Package는 domain, usecase, port를 우선합니다. App은 adapter와 composition root를 조립합니다.
package
-> domain
-> usecase
-> port
app
-> config
-> adapter
-> composition root
-> process or browser entrypoint
Port는 usecase가 외부 세계에 기대하는 capability를 표현합니다. 반대로 side effect를 어떤 순서로 실행할지, 어떤 transaction/outbox/readiness 정책으로 묶을지는 app이 결정합니다. 그래서 package public API에는 제품 의미가 남고, app에는 runtime 실현 방식이 남습니다.
예를 들어 Python risk score 판단과 signal ingestion 결과 contract는 patient-risk-python-core에 있어야 합니다. FastAPI route와 저장 orchestration은 patient-risk-python-api에 있어야 합니다. Repository port는 조회/상태 변경 capability를 core package가 표현할 수 있지만, signal ingestion usecase는 저장하지 않고 결과만 반환합니다. python-persistence는 SQLAlchemy/Redis foundation만 제공하고, patient-risk 전용 PostgreSQL/Redis outbound adapter는 API app 내부에 둡니다. API app은 DATABASE_URL, REDIS_URL, backend 선택, readiness 노출, process lifecycle, raw packet/result 저장 순서를 composition root에서 조립합니다.
TypeScript도 마찬가지입니다. patient-monitor-ui-core는 monitoring snapshot을 view model로 바꾸고, patient-monitor-react-components는 reusable component를 제공합니다. patient-risk-web은 Python API endpoint와 browser runtime을 연결합니다.
4-1. Storage adapter 기준
PostgreSQL과 Redis를 도입할 때도 package/app 경계를 유지합니다.
packages/patient-risk-python-core/
domain/
application/
ports/
clinical_context.py
preprocessing.py
risk_model.py
runtime.py
usecases.py
bootstrap.py
packages/python-persistence/
application/
readiness.py
domain/
config.py
keys.py
readiness.py
storage/
messaging/
reliability/
ports/
health.py
storage/
sql.py
key_value.py
object_store.py
messaging/
message_stream.py
event_log.py
reliability/
idempotency.py
leases.py
outbox.py
adapters/outbound/
sqlalchemy/
redis/
support/
lifecycle.py
observability.py
bootstrap.py
apps/patient-risk-python-api/
domain.py
usecases.py
ports/
observability.py
persistence.py
preprocessing.py
adapters/inbound/http/
routes.py
models.py
presenters.py
request_context.py
readiness.py
observability.py
adapters/inbound/mqtt/
handler.py
runtime.py
topics.py
adapters/outbound/
console_log.py
persistence/
bootstrap.py
events.py
ingestion.py
models.py
mappers.py
repositories.py
keys.py
payloads.py
projections.py
preprocessing/
rust.py
bootstrap.py
composition.py
config.py
__main__.py
Core package는 risk workflow result contract와 clinical/policy/model/preprocessing 같은 제품 capability port를 표현합니다. Signal ingestion core result builder는 SignalPacket과 이미 계산된 PreprocessedSignal을 받아 assessment, review item, monitoring snapshot을 반환하고 저장하지 않습니다. Preprocessing adapter 선택, signal grouping, persistence transaction, projection 갱신, outbox/idempotency 정책은 API app 책임입니다.
python-persistence는 SQLAlchemy/Redis를 직접 쓰기 위한 patient-risk package가 아닙니다. 이 package는 SQL, key-value, object storage, message stream, event log, idempotency, lease, outbox, readiness 같은 generic persistence foundation을 제공합니다. Table/model/schema, Redis key 이름, stream/group 이름, event payload version, projection payload는 app outbound adapter가 소유합니다.
API app은 runtime config를 읽고 /healthz, /readyz 같은 process-facing contract를 노출하며, raw packet과 workflow result 저장 순서를 조율합니다. SQLAlchemy ORM model과 pure domain/application result 사이의 mapping도 app adapter 책임입니다. 그래야 table 변경, Redis key 변경, projection payload 변경이 core package의 clinical workflow를 흔들지 않습니다.
Storage adapter가 risk score, alarm severity, data quality 판단을 직접 계산하면 경계가 깨진 것입니다. Adapter는 저장과 조회를 담당하고, 판단은 domain/usecase에 남깁니다.
4-2. Signal grouping 기준
고주파 waveform과 20 bed 이상 동시 처리를 고려해도 package public contract에 Batch라는 이름을 고정하지 않습니다. 도메인에서는 여러 SignalFrame을 같은 processing window로 묶은 SignalFrameSet을 사용합니다. batch size는 app runtime이 처리량, 지연시간, memory pressure를 조절하기 위해 사용하는 실행 정책입니다.
package contract
SignalFrame
SignalFrameSet
SignalProcessingRunResult
app runtime policy
SignalGroupingPolicy
maxItems
maxLatencyMs
maxPointsPerSet
이 기준에서 Rust package는 SignalFrameSet을 feature result로 바꾸는 deterministic engine입니다. Python core package는 item별 PreprocessedSignal을 받아 risk/alarm/review result를 만듭니다. Python API app은 inbound protocol, grouping policy, Rust package 호출, item별 fan-out, transaction, Redis/PostgreSQL projection을 조율합니다.
단건 HTTP ingestion은 예외 contract가 아니라 크기 1인 SignalFrameSet으로 해석할 수 있어야 합니다. 반대로 ward/gateway MQTT 경로는 여러 bed/device frame을 한 set으로 보내도 같은 core result builder를 호출해야 합니다.
4-3. Observability 기준
Log, metric, tracing은 app과 infra가 함께 책임집니다. Package가 OpenTelemetry collector URL, Loki, Prometheus scrape, Kubernetes label 같은 deployment detail을 알면 경계가 깨집니다. Package는 audit context처럼 제품 의미가 있는 값을 만들고, app은 그 값을 request log, metric attribute, trace attribute에 실을지 결정합니다.
따라서 Python API는 ports/observability.py에 output port를 두고, adapters/inbound/http/observability.py와 adapters/outbound/console_log.py에 concrete adapter를 둡니다. 실제 collector, sampling, metric export 방식은 CD repo와 runner/cluster infra 변경과 함께 적용합니다.
5. DDD 관점
Package 이름과 public API는 bounded context를 드러내야 합니다.
patient-signal-*는 device signal과 preprocessing context입니다.patient-risk-*는 risk assessment와 clinical workflow context입니다.patient-monitor-*는 monitoring UI context입니다.risk-monitor-*는 runtime operations context입니다.
App 이름은 실행 목적을 드러냅니다. *-api, *-web, *-operator, *-firmware, *-simulator처럼 runtime 형태를 이름에 포함합니다.
6. TDD 관점
Package는 빠른 test가 먼저입니다. App은 smoke test가 마지막입니다.
| Boundary | Test |
|---|---|
| Domain package | unit test |
| Usecase package | fake port 기반 integration test |
| Adapter package | contract/integration test |
| App | smoke/acceptance test |
새 기능을 추가할 때 app smoke부터 만들면 debug 범위가 너무 넓어집니다. 먼저 package test로 의미를 고정하고, app smoke로 조립이 깨지지 않았는지 확인합니다.
7. 경계 냄새 체크리스트
아래 신호가 보이면 app/package 경계를 다시 봅니다.
7-1. App이 너무 두꺼운 경우
- FastAPI route, React screen, Zephyr main, operator reconciler 안에 domain rule이 직접 들어갑니다.
- app test만 통과하고 package unit/usecase test 없이 의미가 검증됩니다.
- runtime config, credential, process lifecycle과 risk/alarm/quality 판단이 같은 함수에 섞입니다.
- 같은 판단을 다른 app에서 재사용하려면 app 내부 코드를 import해야 합니다.
이 경우 package usecase나 port를 먼저 만들고, app은 adapter wiring으로 줄입니다.
7-2. Package가 runtime을 너무 많이 아는 경우
- package public API가
FastAPI,Request,Response,Redis,Deployment,AudioContext같은 concrete runtime type을 직접 요구합니다. - package import만으로 environment variable, network connection, file system, browser API가 실행됩니다.
- package test가 framework나 external service 없이는 시작되지 않습니다.
- package가 container image, process port, deployment overlay를 알아야 합니다.
이 경우 concrete runtime type은 adapter/app으로 내리고, package에는 domain type과 port를 남깁니다.
7-3. Package를 나눌 필요가 없는 경우
모든 코드를 package로 빼는 것도 답은 아닙니다. 아래 경우는 app 내부에 남겨도 됩니다.
- 특정 process entrypoint에서만 쓰는 config parsing
- 한 app의 logging, signal handling, server startup
- container image나 firmware artifact를 위한 build glue
- 재사용 가치가 낮고 domain language를 만들지 않는 얇은 adapter wiring
기준은 재사용 가능성보다 “제품 의미를 테스트 가능한 단위로 보존해야 하는가”입니다.
8. README 위치
사람이 처음 들어오는 경계마다 README를 둡니다. Root README가 모든 세부사항을 대신하지 않습니다. Root README는 큰 그림과 MkDocs 입구만 설명하고, package/app README는 해당 단위의 사용법과 계약을 설명합니다.
| Location | Reader | Should explain |
|---|---|---|
source-repo/README.md |
새 팀원, reviewer, platform engineer | repo 목적, 빠른 시작, 문서 링크 |
cd-repo/README.md |
배포 담당자, reviewer | promotion, environment overlay, rollback 기준 |
apps/*/README.md |
app owner, SRE, CD repo maintainer | runtime contract, required env, container image |
packages/*/README.md |
package consumer, library maintainer | public API, architecture boundary, test, package publish 기준 |
README는 코드를 읽기 전의 지도입니다. 상세한 판단 배경과 운영 기준은 MkDocs 문서에 둡니다.