quad/.claude/research/operator-sugar-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

26 KiB

Operator 콤비네이터 슈가 — Sum/Product/Not/비트연산 등

상태: research — 사용자 요청(2026-08-12 세션)으로 신설, 같은 세션 후속 논의에서 :Apply 경유로 확정(아래 "왜 :Apply인가" 절). 구현은 맨 마지막으로 미룸(사용자 본인이 명시): 순수 슈가라 없어도 quad 기능상 문제없고, 각 함수가 서로 의존이 없어 나중에 통째로 추가하거나 개별 함수를 지우고 고쳐도 안전 — 우선순위가 낮은 이유. 지금 이 문서를 쓰는 목적은 구현 착수가 아니라 설계/네이밍 논의를 미리 남겨두는 것뿐.

[2026-08-13 열세 번째 세션, 해소] 아래 모든 Operator.* 예시가 의존하는 h:Get()(self-lazy-핸들 계약)은 그대로 유지로 확정됨 (구 question.md 0-Y) — 예시를 고칠 필요 없음. 나중에 착수할 때는 base/typing-limits.md(특히 7번 "새 타입/API를 설계할 때 체크리스트")를 먼저 볼 것 — Operator.*가 반환하는 파생 State도 사용처에서 명시 주석 바인딩이 필요한 대상임.

동기

:Compute/:Applyfn(self, ...) 람다를 요구하는데, not self:Get()처럼 정말 단순한 연산에도 매번 function(self) return not self:Get() end를 쓰는 건 번거롭고, 같은 패턴이 코드 여기저기 반복되면 가독성도 떨어짐. 기본 연산(산술/논리/비트)을 미리 만들어둔 콤비네이터로 표현하면 보기도 간결해지고 유지보수도 쉬워짐 — 예:

-- 지금(람다 매번 작성)
reduceMotion:Compute(function(self) return not self:Get() end)

-- 슈가로
reduceMotion:Apply(Operator.Not)  -- 네임스페이스 이름은 미정, 아래 참고

메커니즘 — 새 프리미티브 아님, :Apply(factory) 위의 순수 함수

모든 Operator.*는 항상 factory(self) -> State<U> 모양이고 항상 :Apply로 붙인다 — 인자 없는 것(Not)과 커링되는 것(Sum(a,b,c))을 구분하지 않고 하나의 규칙으로 통일(아래 "왜 :Apply인가" 절 근거). 내부적으로는 self:Compute(...)를 호출해 실제 반응형 노드를 만드는 것뿐 — 새 State/Handler 카테고리 불필요.

-- 0항(자기 자신만 변환) — 그 자체로 이미 factory(self) 모양
Operator.Not = function(self)
    return self:Compute(function(h)
        return not h:Get()
    end)
end

-- 사용
reduceMotion:Apply(Operator.Not)
-- N항(self + 다른 state들을 결합) — 커링: Sum(a,b,...)가 factory를 반환
function Operator.Sum(...: State<number>)
    local deps = {...}
    return function(self: State<number>)
        return self:Compute(function(selfH, previous, ...)
            local total = selfH:Get()
            for _, h in {...} do
                total += h:Get()
            end
            return total
        end, table.unpack(deps))
    end
end

-- 사용 — 한 번 만들어 이름 붙여 재사용 가능
local addTaxAndShipping = Operator.Sum(tax, shipping)
price:Apply(addTaxAndShipping)

Product/And/Or/Xor/Band/Bor/Bxor/Bnot/Shl/Shr 등도 전부 같은 두 형태(0항은 그 자체가 factory(self), N항은 커링해서 factory(self)를 반환) — 어느 쪽이든 항상 :Apply로 붙임. 비트 연산은 Luau에 연산자가 없어 bit32 라이브러리 위에 얇게 얹는 형태가 됨.

:Apply인가 — 스타일이 아니라 정합성 문제 (2026-08-12 세션, 후속 논의)

