1051 lines
36 KiB
Markdown
1051 lines
36 KiB
Markdown
# Clean Architecture Frontend Template — Senior Architecture Review
|
|
|
|
> 검토일: 2026-09-17
|
|
>
|
|
> 검토 기준: `develop` / `c10a709f2cdea9d58d2f4816b48b4c41d0b5268b`
|
|
>
|
|
> 검토 관점: 장기 유지보수, Clean Architecture 경계, reusable adapter/capability 설계, TypeScript 타입 안전성, 테스트 품질과 실행 신뢰성, 개발자 경험
|
|
|
|
## 1. 결론
|
|
|
|
이 저장소를 단순한 **React 프로젝트 템플릿**으로 평가하면 현재 구조는 지나치게 크고 복잡하다. 그러나 실제 목적을 반영하면 평가는 달라진다.
|
|
|
|
이 저장소의 목표는 다음에 더 가깝다.
|
|
|
|
> **비즈니스 use case와 domain을 기술 세부사항에서 보호하면서, HTTP·Server State·IndexedDB·OPFS·WebSocket·Upload/Download 같은 클라이언트 capability를 미리 구현해 두고, 새 제품 기능에서는 비즈니스 타입과 mapper/codec/policy를 결합해 최대한 재사용할 수 있는 Frontend Application Foundation.**
|
|
|
|
즉 `Starter Template + Reusable Capability Platform`의 혼합형이다.
|
|
|
|
따라서 IndexedDB runtime이 크다거나 WebSocket 구현이 복잡하다는 이유만으로 제거하거나 단순화해서는 안 된다. 이런 복잡성은 여러 feature에서 한 번 구현한 기술 문제를 재사용하기 위한 비용일 수 있다.
|
|
|
|
진짜 판정 기준은 다음이다.
|
|
|
|
1. feature/use case 개발자가 adapter 내부 구현을 알아야 하는가.
|
|
2. use case가 요구하는 비즈니스 타입을 TypeScript generic으로 안전하게 adapter binding에 적용할 수 있는가.
|
|
3. 기능별 차이를 mapper/codec/policy만 주입해 표현할 수 있는가.
|
|
4. 공통 capability가 요구사항에 맞지 않을 때 custom adapter를 자연스럽게 구현할 수 있는가.
|
|
5. 플랫폼 내부의 registry, lifecycle, retry, storage, browser API 복잡도가 application/domain으로 새지 않는가.
|
|
6. 플랫폼을 사용하는 비용이 플랫폼을 직접 새로 구현하는 비용보다 충분히 낮은가.
|
|
|
|
이 기준으로 보면 현재 저장소는 **방향은 맞고 기술적 기반도 강하지만, consumer surface와 검증 구조가 플랫폼 내부 복잡도를 충분히 숨기지 못하고 있다.**
|
|
|
|
현재 가장 중요한 문제는 “플랫폼 기능이 너무 많다”가 아니라 다음 세 가지다.
|
|
|
|
- **공통 capability를 사용하는 feature 작성 경로가 아직 충분히 작지 않다.**
|
|
- **테스트 taxonomy와 실행 환경이 self-contained하지 않아 테스트 결과를 신뢰하기 어렵게 만든다.**
|
|
- **플랫폼 내부 구현과 CI assurance가 매우 커졌는데, 이 복잡성이 명확한 모듈 경계로 격리되지 않았다.**
|
|
|
|
---
|
|
|
|
## 2. 검토 기준선
|
|
|
|
현재 저장소 규모는 다음과 같다.
|
|
|
|
| 영역 | 파일 수 | LOC |
|
|
| --- | ---: | ---: |
|
|
| `src/**` | 311 | 83,947 |
|
|
| `src/adapters/**` | 129 | 59,120 |
|
|
| `src/application/**` | 41 | 3,525 |
|
|
| `src/bootstrap/**` | 13 | 1,880 |
|
|
| `src/contracts/**` | 39 | 8,144 |
|
|
| `src/features/**` | 24 | 2,096 |
|
|
| `src/presentation/**` | 65 | 9,182 |
|
|
| `tests/**` TypeScript | 308 | 72,120 |
|
|
| `scripts/**` TypeScript | 123 | 28,397 |
|
|
| `package.json` scripts | 114 | - |
|
|
|
|
제품 dependency는 6개, dev dependency는 29개다. 외부 라이브러리 숫자를 무작정 늘린 구조는 아니다. 복잡성의 상당 부분을 저장소가 직접 소유하고 있다는 의미다.
|
|
|
|
가장 큰 production source는 다음과 같다.
|
|
|
|
| 파일 | LOC |
|
|
| --- | ---: |
|
|
| `src/adapters/storage/indexeddb/indexeddb-runtime.ts` | 2,744 |
|
|
| `src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts` | 2,298 |
|
|
| `src/adapters/realtime/websocket/websocket-connection.ts` | 2,003 |
|
|
| `src/adapters/storage/opfs/opfs-worker-runtime.ts` | 1,971 |
|
|
| `src/adapters/cache-storage/public-response-cache-adapter.ts` | 1,949 |
|
|
| `src/adapters/storage/opfs/indexeddb-opfs-journal.ts` | 1,881 |
|
|
| `src/adapters/realtime/reconnect-coordinator.ts` | 1,796 |
|
|
| `src/adapters/browser-rpc/browser-rpc-runtime.ts` | 1,696 |
|
|
| `src/adapters/storage/opfs/opfs-byte-store-adapter.ts` | 1,649 |
|
|
| `src/adapters/http/http-execution-v3.ts` | 1,602 |
|
|
|
|
큰 파일 자체를 결함으로 판정하지 않는다. 다만 이 규모에서는 내부 책임 분리가 명확하지 않으면 수정 시 regression 위험이 빠르게 증가한다.
|
|
|
|
---
|
|
|
|
## 3. 현재 구조에서 잘된 부분
|
|
|
|
### 3.1 Clean Architecture 경계가 실제로 집행된다
|
|
|
|
이 저장소의 가장 강한 부분이다.
|
|
|
|
`presentation`, `application`, feature code가 `fetch`, `IndexedDB`, `WebSocket`, browser storage 같은 native I/O를 임의로 직접 호출하는 구조가 아니며, 정적 architecture gate가 dependency 방향을 검사한다.
|
|
|
|
검토 시점 `check:architecture`는 다음 기준선을 통과했다.
|
|
|
|
```text
|
|
307 modules
|
|
929 dependencies
|
|
PASS
|
|
```
|
|
|
|
금지 dependency fixture도 실제로 거절된다.
|
|
|
|
즉 폴더 이름만 `domain/application/adapter`로 만든 구조가 아니라 **의존 방향을 executable rule로 만든 구조**다.
|
|
|
|
### 3.2 TypeScript generic을 이미 핵심 계약에 사용하고 있다
|
|
|
|
사용자가 원하는 “use case에서 결정되는 비즈니스 타입을 공통 adapter capability에 적용”할 기반은 이미 존재한다.
|
|
|
|
예를 들어 V3 HTTP executor는 다음 형태다.
|
|
|
|
```ts
|
|
interface ContractHttpExecutor {
|
|
execute<Input, WireOutput, Problem>(
|
|
operation: InstalledHttpContract<Input, WireOutput, Problem>,
|
|
input: Input,
|
|
context: HttpExecutionContext,
|
|
): Promise<HttpExecutionOutcome<WireOutput, Problem>>;
|
|
}
|
|
```
|
|
|
|
즉 HTTP runtime은 특정 `User`, `Order`, `ReferenceResource`를 알지 않는다. `Input`, `WireOutput`, `Problem` 타입 파라미터만 안다.
|
|
|
|
Server State도 `BoundMutation<Command, View>`와 같이 feature의 command/result 타입을 generic으로 받는다.
|
|
|
|
또 `ApplicationFeatureInputs`는 module augmentation을 이용해 concrete feature를 generic application에 직접 import하지 않고도 typed feature API를 등록한다.
|
|
|
|
이 방향은 유지할 가치가 높다.
|
|
|
|
### 3.3 reference feature가 vertical slice의 실제 증거 역할을 한다
|
|
|
|
`reference-feature`는 단순 데모 화면이 아니다.
|
|
|
|
- domain은 외부 DTO/React를 모른다.
|
|
- application은 input/gateway 계약을 가진다.
|
|
- outbound adapter가 HTTP executor를 feature gateway로 투영한다.
|
|
- mapper가 transport payload와 domain/application value 사이를 분리한다.
|
|
- presentation은 application input을 호출하며 HTTP나 storage를 직접 알지 않는다.
|
|
- sample removal test로 feature를 제거했을 때 generic platform이 유지되는지 확인한다.
|
|
|
|
즉 목표 아키텍처를 실제 실행 가능한 vertical slice로 증명하고 있다는 점은 좋다.
|
|
|
|
### 3.4 테스트의 사고방식은 깊다
|
|
|
|
테스트는 단순 mock 호출 횟수 검증에 머무르지 않는다.
|
|
|
|
예를 들어 query/mutation test는 cancellation, scope fence, stale data, optimistic mutation, effect certainty, reconciliation, duplicate submission을 실제 lifecycle 관점에서 검증한다.
|
|
|
|
HTTP/storage/realtime도 정상 경로뿐 아니라 timeout, late settlement, retry, mutation effect, lifecycle cleanup 등을 검증한다.
|
|
|
|
따라서 테스트의 핵심 문제는 **검증 깊이가 부족한 것**이 아니다. 오히려 검증 범위가 커지면서 **분류와 실행 경계가 무너진 것**이 문제다.
|
|
|
|
---
|
|
|
|
## 4. 이 저장소의 정체성을 명확히 해야 한다
|
|
|
|
현재 문서와 코드에는 `template`, `platform`, `reference feature`, `optional capability`라는 표현이 모두 존재한다. 각각 틀린 표현은 아니지만, 최상위 모델이 명확하지 않으면 이후 리팩터링에서 서로 반대 방향의 결론을 낼 수 있다.
|
|
|
|
이 저장소는 다음 두 축을 동시에 가진다고 정의하는 것이 가장 정확하다.
|
|
|
|
```text
|
|
Frontend Application Foundation
|
|
│
|
|
├── Starter / Composition Skeleton
|
|
│ ├── bootstrap
|
|
│ ├── routing
|
|
│ ├── application provider
|
|
│ ├── reference feature
|
|
│ └── project conventions
|
|
│
|
|
└── Reusable Capability Platform
|
|
├── HTTP
|
|
├── Server State
|
|
├── Auth boundary
|
|
├── IndexedDB / OPFS
|
|
├── Cache Storage
|
|
├── Realtime / WebSocket
|
|
├── Browser RPC
|
|
├── Upload / Download
|
|
├── Diagnostics / Telemetry
|
|
└── optional browser capabilities
|
|
```
|
|
|
|
중요한 점은 **둘을 물리적으로 반드시 별도 repository/package로 분리해야 한다는 의미가 아니다.**
|
|
|
|
먼저 코드와 문서에서 책임을 명확히 해야 한다.
|
|
|
|
- Starter는 “어떻게 조립하는가”를 보여준다.
|
|
- Feature는 “비즈니스 요구를 어떻게 표현하는가”를 보여준다.
|
|
- Capability Platform은 “기술 문제를 어떻게 재사용하는가”를 책임진다.
|
|
- CI/Assurance는 “이 계약이 깨지지 않았음을 어떻게 증명하는가”를 책임진다.
|
|
|
|
이 네 영역이 같은 repository 안에 있어도 된다. 문제는 한 영역을 사용하기 위해 다른 세 영역의 내부 구현을 알아야 하는 경우다.
|
|
|
|
---
|
|
|
|
## 5. 목표 Adapter 모델
|
|
|
|
### 5.1 use case가 adapter에 의존하면 안 된다
|
|
|
|
사용자의 표현인 “use case에서 나오는 비즈니스 타입을 adapter에 바로 적용한다”는 의도는 맞다. 다만 dependency 방향은 정확히 구분해야 한다.
|
|
|
|
원하는 구조는 다음이다.
|
|
|
|
```text
|
|
Domain / UseCase
|
|
│
|
|
│ owns
|
|
▼
|
|
Business Port
|
|
▲
|
|
│ implements / binds
|
|
│
|
|
Feature Adapter Binding
|
|
│
|
|
│ supplies generic types + mapper + policy
|
|
▼
|
|
Reusable Capability Runtime
|
|
```
|
|
|
|
use case가 `HttpAdapter<User>`를 직접 생성하거나 import하는 구조가 아니다.
|
|
|
|
use case는 자신의 port만 안다.
|
|
|
|
```ts
|
|
interface UserRepository {
|
|
findById(id: UserId): Promise<Result<User>>;
|
|
}
|
|
```
|
|
|
|
그리고 feature-owned outbound adapter/composition이 reusable HTTP capability를 구체화한다.
|
|
|
|
```ts
|
|
createHttpBinding<UserId, UserWireDto, User>({
|
|
contract,
|
|
decode: decodeUserWireDto,
|
|
map: mapUser,
|
|
mapFailure,
|
|
});
|
|
```
|
|
|
|
이때 `User`는 generic type argument로 capability에 들어갈 수 있지만 capability source가 `User` domain module을 import하지는 않는다.
|
|
|
|
### 5.2 제네릭만으로 해결하지 않는다
|
|
|
|
재사용 adapter의 목표를 `Repository<TKey, TValue>` 하나로 모든 기술을 추상화하는 것으로 잡으면 실패한다.
|
|
|
|
HTTP, IndexedDB, WebSocket, Upload는 failure semantics와 lifecycle 자체가 다르다.
|
|
|
|
공통화 단위는 다음 조합이 적절하다.
|
|
|
|
```text
|
|
Generic Type
|
|
+ Codec / Mapper
|
|
+ Policy
|
|
+ Capability Runtime
|
|
```
|
|
|
|
예를 들어 HTTP는 다음을 공통 runtime이 책임질 수 있다.
|
|
|
|
- timeout/deadline
|
|
- cancellation
|
|
- retry
|
|
- auth attachment
|
|
- bounded response read
|
|
- effect certainty
|
|
- diagnostics/telemetry
|
|
- transport failure normalization
|
|
|
|
Feature는 다음만 책임져야 한다.
|
|
|
|
- operation identity
|
|
- request input
|
|
- wire schema
|
|
- wire → domain mapper
|
|
- feature-specific failure mapping
|
|
- 필요한 retry/idempotency policy 선택
|
|
|
|
IndexedDB는 HTTP와 다른 capability factory를 가져야 한다.
|
|
|
|
- key extraction
|
|
- encode/decode
|
|
- schema/migration policy
|
|
- domain-specific repository operation
|
|
|
|
WebSocket/realtime은 다시 다른 API가 맞다.
|
|
|
|
- event decoder
|
|
- event authority/revision policy
|
|
- subscription ownership
|
|
- reconnect/resume policy
|
|
|
|
즉 **“하나의 generic repository”가 아니라 “capability-specific typed factory”**를 목표로 하는 것이 맞다.
|
|
|
|
### 5.3 custom adapter escape hatch는 반드시 남긴다
|
|
|
|
Reusable capability가 80~90%의 일반 요구를 해결하더라도 제품 요구가 capability contract와 맞지 않을 수 있다.
|
|
|
|
따라서 platform API는 다음을 강제해서는 안 된다.
|
|
|
|
> 모든 HTTP/storage/realtime 구현은 반드시 platform factory를 사용해야 한다.
|
|
|
|
정확한 규칙은 다음이 좋다.
|
|
|
|
> 공통 capability contract가 요구사항을 보존할 수 있으면 재사용한다. 요구사항을 왜곡해야 사용할 수 있다면 feature-owned custom adapter를 구현한다. 단 application port와 architecture boundary는 동일하게 유지한다.
|
|
|
|
이 원칙이 있어야 platform abstraction이 business model을 끌어당기지 않는다.
|
|
|
|
---
|
|
|
|
## 6. 현재 구현이 목표 모델에 근접한 부분과 남은 간극
|
|
|
|
### 6.1 이미 근접한 부분
|
|
|
|
현재 `ContractHttpExecutor<Input, WireOutput, Problem>`은 좋은 기반이다.
|
|
|
|
feature에서는 `ReferenceOperationMap`을 통해 operation마다 request/value type을 묶고, raw executor 결과를 domain type으로 투영한다.
|
|
|
|
`ApplicationFeatureInputs`도 concrete feature를 generic application에서 분리한다.
|
|
|
|
즉 **타입을 parameterize하는 방향 자체는 이미 맞다.**
|
|
|
|
### 6.2 아직 feature adapter boilerplate가 크다
|
|
|
|
`reference-http-gateway.ts`를 보면 feature가 다음을 모두 직접 작성한다.
|
|
|
|
- operation map
|
|
- request type projection
|
|
- result type projection
|
|
- `execute` 호출
|
|
- result guard
|
|
- mapping contract failure 생성
|
|
|
|
이 중 일부는 feature-owned이어야 한다. 그러나 동일한 형태가 feature마다 반복된다면 재사용 platform의 consumer API가 충분히 올라오지 않은 것이다.
|
|
|
|
향후 실제 feature 2~3개를 추가해 다음을 측정해야 한다.
|
|
|
|
- HTTP feature 하나 추가 시 작성해야 하는 adapter glue LOC
|
|
- 동일 패턴 반복 비율
|
|
- platform 내부 type 이름을 feature 개발자가 알아야 하는 개수
|
|
- 등록해야 하는 중앙 registry 수
|
|
- 정상 query 하나를 연결하기 위해 건드리는 파일 수
|
|
|
|
이 지표가 platform usability를 판단하는 핵심이다.
|
|
|
|
### 6.3 설치 지점이 여러 곳으로 나뉜다
|
|
|
|
현재 reference feature README가 명시하는 설치 지점은 세 곳이다.
|
|
|
|
- `installed-feature-contracts.ts`
|
|
- `installed-feature-runtimes.tsx`
|
|
- `installed-feature-adapters.ts`
|
|
|
|
현재 총 181 LOC라 파일 크기 자체는 크지 않다. 문제는 feature가 늘었을 때 **한 기능 설치가 여러 중앙 catalog 수정으로 확산되는 구조**라는 점이다.
|
|
|
|
이 구조는 계약/runtime/composition을 분리한다는 장점이 있으므로 무작정 한 객체로 합치면 안 된다.
|
|
|
|
대신 다음 목표가 필요하다.
|
|
|
|
> feature-owned contribution을 각 boundary가 소비하되, 새 feature 개발자가 중앙 설치 파일의 내부 구조를 반복해서 편집하지 않게 한다.
|
|
|
|
예를 들어 feature가 contract/runtime/adapter contribution을 각각 export하고, installed catalog는 단순 aggregation만 담당하는 구조가 적절하다.
|
|
|
|
### 6.4 `ApplicationApi`는 지금은 관리 가능하지만 Service Locator로 성장할 수 있다
|
|
|
|
현재 `ApplicationApi`는 다음 플랫폼 기능을 직접 노출한다.
|
|
|
|
- session
|
|
- preferences
|
|
- diagnostics
|
|
- runtime
|
|
- recovery
|
|
- features
|
|
|
|
구조 자체는 잘못이 아니다. presentation에서 concrete adapter를 숨기는 역할을 한다.
|
|
|
|
그러나 기능이 계속 추가되며 모든 application capability가 하나의 root object 아래 들어가면 다음과 같은 사용 패턴이 일반화될 수 있다.
|
|
|
|
```ts
|
|
const application = useApplication();
|
|
```
|
|
|
|
이후 모든 presentation 코드가 거대한 application service locator를 바라보게 된다.
|
|
|
|
권장 방향은 root provider를 유지하더라도 consumer API를 좁히는 것이다.
|
|
|
|
```text
|
|
useSession()
|
|
useRuntimeCapabilities()
|
|
useReferenceFeature()
|
|
```
|
|
|
|
즉 composition은 하나여도 presentation dependency surface는 필요한 capability만 노출한다.
|
|
|
|
---
|
|
|
|
## 7. P0 — 테스트 실행 환경이 self-contained하지 않다
|
|
|
|
이 문제는 다른 리팩터링보다 먼저 고치는 것이 좋다. 테스트가 신뢰되지 않으면 이후 구조 변경의 안전망도 신뢰할 수 없다.
|
|
|
|
### 7.1 Component test가 host `NODE_ENV`에 영향을 받는다
|
|
|
|
검토 환경의 host에는 다음이 설정되어 있었다.
|
|
|
|
```text
|
|
NODE_ENV=production
|
|
```
|
|
|
|
현재 `package.json`의 `test:component`는 다음과 같이 Vitest를 바로 실행하며 test environment를 고정하지 않는다.
|
|
|
|
```text
|
|
vitest run tests/component ...
|
|
```
|
|
|
|
그 결과:
|
|
|
|
```text
|
|
pnpm test:component
|
|
→ React.act is not a function
|
|
→ 119 tests failed
|
|
```
|
|
|
|
반면 같은 checkout에서:
|
|
|
|
```text
|
|
NODE_ENV=test pnpm test:component
|
|
→ 20 test files passed
|
|
→ 130 tests passed
|
|
```
|
|
|
|
했다.
|
|
|
|
이것은 119개 component가 각각 잘못된 것이 아니라 **test runner가 외부 shell environment를 그대로 받아 React production test path를 로드한 것**이다.
|
|
|
|
템플릿/플랫폼 저장소의 테스트는 개발자 머신의 우연한 환경 값에 따라 의미가 바뀌면 안 된다.
|
|
|
|
### 권장
|
|
|
|
- component/unit/integration runner가 필요한 environment를 명시적으로 소유한다.
|
|
- test bootstrap 단계에서 잘못된 `NODE_ENV`를 fail-fast하거나 고정한다.
|
|
- CI에서만 성립하는 environment assumption을 일반 test script와 분리한다.
|
|
|
|
---
|
|
|
|
## 8. P0 — `unit` test taxonomy가 실제 실행 특성과 맞지 않는다
|
|
|
|
현재 `docs/testing/taxonomy.md`는 `test:unit`을 별도 test level로 정의하지만 `tests/unit` 안에는 실제로 host-level system assurance가 포함된다.
|
|
|
|
검토 환경에서:
|
|
|
|
```text
|
|
pnpm test:unit
|
|
→ 99 failures
|
|
```
|
|
|
|
가 발생했다.
|
|
|
|
하지만 주요 실패는 business/application logic regression이 아니었다.
|
|
|
|
대표 원인은 다음과 같다.
|
|
|
|
```text
|
|
provider sandbox unavailable: /usr/bin/bwrap is required
|
|
systemctl list-units failed: Failed to connect to bus
|
|
npm_config_userconfig / npm_config_prefix / npm_config_globalconfig host environment
|
|
```
|
|
|
|
`ci-artifact-contract.test.ts`는 다음을 검증한다.
|
|
|
|
- bubblewrap sandbox
|
|
- systemd unit
|
|
- cgroup/resource limit
|
|
- process guardian/supervisor
|
|
- background process kill/collection
|
|
- archive/provider isolation
|
|
- host path protection
|
|
|
|
이 테스트들은 가치가 있다. 제거할 대상이 아니다.
|
|
|
|
그러나 **unit test가 아니다.**
|
|
|
|
권장 taxonomy는 다음과 같다.
|
|
|
|
```text
|
|
tests/
|
|
├── unit/
|
|
│ ├── domain
|
|
│ ├── application
|
|
│ └── pure-policy
|
|
│
|
|
├── contract/
|
|
│ └── reusable-capability contracts
|
|
│
|
|
├── component/
|
|
│ └── React / hook / UI behavior
|
|
│
|
|
├── integration/
|
|
│ ├── HTTP/MSW
|
|
│ ├── IndexedDB
|
|
│ └── browser runtime boundaries
|
|
│
|
|
├── system/
|
|
│ └── ci-runner/
|
|
│ ├── sandbox
|
|
│ ├── cgroup
|
|
│ ├── process-supervision
|
|
│ └── supply-chain
|
|
│
|
|
└── e2e/
|
|
```
|
|
|
|
특히 `pnpm test:unit`은 **지원 Node 환경만 있으면 일반 개발 머신에서 deterministic하게 실행**되어야 한다.
|
|
|
|
systemd/bwrap/cgroup이 필요하면 `test:ci-runner` 또는 `test:system`처럼 요구사항이 이름에 드러나야 한다.
|
|
|
|
---
|
|
|
|
## 9. P1 — Adapter runtime은 “크기”가 아니라 내부 경계를 기준으로 재검토해야 한다
|
|
|
|
기존에 2,000~2,700 LOC 파일을 단순히 작게 나누는 것은 권장하지 않는다.
|
|
|
|
이미 adapter review의 D-07이 적절한 원칙을 가지고 있다.
|
|
|
|
> state machine과 Saga 경계로만 큰 runtime을 나누고, public capability identity/failure taxonomy/persisted schema/wire semantics를 유지한다.
|
|
|
|
이 원칙을 그대로 적용하는 것이 맞다.
|
|
|
|
예를 들어 `indexeddb-runtime.ts`를 다음 이유만으로 분리해서는 안 된다.
|
|
|
|
> 2,744줄이니까 500줄씩 다섯 파일로 나누자.
|
|
|
|
대신 다음 책임이 독립적으로 설명되고 테스트 가능한지 본다.
|
|
|
|
- connection/open/upgrade lifecycle
|
|
- transaction ownership
|
|
- schema/migration state machine
|
|
- quota/budget policy
|
|
- serialization/codec
|
|
- mutation settlement
|
|
- cleanup/compensation
|
|
- recovery/reconciliation
|
|
|
|
WebSocket도 같은 기준이다.
|
|
|
|
- connection state machine
|
|
- reconnect/backoff policy
|
|
- heartbeat
|
|
- subscription ownership
|
|
- writer/lease lifecycle
|
|
- resume/gap policy
|
|
- message codec
|
|
|
|
**하나의 변경 이유와 하나의 failure model을 공유한다면 같은 모듈에 있어도 된다.**
|
|
|
|
반대로 독립 lifecycle을 가진 책임이 같은 2,000줄 파일에서 mutable state를 공유한다면 추출 우선순위가 높다.
|
|
|
|
---
|
|
|
|
## 10. P1 — 테스트 파일도 behavior contract 단위로 분리해야 한다
|
|
|
|
현재 가장 큰 테스트 파일은 다음과 같다.
|
|
|
|
| 파일 | LOC |
|
|
| --- | ---: |
|
|
| `ci-artifact-contract.test.ts` | 2,538 |
|
|
| `presigned-transfer.test.ts` | 2,518 |
|
|
| `image-cdn-runtime.test.ts` | 2,430 |
|
|
| `application-query.test.tsx` | 2,196 |
|
|
| `security-followup.test.ts` | 1,859 |
|
|
| `public-response-cache.test.ts` | 1,711 |
|
|
| `ci-workflow-generation.test.ts` | 1,635 |
|
|
|
|
테스트 파일이 production 파일보다 커지는 것 자체는 문제가 아니다. 복잡한 state machine은 많은 테스트를 필요로 한다.
|
|
|
|
문제는 서로 다른 behavior가 하나의 파일 안에 섞이면 실패 메시지가 ownership을 알려주지 못한다는 점이다.
|
|
|
|
예를 들어 `application-query.test.tsx`는 사실상 다음 여러 계약을 검증한다.
|
|
|
|
```text
|
|
query initial/background state
|
|
query scope fence
|
|
result budget
|
|
mutation scope fence
|
|
optimistic mutation
|
|
unknown effect reconciliation
|
|
concurrency / duplicate submission
|
|
cache invalidation
|
|
```
|
|
|
|
따라서 다음처럼 behavior 단위로 분리하는 것이 더 낫다.
|
|
|
|
```text
|
|
application-query/
|
|
├── query-state.test.tsx
|
|
├── query-scope-fence.test.tsx
|
|
├── query-budget.test.tsx
|
|
├── mutation-scope-fence.test.tsx
|
|
├── optimistic-mutation.test.tsx
|
|
├── mutation-reconciliation.test.tsx
|
|
├── duplicate-submission.test.tsx
|
|
└── fixtures.ts
|
|
```
|
|
|
|
테스트 분리의 목표도 LOC가 아니다.
|
|
|
|
> 하나의 실패 파일명이 “어떤 계약이 깨졌는지”를 설명해야 한다.
|
|
|
|
---
|
|
|
|
## 11. P1 — Capability consumer API를 실제 feature 개발 비용으로 평가해야 한다
|
|
|
|
현재 플랫폼은 내부 correctness에 대한 검증은 매우 강하다. 반면 앞으로는 **새 feature를 만드는 개발자의 비용**을 별도 품질 지표로 봐야 한다.
|
|
|
|
추천하는 평가 시나리오는 실제 sample feature 2개를 추가해보는 것이다.
|
|
|
|
### 시나리오 A — 일반 REST CRUD
|
|
|
|
필요 조건:
|
|
|
|
- list/detail/create/update
|
|
- pagination
|
|
- error mapping
|
|
- optimistic mutation 하나
|
|
|
|
측정:
|
|
|
|
- feature-owned LOC
|
|
- platform glue LOC
|
|
- 수정 파일 수
|
|
- 중앙 registry 수정 수
|
|
- transport/runtime type을 직접 알아야 하는 횟수
|
|
|
|
### 시나리오 B — IndexedDB local draft
|
|
|
|
필요 조건:
|
|
|
|
- domain draft type
|
|
- save/find/remove
|
|
- codec
|
|
- migration 없는 단순 store
|
|
|
|
측정:
|
|
|
|
- IndexedDB native API를 feature가 알아야 하는가
|
|
- runtime internal type을 import해야 하는가
|
|
- `create...Repository<T>` 계열 binding만으로 해결되는가
|
|
|
|
이 실험 결과가 다음 platform API 리팩터링의 근거가 되어야 한다.
|
|
|
|
---
|
|
|
|
## 12. P1 — `contracts`는 계속 커지면 제2의 `shared/common`이 된다
|
|
|
|
현재 `src/contracts`는 39개 파일, 약 8,144 LOC다.
|
|
|
|
`contracts`라는 이름은 편리하지만 ownership을 잃기 쉽다.
|
|
|
|
특히 다음이 모두 한 bucket으로 들어가면 문제가 된다.
|
|
|
|
- error vocabulary
|
|
- server-state contracts
|
|
- capability registry
|
|
- boundary mapper
|
|
- mutation intent
|
|
- runtime descriptor
|
|
- compatibility metadata
|
|
|
|
공통 계약이 실제로 여러 capability가 공유하는 SSOT라면 `contracts`에 있어도 된다.
|
|
|
|
그러나 **특정 feature/adapter만 소비하는 contract는 owner 쪽에 두는 것이 더 낫다.**
|
|
|
|
판정 질문은 단순하다.
|
|
|
|
> 이 타입을 바꾸는 이유가 어떤 모듈의 요구사항 변화 때문인가?
|
|
|
|
답이 항상 특정 feature/storage/http라면 global contracts가 아닐 가능성이 높다.
|
|
|
|
---
|
|
|
|
## 13. P1 — CI/Assurance는 가치가 있지만 product development path와 격리해야 한다
|
|
|
|
현재 package script는 114개, `scripts/**/*.ts`는 123개 / 약 28K LOC다.
|
|
|
|
이는 단순 frontend build script 수준을 넘어선다.
|
|
|
|
현재 저장소는 다음을 자체적으로 검증한다.
|
|
|
|
- architecture graph
|
|
- registry compatibility
|
|
- artifact semantic validation
|
|
- supply-chain evidence
|
|
- reproducible build
|
|
- provider sandbox
|
|
- promotion/finalization
|
|
- archive traversal/symlink/hardlink
|
|
- release admission
|
|
- security fixtures
|
|
|
|
이 기능을 단순화한다는 이유로 제거할 필요는 없다. 플랫폼 품질과 supply-chain assurance를 강하게 가져가겠다면 정당한 투자다.
|
|
|
|
다만 개발자가 일반 feature를 수정할 때 이 전체 영역을 알아야 해서는 안 된다.
|
|
|
|
권장 구조적 목표는 다음이다.
|
|
|
|
```text
|
|
Product development path
|
|
feature → focused test → type/lint/architecture
|
|
|
|
Platform capability path
|
|
adapter → capability contract/integration tests
|
|
|
|
Assurance path
|
|
CI runner → supply-chain/system/promotion tests
|
|
```
|
|
|
|
세 경로의 명령과 실패 메시지가 명확히 분리되어야 한다.
|
|
|
|
---
|
|
|
|
## 14. P2 — 실제 중복은 정책 중복부터 제거한다
|
|
|
|
중복 제거의 목표를 “같은 코드 한 줄도 두 번 쓰지 않는다”로 잡으면 이 프로젝트에서는 오히려 abstraction이 과해진다.
|
|
|
|
특히 security/protocol boundary의 작은 validator는 local duplication이 가독성과 auditability에 도움이 될 수 있다.
|
|
|
|
반대로 **정책 중복은 제거해야 한다.**
|
|
|
|
현재 `use-reference-feature.ts`에는 `CREATE_REFERENCE_RESOURCE` mutation definition이 두 경로에 반복된다.
|
|
|
|
반복되는 핵심 값은 다음과 같다.
|
|
|
|
```text
|
|
definitionId
|
|
operationId
|
|
requiresIdempotencyKey
|
|
owner
|
|
duplicatePolicy
|
|
invalidate
|
|
```
|
|
|
|
이것은 단순 syntax 중복이 아니라 하나의 mutation policy다.
|
|
|
|
한쪽만 수정되면 같은 feature 안에서 서로 다른 concurrency/idempotency 동작을 할 수 있다.
|
|
|
|
따라서 feature-owned mutation definition 또는 factory로 단일화하는 것이 맞다.
|
|
|
|
반대로 `isPlainRecord` 같은 3~5줄 helper는 모든 adapter에서 무조건 한 global utility로 합칠 필요가 없다.
|
|
|
|
---
|
|
|
|
## 15. P2 — compatibility re-export는 canonical import path를 흐릴 수 있다
|
|
|
|
현재 일부 UI와 Result 계층에는 canonical 정의와 compatibility re-export가 동시에 존재한다.
|
|
|
|
이런 구조가 migration window를 위해 필요하다면 괜찮다.
|
|
|
|
하지만 템플릿 출발점에서 두 경로가 모두 정식 API처럼 보이면 새 개발자가 어떤 import를 사용해야 하는지 판단해야 한다.
|
|
|
|
원칙은 다음이 적절하다.
|
|
|
|
- public import path는 capability마다 하나를 canonical로 둔다.
|
|
- compatibility export는 deprecated/migration purpose임을 명시한다.
|
|
- migration 종료 조건이 충족되면 제거한다.
|
|
- 테스트 편의를 위해 barrel export를 무한히 확장하지 않는다.
|
|
|
|
---
|
|
|
|
## 16. Hybrid Architecture를 공식 모델로 문서화할 필요가 있다
|
|
|
|
현재 저장소는 순수 horizontal Clean Architecture가 아니다.
|
|
|
|
실제 구조는 다음 hybrid에 가깝다.
|
|
|
|
```text
|
|
Platform horizontal layers
|
|
├── application
|
|
├── contracts
|
|
├── adapters
|
|
├── presentation
|
|
└── bootstrap
|
|
|
|
Feature vertical slices
|
|
└── features/<feature>
|
|
├── domain
|
|
├── application
|
|
├── adapters
|
|
├── contracts
|
|
└── presentation
|
|
```
|
|
|
|
이 구조는 프론트엔드에서 합리적이다.
|
|
|
|
공통 HTTP/storage/query runtime을 모든 feature 안에 복사할 이유는 없고, feature domain/application은 vertical slice로 격리할 수 있기 때문이다.
|
|
|
|
따라서 전역 `src/domain`이 없다는 이유로 억지로 생성할 필요도 없다.
|
|
|
|
대신 문서에서 다음을 명시해야 한다.
|
|
|
|
> platform은 horizontal capability layer를 사용하고, product business feature는 vertical slice를 사용한다. feature는 platform capability를 port 뒤에서 소비하며 platform은 concrete feature를 알지 않는다.
|
|
|
|
이 문장이 공식 architecture model이 되면 “왜 여기에는 domain이 없고 feature 안에는 domain이 있는가” 같은 혼란이 줄어든다.
|
|
|
|
---
|
|
|
|
## 17. 권장 리팩터링 순서
|
|
|
|
### Phase 0 — 테스트 신뢰성 정상화
|
|
|
|
먼저 구조를 바꾸지 않고 검증 기반을 고친다.
|
|
|
|
1. test runner의 `NODE_ENV` 등 필수 environment를 deterministic하게 만든다.
|
|
2. `tests/unit`에서 systemd/bwrap/cgroup 의존 테스트를 분리한다.
|
|
3. `test:unit`, `test:component`, `test:integration`, `test:system`, `test:e2e`의 의미를 다시 고정한다.
|
|
4. Node version requirement와 local runner requirement를 명확하게 fail-fast한다.
|
|
|
|
이 단계가 끝나야 이후 refactoring failure를 실제 regression으로 믿을 수 있다.
|
|
|
|
### Phase 1 — reference feature를 platform consumer UX 기준으로 리팩터링
|
|
|
|
새 abstraction을 먼저 만들지 않는다.
|
|
|
|
현재 reference feature를 기준으로 다음을 측정하고 줄인다.
|
|
|
|
- HTTP gateway boilerplate
|
|
- mapper/result guard 중복
|
|
- query/mutation definition duplication
|
|
- installed contribution 수정 지점
|
|
- platform internal type exposure
|
|
|
|
이 단계에서 capability API의 이상적인 최소 사용 형태를 결정한다.
|
|
|
|
### Phase 2 — capability-specific typed factory 정리
|
|
|
|
반복이 확인된 경우에만 factory를 만든다.
|
|
|
|
후보:
|
|
|
|
```text
|
|
HTTP operation/gateway binder
|
|
IndexedDB typed repository/storage binder
|
|
Realtime event source binder
|
|
Transfer command/session binder
|
|
```
|
|
|
|
하나의 범용 repository/framework로 합치지 않는다.
|
|
|
|
### Phase 3 — Application/Feature installation surface 축소
|
|
|
|
- root ApplicationProvider는 유지 가능
|
|
- feature consumer hook은 narrow API 제공
|
|
- installed catalog는 aggregation 역할로 제한
|
|
- feature-owned contribution 정의를 강화
|
|
- concrete feature를 generic platform에서 import하지 않는 규칙 유지
|
|
|
|
### Phase 4 — 거대 runtime 내부 경계 추출
|
|
|
|
characterization test를 먼저 고정한다.
|
|
|
|
우선순위 후보:
|
|
|
|
1. IndexedDB runtime
|
|
2. resumable upload
|
|
3. WebSocket connection/reconnect
|
|
4. HTTP execution V3
|
|
5. OPFS runtime/journal
|
|
|
|
파일 크기가 아니라 state machine/Saga/lifecycle ownership을 기준으로 추출한다.
|
|
|
|
### Phase 5 — 거대 테스트 분해
|
|
|
|
production runtime extraction과 같은 behavior boundary에 맞춰 test suite도 분리한다.
|
|
|
|
### Phase 6 — CI/Assurance 경로 분리
|
|
|
|
product feature 개발 loop와 release/supply-chain system assurance loop를 명령, 테스트 디렉터리, prerequisite 측면에서 명확히 구분한다.
|
|
|
|
---
|
|
|
|
## 18. 명시적으로 하지 말아야 할 리팩터링
|
|
|
|
### 18.1 모든 adapter를 하나의 generic repository로 통합하지 않는다
|
|
|
|
HTTP, IndexedDB, Realtime, Upload는 lifecycle과 failure semantics가 다르다.
|
|
|
|
타입 파라미터가 비슷하다는 이유로 하나의 abstraction으로 합치면 business 요구를 기술 abstraction에 맞추게 된다.
|
|
|
|
### 18.2 use case가 platform adapter를 직접 import하지 않는다
|
|
|
|
`UseCase → HttpAdapter<T>` 구조가 되면 Clean Architecture의 핵심 목적을 잃는다.
|
|
|
|
Generic type binding은 feature adapter/composition에서 일어나야 한다.
|
|
|
|
### 18.3 단순화를 이유로 hardening을 제거하지 않는다
|
|
|
|
현재 retry/effect certainty/bounded read/recovery/sandbox 같은 계약은 대부분 실제 failure mode를 막기 위해 존재한다.
|
|
|
|
필요한 것은 삭제가 아니라 **internal complexity encapsulation**이다.
|
|
|
|
### 18.4 파일 길이만 보고 나누지 않는다
|
|
|
|
2,000줄이어도 하나의 cohesive state machine이면 함부로 분리하지 않는다.
|
|
|
|
반대로 300줄이어도 서로 다른 lifecycle owner가 섞여 있으면 분리 대상이다.
|
|
|
|
### 18.5 중복률 0%를 목표로 하지 않는다
|
|
|
|
local protocol validator의 작은 중복보다 policy가 여러 곳에 존재하는 중복이 더 위험하다.
|
|
|
|
---
|
|
|
|
## 19. 최종 목표 개발 경험
|
|
|
|
이 Foundation이 잘 리팩터링됐을 때 새 feature 개발자는 대략 다음만 작성하면 되어야 한다.
|
|
|
|
```text
|
|
1. Domain type / invariant
|
|
2. UseCase
|
|
3. Port
|
|
4. Transport/storage schema와 mapper
|
|
5. Capability binding 설정
|
|
6. Presentation controller/page
|
|
```
|
|
|
|
그리고 HTTP/IndexedDB/WebSocket 내부의 다음 내용을 몰라도 되어야 한다.
|
|
|
|
```text
|
|
retry scheduler
|
|
abort ownership
|
|
effect certainty
|
|
transaction lease
|
|
reconnect coordinator
|
|
OPFS journal
|
|
provider lifecycle
|
|
telemetry delivery
|
|
```
|
|
|
|
개념적으로 다음 형태가 목표다.
|
|
|
|
```text
|
|
Business Domain
|
|
↓
|
|
UseCase
|
|
↓
|
|
Port
|
|
↓
|
|
Feature-owned Binding
|
|
<TInput, TWire, TDomain>
|
|
+ mapper/codec
|
|
+ policy
|
|
↓
|
|
Reusable Capability Runtime
|
|
↓
|
|
Browser / Network / Native API
|
|
```
|
|
|
|
이 구조가 성립하면 내부 runtime이 복잡하다는 사실은 문제가 아니다.
|
|
|
|
오히려 복잡한 기술 문제를 한 곳에서 해결했기 때문에 각 제품 feature는 더 단순해진다.
|
|
|
|
---
|
|
|
|
## 20. 우선순위 요약
|
|
|
|
| 우선순위 | Finding | 조치 방향 |
|
|
| --- | --- | --- |
|
|
| P0 | 테스트가 host `NODE_ENV` 영향을 받음 | test environment deterministic하게 고정 |
|
|
| P0 | unit suite에 systemd/bwrap/cgroup 테스트 혼재 | system/CI-runner suite로 분리 |
|
|
| P1 | capability consumer API의 실제 개발 비용이 불명확 | reference + 추가 feature로 사용성 측정 |
|
|
| P1 | feature 설치가 여러 중앙 catalog로 확산 | feature-owned contribution + 단순 aggregation |
|
|
| P1 | 거대 runtime 내부 책임 추적 비용 | state machine/Saga/lifecycle 기준 extraction |
|
|
| P1 | 2K+ LOC test file 다수 | behavior contract별 suite 분리 |
|
|
| P1 | `contracts`가 generic bucket으로 성장 가능 | ownership 기준 재배치 |
|
|
| P1 | product dev path와 CI assurance path가 뒤섞임 | command/test prerequisite 격리 |
|
|
| P2 | `ApplicationApi`의 Service Locator 성장 위험 | narrow consumer hooks/API |
|
|
| P2 | feature mutation policy 중복 | feature-owned definition SSOT |
|
|
| P2 | compatibility/re-export 경로 | canonical public API 지정 |
|
|
| P2 | hybrid architecture 설명 부족 | platform horizontal + feature vertical 공식화 |
|
|
|
|
---
|
|
|
|
## 21. 이번 검토에서 확인한 검증 결과
|
|
|
|
코드 변경 전 기준선에서 확인한 결과다.
|
|
|
|
```text
|
|
check:architecture
|
|
→ PASS
|
|
→ 307 modules / 929 dependencies
|
|
```
|
|
|
|
앞선 검토에서 다음도 통과했다.
|
|
|
|
```text
|
|
check:types
|
|
→ PASS
|
|
|
|
lint
|
|
→ PASS
|
|
```
|
|
|
|
Component test는 host environment를 그대로 사용하면 실패했다.
|
|
|
|
```text
|
|
pnpm test:component
|
|
→ FAIL
|
|
→ React.act is not a function
|
|
→ host NODE_ENV=production
|
|
```
|
|
|
|
동일 checkout에서 test environment를 명시하면 통과했다.
|
|
|
|
```text
|
|
NODE_ENV=test pnpm test:component
|
|
→ 20 files PASS
|
|
→ 130 tests PASS
|
|
```
|
|
|
|
Unit 전체는 현재 Coka host에서 green이 아니다.
|
|
|
|
```text
|
|
pnpm test:unit
|
|
→ FAIL
|
|
→ 99 failures
|
|
```
|
|
|
|
대표 원인은 다음이다.
|
|
|
|
- `/usr/bin/bwrap` 미설치
|
|
- systemd bus 사용 불가
|
|
- host `npm_config_*` 환경 오염
|
|
|
|
따라서 이 결과를 application/domain regression 99건으로 해석하면 안 된다.
|
|
|
|
또한 저장소가 요구하는 Node 범위는 `>=24.11.0 <25.0.0`인데, 현재 Coka host는 Node `22.23.2`다. 정식 full verification은 지원 Node 환경에서 다시 수행해야 한다.
|
|
|
|
---
|
|
|
|
## 22. 최종 평가
|
|
|
|
현재 저장소는 설계가 부족해서 문제가 생긴 코드베이스가 아니다.
|
|
|
|
오히려 반대다.
|
|
|
|
**실제 브라우저와 네트워크에서 발생할 수 있는 많은 실패를 플랫폼이 직접 책임지려 하면서 내부 correctness와 assurance가 매우 강해졌고, 그 결과 플랫폼 사용성과 유지보수 비용이 다음 병목이 된 상태**다.
|
|
|
|
따라서 앞으로의 리팩터링 목표는 abstraction을 더 추가하는 것이 아니다.
|
|
|
|
다음 세 가지가 핵심이다.
|
|
|
|
1. **비즈니스 개발자가 보는 API를 더 작게 만든다.**
|
|
2. **플랫폼 내부의 복잡성은 capability boundary 안에서 더 명확하게 격리한다.**
|
|
3. **테스트와 CI를 실제 실행 성격에 맞게 분리해 검증 결과를 신뢰할 수 있게 만든다.**
|
|
|
|
특히 이 프로젝트의 성공 여부는 “몇 개의 capability를 구현했는가”보다 다음 질문으로 판단해야 한다.
|
|
|
|
> 새로운 비즈니스 기능을 구현할 때, 개발자가 domain과 use case에 집중한 채 이미 구현된 capability를 타입 안전하게 조립할 수 있는가?
|
|
|
|
그 답이 지속적으로 `예`가 되도록 만드는 것이 이후 리팩터링의 기준이 되어야 한다.
|