quad/.claude/base/onchange-plan.md
qwreey 40a5daf694
tooling: 절 인용 규약 신설 + doc-check 절 참조를 ERROR 게이트로 승격
사용자 제기 — 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
2026-08-16 10:47:29 +09:00

6.3 KiB

OnChange 특수 키 — GetPropertyChangedSignal 바인딩

상태: base — 2026-08-10 세션에서 확정. quad-roblox 전용(값 타입/API 레이어 없음, Attribute와 같은 패키지 배치). [2026-08-11 아홉 번째 세션 후속] AttributeKey와 동일한 이름별 weak 캐시로 OnChange(a) == OnChange(a) 동등성도 확정 — 아래 "확정" 절 참고.

문제

이벤트 바인딩은 이미 평범한 문자열 키 + reflection(GetEventsOfClass)으로 확정돼 있음(bind-system-plan.md의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절) — inst[key]가 이미 RBXScriptSignal이라 그냥 Connect하면 됨. GetPropertyChangedSignal(name)은 이 패턴이 그대로 안 통함: 프로퍼티 이름을 인자로 받아 별도 메소드 호출로 시그널을 얻어야 하고, 그 프로퍼티 이름은 이미 "값 세팅" 키 네임스페이스(Frame.Position = x)와 겹침 — 값 타입만으론 "세팅"과 "변경 리스닝"을 구분할 방법이 없어서 별도 마커가 필요함.

확정

  • OnChange(propertyName): OnChangeKey — 프로퍼티 이름을 감싸는 DI 키 팩토리, AttributeKey(name)/Tag(...)와 같은 패턴(AttributeKey는 구 Attribute — 2026-08-11 아홉 번째 세션에 여러 Store를 묶는 그룹 Attribute(...) 프리미티브가 신설되며 이름 충돌 방지로 리네임됨, base/attribute-plan.md 참고). 사용 예: Frame { [OnChange "Position"] = function(v: UDim2) ... end }.
  • 제네릭 타입 파라미터 없음 — OnChange<<T>> 같은 타입 파라미터화는 안 함. 콜백 파라미터 타입은 호출부가 인라인으로 직접 명시 (function(v: UDim2) ... end) — Luau가 그 타입이 실제 프로퍼티 타입과 일치하는지 검증해주지 않음. 이미 확정된 "이벤트 바인딩은 콜백 시그니처를 Luau가 검증 못 하는 대가를 받아들인다"는 결정(base/bind-system-plan.md "이벤트 바인딩 — On.EventName 도트액세스 안 씀" 절, "타입 안전성을 어느 정도 포기하는 대가")과 같은 급의 트레이드오프라 새로 정당화할 것 없음 — 오히려 AttributeKey<<T>>처럼 제네릭으로 정확히 맞추려는 시도는 이벤트 키보다 더 엄격한 걸 요구하는 셈이라 일관성이 깨짐.
  • 기각안 — 프로퍼티별 정적 OnChange.PropertyName 전량 코드 생성: archive/onchange-per-property-codegen-rejected.md 참고. Attribute의 "제네릭 + 자주 쓰는 것만 정적 지름길" 절충과 겉보기엔 비슷해 보이지만 규모가 다른 문제라 기각.
  • 패키지 경계: 전부 quad-robloxHandlers/OnChange.luauOnChange(name) 키 팩토리와 Handler를 같이 둠. [정정, 2026-08-13 열네 번째 세션] 예전엔 "단일 키 AttributeKey.luau와 같은 배치"라고 적었으나 그 AttributeKey는 같은 세션에 quad-base로 옮겨갔음(부기가 엔진 지식을 요구하지 않아서, base/attribute-plan.md "패키지 배치" 절) — OnChange가 quad-roblox에 남는 이유는 그것과 달리 GetPropertyChangedSignal 자체가 로직이라 "한 줄 op 주입"으로 줄어들지 않기 때문 (base/dispatch-core-plan.md "base가 소유하는 핸들러와 주입되는 엔진 op" 절의 분할 기준). GetPropertyChangedSignal 자체가 Roblox 엔진 API라 base에 둘 이유가 없음 — Tag처럼 백엔드 무관한 값/API 레이어가 따로 있는 경우와 다름.
  • process(inst,k,v,index): inst:GetPropertyChangedSignal(name):Connect( function() v(inst[name]) end)그 Connection을 :Disconnect()하는 클로저를 반환. 일반 Handlers/Event.luau와 같은 결(Connection 관리뿐, 새 메커니즘 없음). [정정, 2026-08-13 다섯 번째 세션] 원래는 별도 retract(inst,k,v) 필드가 Disconnect를 담당한다고 적혀 있었으나, Handler 계약이 process 1-메소드로 합쳐지며 그 로직이 반환 클로저로 이동 — connectionprocess의 로컬 변수를 클로저가 upvalue로 그대로 캡처하므로 별도 Relate 저장/재조회가 필요 없음(dispatch-core-plan.md "핸들러 내부 상태 저장" 절).
  • State<function> 지원 — 새 메커니즘 없음. 이미 확정된 "이벤트도 store-bind 가능 — false로 disconnect" 메커니즘(bind-system-plan.md)이 OnChange 키에도 그대로 적용됨 — OnChangeHandlerprocess(와 그 반환 클로저)만 구현하면 되고, v가 State/Source면 범용 Dispatch/StoreBind.luau가 알아서 언랩+재귀 재-dispatch해서 process를 다시 호출해줌. OnChange 전용 분기 불필요.
  • OnChange(name)AttributeKey와 같은 이름별 weak 캐시 적용 (2026-08-11 아홉 번째 세션 후속)AttributeKey<<T>>(name)이 이름만으로 캐시되는 것과 정확히 같은 모양(이름 → 키, 다른 가변 정보 없음)이라 같은 기법 그대로 재사용(base/attribute-plan.md "동등성" 절). State<function>이 되더라도 문제 없이 작동하고(캐시는 키 객체 자체의 identity만 다루지, 그 키에 바인딩된 값/콜백과는 무관), OnChange "a" == OnChange "a"가 외부에서 관찰 가능해지는 것도 의도적으로 허용해도 되는 동작 — 문제 없음(사용자 확인). Handlers/OnChange.luauOnChange(name) 팩토리에 AttributeKey와 동일한 캐시 구현.

다른 특수 DI 키와의 대조

소스 값 타입 패키지 경계
이벤트(MouseButton1Click = fn) inst[key]가 이미 Signal 콜백, 타입 미검증 quad-roblox(Handlers/Event.luau)
AttributeKey(name) 주입된 setAttribute op 값(제네릭 또는 정적 타입 패밀리로 타입 파라미터화) quad-base(키+Handler, 2026-08-13 열네 번째 세션 재배치) / 엔진 op만 백엔드
OnChange(name) GetPropertyChangedSignal(name) 콜백, 타입 미검증(제네릭 없음) quad-roblox(Handlers/OnChange.luau)

OnChange가 Attribute처럼 제네릭화되지 않은 이유는 "콜백을 받는다"는 성질이 Attribute(값을 직접 받음)보다 이벤트에 더 가깝기 때문 — 카테고리가 헷갈리지 않도록 표로 명확히 구분해둠.