처음엔 "0항은 :Compute에 바로, N항만 :Apply"로 나눠 썼으나 사용자가 일관성 문제로 재검토를 요청, 논의 중 실제 정합성 문제까지 발견되어 :Apply 통일로 확정.

  1. 재사용 가능한 커링 팩토리는 :Compute로는 안전하게 못 만든다 — 진짜 버그 가능성. quad는 Vide식 암묵적 자동 추적을 이미 기각했음 (base/source-state-plan.md의 "여러 값을 묶어 파생값 만들기" 절) — 의존성은 오직 :With/:Compute그 호출문 자체에 나열된 trailing args로만 등록됨. 그래서 local addTax = Sum(tax, shipping)처럼 한 번 만들어 재사용하고 싶은 값을 price:Compute(addTax)처럼 바로 꽂으면, addTax 내부에서 클로저로 캡처한 tax/shipping을 아무리 :Get() 해도 :Compute의 구독 목록엔 안 걸림tax/shipping이 바뀌어도 조용히 재계산이 안 일어나는 버그가 됨. 이걸 피하려면 price:Compute(addTax, tax, shipping)처럼 이미 Sum(...)에 넘긴 deps를 호출부에서 또 나열해야 하는데, 이게 바로 2026-08-11 세션에서 "trailing deps를 fn 위치 인자로 노출"하게 만든 그 중복/드리프트 위험(base/source-state-plan.md 해당 절)과 완전히 같은 클래스의 문제 — 재사용 가능한 이름을 만드는 의미 자체가 없어짐. :Apply는 이 문제가 원천적으로 없음: factory가 내부에서 self:Compute(fn, tax, shipping)을 직접 호출해 자기가 캡처한 deps를 스스로 다시 넘기므로(호출자가 재입력하는 게 아니라 factory 자신이 한 번 캡처한 값을 그대로 전달), 중복 없이 안전하게 재사용됨 — price:Apply(addTax), otherPrice:Apply(addTax) 둘 다 안전.
  2. 기존 문서 관용구와 일치. base/source-state-plan.md:Apply 절이 이미 state:Apply(makeFormatter("ko-KR"))를 "커링 팩토리 + :Apply"의 정석 예시로 들어둠 — Operator.*/Animate가 이 관용구를 따르는 게 자연스러움. Animate:Compute를 골랐던 건 오히려 이 기존 관용구에서 벗어난 예외였다는 게 이번 논의에서 드러남(base/ tween-plan.md "왜 :Apply로 정정됐는가" 절 참고).
  3. 일관성 — 0항/N항을 나누지 않음. Not은 deps가 없어서 위 1번 문제와 무관하지만, "이 라이브러리의 콤비네이터는 항상 :Apply로 붙인다"는 단일 규칙을 지키는 게 "0항만 예외적으로 :Compute에 바로 꽂는다"는 케이스 분기를 사용자가 매번 기억해야 하는 것보다 낫다는 게 사용자 판단. 비용은 Not이 내부적으로 self:Compute(...) 한 겹을 더 감싸는 것뿐 — 무시할 만한 오버헤드.
  4. 의미론도 더 맞음. "값에 연산자를 적용한다"는 게 "값으로부터 완전히 새로운 파생값을 계산한다"보다 더 정확한 표현 — :Compute가 v1/Fusion류 "매 스텝 능동적으로 값을 갱신"하는 것처럼 읽힐 수 있다는 우려도 사용자가 제기(오해일 뿐 실제 동작은 pull-recompute지만, 읽는 사람에게 주는 인상까지 고려).

:Compute의 역할 재확인: 이걸로 :Compute가 필요 없어지는 게 아니라, 역할이 명확해짐 — :Compute(fn, ...deps)그 자리에서 한 번 쓰고 마는 인라인 람다(deps도 그 호출문에 바로 나열) 전용 저수준 프리미티브로 남고, 이름 붙여 재사용하는 콤비네이터(라이브러리가 제공하는 것이든 사용자가 직접 만드는 것이든)는 전부 factory(self) 모양

  • :Apply로 통일. Operator.*/Animate의 내부 구현은 여전히 :Compute를 쓴다 — 사용자에게 노출되는 표면만 :Apply.

미래 고려사항 (보류) — 중첩 결합 Sum(a, b, Sum(c, d)) flatten 최적화

