quad/.claude/session/2026-08-24-02-milestone-order-swap.md
qwreey 56f8269236
design: M2/M3 마일스톤 순서 교체 — 반응형 코어를 먼저, 디스패치를 그 위에
`question.md`에 열려 있던 마지막 항목(마일스톤 경계)을 사용자가
**(a) 순서 교체**로 닫았다 — 이제 **M2 = 반응형 코어(Source/State/Store),
M3 = 디스패치 엔진**이다. 결정과 기각된 선택지는
`archive/question-resolved.md`의 "마일스톤 경계" 절, 경위와 시행착오는
`session/2026-08-24-02-milestone-order-swap.md`.

## 왜 (a)인가

의존이 양방향처럼 보였지만 실제로는 한 방향이었다 — 디스패치→반응형은
*본체* 의존(`setLength`가 `State<number>`를, `setOffsetSource`가
`Source<number>`를 받고 `recompute`가 `offset:Set()`을 부름)이라 우회
불가고, 반대는 *등록 표면* 의존(핸들러 셋)이라 미루면 그만이다. 옛
순서로는 디스패치 마일스톤의 `mock 대상 테스트` 체크박스조차 State 없이
불가능했고, 반응형 코어는 순수 Lua라 `luau`만으로 단독 테스트가 된다.
(b) 분할은 기존 `M2` 참조 전부를 "M2a인가 M2b인가"로 만들어 오히려 비싸고,
(c) 유지는 "순서의 소스는 `ROADMAP.md`"라는 원칙을 스스로 무효화한다.

## 그냥 맞바꾸기로는 안 끝났다

- **`Brand`/`Relate`/`LifetimeHandle`(인터페이스)이 M2 앞머리
  `### 공통 기반` 절로** — 반응형이 이 셋을 먼저 요구한다(`Source`가
  `SourceBrand`+`EpochBrand`에 등록되고, State 전파 루프가 매 발화마다
  `canExecute`를 부르며, 그 판정이 `Relate` 위에 얹힌다). 셋 다 State-free
  이자 dispatch-free라 어느 쪽에도 안 걸린다.
- **2026-08-22에 디스패치로 앞당겼던 `EpochMap`/`GateNode`/`Blocker`가
  M2로 복귀** — 앞당길 이유 자체가 순서 교체로 사라졌다. "게이팅 먼저"는
  그대로 지켜진다(게이팅이 디스패치보다 먼저 지어진다).
- **Observer/Effect 동적 경로 가드 등록과 `ObserverEffectLeafHandler`는
  M3로** — 둘 다 "핸들러를 등록한다"뿐이라 본체와 잘라내기 쉽다. 이것이
  M2가 M3에 개념상 지던 유일한 의존이고, 미뤘으므로 빌드 순서엔 역방향
  간선이 없다.
- **`EpochMap`이 State 본체보다 앞으로**(감사 5라운드 발견) — State가
  `valueEpochMap`/`emitEpochMap` 둘을 컴포지션하므로 옛 배치로는
  `State.luau`를 못 짠다. 문서 자신이 *"아래 `Source.luau`/`State.luau`가
  이걸 전제로"*라 쓰면서 그 둘이 위에 있는 형태로 증거를 남기고 있었다.

## 대가 하나 — 게이트 둘이 "바로 다음"으로 올라왔다

`question.md` 낮은 우선순위에 있던 **중간 State GC 미검증**과
**`store:GetDynamic` 위치**가 원래 반응형(옛 M3)의 게이트였는데, 반응형이
M2가 되면서 착수 직전 항목이 됐다 — 최우선 절로 승격했다. 순수 *설계*
결정 대기는 여전히 0건이다(하나는 실측 미완, 하나는 표면 위치 선택).

## 번호 재부여의 경계

