사용자 제기 — doc-check.py가 정규식으로 결정론적 판정을 하는데 표기가 흔들리면 문제가 커지니, 정규식을 늘리기보다 "예상 가능 범위"를 컨벤션으로 좁히는 게 싸지 않냐. 실측해보니 날짜 표기는 이미 100% 균일해서 고칠 게 없었고(강제 장치 없이), 드리프트는 절 인용 쪽이었다 — WARN 86건 중 78건(91%)이 절 참조 불일치. 핵심은 그 78건이 코퍼스가 지저분한 게 아니라 **검사기가 못 읽는 것**이었다는 점이다. 이 코퍼스는 `**볼드**` 줄을 하위 절로 쓰는데 headings()가 `#`만 봤다. ## 동작 변화 (문서 정정으로만 보이지만 게이트가 바뀐다) - 절 참조 불일치가 **WARN → ERROR**. 이제 절 인용 오류가 커밋을 막는다. - 절 인식이 `#` 헤딩 + `**볼드**` 절로 확장. 단 볼드는 **빈 줄 다음이나 리스트 항목 머리**만 인정 — 문단이 줄바꿈되며 우연히 줄머리에 걸린 강조를 절로 오인하던 걸 커밋 전 감사가 잡아 조였다. - 인용 길이 상한 60→160자. 60자를 넘으면 매칭 자체가 안 걸려 검사에서 **조용히** 빠져나갔음(위양성보다 나쁜 구멍). - 비교를 공백 무시로(줄바꿈 인용 대응), 선두 장식·상태/날짜 태그 정규화, `initreq/` 대상 인용은 절 검사 면제(읽기 전용 외부 원본). ## 규약 `conventions.md`에 "문서 표기 규약" 절 신설 — 절 인용 규약(의역 금지, 헤딩은 부분문자열/볼드는 앞부분일치, 태그로 닫히는 볼드 캐비엇, blockquote 함정), 세션은 산문 서수 말고 파일 ID로 지칭. 날짜 마커 라벨 어휘 닫기는 사용자 판단 으로 기각(기계 검사 대상이 아니라 읽는 쪽 판단 재료). ## 결과 절 참조 불일치 78 → 0. 36건은 검사기 수정으로 사라졌고(애초에 위양성), 42건은 인용을 실제 절 제목으로 손으로 고쳤다. 마지막 15건은 서브에이전트 3개에 병렬 위임해 추적 — **설계 서술이 유실된 건은 0건**, 대부분 코드 주석·본문 산문· 주제명처럼 애초에 절이 아닌 걸 절로 인용해온 것이었다. 부수로 드러나 같이 고친 것: onchange-plan이 9차 분할 때 일부러 안 옮긴 절을 잘못된 파일로 가리키던 것, brand-plan이 이미 이행된 정정을 "정정 대상"이라 부르던 것, ROADMAP의 blockquote가 인용 줄바꿈 때문에 깨져 있던 것, pre-implementation-audit의 해소된 항목이 "아직 안 고침" 절에 남아 있던 것 (사용자 결정으로 "이미 고침"으로 이동). 커밋 전 감사 4라운드(에이전트 8개)를 돌렸고, 발견 추이는 2→2→1→0이다. 매 라운드 발견이 "직전 라운드 수정이 만든 새 결함"이었던 게 특징 — 규약을 세우는 커밋이 그 규약의 첫 위반자가 된다는 걸 실측으로 확인했다. 상세는 .claude/session/2026-08-16-03-doc-check-section-convention.md. 부수: __pycache__를 .gitignore에 추가하고 추적 해제(32e9db0에 실수로 딸려 들어가 있었음). quad-doc-auditor에 작업 트리를 바꾸는 git 명령 금지 규약 추가 — 감사자가 git stash를 걸어 메인 세션 스테이지가 반복적으로 풀렸다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zk7XHkSfiBfdPLZUQHdZf
228 lines
16 KiB
Markdown
228 lines
16 KiB
Markdown
# Store — 이름 붙은 Source 모음, 그 이상 아님
|
|
|
|
> **📄 [2026-08-14 신설] `bind-system-plan.md` 3단계 분할 + store-semantics.md
|
|
> 흡수.** Store가 "Source들을 담고, 없으면 만들어주는 도구"로 좁혀지고 나서도
|
|
> 관련 서술이 store-semantics.md(부작용 허용, 값 설정 문법)와
|
|
> `bind-system-plan.md`(dot-access 타이핑, Store가 Store를 담는가)에 반씩
|
|
> 흩어져 있었음 — 한 군데로 합쳤고 **내용/결정은 이동·병합 자체로는 안
|
|
> 바뀜**. 반응형 코어(Source/State 자체)는 짝 문서
|
|
> **`base/source-state-plan.md`**.
|
|
|
|
**상태**: base — Store가 부작용을 허용한다는 핵심 결정, "이름 붙은 Source
|
|
모음"이라는 정의, eager+lazy 생성, `store.key` dot-access 타이핑, `:Set()`
|
|
문법 전환까지 전부 확정. 원본: `.claude/initreq/raw-userinput.md`
|
|
"store는 부작용을 허용함" / "스토어는 스토어를 저장 가능한가" 절.
|
|
|
|
## Store는 부작용을 허용하는 게 기본 디자인
|
|
|
|
부작용 없이(파라메터 패싱만으로) 쓰는 것도 물론 가능하지만, 라이브러리 차원에서
|
|
막지 않는다. 부작용 유무는 **사용자가 직접 문서화**하는 관례로 둔다 — 라이브러리가
|
|
순수성을 강제하지 않음.
|
|
|
|
다만 한 가지는 명확히 구분: **렌더 리턴 위에서 무언가를 observe하는 것은 그냥
|
|
부작용**이다 (`useEffect`와 유사한 것으로 문서화). 이건 "허용되는 부작용"이 아니라
|
|
"당연히 부작용"이라는 뜻 — 문서화 시 이 경계를 분명히 할 것 (`base/
|
|
purity-and-effects-plan.md`와 연결됨).
|
|
|
|
**보강(2026-08-04 검증 라운드): 부작용은 심각도가 다른 두 갈래로 나뉜다.**
|
|
|
|
1. **국소적 부작용** — 입력으로 받았거나 자신이 만들어 소유한 대상에 대한
|
|
부작용(예: 렌더 리턴 아래에서 옵저빙해서 자기 slot을 갱신). 이건 편의성이
|
|
커서 적극 환영하는 영역.
|
|
2. **경계를 넘는 부작용** — globalStore처럼 컴포넌트 바깥의 전역 상태를
|
|
다루는 경우. 게임 UI 특성상(스킬/주변 환경에 영향받는 UI 등) 완전히
|
|
막을 수는 없지만, 라이브러리로 재사용하려는 컴포넌트가 이런 부작용을
|
|
가지면 이식성이 떨어짐(`base/purity-and-effects-plan.md`와 연결).
|
|
|
|
**해소됨(2026-08-04 2차 라운드)**: "state를 옵저빙해서 나온 결과로 slot에
|
|
`clear`/`add` 같은 연산을 할 때, 그 시점에 대상 slot이 이미 죽어있으면
|
|
어떻게 되는가"는 별도 메커니즘 없이 `canExecute` 재사용으로 해결됨 —
|
|
`base/source-state-plan.md`의 "Slot 생존 확인" 절이 소스.
|
|
|
|
## Store = Source들의 이름 붙은 모음 (eager + lazy 생성)
|
|
|
|
`store.a`처럼 키로 접근하면 **이미 만들어진 Source가 있으면 그대로 반환,
|
|
없으면 그 자리에서 만들어 저장한 뒤 반환** — 더 이상 별도 State wrapper를
|
|
매번 만들거나 따로 캐싱하지 않음(Source 자체가 이미 State를 만족하므로
|
|
wrapper 계층 자체가 불필요해짐, `base/source-state-plan.md`의 "Source가
|
|
State를 만족함" 절).
|
|
|
|
- **`defaults`는 선택**(안 줘도 됨, 순수 편의용 초기값 템플릿) —
|
|
`Store({defaults})`가 내부적으로 `{[key] = Source(default), ...}`나
|
|
다름없게 됨.
|
|
- **[정정, 2026-08-07] "Store 생성 시 전부 eager하게만 만들어진다"는 이전
|
|
서술은 부정확 — eager와 lazy가 둘 다 필요하다.** Luau 타입은 런타임에
|
|
강제되지 않으므로 `Store<<SomeType>>()`처럼 `defaults` 없이 만든 뒤
|
|
`.Key:Set(v)`를 부르는 경우, `__index`가 "없으면 그 자리에서 만들어
|
|
저장"까지 해주지 않으면 `.Key`가 `nil`이라 크래시남. 그래서 **Store
|
|
생성 시점의 eager 생성**(각 `defaults` 키마다 미리 만들어둠)과
|
|
**`store.key` 접근 시점의 lazy 생성**(아직 없는 키를 그 자리에서 만들어
|
|
저장, 이후 재접근은 재생성 없이 그대로 반환)이 **둘 다** 필요함.
|
|
- **`defaults` 테이블 원본을 나중에 mutate해도 UB가 아님** — 라이브 백킹
|
|
스토리지가 아니라 "아직 안 만들어진 Source를 만들 때 참고하는 초기값
|
|
템플릿"으로만 반복 참조되기 때문(`bind-system-plan.md`에 남아있던
|
|
"defaults 테이블 직접 mutate는 UB"라는 옛 서술은 2026-08-07에 정정됨).
|
|
별도 `__values`류 그림자 실값 저장소도 불필요 — Source 객체 자체가
|
|
저장소 역할을 함.
|
|
- **구현 스케치(2026-08-07, 성능 근거): eager 생성은 `table.clone(defaults)`
|
|
후 그 결과를 순회하며 각 슬롯을 `Source(v)`로 교체하는 모양이어야 함**
|
|
(`local sources = table.clone(defaults); for k, v in sources do
|
|
sources[k] = Source(v) end` 류) — 빈 테이블을 새로 만들어 키를 하나씩
|
|
넣는 것보다, `table.clone`으로 원본의 해시/배열 슬롯 구조를 그대로
|
|
재사용하는 쪽이 Luau VM 입장에서 더 쌈(직접 해시 슬롯을 처음부터
|
|
구성하는 것보다 기존 슬롯을 복제하는 게 저렴). `Source()`(인자 없이
|
|
호출)는 `Source(nil)`과 동치 — `defaults`에 값이 없는 키를 `store.key`
|
|
접근 시점에 lazy 생성할 때 이 무인자 형태를 씀.
|
|
|
|
v1이 모든 값을 Store 하나에 몰아넣던 습관은 "당시 정적 타입이 없어 단순하게
|
|
쓰는 게 편해서"였다는 게 사용자의 회고적 재평가 — 지금은 타입이 핵심
|
|
제약이라 그 전제 자체가 더 이상 안 맞고, 2026-08-06 후속 세션의 정리로
|
|
Store는 "이름 붙은 Source 모음, 그 이상 아님"으로 더 단순해짐. 값 하나만
|
|
반응형으로 다루고 싶으면 Store를 통째로 만들지 말고 독립
|
|
`Source(default)`를 쓸 것(`base/source-state-plan.md`의 "Source는 독립
|
|
공개 프리미티브로 격상" 절).
|
|
|
|
## Store 값 설정 문법 — `myStore.key = value` 폐기, `source:Set(value)`로 전환 (2026-08-06 후속 세션, 정정)
|
|
|
|
**이전 버전("v1 인체공학 유지, `__newindex` 기반 `myStore.key = value`
|
|
그대로")은 폐기됨.** `base/source-state-plan.md`의 "Source가 State를
|
|
만족함" 절 타입 설계와 맞물려 재검토된 결과:
|
|
|
|
1. **타입 대칭성**: `store.key`가 이제 `Source<T>`를 직접 반환하는
|
|
평범한 레코드 필드(`{key: Source<number>}`)로 타이핑되는데, 레코드
|
|
필드는 읽기/쓰기 타입이 같아야 Luau 구조적 타이핑이 깨끗하게 성립함.
|
|
`store.key = value`(raw `T` 대입)를 유지하면 읽기(`Source<T>`)/쓰기(`T`)
|
|
타입이 갈려 mismatch가 남음 — `store.key:Set(value)`로 통일하면 필드
|
|
타입이 항상 `Source<T>`로 대칭적이라 문제 자체가 안 생김(사용자 지적).
|
|
2. **의미론적 정직성**: `=` 대입 문법은 관례상 "그 자리에서 즉시 확정되는
|
|
부작용 없는 값 쓰기"를 암시하는데, quad의 실제 동작은 **lazy** —
|
|
`Set`은 무효화 신호만 쏘고, 실제 재계산은 나중에 누군가 관측(`Get()`)할
|
|
때만 일어남("Emit으로 필요한 사람 있어? 하고 물어보고, 있어야 진짜
|
|
계산 시작"). 이건 `=`가 암시하는 "즉시 커밋"과 정서가 안 맞고, 메소드
|
|
호출(`:Set()`)이 "이건 프로세스를 트리거하는 연산"이라는 걸 더 정직하게
|
|
신호함(사용자 확정 논거).
|
|
3. `:Set()`은 이미 확정된 "값을 바꾸는 연산엔 `:` 체이닝 허용" 원칙(`base/
|
|
architecture.md`)에도 자연스럽게 들어맞음 — 문법 자체가 새로 생기는 게
|
|
아니라 기존 원칙의 정상적인 적용.
|
|
|
|
**남는 것**: `myStore "key"`(문자열 커링)는 이미 3차 라운드에서 동적 키
|
|
전용 미타입 폴백으로 격하돼 있었으므로 이번 정정과 무관하게 그대로 유지.
|
|
`:` 체이닝 원칙도 `:Set()` 자체가 그 사례라 유지.
|
|
|
|
`base/architecture.md`의 "복사(clone) 구현 지양, 팩토리 함수로 대체" 원칙과 함께
|
|
읽을 것 — v1의 문제는 metatable 체이닝으로 매번 새 테이블을 할당하며
|
|
"불변 빌더"를 흉내낸 것이었지, `:` 체이닝 문법 자체나 커링 문법 자체가
|
|
아니었음.
|
|
|
|
## 타입 추론 문제 — `store.key`(dot-access)를 1급 경로로 확정 (2026-08-04 3차 라운드)
|
|
|
|
- `store "key"`(문자열 커링)로 `state<T>`를 오버로드 함수 타입으로 정확히
|
|
추론하려는 시도는 포기하고, **`store.key`(dot-access)를 1급 경로로 확정**
|
|
— Store 타입을 `{key: Source<number>, other: Source<string>}`류 평범한
|
|
레코드 타입으로 지으면 일반 구조적 필드 타이핑으로 자동 해결되고, 문자열
|
|
리터럴 narrowing 문제 자체가 안 생김([정정, 2026-08-06] 원래 `State<T>`
|
|
필드로 적혀있었으나 Source가 State를 만족하는 구조로 바뀌며 `Source<T>`로
|
|
갱신 — `store.key = value` 쓰기 문법이 `:Set()`으로 옮겨가 이 필드가
|
|
더 이상 `__newindex`로 쓰이지 않으므로 읽기/쓰기 타입 대칭 문제도 같이
|
|
해소됨, 위 "Store 값 설정 문법" 절 참고).
|
|
`store "key"` 문자열 커링은 동적 키가 필요할 때 쓰는 미타입(`Source<any>`)
|
|
폴백으로 격하.
|
|
- 이 패턴은 Store에만 국한되지 않고 **인스턴스 생성까지 관통하는 프로젝트
|
|
전역 관습으로 확정**됨 — 단 이벤트는 이후 4차 라운드에서 이 관습의
|
|
**유일한 예외**로 빠졌음(PA님 방식인 문자열 키+런타임 리플렉션으로 전환).
|
|
`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학"
|
|
절이 최신 확정 내용.
|
|
|
|
### `store.key` 레코드 필드 타이핑 — Luau 타입함수로 해결 확인 (2026-08-12 열일곱 번째 세션, `pre-implementation-audit.md` 1-10 해소)
|
|
|
|
위 절이 "`store.key`를 평범한 레코드 필드 타이핑으로 자동 해결"이라
|
|
서술했지만, `Store<T>`가 입력 `T`(예: `{ty: string}`)를 받아
|
|
`{ty: Source<string>}`류 결과 타입을 실제로 어떻게 합성하는지는 미검증으로
|
|
남아있었음. **Luau의 `type function`**(컴파일타임에 타입을 인자로 받아 새
|
|
타입을 조립하는 기능, https://luau.org/types/type-functions/ ,
|
|
https://luau.org/types-library/ — tbox에서도 이미 쓰이는 검증된 패턴)으로
|
|
정확히 풀림:
|
|
|
|
```luau
|
|
type function WrapStore(ty: type): type
|
|
-- Source<T> 형태를 그대로 조립(:Get/:Set/:Compute/:With 등)
|
|
local result = types.newtable()
|
|
result:setproperty(types.singleton("Get"), types.newfunction(...))
|
|
return result
|
|
end
|
|
|
|
type function ProcessStoreType(ty: type): type
|
|
local props = ty:properties() :: { [type]: { read: type?, write: type? } }
|
|
local result = types.newtable()
|
|
for i, v in props do
|
|
-- i는 프로퍼티 이름을 담은 singleton 타입, i:value()로 실제 문자열
|
|
result:setproperty(i, WrapStore(v))
|
|
end
|
|
return result
|
|
end
|
|
```
|
|
|
|
`ProcessStoreType<{ty: string}>` → `{ty: Source<string>}`가 나옴 — 결과는
|
|
선언 시점에 이름 붙은 `Source<string>` 그 자체가 아니라 구조를 그대로 풀어낸
|
|
(flatten) 익명 타입이지만, **Luau는 이름이 아니라 "만족하는가"로 구조적
|
|
일치를 검사**하므로 문제없이 `Source<string>` 자리에 대입 가능 — 오히려 이
|
|
방식과 정확히 맞는 조합. 이걸로 `store.key`가 실제로 타입 명시 가능함이
|
|
확인돼 M0/M3 어느 시점에 검증해도 기술적으로 막힐 위험은 없음 —
|
|
`ROADMAP.md`의 M0/M3 배치를 강제로 바꿀 필요는 없어짐, 설계 레벨의 검증
|
|
난이도 문제였던 것만 해소. **[2026-08-15] 이 `type function` 접근 자체의
|
|
실측도 완료** — 스파이크(`luau-test/done/16-type-store-key-
|
|
typefunction.luau`)는 원래 `types.newfunction` 시그니처 불일치로 깨져
|
|
있었으나 원인이 설계 문제가 아니라 API 버전 드리프트였음이 드러나 수정
|
|
후 통과(음성 대조군 4건 포함), `base/typing-limits.md` §5로 승격.
|
|
상세는 `audit/type-recursive-issue-with-typeof/REPORT.md` 6-1절.
|
|
|
|
## Store가 Store를 저장 가능한가
|
|
|
|
사용자 원 메모: "슬롯을 스토어처럼 생각 가능하다면 이건 가능하다고 봐야하는가?
|
|
아니면 아예 다른 값으로 둬야 하는가? table/number 같은 프리미티브 타입이나
|
|
ref 타입처럼 생각하는 게 맞는 거 같음 — 그걸 처리하는 플러그를 만드는 걸로."
|
|
|
|
**2026-08-04 6차 확정: 그런 경우는 없다고 본다.** "재실행 래핑으로
|
|
기계적으로는 커버 가능하다"는 제안은 메커니즘상 틀리지 않지만, 실제 설계
|
|
의도와 안 맞음 — Store는 Source에 준하는 존재로 모든 반응형 값의 "시작점"
|
|
역할만 함. 시작점은 다른 변화하는 무언가에 연결되는 것을 제공하고자 하지
|
|
않음(= Store가 다른 Store/State를 값으로 담아 자동으로 따라가게 하는 용도로
|
|
쓰지 않음). Store에서 값을 꺼내 State를 옵저빙하다가 콜백으로 다른 Store 값을
|
|
바꾸는 식의 수동 연결은 있을 수 있지만, 잘 짜인 UI에서 실사용 사례를 거의
|
|
보지 못했다는 게 사용자 판단 — 그래서 이 케이스를 위해 별도로 신경 쓰지 않음.
|
|
|
|
**[2026-08-13 세션, 스코프 명확화, 같은 날 다섯 번째 세션에 결론 갱신]**
|
|
이 절은 "Store *필드*가 Store/State를 담는가"(예: `store.a = otherStore`)
|
|
얘기이고, "State가 *emit하는 값*이 State/Source인가"(`State<State<T>>`,
|
|
예: `store.key`에 대입된 값 자체가 State)는 다른 축. 이 절의 "별도로
|
|
신경 쓰지 않음"(Store 필드 얘기)은 그대로 유지 — 후자(`State<State<T>>`)는
|
|
한때 실제 체인 파손 버그로 확인돼 `Dispatch.process`가 명시적으로 error
|
|
하도록 막았었으나, 같은 날 다섯 번째 세션에 `chains`의 인덱스 기반
|
|
재설계로 그 버그의 근본 원인이 없어져 **지금은 정상 지원 대상**
|
|
(`base/dispatch-core-plan.md`의 "Dispatch 체인" 절 참고 — 열네 번째
|
|
세션의 하강 diff로 깜빡임 방지 힌트까지 깊은 체인에서 유지됨) — "신경 안 씀"의
|
|
의미가 "조용히 UB"도 "즉시 실패"도 아니라 "그냥 정상적으로 동작함"으로
|
|
다시 한번 바뀜.
|
|
|
|
**따라서 "Store가 Store를 담는 경우 이중 해제(double-dispose) 방지가
|
|
필요한가"라는 질문도 성립 안 함으로 종결** — 두 가지 독립적인 이유로
|
|
이중 해소됨. (1) 애초에 그런 경우를 만들지 않기로 확정(위 문단). (2) 설령
|
|
발생해도 State/Source 그래프 구독이 전부 weak-keyed GC-native(명시적
|
|
`dispose()` 호출이 아예 없음, `base/lifecycle-pattern.md`의 GC 위임 원칙
|
|
재사용)라 "같은 걸 두 번 해제"할 행위 자체가 존재하지 않음(GC는 멱등).
|
|
|
|
## Store가 담을 수 없는 값 — Modifier
|
|
|
|
`Store<T>`/`Source<T>`의 `T`는 Modifier가 될 수 없음(런타임 `error`) —
|
|
근거와 검사 지점은 `base/source-state-plan.md`의 "따름정리" 절이 소스.
|
|
|
|
## 여러 스토어 값을 묶어 처리하는 것 (dependency array) — 확정
|
|
|
|
`useEffect`처럼 여러 store 값을 디펜던시로 묶어 파생값을 계산하고 싶다는
|
|
요구는 `:With(...)`로 의존성을 모으고 `:Compute(fn)`으로 파생 State를
|
|
만드는 것으로 확정 — `Store.Combine({a,b}, fn)`류 포지셔널 인자 방식은
|
|
기각됨. 정확한 lazy 인자 규칙(self/with 값 둘 다 State 핸들로 넘기고
|
|
`:Get()`을 실제로 읽을 때만 계산), v1 `myStore "a,b"` 콤마-조인 문자열
|
|
방식의 폐기, 여러 값을 한 번에 바꿀 때 재계산을 한 번으로 묶는 `Blocker`
|
|
연결은 전부 `base/source-state-plan.md`의 "여러 값을 묶어 파생값 만들기"
|
|
절이 소스.
|