사용자 제기(2026-08-12 세션), 지금은 착수 안 함 — 실사용 사례가 나오면 재검토. Sum(a, b, Sum(c, d))처럼 콤비네이터를 중첩하는 것 자체는 지금 설계로도 이미 가능Sum(c, d)를 먼저 실제 self에 적용해 구체적인 State<number>로 만든 뒤(c:Apply(Operator.Sum(d))), 그 결과를 바깥 Sum의 평범한 operand로 넘기면 됨. 다만 이러면 안쪽 Sum(c,d)가 독립된 State 노드를 하나 더 만들어서(중첩 :Compute), 바깥 Suma+b+c+d를 한 번에 계산하는 것보다 그래프 레이어가 한 겹 더 생김.

사용자가 제안한 최적화 방향: Sum(...)이 리턴하는 클로저가 자기가 캡처한 operand 목록(local keep = {c, d})을 약한 릴레이션(Relate, base/relate-plan.md)으로 그 클로저 자신에 붙여두면, 나중에 다른 Sum 호출이 자신의 operand 중 하나가 "이미 Operator 콤비네이터가 만든 클로저"임을 감지해서 그 안에 보관된 operand들을 꺼내 자기 자신의 operand 목록에 합쳐 넣을 수 있음(Sum(a, b, Sum(c, d)) → 실질적으로 Sum(a, b, c, d)와 동일한 단일 :Compute 노드로 flatten) — 클로저가 GC되면 약한 릴레이션도 같이 사라지므로 메모리 누수 없음.

보류 이유: 순수 최적화(그래프 노드 한 겹 줄이기)일 뿐 기능 격차가 아님 — 지금도 위 방법으로 중첩 자체는 문제없이 됨. Sum류 생성 팩토리에 introspection 로직을 추가하는 거라 라이브러리 복잡도가 늘어남 — 사용자 본인이 "실제 사용사례를 보고 필요한지 나중에 검토"로 명시적으로 유보. 나중에 착수하게 되면 Sum/Product 등 각 생성 팩토리를 개별적으로 살짝 고치면 되는 수준이라, 지금 다른 설계에 영향 주지 않음.

패키지 배치

quad-base — Store/State 계층 위에서만 동작하는 순수 함수라 엔진 종속 없음(quad-roblox 아님). AnimateTween과 함께 어디에 배치됐는지와 같은 결로 맞추면 됨(base/tween-plan.md 참고, 단 Animate 자체는 Tween이 quad-roblox 개념(PropertyHandler)에 연결되므로 quad-roblox 배치 — Operator 슈가는 그런 엔진 종속이 없다는 점이 다름).

열린 질문 — 네임스페이스 이름 (미정)

Not/Sum/And/Or 같은 이름은 흔한 단어라 top-level에 그냥 두면 충돌 위험이 큼 — Tag/Attribute처럼 네임스페이스로 묶여야 함 (Operator.Not처럼). 문제는 짧으면서 "이 연산자 콤비네이터 슈가 모음"이라는 목적을 잘 담는 이름을 아직 못 찾음 — 사용자가 직접 이 문제를 제기(2026-08-12 세션). 코퍼스 전체에 Operator/Op 이름 충돌은 없음을 확인함(grep 결과 없음), 아래는 후보:

  • Operator — 의미는 제일 정확(산술/논리/비트 전부 "연산자"로 포괄). 다만 다소 길어서 Operator.Sum(a, b)처럼 매번 타이핑하기엔 무거울 수 있음.
  • Op — 짧지만 무엇의 축약인지 처음 보면 바로 안 와닿을 수 있음.
  • OpsOp의 복수형, 뉘앙스는 비슷.
  • Combinator — 코퍼스 전반에서 :Apply/Animate 같은 패턴을 설명할 때 이미 일반명사로 "콤비네이터"라는 말을 자주 써서(예: modifier-plan.md 8번 절), 네임스페이스 이름으로 쓰면 "이 특정 모듈"과 "패턴을 가리키는 일반 용어"가 헷갈릴 수 있어 후보에서 제외.

.claude/question.md 3번(낮은 우선순위)에도 반영. 용어 정리 라운드 (question.md 1번, Brand/Tag류)와 같은 카테고리로 나중에 같이 검토해도 됨 — 급하지 않음.