라이브 문서의 `M2`/`M3` 참조 248건은 전량 새 번호로 맞췄다(코퍼스의 참조가
거의 전부 "그 내용이 사는 마일스톤"을 가리켜 기계적 맞교환으로 의미가
보존됨). **`session/`·`archive/`·`qa-request/`는 히스토리라 소급 수정하지
않았다** — 그 문서들의 `M2`/`M3`는 옛 의미(M2=디스패치, M3=반응형)이고,
이 경고를 인덱스 다섯 곳에 박아뒀다.

일괄 치환에 `\bM([23])\b`를 썼다가 Python `\w`가 유니코드라 한글이 붙은
93건(`M2는`/`M2로`)이 안 바뀌는 실수를 냈고, `git show HEAD:<경로>`로
되돌린 뒤 ASCII 경계 lookaround로 재실행했다. 그 부수로 `Relate`/
`LifetimeHandle` 참조들은 구 M2 → 신 M2로 두 번 옮겨져 **번호가 우연히
보존**됐다는 것도 드러났다(치환하면 안 되는 자리 — 감사가 잡아 되돌렸다).

## 검증

`quad-doc-auditor` 루프가 라운드마다 각도를 바꿔 **7라운드에서 새 발견
0건으로 수렴**(10→8→1→1→2→2→0). 가장 값이 큰 건 5라운드(구현 순서
시뮬레이션)로, 위 `EpochMap` 배치 오류를 잡았고 **순서 교체의 전제 자체도
검증**했다(새 M2 전체를 디스패치 심볼로 훑어 "가드 등록 둘 말고는 없음").

**수렴 뒤 사용자가 돌린 `/code-review high`가 5건을 더 잡았고 전부 유효** —
넷이 *"라벨은 치환됐는데 그 라벨을 설명하던 산문이 안 고쳐진"* 종류였고,
그중 하나는 `research/` 안의 **히스토리 블록**이 치환을 맞아 원래 논거가
문장 그대로 거짓이 된 것이었다(소급 수정 제외 대상을 세 폴더로만 잡은 게
샜다). `conventions.md`의 *"`/code-review`는 감사자를 대체하지 않는다"*가
또 재확인됐다. `doc-check.py` ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01Jjrec9xAS7TZstMx5gi3cm
2026-08-25 00:29:15 +09:00

170 lines
11 KiB
Markdown

