Files
llm-wiki/raw/branch-notes/feature-async-ui-state-contract.md
T

365 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: branch / feature-async-ui-state-contract
source_type: branch-note
status: raw
branch: feature-async-ui-state-contract
parent_branch:
related_projects: [ca-skeleton-frontend, ca-skeleton]
governing_docs: [raw/project-notes/ca-skeleton-frontend-operational-contract.md]
tags: [branch, ca-skeleton, frontend, application, react, error-handling]
created: 2026-07-18
target_merge:
status_label: in-progress
id: BR-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-013
kind: project-work-item
project: ca-skeleton-frontend-operational-contract
work_item: WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-013
inherits: [DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-UI-001@1, DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SERVER-STATE-001@1]
refines: []
overrides: []
depends_on: [WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-007, WI-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-010]
contract_packet: 1
contract_packet_sha256: 106ffdb88a0c1937bbc4d9387b99c06b90050d7e375ab4e29c7ed2b1a0dfe570
imports: [FE-OC-002@1, FE-OC-008@1, FE-OC-012@1, FE-OC-015@1, FE-OC-020@1]
delegates: [DELEG-FE-006@1]
---
# branch: feature-async-ui-state-contract
> Layer: `raw/branch-notes/` — 단일 브랜치의 TODO·결정·진행 기록. 현재는 scaffolding 단계다.
<!-- section-id: branch-parent -->
## 부모 (필수)
- [[raw/project-notes/ca-skeleton-frontend-operational-contract]]
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: required와 non-blocking state matrix component test가 통과한다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-UI-001@1` | UI composition은 React를 사용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
| `DEC-CA-SKELETON-FRONTEND-OPERATIONAL-CONTRACT-SERVER-STATE-001@1` | application-owned QueryCachePort와 TanStack Query adapter를 사용하고 client store에 server state를 복제하지 않는다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
> 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|---|---|---|---|
<!-- section-id: declared-overrides -->
### 선언한 예외
| Override ID | Overrides | Reason | Approval | Status |
|---|---|---|---|---|
<!-- GENERATED: branch-contract:end -->
<!-- section-id: branch-goal -->
## 목표
이 브랜치는 project-wide contract `FE-OC-011`(async surface는 initial-loading·success·empty·terminal-error를 MUST 표현)의 **single owner**로서, hub §9.1 Async surface state model을 *되묻지 않고 구현할 수 있는 spec*으로 내린다. 원격 데이터에 의존하는 모든 view는 `loading` boolean 하나로 상태를 뭉개지 않고 required 4-state + non-blocking 4-state를 discrete하게 표현해야 하며(§9.1), 이 상태들을 React 함수형 컴포넌트 + 단방향 props 흐름으로 렌더한다(`FE-D004`, [[raw/official-docs/react-ui-library-official]] `REACT-UI-C1`·`REACT-UI-C5`). 부수적으로 `FE-OC-015`(operational failure를 state로 반환·render defect만 boundary throw), `FE-OC-020`(component state matrix test artifact), `FE-OC-024`(sample slice가 async surface를 fixture로 exercise)에 기여한다. 현재 frontend repository가 없으므로 본 노트의 모든 구현 주장은 `planned`이며 코드 evidence는 0건이다.
- 이슈: (없음 — repository 생성 전)
- PR: (없음)
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- `FE-OC-011` 소유: async surface의 **required visible state** 4종(`initial-loading`/`success`/`empty`/`terminal-error`)과 **non-blocking state** 4종(`refreshing`/`stale-degraded`/`mutation-pending`/`mutation-conflict`)의 discrete 표현 계약 (hub §9.1).
- 단일 `loading` boolean 금지 규칙의 코드 표현(base tagged union + non-blocking overlay flag 2축, §1) + base×overlay 합법 조합·indicator 우선순위 규칙.
- server query/mutation 신호 → `{base, overlay}` 파생 계약의 **presentation 측 소비 형태**(view-model만 소비, TanStack Query client 직접 import 금지 — §4.3/§9.2).
- `terminal-error` state가 normalized failure의 `userMessageKey` + `action`만 렌더하는 계약(§8.1/§8.4 소비).
- `FE-D004`(UI composition = React) 소유 — 함수형 컴포넌트·props 단방향 흐름을 async state 렌더 기반으로 채택.
- measurable completion: state matrix component test(base 4 + overlay 4 + 교차 2 + latch 전이 1 = 11 fixtures, `pnpm test:component` async fixtures).
### 제외 범위
> 의도적으로 제외. "이건 다른 owner 브랜치 범위"라고 답할 근거.
- **failure의 정규화(raw → 26-kind)**: `feature-frontend-error-classification-boundary-contract`(`FE-OC-008`) 소유. 본 브랜치는 normalized failure를 *소비*만 한다.
- **server state의 fetch/cache/invalidation·QueryCachePort 정의**: `feature-server-state-caching-contract`(`FE-OC-012`) 소유. 본 브랜치는 port가 노출하는 상태 신호를 *소비*한다.
- **error boundary topology·recovery 배치(boot/route/feature/async boundary 소유권)**: `feature-frontend-render-recovery-boundary-contract`(`FE-OC-015`) 소유. 본 브랜치는 "operational failure는 throw하지 않는다"는 계약만 제공.
- **component test 하네스 구성(Vitest/RTL/MSW 설정·gate 분리)**: `feature-frontend-test-taxonomy-contract`(`FE-OC-020`) 소유. 본 브랜치는 async fixture 목록·기대치만 제공.
- **loading/error live region·focus 관리의 axe 검증**: `feature-accessibility-baseline-contract` 소유(이 브랜치에 depend). async state는 a11y hook point만 노출하고 axe 규칙을 정의하지 않는다.
- **auth token lifecycle / 401 replay**: 외부 auth owner + [[raw/branch-notes/feature-frontend-auth-session-integration-contract]] `FE-OC-010`.
- **telemetry event 정의·redaction·sink 정책**: 본 브랜치는 async state 전용 telemetry event를 정의하지 않으며 [[raw/branch-notes/feature-frontend-observability-logging-trace-contract]] `FE-OC-014`가 소유한다(hub §2.2 Q7 응답). error 표기 state가 남기는 telemetry rule은 §8.2 failure matrix의 kind별 rule을 그대로 따르고, 본 브랜치는 §8.1 금지 필드(raw body/token/stack)를 UI·telemetry 양쪽에 노출하지 않는 계약만 제공한다.
## 근거 (필수, 최소 1개+)
| Source | 정당화하는 결정 |
|---|---|
| [[raw/official-docs/react-ui-library-official]] `REACT-UI-C1` | D1 — UI composition을 React로 채택(재사용 컴포넌트 단위로 async surface 구성). `FE-D004`의 official 근거. |
| [[raw/official-docs/react-ui-library-official]] `REACT-UI-C5` | D1·D4 — 부모 state를 props로 자식에 전달하는 단방향 흐름을, discrete async state의 렌더/전파 모델로 채택. |
| [[raw/official-docs/tanstack-query-server-state-official]] `TSQ-C1` | D4 — async surface가 소비하는 "server state"(loading/staleness/refetch 신호)의 정의적 근거. 단, port 소유·구현은 `FE-OC-012`에 위임(delegated). |
## TODO
각 항목 옆 증거 등급 표기.
- [ ] base 4-state tagged union + non-blocking overlay flag 집합(2축) + `deriveAsyncState` selector 계약 정의 — 등급: `planned`
- [ ] base × overlay 합법 조합표(§1.2, 20조합) + 단일 슬롯 indicator 우선순위(§1.3) 확정 — 등급: `planned`
- [ ] server query/mutation 신호 → `{base, overlay}` 파생 매핑표 확정(server-state 계약 fix 후) — 등급: `planned`
- [ ] `terminal-error` 렌더 컴포넌트(`userMessageKey` + `action` only, raw body/stack 금지) — 등급: `planned`
- [ ] non-throw 규율 + async→render boundary handoff 계약 문서화 — 등급: `planned`
- [ ] `{base, overlay}` state matrix fixture 11종(base 4 + overlay 4 + 교차 2 + latch 전이 1, `pnpm test:component`) — 등급: `planned`
- [ ] 구현 repository·검증 evidence 식별 — 등급: `needs-confirmation`
## 진행 중 메모
- 본 노트는 `/branch-spec` self-map으로 hub `FE-OC-011` owner scope에서 도출. frontend 코드는 아직 없음 → 전부 `planned` blueprint.
## 결정 사항
> 아래 Decision Evidence Map의 prose mirror. 근거는 hub §9.1/§8/§10.1/§4 + `raw/official-docs/react-ui-library-official`.
- **D1**: UI composition은 React 함수형 컴포넌트 + 단방향 props 흐름으로 하고, async surface의 discrete state를 그 위에 렌더한다(`FE-D004`). 대안: native custom-element / 다른 framework fork(revisit trigger).
- **D2**: 원격 데이터에 의존하는 모든 async surface는 required 4-state(`initial-loading`/`success`/`empty`/`terminal-error`)를 MUST 표현한다(§9.1). 대안: 없음(surface당 불변).
- **D3**: non-blocking 4-state(`refreshing`/`stale-degraded`/`mutation-pending`/`mutation-conflict`)를 required state와 **별개 축으로** 표현한다. hub §9.1이 금지하는 것은 "`loading` boolean 하나로 empty/error/refreshing을 합치는 것"이므로, 금지 대상은 *상태 개수를 1개 boolean으로 붕괴시키는 것*이지 다축 구조 표현이 아니다.
- **D8**: async surface 상태는 **`base` (required 4 중 정확히 1개) + `overlay` (non-blocking 4의 flag 집합)** 2축으로 표현한다. required 4는 §9.1 Data 열이 상호배타(none / present / valid empty / none-or-unusable)이므로 한 시점에 정확히 하나이고, non-blocking 4는 §9.1이 "Additional"로 분류하며 `refreshing`이 "existing content 유지"를 요구하므로 base를 *대체하지 않고 겹친다*. 단일 flat 8-union은 `success`+`refreshing` 동시 성립을 표현할 수 없어 기각. overlay 간 동시 성립 시 단일 슬롯 indicator 우선순위는 `mutation-conflict` > `mutation-pending` > `stale-degraded` > `refreshing`(§구현 가이드 §1.3).
- **D4**: async state는 §1의 2축 구조(base tagged union + overlay flag set)로 표현하고 presentation은 application facade view-model만 소비한다. server query/mutation 신호 → `{base, overlay}` 파생은 application/adapter 경계에서 하며 presentation은 TanStack Query client를 직접 import하지 않는다(§4.3/§9.2).
- **D5**: `terminal-error`(및 stale-degraded/mutation-conflict의 error 표기)는 error-classification이 낸 normalized failure의 `userMessageKey` + closed `action`만 렌더하고 raw body/stack을 노출하지 않는다(§8.1/§8.4 소비).
- **D6**: async surface는 operational failure를 normal state로 반환하고 render boundary로 throw하지 않는다; render defect(programmer error/invariant breach)만 boundary가 잡는다(§10.1).
- **D7**: 완료 판정은 base 4 + overlay 4 + 교차 2 + latch 전이 1(총 11 fixture)을 결정론적으로 재현하는 component matrix test(Vitest + RTL, `pnpm test:component`)다(§20 measurable completion).
## 결정-근거 매핑
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | UI composition은 React 함수형 컴포넌트 + 단방향 props 흐름을 채택하고, async surface의 discrete state를 그 위에 렌더한다 (`FE-OC-011` / `FE-OC-002`) | component-based UI를 유지하는 한 React default / native custom-element·다른 framework로 project fork 시 재검토(`FE-D004` revisit trigger) | `raw/official-docs/react-ui-library-official.md#REACT-UI-C1`, `#REACT-UI-C5`; [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D004` | `official-vendor-doc` + `project-decision (accepted-documented-only)` | React 채택은 code evidence 없음(문서상 채택). 실제 컴포넌트 트리가 nesting/props 패턴을 따르는지 로컬 검증 필요(react-ui doc Usage Boundaries) |
| D2 | 원격 데이터 의존 async surface는 required 4-state(`initial-loading`/`success`/`empty`/`terminal-error`)를 MUST 표현 (`FE-OC-011`) | async surface(원격 데이터 view)가 존재하는 한 항상 4-state / 순수 정적 view(원격 데이터 없음)엔 async state 계약 불필요 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §9.1 required visible states 표 | `project-decision` | exhaustive coverage는 `{base, overlay}` state matrix test 11종(base 4 + overlay 4 + 교차 2 + latch 전이 1)으로만 증명(measurable completion) — 미구현 시 empty/error 누락 경로 leak |
| D3 | non-blocking 4-state(`refreshing`/`stale-degraded`/`mutation-pending`/`mutation-conflict`)를 required state와 **분리된 축**으로 표현; 금지 대상은 "`loading` boolean 하나로 empty/error/refreshing 합치기"로 한정 (`FE-OC-011`) | background activity·write-in-flight·retry-exhausted·conflict가 발생 가능한 surface에 적용 / 발생 불가한 surface는 해당 overlay 생략(단 required 4-state는 유지) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §9.1 "Additional non-blocking states" 표 + 인용 "`loading` boolean 하나로 empty/error/refreshing을 합치면 contract violation이다" | `project-decision` | 어떤 surface가 어떤 non-blocking state를 갖는지는 operation semantics에 의존 — surface별 적용 범위 판단 필요 |
| D8 | async surface 상태는 `base`(required 4 중 1개) + `overlay`(non-blocking 4의 flag 집합) 2축으로 표현하고, overlay 동시 성립 시 단일 슬롯 indicator 우선순위는 `mutation-conflict` > `mutation-pending` > `stale-degraded` > `refreshing` (`FE-OC-011`) | §9.1이 required/additional 2표를 유지하고 `refreshing`이 기존 content를 유지하는 한 2축 / 만약 hub가 non-blocking state를 base와 상호배타로 재정의하면 flat union으로 회귀 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §9.1 required 표(Data 열 none/present/valid empty/none-or-unusable = 상호배타) + "Additional non-blocking states" 표 + `refreshing` UI 요구 "existing content 유지" + `mutation-pending` Data "current view" | `project-decision` (구조) + `UNSUPPORTED_IMPL_DECISION` (표현 shape·indicator 우선순위) | §9.1은 overlay 동시 성립 시 렌더 우선순위를 규정하지 않음 — §1.3 우선순위는 사용자 trade-off. base×overlay 합법 조합표(§1.2)는 §9.1 Data 열에서 도출한 해석이며 hub가 명시한 표가 아님 |
| D4 | async state는 §1의 2축 `{base, overlay}`(base tagged union 1개 + non-blocking overlay flag 집합)로 표현, presentation은 application facade view-model만 소비하고 TanStack Query client를 직접 import하지 않음; server 신호 → `{base, overlay}` 파생은 application/adapter 경계 (`FE-OC-011``FE-OC-012` 소비) | server state가 `QueryCachePort`로 소유되는 한(`FE-D006`) 유지 / presentation 직접 import는 §4.3 dependency rule 위반이라 대안 아님 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §4.3 dependency matrix·§9.2 (presentation·application은 TanStack Query 직접 import 안 함); `raw/official-docs/react-ui-library-official.md#REACT-UI-C5`; `raw/official-docs/tanstack-query-server-state-official.md#TSQ-C1` | `project-decision` + `official-vendor-doc` | server 신호(status/fetchStatus) → `{base, overlay}` 매핑 함수 shape는 hub가 규정 안 함(§구현 가이드 UNSUPPORTED_IMPL). port 신호 형태는 `FE-OC-012` owner 소유 — 계약 fix 전엔 매핑 잠정 |
| D5 | `terminal-error`(및 error 표기 state)는 normalized failure의 `userMessageKey` + closed `action`만 렌더, raw body/stack 노출 금지 (`FE-OC-011``FE-OC-008` 소비) | 모든 error 표기에서 불변 / 예외 없음 — raw 노출은 `FE-OC-008`이 금지 | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §8.1 normalized failure shape·§8.4 UX action vocabulary | `project-decision (delegated consume)` | `kind → action` 계약 shape은 error-classification(`FE-OC-008`) 소유 — 그 계약 미확정 시 렌더 계약 모호(해당 브랜치 D6이 "action 실제 UI 실행은 async-ui 소유"라고 위임함) |
| D6 | async surface는 operational failure를 normal state(`terminal-error`/`stale-degraded`)로 반환하고 render boundary로 throw하지 않음; render defect만 boundary가 catch (`FE-OC-011``FE-OC-015` 기여) | normalized operational failure는 항상 state 반환 / programmer defect·invariant breach만 throw | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §10.1 error boundary ownership(async boundary는 normalized state를 catch, thrown render defect는 catch 안 함) | `project-decision` | boundary topology·recovery 배치는 render-recovery(`FE-OC-015`) 소유 — async surface는 "throw 안 함" 계약만 제공. 경계 계약이 어긋나면 operational failure가 render boundary로 새어 reload loop 위험 |
| D7 | 완료 판정은 `{base, overlay}` 11 fixture(base 4 + overlay 4 + 교차 2 + latch 전이 1)를 결정론적으로 재현하는 component matrix test(Vitest + RTL, `pnpm test:component`) (`FE-OC-011``FE-OC-020` 기여) | 2축 `{base, overlay}` 계약이 유효한 한 매트릭스 test / 대안 없음 — 완료의 유일 evidence(§20 measurable completion) | [[raw/project-notes/ca-skeleton-frontend-operational-contract]] §20 measurable completion·§16 `pnpm test:component`(async fixtures)·`FE-D022` test stack | `project-decision` + `conditional-default (test stack)` | RTL/Vitest 하네스·fixture 구조는 test-taxonomy(`FE-OC-020`) 소유 — 본 브랜치는 async fixture 목록·기대치만 확정 |
## 구현 가이드
> 전부 `planned` blueprint. 경로는 hub §4.6 Planned directory blueprint에서 도출(repository 생성 시 변경 가능). 코드는 존재하지 않는다.
### 1. Async surface state machine (base tagged union + non-blocking overlay flags)
> **Trace**: D2·D3·D8 + `FE-OC-011` + hub §9.1. §9.1은 두 개의 표를 유지한다 — "Required visible states"(4) 와 "Additional non-blocking states"(4). 후자는 전자를 *대체하지 않는다*: `refreshing`의 UI 요구가 "existing content 유지"이고 `mutation-pending`의 Data가 "current view"이므로, 이 state들은 데이터를 이미 가진 base 위에 겹친다. 따라서 8개를 하나의 상호배타 union으로 뭉치면 `success`+`refreshing` 또는 `success`+`mutation-pending` 동시 성립을 표현할 수 없다. 본 절은 이를 **2축**(base 1개 + overlay flag 집합)으로 계약화한다.
>
> - **UNSUPPORTED_IMPL_DECISION**: 2축 값 객체의 구체 shape(`{ base: 'success', overlay: { refreshing: false, staleDegraded: false, mutationPending: false, mutationConflict: false } }`)·tag 필드명·모듈 경로(`src/presentation/components/async/async-surface-state.js`)·`isValidEmpty(data)` 판별자 — hub §9.1은 state 이름과 UI 요구만 규정하고 JS 표현 shape/파일 경로/empty 판별 predicate를 규정하지 않음. Trade-off: base를 tagged union으로 두어 exhaustive `switch` + RTL fixture addressability를 유지하고, overlay는 flag 집합으로 두어 동시 성립을 손실 없이 표현. hub §9.1이 실제로 금지하는 문장은 "`loading` boolean 하나로 empty/error/refreshing을 합치면 contract violation이다"이므로 금지 대상은 *단일 boolean으로의 붕괴*이고, base+overlay 구조 표현은 그 금지에 해당하지 않는다(오히려 empty/error/refreshing이 서로 구분 가능하게 남는다).
> - **UNSUPPORTED_IMPL_DECISION**: §1.3 indicator 우선순위 — §9.1은 overlay가 동시에 성립할 때 어떤 UI 요구를 우선할지 규정하지 않음. Trade-off: "사용자 조치를 요구하는 것이 조용한 배경 신호보다 우선"이라는 단일 원칙으로 정렬.
> - **UNSUPPORTED_IMPL_DECISION**: §1.1.1 `staleFailure` latch — hub §9.1은 `stale-degraded`의 진입 조건만 주고 clear/exit 조건을 규정하지 않으며, latch의 보관 위치·수명(query key 단위 / adapter 내부 vs selector 인자)과 refetch 진행 중 stale label 유지 여부도 규정하지 않음. Trade-off: latch를 `!refetchInFlight`와 곱해 read 축 두 overlay를 *정의상* 배타로 만들어(§9.2 focus refetch가 발동하는 정상 경로에서 invariant throw 회피), refetch 진행 중에는 stale label은 유지하되 manual retry affordance만 비활성화한다("아직 stale이지만 재시도 중"을 전달). alt = "동시 성립을 합법으로 허용"은 §9.1의 두 UI 요구(subtle indicator vs stale label + manual retry)가 같은 슬롯에서 충돌해 기각.
> - **UNSUPPORTED_IMPL_DECISION**: §1.2 query-less(mutation-only) surface의 base 규칙 — hub §9.1은 "원격 데이터에 의존하는 surface"만 다루고 read query가 없는 write-only surface의 base를 규정하지 않음. Trade-off: base = `success` 고정 + write 축 overlay만 허용해, form이 항상 렌더 가능하다는 사실과 §9.1 required-state 표현 의무를 동시에 만족. alt = 이런 surface를 계약 밖으로 배제하면 `mutation-pending`의 "duplicate action 차단"(§9.1) 근거가 submit form에서 사라져 기각.
> - **해석 주의(§1.2 조합표의 지위)**: §1.2 base×overlay 합법 조합표는 hub가 명시한 표가 **아니라** §9.1 Data 열(none / present / valid empty / none-or-unusable, 그리고 overlay 4종의 데이터 전제)에서 도출한 *해석*이다. 위반 시 render defect로 취급해 throw하는 근거(D6 invariant breach 경로)도 이 해석 위에 서 있다. hub가 §9.1에 조합표를 명시하면 본 절이 그것으로 대체된다.
#### 1.1 두 축
Base state (§9.1 required 표 — 한 시점에 **정확히 1개**, Data 열이 상호배타):
| base | data | activity | UI 요구 (§9.1) | 파생 신호(소비) |
|---|---|---|---|---|
| `initial-loading` | none | first request | 안정적 skeleton, focus theft 금지 | query pending & no cached data |
| `success` | present | idle | view-model render | query success & non-empty |
| `empty` | valid empty | idle | empty 사유 + 가능 시 primary action | query success & `isValidEmpty` |
| `terminal-error` | none/unusable | stopped | safe message + registry action(§3 참조) | normalized failure(retry 소진/비재시도) |
Overlay flags (§9.1 "Additional non-blocking states" 표 — **0개 이상 동시 성립**, base를 대체하지 않음):
| overlay | data 전제 | activity | UI 요구 (§9.1) | 파생 신호(소비) |
|---|---|---|---|---|
| `refreshing` | stale/present | background | 기존 content 유지 + subtle indicator | `refetchInFlight` — background refetch가 진행 중 |
| `stale-degraded` | cached | retry exhausted | stale label + manual retry | `staleFailure` latch set(직전 refetch가 재시도 소진/비재시도로 실패 & cached 존재) **AND** 현재 refetch in-flight 아님 |
| `mutation-pending` | current view | write in flight | 중복 action 차단 | mutation pending |
| `mutation-conflict` | authoritative refetch 필요 | stopped | conflict action | `CONFLICT`(409) normalized failure |
#### 1.1.1 read-overlay latch 전이 (`refreshing` ⊕ `stale-degraded`의 배타성 근거)
`stale-degraded`는 독립 flag가 아니라 **latch 1개 + in-flight 부정**의 파생값이다. read 축 전체를 `refetchInFlight`(현재 refetch 진행 여부)와 `staleFailure`(직전 refetch 실패가 아직 해소되지 않음) 두 신호로 계산한다:
```text
refreshing := refetchInFlight
stale-degraded := staleFailure && !refetchInFlight
```
`staleFailure` latch가 필요한 이유: hub §9.1은 `stale-degraded`의 진입 조건(retry exhausted)만 규정하고 **exit 조건을 규정하지 않는데**, hub §9.2의 cache default가 "refetch on focus = enabled for stale query"이므로 `stale-degraded` surface는 window focus만으로 자동 background refetch에 진입한다. latch 없이 `stale-degraded`를 "직전 실패 & cached"로만 정의하면 그 정상 경로에서 `refreshing`과 동시 성립해 §1.2 배타 불변식이 깨진다. 위 정의는 두 flag를 `refetchInFlight` 하나의 참/거짓으로 갈라 **정의상(구조적으로)** 배타로 만든다 — 런타임 검사에 의존하지 않으므로 focus refetch 경로에서 invariant가 throw되지 않는다.
| 전이 | 트리거 | latch 변화 | 결과 read overlay |
|---|---|---|---|
| `stale-degraded``refreshing` | 재refetch **진입** — window focus 자동 refetch(§9.2) 또는 stale label의 manual retry | `staleFailure` **유지**(clear하지 않음) | `refreshing` only |
| `refreshing` → ∅ | refetch **성공** | `staleFailure` clear | ∅ (base가 `success`/`empty`로 갱신) |
| `refreshing``stale-degraded` | refetch **실패** & cached 존재 | `staleFailure` set(유지) | `stale-degraded` only |
| `refreshing` → (base 전환) | refetch **실패** & cached 없음 | — | read overlay ∅ — §1.2에 따라 base = `terminal-error` |
| ∅ → `refreshing` | 최초 background refetch(직전 실패 없음) | 변화 없음(unset) | `refreshing` only |
manual retry와 focus 자동 refetch는 **같은 전이**를 쓴다(둘 다 refetch 진입). "manual retry는 foreground라 `refreshing`이 아니다"라는 구분은 두지 않는다 — 그 구분은 §9.1에 근거가 없고, focus refetch 경로가 자동이므로 배타성을 구제하지도 못한다.
#### 1.2 합법 조합 (base × overlay)
§9.1 Data 열에서 도출: 4개 overlay 모두 *이미 렌더 가능한 데이터가 존재함*을 전제(stale/present · cached · current view · authoritative refetch 필요)하므로, 데이터가 없는 base에는 붙을 수 없다.
| base | 허용 overlay | 근거 |
|---|---|---|
| `initial-loading` | 없음 (∅) | Data = none — 유지할 기존 content가 없어 "existing content 유지"·"current view"가 성립 불가 |
| `success` | 4종 모두 | Data = present |
| `empty` | 4종 모두 | Data = valid empty(유효한 데이터) — refetch·mutation 모두 성립 가능 |
| `terminal-error` | 없음 (∅) | Data = none/unusable, Activity = stopped — cached content가 남아 있다면 base는 `terminal-error`가 아니라 `success`/`empty` + `stale-degraded` |
Overlay 내부 상호배타(정의상 도출):
- `refreshing``stale-degraded` — §1.1.1 latch 정의(`stale-degraded := staleFailure && !refetchInFlight`)에서 **구조적으로** 도출. 직전 refetch 실패 후 focus 자동 refetch(§9.2)가 다시 걸리면 `stale-degraded → refreshing`으로 *전이*하며 동시 성립하지 않는다. 이 배타성은 런타임 assert가 아니라 파생식의 성질이다.
- `mutation-pending``mutation-conflict` — 전자는 "write in flight", 후자는 Activity "stopped". 동시 성립 불가.
query-less(mutation-only) surface 규칙: read query가 없는 surface(제출 전용 form 등)는 base를 `initial-loading`으로 두지 않는다. 읽을 원격 데이터가 없어 "first request 대기"가 성립하지 않고 렌더 가능한 form view가 항상 존재하므로 **base = `success` 고정**이며, write 축 overlay(`mutation-pending`/`mutation-conflict`)만 사용한다. read 축 overlay(`refreshing`/`stale-degraded`)는 성립하지 않는다. `deriveAsyncState``queryResult``undefined`일 때 이 규칙을 적용한다.
→ 따라서 동시 성립하는 overlay는 최대 2개(read 축 1 + write 축 1)이며, 전체 합법 조합 수는 `initial-loading`(1) + `terminal-error`(1) + (`success`·`empty`) × 3(read: none/refreshing/stale-degraded) × 3(write: none/pending/conflict) = 20이다.
#### 1.3 동시 성립 시 우선순위 (indicator precedence)
read overlay와 write overlay는 서로 다른 affordance를 점유하므로(read = content 영역 indicator/stale label, write = action 영역 차단/conflict action) **기본은 동시 렌더**다. 단일 슬롯(예: surface 헤더의 status indicator 1칸)만 있는 경우에만 다음 순서로 하나를 고른다:
```text
mutation-conflict > mutation-pending > stale-degraded > refreshing
```
원칙: 사용자 조치를 요구하며 activity가 stopped인 것 → 사용자 조작을 차단하는 것 → 수동 retry를 요구하는 것 → 조용한 배경 신호. `deriveAsyncState`는 이 우선순위를 *렌더 힌트*(`overlay.primary`)로만 계산하고, overlay flag 자체는 절대 삭제하지 않는다(삭제하면 §9.1 요구가 유실됨).
### 2. Server-signal → state 파생 (consume, not define)
> **Trace**: D4·D8 + `FE-OC-011` → `FE-OC-012` 소비. TanStack Query query/mutation 신호를 §1의 2축 상태(`{base, overlay}`)로 파생하는 순수 selector를 application/adapter 경계에 둔다. query 신호는 base + read overlay를, mutation 신호는 write overlay를 결정하며, 두 축은 독립적으로 계산된 뒤 §1.2 합법 조합표로 검증된다. presentation은 결과 view-model만 받는다.
>
> - **UNSUPPORTED_IMPL_DECISION**: query `{status, fetchStatus, data, isPlaceholderData}` 및 mutation `{status}` 튜플 → `{base, overlay}`의 구체 매핑표와 selector signature(`deriveAsyncState(queryResult, mutationResult, { isValidEmpty, staleFailure })`) — hub는 state 집합만 정의하고 TanStack 필드→state 매핑은 규정하지 않음. signature는 §1.1.1의 `staleFailure` latch를 명시 입력으로 받고(selector를 순수 함수로 유지), `queryResult`가 `undefined`이면 §1.2 query-less 규칙(base = `success`, write 축 overlay만)을 적용한다. Trade-off: 파생을 경계에 두어 presentation을 framework-neutral로 유지(§4.3), alt = page-local 파생은 dependency rule 위반이라 기각.
> - **R3(위임)**: `QueryCachePort`가 노출하는 실제 신호 형태·query key·invalidation은 `feature-server-state-caching-contract`(`FE-OC-012`)가 소유한다. 본 절은 그 신호를 *소비*하는 매핑만 명세하며, port 신호 shape이 확정되면 매핑표를 fix한다.
### 3. error-표기 state 렌더 계약
> **Trace**: D5 + `FE-OC-011` ← `FE-OC-008` 소비. error를 표기하는 state(`terminal-error`, `stale-degraded`, `mutation-conflict`)는 normalized failure의 safe 필드만 사용한다.
>
> - **UNSUPPORTED_IMPL_DECISION**: `action`(6-closed: `retry`/`reauth`/`navigate`/`reload-once`/`contact-support`/`none`) → 구체 버튼/handler 컴포넌트(`AsyncErrorSurface`) 매핑, `userMessageKey` → copy 카탈로그 lookup — hub §8.4는 action 어휘와 allowed-when/MUST-NOT만 규정하고 컴포넌트/카피 구현은 규정 안 함. Trade-off: action별 단일 presentational 컴포넌트로 고정해 테스트 대상을 좁힘; copy 카탈로그(i18n)는 본 브랜치 밖.
> - **R3(위임)**: `kind → action`·`kind → userMessageKey` 매핑 계약은 `feature-frontend-error-classification-boundary-contract`(`FE-OC-008`)가 소유(그 브랜치 D6이 "action의 실제 UI 실행은 async-ui가 소유"라고 위임). 본 절은 소비/렌더만.
렌더 불변식: raw response body·token·authorization header·full URL/query·stack·storage value를 error state UI에 노출하지 않는다(§8.1).
### 4. Non-throw 규율 + async→render boundary handoff
> **Trace**: D6 + `FE-OC-011` → `FE-OC-015` 기여. async surface는 normalized operational failure를 반드시 state로 반환하고 render boundary로 throw하지 않는다.
>
> - **UNSUPPORTED_IMPL_DECISION**: "state로 반환됐고 throw되지 않았음"을 강제하는 test 어서션 형태(예: failure 주입 후 nearest error boundary 미발동 assert) — hub는 원칙만 규정. Trade-off: integration test에서 boundary render 여부로 검증(별도 boundary mock 대신 실제 boundary 미발동 관찰).
> - **R3(위임)**: boundary 배치·소유권(boot/route/feature/async boundary)·reload loop 방지(§10.2)는 `feature-frontend-render-recovery-boundary-contract`(`FE-OC-015`)가 소유. 본 절은 async surface가 그 boundary를 발동시키지 않는다는 계약만 제공.
### 5. Component state matrix tests (measurable completion)
> **Trace**: D7·D8 + `FE-OC-011` → `FE-OC-020` 기여. base 4종과 overlay 4종을 각각 결정론적으로 재현하는 component fixture(8종) + overlay 동시 성립 우선순위 fixture(2종) + §1.1.1 read-overlay latch 전이 fixture(1종)를 작성하고 `pnpm test:component` gate(`artifacts/tests/component.xml`)에 편입.
>
> - **UNSUPPORTED_IMPL_DECISION**: fixture 파일명·경로(`tests/component/async-surface.state-matrix.test.jsx`)·RTL query 전략(role/label 기준) — hub §20은 "state matrix component tests" 결과만 요구하고 파일 배치·query 전략은 규정 안 함. Trade-off: base/overlay당 최소 1 fixture로 1:1 addressable하게 배치하고, §1.2의 20개 합법 조합 전수 대신 축별 1개 + 교차 2개 + latch 전이 1개로 축소(전수는 fixture 유지비가 계약 가치를 넘어섬). latch 전이만 예외적으로 fixture를 추가한 이유는 그것이 정적 조합이 아니라 §9.2 focus refetch가 발동시키는 *시간 축* 경로여서 정적 조합 fixture로는 재현되지 않기 때문.
> - **R3(위임)**: Vitest/RTL/MSW 하네스 구성·gate 분리·artifact 규약은 [[raw/project-notes/ca-skeleton-frontend-operational-contract]] `FE-D022`가 규정하고, 구현 소유자는 [[raw/branch-notes/feature-frontend-test-taxonomy-contract]](`FE-OC-020`)다. 본 절은 async fixture 목록(11종)과 각 fixture의 기대 `{base, overlay}`만 확정.
| fixture | 주입 조건 | 기대 base | 기대 overlay |
|---|---|---|---|
| initial-loading | pending & no cache | `initial-loading` | ∅ |
| success | success & non-empty | `success` | ∅ |
| empty | success & valid empty payload | `empty` | ∅ |
| terminal-error | normalized failure(비재시도/재시도 소진) & cache 없음 | `terminal-error` | ∅ |
| refreshing | success & background refetch in flight | `success` | `refreshing` |
| stale-degraded | refetch 실패 & cached 존재 | `success` | `stale-degraded` |
| mutation-pending | success & mutation in flight | `success` | `mutation-pending` |
| mutation-conflict | success & `CONFLICT`(409) normalized failure | `success` | `mutation-conflict` |
| overlay-cross | refetch in flight + mutation in flight 동시 | `success` | `refreshing` + `mutation-pending`(단일 슬롯 = `mutation-pending`) |
| overlay-precedence | stale-degraded + mutation-conflict 동시 | `success` | `stale-degraded` + `mutation-conflict`(단일 슬롯 = `mutation-conflict`) |
| stale-degraded → 재refetch | `stale-degraded` 상태에서 window focus 자동 refetch 진입(§9.2) — 이어서 (a) 성공 / (b) 실패 & cached 존재 | 진입 중 `success` → (a) `success` / (b) `success` | 진입 중 `refreshing` **only**(`stale-degraded` false, `staleFailure` latch는 유지) → (a) ∅ / (b) `stale-degraded` only. 세 시점 모두 두 flag 동시 true 아님을 assert(§1.1.1 latch 전이) |
## 엣지·실패·의존
- **실패·엣지 경로**:
- `empty` vs `terminal-error` 오분류: valid empty payload를 error로 렌더하면 안 됨 → per-operation `isValidEmpty` predicate 필요(§9.1 empty = "valid empty").
- `initial-loading`: skeleton 안정성 유지 + focus theft 금지(§9.1). live region 반복 announcement 억제는 a11y 브랜치 위임.
- `refreshing` 중 background refetch 실패 → `stale-degraded`로 전이 + stale label + manual retry(§9.1), 기존 content 유지(§1.1.1 전이표).
- `stale-degraded` surface가 window focus를 되찾아 자동 refetch(§9.2 "refetch on focus = enabled for stale query")에 진입 → `staleFailure` latch는 유지한 채 `stale-degraded → refreshing`으로 전이한다. 두 flag가 동시에 true가 되지 않으므로 §1.2 read 축 배타 불변식은 이 정상 경로에서 깨지지 않는다(§1.1.1).
- `mutation-pending` 중 중복 submit → duplicate action 차단(§9.1).
- `mutation-conflict`(409) → authoritative refetch를 요구하는 conflict action(§8.2 `CONFLICT` row).
- async surface가 normalized failure를 못 만든 채 throw되는 경로: 이는 error-classification total-function(§8.2) 위반이며, 만약 새면 render boundary(`FE-OC-015`)가 최후로 catch — async surface는 이를 유발하지 않아야 함.
- **다른 계약 의존**:
- [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] `FE-OC-008` — normalized failure(`userMessageKey`/`action`/safe 필드)를 consume. 그 계약이 바뀌면 error 표기 state 렌더가 영향.
- [[raw/branch-notes/feature-server-state-caching-contract]] `FE-OC-012``QueryCachePort`의 query/mutation 상태 신호를 consume해 §1의 2축 `{base, overlay}`를 파생. port 신호 shape 변경 시 매핑 재조정. 특히 cache default "refetch on focus = enabled for stale query"(hub §9.2)가 §1.1.1 read-overlay latch 전이(`stale-degraded → refreshing`)를 발동시키는 경로이므로, focus refetch를 opt-out하는 surface는 그 전이가 manual retry로만 일어난다.
- [[raw/branch-notes/feature-frontend-render-recovery-boundary-contract]] `FE-OC-015` — boundary ownership에 의존. async surface는 throw하지 않는다는 계약을 제공하고 boundary 배치는 위임.
- [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] `FE-OC-020` — component test 하네스·gate를 consume해 matrix fixture를 편입.
- [[raw/branch-notes/feature-sample-feature-slice-contract-fixture]] `FE-OC-024` — sample slice가 async surface를 fixture로 exercise(sample removal smoke 대상).
- [[raw/branch-notes/feature-accessibility-baseline-contract]] — loading/error live region·focus(§10.3)를 소유. async state는 a11y hook point만 노출.
- [[raw/branch-notes/feature-tailwind-design-token-styling-contract]] `FE-OC-011` — 본 브랜치가 요구하는 시각 primitive(안정적 skeleton, `refreshing`의 subtle indicator, `stale-degraded`의 stale label, 단일 슬롯 status indicator)의 token-driven 어휘를 소유하는 co-tenant(hub Decision Register `FE-D005``FE-OC-011`에 영향). 본 브랜치는 *어떤 state가 존재하고 언제 성립하는지*를 소유하고, 그 state의 시각 표현 어휘는 위임한다. 해당 브랜치 D4가 동일 경계를 반대편에서 명시("state machine·required-state는 위임"). primitive 어휘가 §1.2 조합표의 동시 렌더(read overlay + write overlay)를 표현하지 못하면 §1.3 단일 슬롯 fallback으로 축약된다.
## 검증해야 할 주장
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| component matrix가 base 4 + overlay 4 + 교차 2 + latch 전이 1을 결정론적으로 재현한다 | 코드·fixture가 아직 없음 | `pnpm test:component` async state matrix fixtures(`artifacts/tests/component.xml`) — base/overlay당 최소 1 fixture + `stale-degraded → 재refetch` 전이 fixture exit 0 | `needs-confirmation` |
| `stale-degraded` 상태에서 focus 자동 refetch(§9.2)가 걸려도 `refreshing`·`stale-degraded`가 동시 true가 되지 않는다 | hub §9.1이 `stale-degraded`의 exit 조건을 규정하지 않아 latch 정의(§1.1.1)는 본 브랜치의 해석 | `deriveAsyncState` unit test — `staleFailure` latch set 상태에서 `refetchInFlight` true/false를 토글하며 두 flag의 동시 true 부재 assert + §5 `stale-degraded → 재refetch` fixture | `needs-confirmation` |
| server 신호(status/fetchStatus/data + mutation status) → `{base, overlay}` 파생이 gap 없이 exhaustive하다 | hub가 매핑표를 규정하지 않아 잠정 | `deriveAsyncState` selector unit test(모든 튜플 조합 → base 정확히 1개 + overlay flag 집합이 §1.2 합법 조합에 속함) | `needs-confirmation` |
| §1.2 합법 조합표가 실제 surface에서 위반되지 않는다(예: `terminal-error` + `refreshing` 동시 방출 없음) | 조합표는 §9.1 Data 열에서 도출한 해석이며 hub 명시 표가 아님 | `deriveAsyncState` invariant test — 불법 조합 방출 시 throw(render defect로 취급, D6의 "invariant breach" 경로). **단 read 축 배타(`refreshing` ⊕ `stale-degraded`)는 §1.1.1 파생식의 성질이라 런타임 throw 대상이 아니다** — throw가 걸리는 것은 base×overlay 조합(데이터 없는 base에 overlay 부착) 위반뿐이며, read 축은 `stale-degraded := staleFailure && !refetchInFlight`가 성립하는지 unit test로 확인한다 | `needs-confirmation` |
| error 표기 state가 raw body/stack/token을 노출하지 않는다 | 렌더 경로가 미구현 | negative test — 직렬화 후 금지 필드 부재 assert(§8.1, error-classification D2 패턴 mirror) | `needs-confirmation` |
| async surface가 operational failure에 render boundary로 throw하지 않는다 | boundary 계약·구현 미확정 | integration test — failure 주입 후 nearest error boundary 미발동 assert(§10.1) | `needs-confirmation` |
| `isValidEmpty` predicate가 valid-empty를 error로 오분류하지 않는다 | per-operation empty 판별자가 미정 | component fixture(empty payload) → `empty` state assert | `planned` |
| React 컴포넌트 트리가 nesting/props 단방향 흐름을 준수한다 | react-ui doc Usage Boundaries가 로컬 검증 요구 | 구현 후 architecture lint(`FE-OC-002` dependency-cruiser/ESLint) | `planned` |
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
| 관심사 | 상태 | owner | 심각도 | 근거 |
|---|---|---|---|---|
## 마주친 문제
- 없음 — scaffolding 단계
## 묶음 (이 branch에서 파생된 자료)
<!-- GENERATED: project-contract-imports:start -->
## 가져온 프로젝트 계약
| Ref | Owner | 요약 | Branch 적용 |
|---|---|---|---|
| `FE-OC-002@1` | [[raw/branch-notes/feature-frontend-clean-architecture-layering-contract]] | `domain <- application <- presentation` 의존 방향과 application-owned output port를 MUST 지킴 | import 참조로 적용 |
| `FE-OC-008@1` | [[raw/branch-notes/feature-frontend-error-classification-boundary-contract]] | 모든 failure는 stable frontend error kind로 MUST 정규화하고 raw body·stack을 UI에 노출하면 안 됨 | import 참조로 적용 |
| `FE-OC-012@1` | [[raw/branch-notes/feature-server-state-caching-contract]] | query key와 invalidation은 registry factory만 MUST 사용 | import 참조로 적용 |
| `FE-OC-015@1` | [[raw/branch-notes/feature-frontend-render-recovery-boundary-contract]] | expected operational error와 render defect를 MUST 분리하고 reload loop를 금지 | import 참조로 적용 |
| `FE-OC-020@1` | [[raw/branch-notes/feature-frontend-test-taxonomy-contract]] | gate 종류별 책임·fixture·artifact를 분리하고 실패를 warning으로 낮추면 안 됨 | import 참조로 적용 |
<!-- GENERATED: project-contract-imports:end -->
### Sub-branches (세부 작업)
- 없음 — scaffolding 단계
### 오류 기록 (이 branch 작업 중 발생)
- 없음 — scaffolding 단계
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- 없음 — scaffolding 단계
### 강의 (이 작업을 위해 학습한 강의)
- 없음 — scaffolding 단계
### job-posting tie-ins (이 작업에서 파생된 글감)
- 없음 — scaffolding 단계
## 관련 일일 노트
- 없음 — scaffolding 단계
## 완료 후 정리
- PR 링크: TODO
- 리뷰 메모: TODO
- 머지 결과 / 배포 환경: TODO
- **wiki 추출 대상**: 없음 — scaffolding 단계
- **추출하지 않을 항목**: 없음 — scaffolding 단계