[2026-08-12 추가] 서브 에이전트 외부 리서치(아래 "외부 리서치 결과" 절) 결과, Operator가 가장 강한 실제 선례를 가짐 — Python 표준 라이브러리 operator 모듈(operator.add/operator.and_/operator.lt 등)이 quad의 정확히 같은 동기(연산자를 map/reduce 등에 넘길 수 있는 이름 붙은 함수로 만드는 것, quad에선 :Apply가 그 자리)로 존재하는 직접 선례. Ops는 Rust std::ops가 근거이나 그건 연산자 오버로딩용 trait 네임스페이스라 "콤비네이터 함수 모음"이라는 quad의 용도와는 결이 다름(약한 선례). Op (단수)는 오히려 Slate.js Op/Immer patch처럼 "낱개 연산 객체 하나"를 가리키는 데 더 흔히 쓰여 네임스페이스 이름으로는 가장 약한 후보 — 최종 결정은 여전히 사용자 몫이지만, 후보 중 고르라면 Operator가 근거가 가장 탄탄함.

열린 질문 — 포함 범위

  • 산술(Sum/Product/Sub/Div?)·논리(Not/And/Or/Xor)·비트 (Band/Bor/Bxor/Bnot/Shl/Shr)까지는 비교적 명확한데, 비교 연산자(Eq/Lt/Gt/Lte/Gte)까지 포함할지는 미정 — 포함해도 같은 패턴(커링 팩토리 + :Apply)으로 자연스럽게 들어감. [2026-08-12 외부 리서치로 갱신, 아래 절 참고] 비트/비교 그룹은 "리액티브 파생값 콤비네이터"로서의 실제 선례가 전혀 없는 것으로 확인됨(양쪽 다 다른 라이브러리에서 인라인 연산자로만 쓰임) — 포함하더라도 업계 관행을 따르는 게 아니라 quad가 처음 시도하는 조합이라는 점을 인지하고 판단할 것. Sub/Div도 명명된 콤비네이터로서의 선례가 전혀 없어(어디서든 인라인 -//만 씀) 드랍 후보. Xor도 VueUse가 And/Or/Not은 다 갖췄으면서 의도적으로 뺀 것으로 보여 약한 후보.
  • Sum(a, b, ...)가 self까지 포함해서 더하는 형태(위 예시)로 확정 — 사용자 원 예시(:Apply(Sum(state, state...)))와 일치. self 없이 여러 state를 독립적으로 합치는 형태가 따로 필요한지는 실사용 사례가 나오면 재검토(지금은 Store.Combine류로 이미 커버된다고 봄).
  • [2026-08-12 외부 리서치로 신설] Clamp/Min/Max — VueUse useClamp/useMax/useMin, Ramda R.clamp 등 리액티브 파생값 콤비네이터로서의 선례가 뚜렷함. 기존 "산술" 뭉치보다 오히려 이쪽이 선례가 강해 별도 그룹으로 추가할 후보.
  • [2026-08-13 세션 신설] Alternative(nil 대체값 — Haskell Alternative/<|>, 흔히 coalesce/??/엘비스 연산자라고도 부름) — State<T?>:Apply(Alternative(default))처럼 값이 nil이면 기본값으로 치환하는 콤비네이터. 지금까지 카탈로그에 없던 게 확인됨(전수 grep 결과). 확정 규칙(모든 Operator.*factory(self) -> State<U> + :Apply)과 그대로 맞음 — default가 상수면 deps 없이 클로저 캡처만으로 충분, default가 State면 trailing arg로 구독(위 Sum 패턴과 동일). 업계 선례로 RxJS defaultIfEmpty, Kotlin 엘비스 연산자(?:), VueUse 대부분 유틸의 기본값 인자 등 흔한 연산이라 포함 근거는 있음 — 이름/최종 포함 여부는 다른 항목과 동률로 사용자 판단 대기.
  • [2026-08-12 외부 리서치로 신설, 별도 검토 필요 — Operator 카탈로그와 성격이 다름] Debounce/Throttle — RxJS debounceTime/throttleTime, VueUse useDebounce/useThrottle 등 업계 전반에서 가장 흔한 리액티브 콤비네이터 카테고리 중 하나라 부재가 눈에 띔. 단, quad의 Blocker (base/blocker-plan.md)와는 다른 메커니즘Blocker는 유저 코드가 직접 :On()/:Off()로 여닫는 값 기반 게이트(타이머 없음)라 시간 기반 지연/합치기가 아님. 실제 debounce/throttle을 만들려면 타이머(엔진 종속, Roblox task.delay 등)가 필요해 factory(self) -> State<U> 순수 함수 모양을 벗어남 — Operator.*(quad-base, 엔진 무종속)에 넣을 수 있는 게 아니라 quad-roblox 쪽 별도 프리미티브(Tween과 비슷한 위치)로 다뤄야 할 가능성이 큼. 이 문서 범위 밖의 별도 설계 질문으로 분리해서 판단 필요. [2026-08-14, 분리 완료] 사용자 요청으로 research/debounce-throttle-plan.md 신설 — 이 항목은 그 문서로 이관됐고 여기선 더 이상 다루지 않음. 그 문서의 결론 두 가지만 여기 관련 있음: (1) 붙이는 모양은 이 카탈로그와 똑같이 factory(self) + :Apply라 위 "왜 :Apply인가" 규칙이 그대로 적용됨, (2) 다만 배치는 위 추측과 달리 quad-base + 주입 op 2개로 확정(부기 알고리즘이 순수 로직이라 백엔드마다 복제할 이유가 없음 — Tween이 quad-roblox인 이유와 근거가 다름).

열린 질문 — 컬렉션 계열 후보: Concat/Sorted/Filtered

사용자 제안(2026-08-12 세션). 위 산술/논리/비트 스칼라 연산과 달리 문자열 결합·테이블 정렬·필터링이라 범주가 다름 — 별도로 정리:

  • Concat(state<any>, ...) — 각 operand를 tostring으로 변환한 뒤 ..으로 이어붙임. Sum과 완전히 같은 N항 커링 shape(self 포함해서 전부 이어붙임)이라 이 카탈로그에 무리 없이 들어감.
  • Sorted(diffFn)self가 들고 있는 테이블을 table.clone한 뒤 table.sort(clone, diffFn)로 정렬해 새 State로 반환. -ed 어미는 기존 Tag.Added/Removed, Overridden과 같은 관례(뮤테이션이 아니라 계산되어 반환되는 새 값임을 신호)와 일치해 이름도 자연스러움. diffFntable.sort의 비교 함수와 동일한 시그니처((a, b) -> boolean)로 두면 됨. 구조적으로 문제없어 보임.
  • Filtered — 반쯤 기각. 원소 개수 자체가 바뀌는 연산이라 Slot의 인덱스 기반 조정 모델(Length/Offset/bindLifetime/unbindLifetime, base/slot-plan.md)과 정면으로 부딪힘 — Slot이 관리하는 리스트에 이걸 직접 꽂으면 "원소가 사라졌다 다시 들어옴" 문제가 생겨서, 단순 Operator 슈가가 아니라 Slot 쪽 조정 로직과 맞물리는 별도 메커니즘이 필요할 가능성이 큼. Slot을 거치지 않는 순수 State<table> -> State<table> 값 변환 용도라면 문제없이 들어갈 수 있음 — 실사용 사례가 나오면 재검토. [2026-08-12 외부 리서치로 이 판단이 실제 선례로 뒷받침됨] ReactiveUI의 IReactiveDerivedList/CreateDerivedCollection은 필터링을 "일반 파생값"이 아니라 아예 별도의, 변경분만 증분 반영하는 전용 컬렉션 타입으로 다룸 — 즉 quad의 Slot과 같은 결의 "더 무거운 전용 프리미티브가 필요하다"는 판단과 정확히 같은 결론. 반대로 SolidJS의 createMemo(() => items.filter(...))<For>에 바로 먹이는 흔한 패턴은 문제없이 동작하는데, 이건 memo 자신이 아니라 <For>가 내부적으로 keyed reconciliation을 따로 하기 때문 — memo의 identity 처리와 무관하게 하위 컴포넌트가 스스로 재조정한다는 뜻. 이 대조가 quad의 결론을 그대로 뒷받침: 원소 identity 보존이 필요 없는 자리(Slot을 안 거치는 순수 값 변환)라면 Filtered를 plain value transform으로 둬도 되지만, identity를 보존해야 하는 자리는 이미 Slot이라는 별도 무거운 프리미티브가 담당해야 하는 영역 — 어중간한 타협이 아니라 정확한 경계선.

열린 질문 — Attribute 그룹 명시적 unset 유틸 (2026-08-12 세션 후속, 신설)

배경: base/attribute-plan.md가 "그룹에서 이름이 사라져도(diff든 통째 언마운트든) 프레임워크가 자동으로 SetAttribute(name,nil)을 안 해준다 — 지울 거면 명시적으로 None을 써라"로 확정됨(사용자 결정, Ref의 "Destroy 무관, 정리 필요하면 Effect" 철학과 통일). 이유는 diff로 조용히 빠지는 것과 통째 소멸을 다르게 취급하면 오히려 모호해지고, Attribute는 이미 "겹치면 error"로 소유 코드가 명확히 갈리는 설계라 프레임워크가 대신 판단할 근거가 불투명하기 때문(상세는 해당 문서 "그룹 Attribute(...)" 절).

아이디어(사용자 제시, 착수 안 함): 그래도 자동 unset이 갖고 싶으면, Animate와 같은 모양의 :Apply 팩토리로 명시적 opt-in 유틸을 나중에 추가하면 됨 — State<data> -> State<Attribute>를 만들면서 내부적으로 이전 이름 집합과 비교해 사라진 이름을 자동으로 None으로 채워 넣어주는 콤비네이터. 이 카탈로그의 다른 항목들(Sum/Concat/Sorted)과 같은 성격 — 순수 슈가, 없어도 기능 격차 없음, 사용자가 명시적으로 골라야만 동작(자동/암묵적이 아님이 핵심 — 그래야 "프레임워크가 대신 판단"이라는 모호함 문제 자체가 안 생김).

우선순위: 이 문서 전체와 동급으로 맨 마지막, 실사용 사례가 나오면 재검토. 지금은 이름도 모양도 구체화 안 함 — 착수 시점에 이 문서의 Animate/Sum 패턴을 그대로 참고.

열린 질문 — 중첩 State 평탄화 State<State<T>>State<T> (2026-08-13 여섯 번째 세션, 사용자 제시, 백로그)

배경: 2026-08-13 다섯 번째 세션의 인덱스 기반 Dispatch 재설계로 State<State<T>>가 UB에서 정상 지원 대상이 됐음(각 재귀 단계가 다른 인덱스를 써서 슬롯 충돌이 없어짐, base/dispatch-core-plan.md "Dispatch 체인" 절). 하지만 동작한다고 해서 권장 방향인 건 아님 (사용자 판단: "UB는 아니지만 우리가 원치 않는 방향인건 맞습니다").

[근거 축소, 2026-08-13 열네 번째 세션] 원래 이 항목의 주 근거였던 "깊은 체인에선 힌트가 사라져 깜빡임 방지가 꺼진다"는 손실은 없어졌음. 당시 서술: 옛 Dispatch.retractFrom(inst,k,index,v)가 힌트 v를 정확히 index 자리에만 넘기고 더 깊은 인덱스엔 nil을 넘겼기 때문에 State<State<Tag>>에서 바깥이 재발행하면 TagHandler가 nil을 받아 RemoveTagAddTag 왕복이 일어났음. 하강 diff 재디스패치에선 각 레벨이 자기 재프로세스에서 자기 값을 받으므로 깊이와 무관하게 진짜 Tag 객체가 전달됨(base/dispatch-core-plan.md "Dispatch 체인" 절). 남은 근거는 (a) 편의성/의도 표현, (b) state<state<Frame>>류에서 Slot offset이 밀리고 당겨지는 케이스(이건 이미 "그냥 확인된 것"으로 수용) 정도라 우선순위가 더 내려감.

아이디어(착수 안 함): 중첩을 Dispatch 층에서 감내하는 대신, 값 층에서 평탄화하는 콤비네이터를 제공 — State<State<T>>를 받아 State<T>를 돌려주는 join/flatten(하스켈 모나드 join, RxJS switchAll/switchMap에 대응). 그러면 체인이 항상 한 겹으로 유지돼 힌트도 안 잃고, 사용자 의도("안쪽 값이 바뀌면 그걸 따라간다")도 더 직접적으로 표현됨.

  • 2026-08-13 두 번째 세션의 Haskell 비교에서 이미 **"Monad bind/join이 StoreBind/Slot:Single/NoneHandler에 각자 따로 재구현돼 있는 미일반화 후보"**로 식별해뒀던 것과 같은 자리 — 그 세션은 "착수 안 함" 으로 남겼고, 이번에 구체적 동기(힌트 유실)가 붙은 것. 모양(2026-08-13 여섯 번째 세션 후속, 사용자 구체화): 이 카탈로그의 다른 항목들과 달리 Operator.* 네임스페이스가 아니라 State의 메소드로 제공되어야 할 것으로 보임state:Flatten() 또는 state:Flat()(이름 미정). 이유: Sum/Not류는 인자를 받아 값을 만드는 순수 함수라 자유 함수가 자연스럽지만, 평탄화는 특정 State 노드 하나를 받아 그것을 따라가는 새 노드를 만드는 것이라 :Compute/:With와 같은 층위의 체이닝 메소드가 맞음.

  • 대상 타입은 State<State<T> | T> — 즉 안쪽이 State일 수도, 그냥 값일 수도 있는 섞인 경우까지 흡수해야 함(사용자 명시). 동작: 바깥 State의 변경을 리슨하다가, 흘러나온 값이 T면 그대로 State<T>로 내보내고, State<T>면 그 안쪽을 따라가는 State<T>를 내보냄.

⚠️ 핵심 난점 — 반환 노드가 동적 의존성을 가짐(사용자 지적). 이게 이 항목을 단순 슈가로 못 만드는 이유이자, 백로그에서 따로 더 파야 하는 지점:

  • quad는 암묵적 자동 추적을 기각했고(base/source-state-plan.md), 의존성은 :With정적으로 선언하게 돼 있음. 게다가 ":With의 동적 의존성 미지원"은 2026-08-12 열여덟 번째 세션에 의도된 트레이드오프로 확정됨(research/framework-comparison-findings.md) — State immutable 가정과 정면으로 부딪힌다는 이유.
  • 그런데 평탄화 노드는 본질적으로 안쪽 State가 바뀔 때마다 구독 대상을 갈아타야 함 = 의존성 집합이 런타임에 변함. 즉 이 도구는 방금 그 "의도적 비지원" 결정의 유일한 정당한 예외를 요구함.
  • 그래서 확정 전에 답해야 할 것:
    1. 동적 의존성을 이 노드 안에만 가둘 수 있는가 — 바깥에서 보면 여전히 평범한 State<T> 하나(정적 의존성 1개)이고, 구독 갈아타기는 노드 내부 구현 디테일로 숨겨지는가? 숨겨진다면 "동적 With 미지원" 결정과 실제로는 안 부딪힘(그 결정은 사용자가 선언하는 의존성 목록에 대한 것이므로).
    2. 안쪽 State 교체 시 옛 구독 해제 타이밍bindLifetime 귀속 — 이 노드는 inst에 안 묶인 순수 값 계층이라 :Subscribe() 계열 규칙을 따라야 하는지, 아니면 다운스트림이 살아있는 동안만 유지되는 별도 규칙이 필요한지.
    3. 그래서 결국 :Apply 위의 순수 슈가가 아니라 진짜 새 프리미티브 인지 — 지금 판단으로는 상태를 갖는 노드라 후자에 가까움. 그렇다면 이 문서(순수 슈가 카탈로그)가 최종 거처가 아닐 수도 있음.

우선순위: 백로그. State<State<T>>가 이제 정상 동작하므로 이게 없다고 막히는 건 없고, 힌트 유실도 흔한 경로(한 겹)엔 영향이 없음. 다만 위 난점 때문에 다른 카탈로그 항목들보다 설계 비용이 확실히 큼 — 착수 시 "슈가 하나 추가"로 접근하지 말 것.

우선순위

맨 마지막. 없어도 quad는 기능상 완전하고, 함수 간 의존이 없어 나중에 일부만 먼저 만들거나 전부 미뤄도 리스크가 없음. 지금은 이 문서로 동기/모양/열린 질문만 남겨두고, 실제 구현은 다른 마일스톤이 다 끝난 뒤로 미룸.