From 2467c68ebb8b28b4b59c9ac02943ef1dc927183f Mon Sep 17 00:00:00 2001 From: qwreey Date: Thu, 6 Aug 2026 19:40:15 +0900 Subject: [PATCH] =?UTF-8?q?Observer=20:Subscribe()/:Unsubscribe()=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=E2=80=94=20=EC=A0=84=EC=97=AD/=EB=8F=85?= =?UTF-8?q?=EB=A6=BD=20=EC=82=AC=EC=9A=A9=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit children 배열에 안 붙는 Observer(디버깅용 Store 직접 print 패턴 등)를 위한 명시적 라이프사이클 경로. PA님 코드 교차검증 때 예고해둔 확장 지점("GC만으로 부족하면 명시적 dispose 경로 추가 가능")을 실제로 채움. liveness는 self.Subscribed 필드 우선 + self.Connection.Connected 폴백, 내부 강참조 레지스트리로 GC 방지(weak table과 역할 분리). 둘 다 idempotent, Unsubscribe는 자동(리프) 케이스 조기 해제에도 재사용. CLAUDE.md 핸드오버 6번 항목 갱신(이벤트 store-bind 부차적 옵션 재조정 포함). Co-Authored-By: Claude Sonnet 5 --- .claude/base/bind-system-plan.md | 47 +++++++++++++++++++++++++++++--- CLAUDE.md | 21 ++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 05f162d..63b9612 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -442,10 +442,49 @@ retract/Destroy되면 자동으로 정리됨. 내부적으로 no-op 콜백을 쓰는 것으로 취급해, 그냥 "이 State를 계속 능동적으로 관측 상태로 유지"하는 용도로만 씀. 위 "`previous` 인자" 절의 캐비엇("능동적 관측 경로가 안 남아있으면 mutate 로직이 조용히 - 멈춘다")을 만족시키는 가장 단순한 도구 — 별道 콜백 로직 없이 그냥 - 이 State가 계속 재계산되게만 강제하고 싶을 때 씀 — 별도 콜백 로직 없이 - 이 용도로만 쓰고 싶을 때. 문서화만 확실히 하면 별문제 없음(사용자 - 판단). + 멈춘다")을 만족시키는 가장 단순한 도구 — 별도 콜백 로직 없이 그냥 + 이 State가 계속 재계산되게만 강제하고 싶을 때 씀. 문서화만 확실히 + 하면 별문제 없음(사용자 판단). + +### `:Subscribe()`/`:Unsubscribe()` — 리프에 안 붙는 "전역/독립" Observer용 (2026-08-06 후속 세션) + +**문제**: children 배열에 넣는 자동 라이프사이클 바인딩은 Observer가 +"어딘가 leaf에 붙어있다"는 걸 전제함. 근데 흔한 실사용 패턴 하나가 이 +전제를 깨뜨림 — 개발자가 디버깅용으로 `RunService:IsStudio()` 가드 +안에서 Store에 직접 Observer를 걸어 `print`하는 패턴(원하면 BooleanValue +로 부분부분 켰다 껐다 하기도 함). 이건 다크패턴이 아니라 오히려 방어적인 +엔지니어링이고, 붙일 leaf 자체가 없는 "전역/독립" 사용이라 위 weak-table +기반 자동 추적이 적용 안 됨. + +**해결**: 명시적 `:Subscribe()`/`:Unsubscribe()`를 추가로 지원. 이건 새 +설계가 아니라 `bind-system-plan.md`의 PA님 코드 교차검증(라이프사이클 +절)에서 이미 예고해둔 확장 지점을 실제로 채우는 것 — "나중에 GC만으로 +정말 부족한 케이스가 생기면 명시적 dispose 경로를 추가로 얹는 게 가능한 +디자인"이라고 그때 이미 못박아뒀음. + +- **`local` 변수로 참조만 들고 있는 것으로는 부족한 이유**: 토글(BooleanValue로 + 로깅 껐다 켰다) 케이스에서, 참조를 끊어도 실제 GC는 결정론적으로 즉시 + 일어나지 않음 — "껐다"고 생각한 뒤에도 한동안 계속 발화할 수 있음. + `:Unsubscribe()`는 즉시/결정론적으로 끊는 경로라 이 문제가 없음. +- **liveness 체크는 필드 우선, weak table은 폴백**(사용자 제안): 외부 + weak table 조회보다 리터럴 필드 접근이 더 쌈(Luau가 문자열 키 접근을 + 미리 해시해둠) — + ```lua + if self.Subscribed then return true end + if self.Connection then return self.Connection.Connected end + ``` + 자동(리프 부착)/수동(구독) 두 라이프사이클 경로를 하나의 `canExecute`류 + predicate로 OR 묶는 자연스러운 형태. 실측은 구현 단계에서 확인. +- **내부 강참조 레지스트리**: `SubscribedObservers: {[observer]: true}`류를 + **weak 아닌 강참조**로 둠 — 여기서 weak면 "구독해서 살려둔다"는 목적 + 자체가 무의미해짐. 위 자동 케이스의 weak table과 역할이 명확히 갈림 + (weak table=자동/리프 전용, 강참조 레지스트리=수동 구독 전용). +- **`:Subscribe()`/`:Unsubscribe()` 둘 다 idempotent** — 이미 구독 중인데 + 또 Subscribe해도, 구독 안 했는데 Unsubscribe해도 에러 안 나고 그냥 + no-op. 토글 로직 짤 때 상태 추적 부담을 줄여줌. +- **`:Unsubscribe()`는 자동(리프) 케이스에도 동일하게 씀** — Instance가 + 파괴되기 전에 수동으로 조기 해제하고 싶을 때도 같은 메소드 하나로 + 충분, 별도 API 안 만듦. ## Unix 파이프에서 영감 받은 스트림 지향 — 원래 의도, 해소됨 diff --git a/CLAUDE.md b/CLAUDE.md index 50ec0cc..11ef18b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -390,3 +390,24 @@ Ref 일반화 — 네 가지 다 확정, 실제 base 문서에 반영 완료.** disconnect 센티널로 씀(`nil`은 테이블에서 사라져서 부적합) — `bind-system-plan.md`. Modifier가 이벤트 키를 담아도 되는지는 (d)로 자동 해소(Modifier가 애초에 키 종류를 구분 안 하므로). + +**6. 이벤트 store-bind는 부차적 옵션으로 재조정, Observer의 `:Subscribe`/ +`:Unsubscribe` 추가 — 둘 다 확정, 반영 완료.** +- 이벤트 store-bind(5번 (f))를 다시 검토 — "구현이 쉽다"가 "구현할 + 가치가 있다"를 보장 안 함을 재확인. 저빈도 UI 이벤트의 조건부 처리는 + "핸들러 하나 계속 연결 + 내부에서 `store.enabled:Get()` 분기"가 이미 + Connect/Disconnect 없이 더 싸고 표준적이라 **이걸 기본 권장 패턴으로 + 확정**. store-bind는 고빈도 신호(Heartbeat 등)나 로직 자체가 바뀌는 + 드문 케이스를 위한 부차적 옵션으로 격하(메커니즘 자체는 유지 — 일관성 + 위해 예외로 뺄 근거는 약함). 자주 재계산되는 State에 물리면 Connect/ + Disconnect churn이 숨은 비용이 된다는 캐비엇도 추가. +- **Observer의 `:Subscribe()`/`:Unsubscribe()`** — children 배열에 안 + 붙는 "전역/독립" Observer(디버깅용으로 Store에 직접 걸어 print하는 + 흔한 패턴, `RunService:IsStudio()` 가드 + BooleanValue 토글)를 위한 + 명시적 라이프사이클 경로. 이건 새 설계가 아니라 PA님 코드 교차검증 + 때 이미 예고해둔 확장 지점("GC만으로 부족하면 명시적 dispose 경로 + 추가 가능")을 실제로 채운 것. liveness 체크는 `self.Subscribed` 필드 + 우선, `self.Connection.Connected` 폴백(필드 접근이 weak table 조회보다 + 쌈). 내부 레지스트리는 자동 케이스의 weak table과 별개로 강참조 + (weak면 "살려둔다"는 목적이 무의미해짐). 둘 다 idempotent, `:Unsubscribe()`는 + 자동 케이스 조기 해제에도 재사용.