36 KiB
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에서 한 번 구현한 기술 문제를 재사용하기 위한 비용일 수 있다.
진짜 판정 기준은 다음이다.
- feature/use case 개발자가 adapter 내부 구현을 알아야 하는가.
- use case가 요구하는 비즈니스 타입을 TypeScript generic으로 안전하게 adapter binding에 적용할 수 있는가.
- 기능별 차이를 mapper/codec/policy만 주입해 표현할 수 있는가.
- 공통 capability가 요구사항에 맞지 않을 때 custom adapter를 자연스럽게 구현할 수 있는가.
- 플랫폼 내부의 registry, lifecycle, retry, storage, browser API 복잡도가 application/domain으로 새지 않는가.
- 플랫폼을 사용하는 비용이 플랫폼을 직접 새로 구현하는 비용보다 충분히 낮은가.
이 기준으로 보면 현재 저장소는 방향은 맞고 기술적 기반도 강하지만, 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는 다음 기준선을 통과했다.
307 modules
929 dependencies
PASS
금지 dependency fixture도 실제로 거절된다.
즉 폴더 이름만 domain/application/adapter로 만든 구조가 아니라 의존 방향을 executable rule로 만든 구조다.
3.2 TypeScript generic을 이미 핵심 계약에 사용하고 있다
사용자가 원하는 “use case에서 결정되는 비즈니스 타입을 공통 adapter capability에 적용”할 기반은 이미 존재한다.
예를 들어 V3 HTTP executor는 다음 형태다.
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라는 표현이 모두 존재한다. 각각 틀린 표현은 아니지만, 최상위 모델이 명확하지 않으면 이후 리팩터링에서 서로 반대 방향의 결론을 낼 수 있다.
이 저장소는 다음 두 축을 동시에 가진다고 정의하는 것이 가장 정확하다.
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 방향은 정확히 구분해야 한다.
원하는 구조는 다음이다.
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만 안다.
interface UserRepository {
findById(id: UserId): Promise<Result<User>>;
}
그리고 feature-owned outbound adapter/composition이 reusable HTTP capability를 구체화한다.
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 자체가 다르다.
공통화 단위는 다음 조합이 적절하다.
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.tsinstalled-feature-runtimes.tsxinstalled-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 아래 들어가면 다음과 같은 사용 패턴이 일반화될 수 있다.
const application = useApplication();
이후 모든 presentation 코드가 거대한 application service locator를 바라보게 된다.
권장 방향은 root provider를 유지하더라도 consumer API를 좁히는 것이다.
useSession()
useRuntimeCapabilities()
useReferenceFeature()
즉 composition은 하나여도 presentation dependency surface는 필요한 capability만 노출한다.
7. P0 — 테스트 실행 환경이 self-contained하지 않다
이 문제는 다른 리팩터링보다 먼저 고치는 것이 좋다. 테스트가 신뢰되지 않으면 이후 구조 변경의 안전망도 신뢰할 수 없다.
7.1 Component test가 host NODE_ENV에 영향을 받는다
검토 환경의 host에는 다음이 설정되어 있었다.
NODE_ENV=production
현재 package.json의 test:component는 다음과 같이 Vitest를 바로 실행하며 test environment를 고정하지 않는다.
vitest run tests/component ...
그 결과:
pnpm test:component
→ React.act is not a function
→ 119 tests failed
반면 같은 checkout에서:
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가 포함된다.
검토 환경에서:
pnpm test:unit
→ 99 failures
가 발생했다.
하지만 주요 실패는 business/application logic regression이 아니었다.
대표 원인은 다음과 같다.
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는 다음과 같다.
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는 사실상 다음 여러 계약을 검증한다.
query initial/background state
query scope fence
result budget
mutation scope fence
optimistic mutation
unknown effect reconciliation
concurrency / duplicate submission
cache invalidation
따라서 다음처럼 behavior 단위로 분리하는 것이 더 낫다.
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를 수정할 때 이 전체 영역을 알아야 해서는 안 된다.
권장 구조적 목표는 다음이다.
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이 두 경로에 반복된다.
반복되는 핵심 값은 다음과 같다.
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에 가깝다.
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 — 테스트 신뢰성 정상화
먼저 구조를 바꾸지 않고 검증 기반을 고친다.
- test runner의
NODE_ENV등 필수 environment를 deterministic하게 만든다. tests/unit에서 systemd/bwrap/cgroup 의존 테스트를 분리한다.test:unit,test:component,test:integration,test:system,test:e2e의 의미를 다시 고정한다.- 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를 만든다.
후보:
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를 먼저 고정한다.
우선순위 후보:
- IndexedDB runtime
- resumable upload
- WebSocket connection/reconnect
- HTTP execution V3
- 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 개발자는 대략 다음만 작성하면 되어야 한다.
1. Domain type / invariant
2. UseCase
3. Port
4. Transport/storage schema와 mapper
5. Capability binding 설정
6. Presentation controller/page
그리고 HTTP/IndexedDB/WebSocket 내부의 다음 내용을 몰라도 되어야 한다.
retry scheduler
abort ownership
effect certainty
transaction lease
reconnect coordinator
OPFS journal
provider lifecycle
telemetry delivery
개념적으로 다음 형태가 목표다.
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. 이번 검토에서 확인한 검증 결과
코드 변경 전 기준선에서 확인한 결과다.
check:architecture
→ PASS
→ 307 modules / 929 dependencies
앞선 검토에서 다음도 통과했다.
check:types
→ PASS
lint
→ PASS
Component test는 host environment를 그대로 사용하면 실패했다.
pnpm test:component
→ FAIL
→ React.act is not a function
→ host NODE_ENV=production
동일 checkout에서 test environment를 명시하면 통과했다.
NODE_ENV=test pnpm test:component
→ 20 files PASS
→ 130 tests PASS
Unit 전체는 현재 Coka host에서 green이 아니다.
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을 더 추가하는 것이 아니다.
다음 세 가지가 핵심이다.
- 비즈니스 개발자가 보는 API를 더 작게 만든다.
- 플랫폼 내부의 복잡성은 capability boundary 안에서 더 명확하게 격리한다.
- 테스트와 CI를 실제 실행 성격에 맞게 분리해 검증 결과를 신뢰할 수 있게 만든다.
특히 이 프로젝트의 성공 여부는 “몇 개의 capability를 구현했는가”보다 다음 질문으로 판단해야 한다.
새로운 비즈니스 기능을 구현할 때, 개발자가 domain과 use case에 집중한 채 이미 구현된 capability를 타입 안전하게 조립할 수 있는가?
그 답이 지속적으로 예가 되도록 만드는 것이 이후 리팩터링의 기준이 되어야 한다.