# 2026-08-24-02 — M2/M3 마일스톤 순서 교체 (반응형 먼저)
**요청**: *"질문절에 마일스톤 순서 이슈는 (a) M2와 M3의 순서를 바꾼다 를
택하는게 맞겠던데 어떻게 봐?"* → 검토 후 동의, *"진행해줘"*로 전량 반영.
## 1. 발단
2026-08-22 `ROADMAP.md` 전반 점검이 남긴 마지막 열린 항목. 옛 M2(디스패치
엔진)와 옛 M3(Store/State/Source)의 의존이 양방향이라 그 순서로는 M2를
끝까지 짤 수 없었다. 설계 결정이 아니라 **순서** 문제라 `question.md`
최우선 절이 아니라 2번 항목으로 따로 뒀었다.
## 2. 검토 — (a)에 동의한 근거
사용자가 이미 (a)로 기울어 있었고, 확인 결과 방향은 맞았다. 근거 셋:
1. **의존의 비대칭이 명확하다.** 디스패치 → 반응형은 *본체* 의존이라
우회 불가(`setLength`가 `State<number>`를, `setOffsetSource`
`Source<number>`를 받고 `recompute``offset:Set()`을 부름). 반대는
*등록 표면* 의존(핸들러 3개 등록)이라 뒤로 미루면 그만이다.
2. **옛 순서면 디스패치 마일스톤의 `mock 대상 테스트` 체크박스가 원리적으로
불가능했다** — `setLength`/`recompute`를 State 없이 테스트할 수 없다.
반대로 반응형 코어는 순수 Lua라 `luau`만으로 단독 테스트가 된다.
3. **2026-08-22의 `EpochMap`/`GateNode`/`Blocker` 앞당김이 불필요해진다.**
그 이동은 "디스패치가 먼저인데 디스패치가 쟤들을 호출한다"는 이유였다.
순서를 바꾸면 마일스톤 내용이 의존 계층과 그대로 일치하고 끌어온 항목이
0개가 된다. "게이팅 먼저"도 그대로 지켜진다(게이팅이 디스패치보다 먼저).
**(b) 분할을 안 고른 이유**: 참조 비용이 오히려 크다 — 기존 `M2` 참조
전부가 "M2a인가 M2b인가"로 애매해지는데 순서 교체처럼 기계적으로 못 푼다.
`Brand`를 앞으로 빼는 일은 (b)에서도 똑같이 해야 하고, 얻는 건 "디스패치
코어를 조금 일찍 짠다"뿐인데 M4 전까지 그걸 소비하는 게 없다.
**(c) 유지를 안 고른 이유**: `project-context.md`가 못박은 "순서의 소스는
`ROADMAP.md`"를 스스로 무효화한다.
## 3. 그냥 맞바꾸기로는 안 끝났다 — 공통 기반 셋
검토 중 드러난 것. 옛 M2 안에 **반응형보다 먼저 와야 하는** 항목이 셋
섞여 있었고, State-free이자 dispatch-free라 어느 쪽에도 안 걸린다:
- `Brand.luau``Source``SourceBrand`+`EpochBrand`에 등록돼야 함.
- `Relate.luau``bindLifetime`/`unbindLifetime`이 그 위에 구현됨.
- `LifetimeHandle.luau` 인터페이스 — State 전파 루프가 매 발화마다
`canExecute`를, 이중 바인딩 게이트가 `canBound`를 부름.
새 M2 앞머리에 `### 공통 기반` 절을 만들어 셋을 옮겼다. 반대로 새 M3로
넘어간 것은 둘 — Observer/Effect **동적 경로 가드 등록**과
**`ObserverEffectLeafHandler`**(`H-39`). 결과적으로 역방향 간선이 없다.
## 4. 실행 중 낸 실수 하나 — 한글 인접 `M2`가 안 바뀌었다
첫 일괄 치환에 `re.sub(r'\bM([23])\b', ...)`를 썼는데, **Python `re`
`\w`는 유니코드라 한글도 단어 문자**다. 그래서 `M2는`/`M2로`/`M2가`처럼
한글이 바로 뒤에 오는 경우 `\b`가 성립하지 않아 **155건만 바뀌고 93건이
그대로 남았다** — 코퍼스가 옛 번호와 새 번호가 섞인 상태가 됐다.
`git checkout -- .`은 하네스가 막아서(destructive) `git show HEAD:<경로>`
26개 파일을 되돌린 뒤, ASCII 경계만 보는
`(?<![0-9A-Za-z_])M([23])(?![0-9A-Za-z_])`로 다시 돌려 **248건 전량**을
바꿨다. **교훈: 한국어 문서에서 `\b`로 영숫자 토큰 경계를 잡지 말 것.**
## 5. 부작용 — 게이트 둘이 "바로 다음"으로 올라왔다
순서 교체의 실질적 대가. `question.md`의 낮은 우선순위 절에 있던 둘이
반응형(옛 M3)의 게이트였는데, 반응형이 M2가 되면서 **착수 직전 항목**이
됐다 — 그래서 최우선 절로 승격했다:
- **중간 State GC 미검증** — 상류 strong / 하류 weak 불변식 명문화 여부 +
`luau-test` 실측(`base/source-state-plan.md`).
- **`store:GetDynamic` 위치** — 콜론 메소드 vs 탑레벨 함수
(`base/store-plan.md`).
순수한 *설계* 결정 대기는 여전히 0건이다(하나는 실측 미완, 하나는 표면
위치 선택).
## 6. 번호 재부여의 사각지대
라이브 문서의 `M2`/`M3` 참조는 전부 새 번호로 맞췄다 — 코퍼스의 참조가
거의 전부 "그 내용이 사는 마일스톤"을 가리켜서 기계적 맞교환으로 의미가
보존된다. 반면 **`session/`·`archive/`·`qa-request/`는 히스토리 문서라
소급 수정하지 않았다.** 2026-08-24 이전에 쓰인 그 문서들의 `M2`/`M3`는
옛 의미(M2=디스패치, M3=반응형)다 — 이 경고를 `ROADMAP.md` M2 배너,
루트 `CLAUDE.md`, `project-context.md`, `todos.md`, `archive/
question-resolved.md`에 같이 박아뒀다.
## 7. 반영 범위
실제로 diff에 남은 파일은 **25개 + 이 세션 로그**다(정확한 목록은
`git show --stat`이 소스 — 여기서 세지 않는다). 갈래만 적으면:
- `ROADMAP.md` — 두 절 재편 + 최상단 배너 + 흩어진 마일스톤 참조. diff의
대부분이 여기다.
- `question.md` — 마일스톤 경계 항목 삭제·아카이브 이관, 3번→2번 재번호,
최우선 절 재작성(두 항목 승격), 번호 재사용 경고.
- `archive/question-resolved.md` — 해소 기록 신설.
- `base/gate-plan.md`/`blocker-plan.md`/`debounce-throttle-plan.md`/
`state-epoch-plan.md`/`source-state-plan.md` — 2026-08-22 앞당김 서술을
되돌림 서술로. 뒤의 둘엔 "동적 경로 가드 등록은 M3" 캐비엇도 신설.
- `base/effect-plan.md` — 같은 캐비엇.
- `base/quad-types-plan.md` — "마일스톤마다 갱신" 규칙의 첫 적용 지점이
M2가 된 것.
- `base/store-plan.md`/`attribute-plan.md`/`slot-plan.md`/
`dispatch-core-plan.md`/`project-setup-plan.md`,
`research/operator-sugar-plan.md`/`debug-tooling-plan.md`,
`luau-test/STATUS.md` — 번호 참조 갱신.
- `research/pre-implementation-audit.md` — 번호 갱신 + 히스토리 블록 정정
(8절 참고).
- 인덱스 레이어: `.claude/README.md`/`todos.md`/`project-context.md`/
루트 `CLAUDE.md`/`HUMAN_TODO.md`.
- `session-summary.md` + 이 파일.
**⚠️ diff에 *안* 남은 파일이 있다는 게 오히려 중요하다.**
`base/relate-plan.md`·`base/lifecycle-pattern.md`·`luau-test/README.md`·
`luau-test/`의 스파이크 둘·`reference/slot-attach-decomposition.md`는
일괄 치환으로 한 번 바뀌었다가 감사 1라운드 수정으로 **HEAD와 글자 단위로
같아졌다**. 그 참조들이 가리키는 것(`Relate`/`LifetimeHandle`)이 구 M2에서
신 M2로 **두 번 옮겨져 번호가 우연히 보존**됐기 때문이다 — 즉 "치환하면
안 되는 자리"였고, 감사가 그걸 잡아 되돌린 결과 diff가 비었다.
## 8. 검증 — 감사 7라운드 수렴 후 `/code-review high`가 5건 더
`quad-doc-auditor` 루프는 라운드마다 각도를 바꿔 **7라운드에서 새 발견
0건으로 수렴**했다(라운드별 10→8→1→1→2→2→0, 총 24건 수정). 각도는
순서대로 — (1) 마일스톤 번호 정합성, (2) 인덱스 레이어와 이번에 새로 쓴
서술의 자기모순, (3) `base/` 본문의 선행관계 서술, (4) 완결성(열거),
(5) 구현 순서 시뮬레이션, (6) 관례 준수와 2차 stale, (7) 조사가 가장 적게
닿은 파일.
**가장 값이 컸던 건 5라운드(구현 순서 시뮬레이션)**다. 문서 정합성이
아니라 *"이 로드맵대로 실제로 코드를 짤 수 있는가"*를 물었더니, 새 M2에서
`Source`/`State`/`Store`가 `EpochMap.luau`보다 **앞에** 놓여 있는 게
드러났다 — State가 `valueEpochMap`/`emitEpochMap` 둘을 컴포지션하므로 그
순서로는 `State.luau`를 못 짠다. 같은 라운드가 **순서 교체의 전제 자체도
검증**했다(새 M2 전체를 디스패치 심볼로 훑어 "가드 등록 둘 말고는 없음"
확인).
**그리고 수렴 뒤 사용자가 돌린 `/code-review high`가 5건을 더 잡았고 전부
유효했다** — `conventions.md`의 *"`/code-review`는 감사자를 대체하지
않는다"*가 또 한 번 실측으로 재확인된 셈이다. 다섯 중 **넷이 "라벨은
치환됐는데 그 라벨을 설명하던 산문이 안 고쳐진" 종류**였고, 그중 둘
(아래 두 번째·세 번째)은 같은 뿌리 — `quad-types`의 "마일스톤마다 갱신"
규칙이 M3에 앵커된 채 첫 적용 지점만 M2로 옮겨간 것이다:
- **(HIGH)** M3의 `setLength` 항목이 여전히 *"필요한 `GateNode`/`Blocker`/
`EpochMap`은 이제 **이 마일스톤에** 체크박스로 있다(위 세 항목)"*라고
말하고 있었다 — 셋은 M2로 되돌아갔고, "위 세 항목"이 가리킬 대상이 M3에
없으며, 15줄 위의 M3 배너가 정반대를 말한다. 핸드오버 체크리스트 2번
("배너를 달았으면 그 배너가 부정하는 문장을 같은 커밋에서 고쳤는지")의
전형적 실패.
- **(MEDIUM)** `quad-types`의 "마일스톤마다 갱신" 규칙이 M3에 앵커돼 있는데
**첫 적용 지점은 M2**가 됐다 — M2 구현자가 아직 안 나온 마일스톤을 앞으로
참조해야 규칙을 알게 되는 구조. 규칙 요지를 M2 항목에도 적었다
(`base/quad-types-plan.md`와 `ROADMAP.md` M2/M3 항목).
- **(MEDIUM)** 같은 뿌리의 다른 자리 — `.claude/README.md``quad-types-plan.md`
색인 행이 *"**M3(`Dispatch`)를 시작으로** M2/M6/M7/M8/M10에 체크박스가
뿌려졌다"*로 치환돼 **나열 순서가 뒤집혔다**. M2가 먼저 지어지므로
"M3를 시작으로 … M2"는 성립하지 않는다 — 서브시스템을 붙이는 모든
마일스톤을 순서대로 적고 "계기는 `Dispatch`, 첫 적용은 M2"로 갈라 썼다.
- **(MEDIUM)** `research/pre-implementation-audit.md`의 **히스토리 블록**이
기계 치환을 맞았다 — *"M1·M3(디스패치) 투자가 먼저 이뤄진 뒤에야
검증되는 셈"*이라는 원래 우려가 새 번호에선 **문장 그대로 거짓**이 됐다
(Store/State가 이제 디스패치보다 먼저다). `session/`·`archive/`·
`qa-request/`는 소급 수정 대상에서 뺐는데 **`research/` 안의 명시적
히스토리 블록은 그 방침에서 새어 있었다.**
- **(LOW)** M2로 옮겨온 `Brand.luau` 항목이 `isNone`을 자기 책임처럼 적고
있었는데, `brand-plan.md`는 2026-08-18에 *"`Brand → None` 의존을 만들지
않는다"*로 확정해뒀다 — 마일스톤이 갈리면서 처음 눈에 띈 잔재.
**교훈**: 기계 치환은 *토큰*을 바꾸지 *주장*을 바꾸지 않는다. 치환 대상이
많을수록 "그 토큰을 설명하던 산문"과 "히스토리 블록"을 따로 훑어야 한다.