Repository navigation
refactor(health): 전역 QueryBus를 걷어내고 inbound port로 진입한다 - #10
Conversation
파생 프로젝트(ceseem-backend CES-125)에서 이 문서의 규칙이 실제 코드와 부딪혀 값을 못 한다는
것이 드러났다. 교차 검증 2건(Claude Fable 5.1 · Codex gpt-5.6-sol)과 후속 조사가 각자 같은
결론에 왔고, 그 결과를 정본으로 되돌린다.
## 바뀐 것
1. capability 구조에 ports/in · ports/out. 방향이 곧 공개 여부다 —
in은 밖에서 나를 부르는 계약이라 공개, out은 내가 DB를 부르는 계약이라 비공개.
adapters/in·adapters/out과 어휘가 통일된다.
2. 「CQRS-lite」를 「Inbound port」로 바꿨다. 전역 Command/Query 버스는 더 이상 기본이 아니다.
버스가 사려던 것 셋 중 하나도 실현되지 않았다:
- "소비자가 상대 module을 import하지 않는다" — 네 module이 그대로 import하고 있었다
- "나중에 프로세스를 나눌 수 있다" — handler 절반이 MANDATORY라 호출자의 트랜잭션과 행
잠금을 공유한다. 나누면 에러 없이 깨진다
- "결합이 준다" — 아는 대상이 클래스에서 메시지 클래스로 바뀌었을 뿐 강도가 같다
대신 handler 17개 중 8개가 port 메서드를 그대로 전달했고, 같은 규칙을 다른 capability에
적용하면 9/18, 5/12가 같은 모양이 됐다. 원인은 "다른 capability의 데이터에 메시지로만
닿는다"는 규칙이고, cross-capability 읽기·잠금은 모듈러 모놀리스에서 정상이다.
버스를 쓰는 조건을 명시했다 — 여러 inbound가 같은 메시지를 보내거나 dispatch 자체가
값을 하는 경우.
3. 「Mandatory outbound ports」를 「Outbound port」로. 의존 역전이 실제로 값을 하는 자리가
여기 하나뿐임을 적었다. IRepository<T>를 새 capability의 기본으로 삼지 말라는 경고와
그 근거(FOR UPDATE, ON CONFLICT RETURNING, raw SET, 범위 비교가 표현되지 않는다)를 넣었다.
4. domain/이 cross-capability 공개 표면이라는 판정. 조건도 함께 적었다 — 타입과 순수 함수만.
5. cross-capability ORM relation은 클래스가 아니라 엔티티 이름 상수로 가리킨다.
FK는 relation decorator에서만 유도되므로 컬럼만 남기면 migration이 FK를 DROP한다(실측).
6. ESLint 강제 목록을 넷으로 명시하고 4번(capability를 넘는 adapter import 차단)을 추가했다.
규칙 2번은 import하는 파일이 application/·domain/일 때만 걸려서 scripts/·test/와 전환하지
않은 코드가 그대로 새고 있었다.
⚠ base no-restricted-imports를 쓴 것이 의도다. 기존 셋이 전부
@typescript-eslint/no-restricted-imports이고 files가 겹치므로 같은 이름으로 블록을 더하면
flat config가 그 셋을 조용히 교체한다. 추가 후 위반 코드를 실제로 넣어 새 규칙이 물고
기존 spread 금지가 여전히 무는 것을 확인했다.
7. 머리말에 "파생 프로젝트의 실측이 이 문서를 고친다"를 넣었다. 템플릿이 뒤처지면 새
프로젝트가 이미 폐기된 형태로 시작한다.
## 이 커밋이 코드를 고치지 않는다
문서를 먼저 고치고 코드를 뒤에 옮긴다. 지금 어긋난 자리를 「이 템플릿이 아직 문서를 따라가지
못하는 곳」 표에 적어뒀다 — src/health가 QueryBus로 진입하는 것, health.controller가
execute()에 타입 인자를 둘 명시해 결과 타입 거짓말이 tsc를 통과하는 것, ports/가 in/out으로
나뉘지 않은 것, architecture-rule-fixture의 빈 디렉토리 셋.
게이트: lint / typecheck 통과, 35 files 317 tests 전부 pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012SpbEMwFkuau6vEGhJMG7o
ARCHITECTURE.md의 「이 템플릿이 아직 문서를 따라가지 못하는 곳」표 다섯 줄을 닫는다. 문서는 d3aeff6에서 inbound/outbound port로 바뀌었는데 코드가 따라가지 않았고, 이 저장소는 파생 프로젝트가 보고 시작하는 유일한 참조 구현이라 어긋난 채 두면 다음 프로젝트가 이미 폐기된 형태로 시작한다. 지운 것 - `application/queries/` — `GetHealthQuery`, `GetHealthQueryHandler`와 그 spec. handler는 `coordinator.check()`를 그대로 전달했고, 보내는 곳이 `health.controller.ts` 하나였다. ARCHITECTURE.md가 정한 버스 사용 조건(같은 메시지를 여러 inbound가 보내거나 dispatch 자체가 값을 하는 경우)을 만족하지 못한다. - `@nestjs/cqrs` 의존성과 `AppModule`의 `CqrsModule.forRoot()` — 사용처가 0이 됐다. 함께 사라지는 것이 `execute<GetHealthQuery, SystemHealth>(...)` 함정이다. 타입 인자를 둘 명시하면 두 번째 오버로드가 잡혀 결과 타입을 거짓말해도 tsc가 통과시켰다(파생 프로젝트 실측). 메서드 시그니처에는 그 문제가 없다 — 결과 타입이 port에 한 번 적히고 호출부는 추론만 받는다. - `src/architecture-rule-fixture/`의 빈 디렉토리 셋. git이 추적하지 않던 것이라 체크아웃에서만 지웠다(새 clone에는 원래 없다). 이 커밋에 파일 변경은 없다. 옮긴 것 - `application/ports/in/health.port.ts` — `HealthPort` + `HEALTH` Symbol. `check()`와 `inspectIndicators()` 둘 다 담는다. entry point가 HTTP와 Prometheus scrape 둘이고, 둘 다 이 계약을 지난다. - `application/ports/health-indicator.port.ts` → `ports/out/`. 방향이 곧 공개 여부이므로 `in`은 공개, `out`은 비공개다. - `domain/system-health.ts` — `HealthStatus`, `HealthIndicatorSnapshot`, `SystemHealth`. 두 방향의 port가 모두 쓰는 타입이라 domain에 둔다. 비공개인 `ports/out/`에 두면 소비자가 결과 타입을 이름 부르려고 비공개 파일을 열어야 한다. - `HealthCoordinator`가 `HealthPort`를 구현하고, module은 `exports: [HEALTH, ...]`로 토큰만 내보낸다. controller와 `HealthMetricsService`는 `@Inject(HEALTH)`로 받는다. 구현 클래스가 소비자의 런타임 import 그래프에 들어가지 않는다. - `health.controller.spec.ts` 추가 — 소비자 test가 inbound port fake를 주입하는 형태의 표본. 대역이 `check`라는 메서드 이름을 갖는 것이 값이다. ESLint 규칙 4번(capability를 넘는 adapter import 차단)은 d3aeff6에 이미 들어와 있었다. 표의 그 줄만 stale이었다. 위반 파일을 실제로 만들어 `src/`·`scripts/`· `test/` 세 곳에서 무는 것과, flat config 교체 함정에도 불구하고 기존 규칙(A-5 객체 spread, port의 @nestjs 차단, application → adapters 역방향)이 여전히 무는 것을 확인했다. 문서 - ARCHITECTURE.md의 표를 비우고 무엇을 어떻게 닫았는지 남긴다. `Current references`의 health 줄을 두 방향 port 기준으로 고친다. - README.md·AGENTS.md·CLAUDE.md의 "CQRS-lite" 절이 여전히 Command/Query handler를 기본 규칙으로 규정하고 있었다. 에이전트와 파생 프로젝트가 읽는 것이 이 셋이라 코드만 바꾸면 다음 프로젝트가 여전히 폐기된 형태를 지시받는다. port 경계 서술로 바꾼다. `docs/`의 과거 조사 기록은 그 시점의 관측이므로 그대로 둔다. 검증: lint, typecheck, test(35 files / 318), check:scaffold, check:observability, build, test:e2e(2 files / 20, Docker postgres) 모두 통과. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BpFLpds7xSWRhwheHcyZYf
Codex 리뷰 레인이 여섯 건을 냈다. 다섯 건을 고치고 한 건은 근거를 적어 문서의
「아직 문서를 따라가지 못하는 곳」표에 남겼다.
P1 — cross-capability adapter 규칙이 깊은 경로에서 우회됐다
`../`를 1~4번 쓴 여섯 패턴만 열거해서, 여섯 단계 깊이의 위반이 `exit 0`으로 통과했다.
열거를 늘리는 것은 경계를 옮길 뿐이라 glob의 실제 동작을 재보고 다시 썼다.
- 이 rule의 glob은 import 문자열을 그대로 본다. 경로를 resolve하지 않으므로 `..`가
리터럴 segment다.
- `**`는 `..`를 건너지 못한다. `**/*/adapters/**` 하나로 끝내려 하면 깊이 1도 통과한다.
- 리터럴 `../`로 시작한 뒤의 `**`는 남은 `..`를 건넌다. 그래서
`../**/*/adapters/**`가 깊이와 무관하게 문다.
- 이미 capability 밖에 있는 파일(`src/app.module.ts`)의 `./health/adapters/**`를 잡으려면
`./`로 시작하는 짝이 따로 필요하다.
네 패턴으로 다시 확인했다: 깊이 1·6, `test/`·`scripts/`의 `../src/<cap>/adapters/**`,
src root의 `./<cap>/adapters/**` 모두 차단. 자기 capability의 `./adapters/**`·
`../adapters/**`는 통과. 오검출 하나(`../../adapters/**`)는 남는데, 그 자리는
`application/`·`domain/`뿐이고 역방향 차단이 이미 막는다 — 근거를 config 주석에 적었다.
P2 — Prometheus의 1/0 표현이 public inbound port로 샜다
`HealthPort.inspectIndicators(): Promise<Record<string, number>>`를 지웠다. gauge
encoding을 capability의 공개 계약으로 만들면 metrics backend를 바꿀 때 health의 외부
API가 따라 바뀐다. port는 `check(): Promise<SystemHealth>` 하나만 주고, up/down →
1/0 변환은 그것을 요구하는 `health-metrics.service.ts`가 소유한다. coordinator에서도
지웠고 spec을 adapter 쪽으로 옮겼다. 대역이 실제 `HealthPort`를 구조적으로 만족하게
되면서 `as never`도 필요 없어졌다.
P2 — module이 concrete service를 다시 공개했다
`exports: [HEALTH, HealthMetricsService]` → `exports: [HEALTH]`. 소비자가 없었고,
`HealthMetricsService`는 gauge를 등록하는 이 capability의 inbound infrastructure
adapter라 밖에서 주입받을 것이 아니다. e2e의 `app_health_check_status` 검증이
그대로 통과한다.
P3 — `pnpm-workspace.yaml`의 `@nestjs/cqrs@12.0.0` minimumReleaseAgeExclude 제거.
P3 — README 다이어그램이 CLI를 Controller에 묶었다. `HTTP / CLI / message → inbound
adapter (Controller, CLI command, consumer)`로 고쳐 ARCHITECTURE.md의 고정 문법과 맞춘다.
고치지 않은 것
`health-metrics.service.ts`가 optional한 `MetricsService`를 `ModuleRef.get(...,
{ strict: false })`로 찾는다. optional dependency와 실패 모드가 module wiring이 아니라
DI lookup에 숨는 것이 맞다. 다만 고치려면 metrics 유무에 따른 provider 구성을 바꿔야
하고 그것은 health 밖의 결정이라 「이 템플릿이 아직 문서를 따라가지 못하는 곳」표에
한 줄로 남겼다.
검증: lint, typecheck, test(35 files / 318), check:scaffold, check:observability,
build, test:e2e(2 files / 20, Docker postgres) 통과. lint 규칙은 위반 파일을 실제로
만들어 확인했고 probe는 전부 지웠다.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BpFLpds7xSWRhwheHcyZYf
Codex 리뷰 반영 (
|
| 측정 | 결과 |
|---|---|
**/*/adapters/** (하나로 끝내기) |
깊이 1도 통과 — **는 .. segment를 건너지 못한다 |
../**/*/adapters/** |
깊이 1·6 모두 차단 — 리터럴 ../ 뒤의 **는 남은 ..를 건넌다 |
./**/*/adapters/** |
src/app.module.ts의 ./health/adapters/** 차단 |
자기 capability ./adapters/**·../adapters/** |
통과(의도대로) |
이 rule의 glob은 import 문자열을 그대로 본다 — 경로를 resolve하지 않으므로 ..가 리터럴
segment이고, 위 두 성질이 거기서 따라온다. 근거를 eslint.config.mjs 주석에 적었다.
오검출 하나는 남긴다: *가 ..에도 매치되므로 ../../adapters/**도 걸린다. 그 자리는
application/·domain/뿐이고 역방향 차단이 이미 막는다.
P2 — Prometheus의 1/0이 public inbound port로 샜다
HealthPort.inspectIndicators(): Promise<Record<string, number>>를 지웠다. 이전 커밋에서 내가
넣은 것이고, gauge encoding을 capability의 공개 계약으로 만들면 metrics backend를 바꿀 때
health의 외부 API가 따라 바뀐다. port는 check(): Promise<SystemHealth> 하나만 주고 up/down →
1/0 변환은 그것을 요구하는 health-metrics.service.ts가 소유한다. 대역이 실제 HealthPort를
구조적으로 만족하게 되면서 spec의 as never도 필요 없어졌다.
P2 — module이 concrete service를 다시 공개했다
exports: [HEALTH, HealthMetricsService] → exports: [HEALTH]. 소비자가 없었고,
HealthMetricsService는 gauge를 등록하는 이 capability의 inbound infrastructure adapter다.
e2e의 app_health_check_status 검증이 그대로 통과한다.
P3 둘
pnpm-workspace.yaml의@nestjs/cqrs@12.0.0minimumReleaseAgeExclude제거.- README 다이어그램이 CLI를 Controller에 묶었다 →
HTTP / CLI / message → inbound adapter (Controller, CLI command, consumer).
고치지 않은 것 — 표에 한 줄로 남겼다
health-metrics.service.ts가 optional한 MetricsService를 ModuleRef.get(..., { strict: false })
로 찾는다. optional dependency와 실패 모드가 module wiring이 아니라 DI lookup에 숨는 것이 맞다.
고치려면 metrics 유무에 따른 provider 구성을 바꿔야 하고 그것은 health 밖의 결정이라
ARCHITECTURE.md의 「이 템플릿이 아직 문서를 따라가지 못하는 곳」표에 남겼다. 그 절이 비어
있지 않게 된 것이 오히려 정상이다.
검증
lint · typecheck · test(35 files / 318) · check:scaffold · check:observability ·
build · test:e2e(2 files / 20, Docker postgres) 전부 통과. lint 규칙은 위반 파일을 실제로
만들어 확인했고 probe는 전부 지웠다.
🤖 Generated with Claude Code
ARCHITECTURE.md의 「이 템플릿이 아직 문서를 따라가지 못하는 곳」표 다섯 줄을 전부 닫는다.문서는
d3aeff6에서 inbound/outbound port로 바뀌었는데 코드가 따라가지 않았다. 이 저장소는파생 프로젝트가 보고 시작하는 유일한 참조 구현이라, 어긋난 채 두면 다음 프로젝트가 이미
폐기된 형태로 시작한다.
표의 다섯 줄
src/health/QueryBus+GetHealthQuery대신application/ports/in/health.port.ts로 진입한다health.controller.tsexecute<A, B>(...)함정이 버스와 함께 사라졌다application/ports/in/out으로 나눴다src/architecture-rule-fixture/d3aeff6에 이미 들어와 있었다. 표의 그 줄만 stale이었다무엇이 사라졌나
GetHealthQuery와GetHealthQueryHandler는coordinator.check()를 그대로 전달했고 보내는 곳이health.controller.ts하나였다.ARCHITECTURE.md가 정한 버스 사용 조건 — 같은 메시지를 여러inbound가 보내거나 dispatch 자체가 값을 하는 경우 — 을 만족하지 못한다.
함께 사라지는 것이 함정이다.
execute<GetHealthQuery, SystemHealth>(...)처럼 타입 인자를 둘명시하면
@nestjs/cqrs의 두 번째 오버로드가 잡혀 결과 타입을 거짓말해도tsc가 통과시켰다(파생 프로젝트 실측). 메서드 시그니처에는 그 문제가 없다 — 결과 타입이 port에 한 번 적히고
호출부는 추론만 받는다.
@nestjs/cqrs의존성과CqrsModule.forRoot()도 사용처가 0이 되어 뺐다.무엇이 생겼나
application/ports/in/health.port.ts—HealthPort+HEALTHSymbol.check()와inspectIndicators()둘 다 담는다. entry point가 HTTP와 Prometheus scrape 둘이고 둘 다 이계약을 지난다.
application/ports/out/health-indicator.port.ts— 기존 파일을 옮긴 것. 방향이 곧 공개 여부다.domain/system-health.ts—HealthStatus·HealthIndicatorSnapshot·SystemHealth. 두 방향의port가 모두 쓰는 타입이라 domain에 둔다. 비공개인
ports/out/에 두면 소비자가 결과 타입을이름 부르려고 비공개 파일을 열어야 한다.
health.controller.spec.ts— 소비자 test가 inbound port fake를 주입하는 표본. 대역이check라는 메서드 이름을 갖는 것이 값이다(버스 시절에는
execute()하나였다).module은
exports: [HEALTH, HealthMetricsService]로 토큰만 내보낸다.HealthCoordinator는{ provide: HEALTH, useClass: HealthCoordinator }로만 등록되고 소비자의 런타임 import 그래프에들어가지 않는다.
ESLint 규칙은 실제로 무는가
위반 파일을 직접 만들어 돌렸다. 네 규칙이 모두 물었고, flat config 교체 함정에도 불구하고
기존 규칙이 살아 있다.
src/lint-probe/probe.ts→../health/adapters/out/...no-restricted-imports(규칙 4번)test/lint-probe.ts→../src/health/adapters/out/...no-restricted-imports(규칙 4번)scripts/lint-probe/probe.ts→../../src/health/adapters/out/...no-restricted-imports(규칙 4번)ports/in/probe.ts의@nestjs/common@typescript-eslint/no-restricted-imports(규칙 3번)ports/in/probe.ts→../../../adapters/out/...{ ...base, b: 2 }no-restricted-syntax(constitution A-5)no-restricted-syntax블록에는 A-5 객체 spread 규칙만 있고execute()타입 인자 규칙은 없었다.뺄 것이 없어 블록은 그대로 두었다. probe 파일은 확인 후 전부 지웠다.
문서
README.md·AGENTS.md·CLAUDE.md세 문서가 여전히 "CQRS-lite — create/update/delete는CommandHandler, read는 QueryHandler"를 기본 규칙으로 규정하고 있었다. 에이전트와 파생
프로젝트가 읽는 것이 이 셋이라, 코드만 바꾸면 다음 프로젝트가 여전히 폐기된 형태를 지시받는다.
port 경계 서술로 바꿨다.
docs/의 과거 조사 기록은 그 시점의 관측이므로 그대로 둔다.검증
lint·typecheck·test(35 files / 318) ·check:scaffold·check:observability·build·test:e2e(2 files / 20, Docker postgres) 전부 통과.🤖 Generated with Claude Code
https://claude.ai/code/session_01BpFLpds7xSWRhwheHcyZYf