quad/.claude/base/modifier-plan.md
qwreey 5c9d10df66
docs: .claude/ 코퍼스 정리 — reference/ 신설, 승격/기각 분리, 역전 이력 트리밍
base 밖으로 늘 읽을 필요 없는 참고자료(quad-v1-architecture, comparison-fusion-vide)를
새 reference/ 폴더로 분리하고, ui-shorthand-plan을 base로 승격(RoundSize 드롭+
UICorner/UIPadding/UIScale 리네임), additional-primitives-plan을 Blocker/Effect(base
승격)·Batch/Context(archive 기각)·키 기반 컬렉션 재조정(research 잔류)으로 4분할했다.
component-composition-plan의 중복 역전 서사는 기존 archive 포인터로 압축하고, archive
제목 컨벤션을 [역전됨]/[기각됨]로 분화했다. tween-plan에는 retract/canExecute 구분
메모와 트윈 옵션 값 모양 논의를 추가했다. Effect가 Observer 변형인지는 임의로
결론내지 않고 question.md에 열린 질문으로 남겼다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 14:39:20 +09:00

18 KiB

Modifier 설계 (정적 merge, immutable 체이닝)

상태: base — 핵심 메커니즘(런타임 plug 아님/정적 merge, immutable 값+clone 기반 체이닝, 이중 setter)은 2026-08-04 세션 채팅 논의로 확정. Getter는 별도로 안 만들기로 확정(2026-08-06 후속 세션) — 아래 "4. Setter는 리터럴 값과 변환 함수 둘 다 받음" 절 참고. Modifier가 컴포넌트 경계를 어떻게 통과하는지(named parameter로 전달, multi-root 개념 폐기)는 별개 문제로 **[정정] research/component-composition-plan.md는 2026-08-04 세션에 수렴 완료돼 base/component-composition-plan.md로 승격됨 — 이 문서는 "Modifier 값 자체가 어떻게 동작하는가"만 다룸.

문제

base/architecture.md 7번 항목("Style(Default) 시스템 폐기, modifier 지향")이 방향만 정하고, 실제 메커니즘은 미정이었음: 핸들러 레지스트리에 넣을 것인가, 여러 modifier가 같은 키를 건드리면 어떻게 되는가, 트리를 타고 내려가며 조금씩 변형되는 modifier(예: 문서 뷰어의 TextStyle 상속)를 어떻게 안전하게 다룰 것인가.

확정된 결론

1. 런타임 pluggable 핸들러 아님 — 정적 merge

Modifier는 isHandlable/priority/process/retract 핸들러 레지스트리에 안 들어감. 그냥 평범한 테이블(데이터)을 보유하는 값이고, 디스패치 들어가기 전에 한 번 평탄화(flatten)돼서 최종 props 테이블에 합쳐짐. 이유: 런타임 pluggable로 만들면 여러 modifier가 반응형으로 같은 키를 계속 다투는 CSS cascade 문제가 그대로 오는데, 이건 이미 확정된 "Store 바인드 변경은 전체 교체, 부분 오버레이 없음"(base/architecture.md 3번) 원칙과 충돌함.

관련: 이미 마운트된 Instance에 재바인드할 때 Default→실값 flatten을 다시 해야 하는지/clone이 필요한지는 별개 미정 문제로 research/existing-instance-bind-plan.md의 "Default 값과 얽히는 문제" 절 참고 — 여기서 다루는 건 컴포지션 타임의 modifier 값 자체 flatten이라 층위가 다름.

2. Merge 우선순위: 배열 순서와 인라인은 독립된 두 규칙

Frame { modifier1, modifier2, Name = ... } 평탄화 시: (a) 배열에 나열된 modifier들끼리는 순서상 나중 것이 우선. (b) 명시적 키(인라인)는 modifier가 뭘 하든 무조건 우선. Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스트 순서를 보존하지 않으므로 "순서상 나중이 이긴다"는 단일 규칙만으로는 구현 불가 — 반드시 두 규칙으로 쪼개야 함.

2-1. 인라인 키로 modifier 필드를 명시적으로 "지우기"는 아직 안 풀림 — None 센티널 후보만 메모 (2026-08-07 세 번째 세션, 미확정)

문제: { Override = nil, mod }처럼 인라인 키로 modifier가 주는 값을 명시적으로 취소하고 싶어도, Lua 테이블 리터럴에서 키 = nil은 그 키 자체가 아예 존재하지 않는 것과 구별이 안 됨(pairs에서도 안 보임) — 그래서 위 2번 "인라인은 무조건 우선" 규칙이 실제로 작동할 근거(인라인 키가 존재한다는 사실 자체)가 사라지고, mod가 주는 Override 값이 그대로 새어나옴.

후보(미확정): 이벤트 store-bind에서 이미 쓴 "nil 대신 실재하는 센티널 값" 패턴(false로 disconnect, base/bind-system-plan.md "이벤트도 store-bind 가능" 절) 재사용 — None(가칭) 프리미티브를 만들어 { Override = None, mod }로 쓰면 flatten 로직이 None을 만난 인라인 키를 "명시적으로 지움"으로 해석해 modifier 쪽 값을 덮어씀. 상세 설계(타입, flatten 내부 표현, State 필드에도 같은 문제가 적용되는지 등)는 다음 세션에서 이어감 — 지금은 문제와 방향성만 기록.

3. Immutable 값 + clone 기반 체이닝

컴포지션 트리를 타고 내려가며 조금씩 변형되는 modifier(문서 뷰어에서 상위 TextStyle을 상속해 타이틀만 1.2배 키우는 경우 — Jetpack Compose의 TextStyle.merge()/CompositionLocal과 동일한 use case)는 특히 위험함 — mutable하게 구현하면 같은 modifier 레퍼런스를 공유하는 형제 서브트리가 오염되거나(한쪽이 mutate하면 다른 쪽도 영향받음), 재렌더 시 값이 누적 드리프트하는 버그가 생김(.claude/question.md 초기 논의의 "원본 테이블 덮어쓰기/루프 깨짐" 우려와 동일 클래스).

해결: 모든 변환 메소드(:FontSize(...)류 체이닝)는 내부에서 table.clone(self)로 새 테이블을 만든 뒤 필드만 덮어써 반환 — 원본은 절대 mutate하지 않음. 별도의 제네릭 clone 콤비네이터 타입 (modifier<<Frame>>(modifier):Set 류 아이디어)은 기각 — 그런 타입을 만들면 base/architecture.md 3번의 "복사 구현 지양, 필요한 곳만 팩토리 함수로 명시적 복사" 원칙을 다시 재작업하는 셈이라, 각 변환 메소드 자체가 그 원칙을 따라 알아서 최소한만 복사하면 충분.

성능: Luau table.clone은 native shallow-copy라 modifier 크기(보통 한 자리~여남은 개 필드) 기준 비용 무시 가능, 렌더/컴포지션 타임에만 발생(프레임마다 도는 게 아님). State가 이미 :With/:Compute마다 새 노드를 할당하는 것과 같은 급의 비용이라 일관되고, mutable+문서화 경고보다 오염 버그를 원천 차단하는 쪽이 라이브러리 복잡도/사용자 편의 양쪽에서 낫다고 판단 — immutable 기본으로 확정.

4. Setter는 리터럴 값과 변환 함수 둘 다 받음, 별도 Getter는 없음

:FontSize(value)(리터럴) / :FontSize(function(current) return current*1.2 end)(변환 함수) 둘 다 지원 — 한 줄로 끝내고 싶을 때는 리터럴, 이전 값을 바탕으로 계산하고 싶을 때는 변환 함수 하나로 충분.

Getter는 만들지 않기로 확정(2026-08-06 후속 세션). 애초에 getter가 필요했던 유일한 이유가 "현재 값을 꺼내서 여러 줄에 걸쳐 계산한 뒤 리터럴로 다시 넣는" 멀티라인 스타일이었는데, :FontSize(function(old) ... end) 변환 함수 하나가 그 케이스를 인라인으로 완전히 커버함 — 별도 :Get(key)/ dot-access 겸용 여부를 고민할 이유 자체가 없어짐(모양을 정하는 대신 개념을 없애는 걸로 해소).

변환 함수는 State의 :Compute처럼 lazy State 핸들을 넘길 필요가 없음(필드가 순수 데이터인 일반적인 경우에 한해 — 필드가 State일 때의 예외는 아래 참고). 계산 비용 자체가 없는 순수 데이터라면 콜백엔 그냥 raw 현재 값을 즉시 넘기면 충분(State의 self-lazy-핸들 문제와는 다른 카테고리).

old는 항상 "현재 저장된 그대로" 넘김 — 일관된 원칙. 필드가 plain이면 raw 값, State면 State 핸들 그 자체(아래 4-1 표의 "State + 함수" 행이 field:Compute(fn)으로 위임하는 것과 동일 — :With/:Compute의 self가 이미 raw 값이 아니라 State 핸들로 통일된 것과 같은 결). 별도 변환/정규화 없이 그냥 지금 들고 있는 걸 그대로 준다는 원칙 하나로 이 절과 4-1절 표가 전부 설명됨.

별도 func(state) -> state 인자 모양은 불필요(검토 후 기각). "여러 Compute를 합치고 싶다"는 동기였는데, 이미 두 가지로 다 커버됨: (1) 여러 계산을 합치고 싶으면 변환 함수 본문 안에서 다른 함수를 그냥 호출하면 됨(평범한 함수 합성, 새 계약 불필요), (2) 필드 자체를 State로 만들고 싶으면 리터럴 자리에 State를 직접 넘기면 됨(위 4-1 표 "State" 행). 즉 "함수가 State를 반환"하는 세 번째 모양이 커버할 새 유스케이스가 없음.

내부 구현: __real 같은 별도 래퍼는 불필요해 보임 — 데이터를 테이블에 직접 두고 메소드는 공유 메타테이블 __index로 붙이면, table.clone이 메타테이블까지 그대로 복사해주는 Luau 동작 덕분에 클론해도 체이닝이 안 끊김. flatten도 그 테이블 필드를 직접 읽으면 됨.

런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨(2026-08-06 후속 세션, 핵심 통찰). mod:FontSize(14)mod.FontSize(mod, 14)로 풀리는 문법 설탕이고, mod.FontSizeFontSize가 리터럴 키로 안 박혀있으니 __index(self, key)가 잡음 — 그러니 __index어떤 key가 오든 그 key를 클로저에 캡쳐한 function(self, arg) local clone = table.clone(self) ... end류 함수를 즉석에서 만들어 리턴하기만 하면 끝. 즉 :FontSize/ :Round/앞으로 생길 어떤 필드 이름이든 전부 이 하나의 제네릭 __index 구현이 처리 가능 — 필드별로 미리 등록된 메소드가 하나도 없어도 됨. 중요한 결론: 위 "FrameModifier 타입" 문제(클래스별로 flat 타입을 생성기로 뽑아야 하는 것)는 순전히 정적 타입 체크를 위한 것이고, 런타임 구현에는 아무 영향 없음 — quad-roblox의 클래스별 코드 생성이 늘어나도 런타임 쪽 코드량은 절대 안 늘어남. 그리고 이 __index 메커니즘 자체는 Roblox API에 전혀 의존 안 하는 순수 Lua 테이블 조작이라, "base는 인터페이스만, 구현은 백엔드 팩토리가 주입"(base/bind-system-plan.md) 원칙과 무관하게 Modifier의 체이닝 엔진 자체는 quad-base에 완결된 구현으로 그대로 존재해도 됨 — 주입할 엔진별 구현이 애초에 없음.

Modifier는 핸들러 계층을 모름 — 순수 데이터 merge 레이어. Modifier는 process/retract/isHandlable 핸들러 레지스트리보다 아래층(위 "1. 런타임 pluggable 핸들러 아님" 참고)이라, Ref/Slot 같은 핸들러 계층 개념을 아예 몰라도 됨 — 필드에 Ref나 Slot 같은 값이 들어가도 Modifier 입장에선 그냥 flatten해서 최종 props 테이블에 얹을 뿐. 권장하는 사용법은 아니지만(Modifier는 스타일링 데이터 지향이니), 막을 이유도 없음 — 방어 로직 없는 UB로 남겨둠(오늘 세션 내내 반복된 "드문 오용까지 방어하려고 구조 복잡하게 안 만든다" 원칙과 동일).

4-1. 필드가 State일 수도 있음 — Setter가 State/plain 여부로 분기

architecture.md 7번 항목이 "함수형 modifier가 store 바인드를 받을 수도 있음"이라고 이미 언급한 대로, Modifier 필드는 plain 값뿐 아니라 State일 수도 있음(예: 상위에서 내려온 테마 색상이 Store에 바인드된 반응형 값). 이 경우 위 4번의 setter가 그대로 통하려면, 현재 저장된 필드 값이 State냐 plain이냐에 따라 setter 내부 동작이 갈려야 함 — 새 개념이 아니라 State에 이미 있는 lazy/:Compute 체이닝을 그대로 재사용하는 것뿐:

현재 필드 인자 동작
plain 리터럴 clone 후 그 값으로 덮어씀
plain 함수 clone 후 즉시 호출해 나온 값으로 덮어씀(현재 값이 raw로 넘어감)
State 리터럴 clone 후 State를 통째로 리터럴로 덮어씀 — 의도적으로 반응성이 끊김(Store의 "부분 오버레이 없음, 전체 교체" 원칙과 같은 결)
State 함수 clone 후 field:Compute(fn)으로 새 파생 State를 만들어 대입 — 반응성 유지, State의 기존 :Compute 메커니즘에 그대로 위임

즉 함수형 셋터는 필드가 State일 때 반응성을 보존하고, 리터럴 셋터는 그 순간 값을 확정시켜 반응성을 끊음 — 이 차이는 사용자가 인지하고 골라 쓰는 것으로 문서화.

4-2. Modifier는 소유권/유일성 제약이 없음

Modifier는 자식(child)을 담지 않음 — 마운트 정체성이 없는 순수 값. 그래서 어떤 컴포넌트가 특정 modifier를 실제로 적용하든 안 하든, 또 같은 modifier를 트리 여러 곳에 반복 적용하든 에러가 나지 않고 상관없음(Ref나 Slot 자식처럼 "정확히 한 곳에만 마운트돼야 한다"는 소유권 제약이 이들에게는 있지만 Modifier에는 없음).

5. 타입 출처는 이미 확정된 dot-access 관습 재사용

"누가 modifier에 타입을 붙여주냐"는 새 문제가 아니라, Store/인스턴스 생성에 이미 적용한 "정적으로 알려진 건 dot-access, 동적인 건 문자열 폴백" 프로젝트 전역 관습(base/bind-system-plan.md "타입 추론 문제" 절)을 그대로 적용하면 됨 — Modifier.Rounded(8)/Modifier.FontSize(...)처럼 DI 쪽 "제네릭 생성자 함수 하나 + 자주 쓰는 것만 정적 필드로 미리 바인딩" 패턴 재사용. (주의: 이벤트는 이 관습의 유일한 예외라 인용 대상에서 제외 — 이벤트 바인딩은 PA님 방식인 문자열 키 + 런타임 리플렉션으로 감, base/bind-system-plan.md "이벤트 바인딩 정정" 절 참고. Modifier는 이벤트가 아니라 Store/인스턴스 생성과 같은 카테고리라 dot-access 관습이 그대로 적용됨.)

Modifier.Rounded(8)가 실제로 어떻게 UICorner 자식을 만들어 붙이는지(v1의 Corner 특수 프로퍼티 선례, 핸들러 배치 소견)는 base/ui-shorthand-plan.md 참고 — 이 문서는 Modifier 값 자체의 동작만 다루므로 분리.

6. State/Pipe 쪽엔 영향 없음 — 이미 있던 결정의 재확인일 뿐

Modifier가 immutable해야 하는 이유(변환마다 clone)와 State가 이미 ":With/:Compute마다 새 노드를 만든다"(base/bind-system-plan.md 2차 라운드 확정)로 확정해둔 이유는 같은 클래스의 문제(공유 mutable 상태로 인한 오염 방지)임을 이번 논의에서 재확인했을 뿐 — State/Source 온톨로지 자체엔 변경 사항 없음. 파이프 분기(:With(...):Compute(fn))는 이미 코드에 명시적으로 쓰는 구조라 "암묵적 분기"가 애초에 존재하지 않음 — 새로 결정할 것 없음.

7. State가 Modifier를 값으로 담는 것은 UB — 타입으로 막을 것 (2026-08-04, 로드맵 인수인계 라운드)

Modifier "필드"가 State일 수 있는 것(4-1번)과는 별개로, State 자체의 value가 Modifier인 경우(예: someState:With(fn)이 Modifier를 반환)는 지원 대상이 아님 — Modifier는 "flatten해서 한 번 적용"이 전제인 정적 값인데, State에 담기면 그 값이 반응형으로 바뀔 수 있다는 뜻이 되어 매번 재-flatten이 필요해지고, 이는 "정적 merge" 확정(1번)과 정면으로 충돌함 — 사용자 확정("state 안에 modifier가 있으면 그건 끔찍히 힘들꺼야... 타입 상 받지 못하게 만들어야 할 수도 있고"). UB로 확정, 가능하면 타입 시스템으로 아예 못 넣게 막을 것(State<Modifier> 같은 조합을 타입 정의 단계에서 거부) — 런타임 가드가 아니라 타입 차단을 우선 검토.

8. :Apply(factory) — 팩토리 함수 체이닝 지원 (2026-08-07)

동기: 재사용 가능한 스타일 프리셋을 만들고 싶을 때(예: Boldify(mod) -> mod처럼 어떤 modifier든 받아 기본값보다 더 두껍게 만들어 돌려주는 함수, 커링해서 Boldify(10)(mod) -> mod처럼도 씀) 이런 "modifier 팩토리"를 체이닝에 자연스럽게 끼워 넣을 방법이 없었음 — 팩토리를 직접 호출하면 Italicify(Boldify(10)(mod:FontSize(14)))처럼 안에서 밖으로 쌓여 읽는 순서가 실행 순서와 반대로 뒤집힘.

결정: mod:Apply(factory)를 지원 — factory는 그냥 Modifier -> Modifier 평범한 함수(커링된 클로저 포함, 새 타입 개념 아님). 동작은 function(self, factory) return factory(self) end이 전부. 이걸로 mod:FontSize(14):Apply(Boldify(10)):Apply(Italicify)처럼 필드 setter 체이닝과 팩토리 적용을 같은 fluent 문법 하나로 섞어 쓸 수 있음 — 읽는 순서 = 적용 순서.

왜 좋은 아이디어인가: Jetpack Compose의 커스텀 Modifier 확장 함수 패턴(fun Modifier.myStyle(): Modifier = this.then(...))과 동일한 효용(모듈화된 스타일 프리셋을 라이브러리로 나눠 배포/재사용, 체이닝으로 조합)을 Luau엔 확장 함수 문법이 없으니 :Apply 콤비네이터로 흉내낸 것. 새 개념을 추가하는 게 아니라 "펑션도 그냥 값"이라는 Lua 특성과 이미 있는 immutable clone 체이닝(3번)에 얹는 얇은 sugar라 구현/개념 비용이 거의 없음 — 팩토리 자신이 내부에서 이미 :FontSize(...) 같은 필드 setter를 호출해 clone된 새 Modifier를 반환하므로, Apply 자체는 clone할 필요조차 없음(factory(self)가 이미 새 값을 만들어 줌).

구현 시 주의: Apply는 제네릭 __index가 즉석에서 만들어주는 필드 setter 클로저(4번)와 이름이 겹치면 안 됨 — __index가 고정 메소드 테이블(현재는 Apply 하나)을 먼저 확인하고, 없을 때만 필드 setter를 합성하도록 구현. 따라서 Apply는 Modifier 필드 이름으로 예약됨(실제 스타일 프로퍼티 이름과 겹칠 일은 거의 없어 보이지만 문서화 필요).

열린 질문 (.claude/question.md에도 취합)

  • [해소됨] Getter 정확한 이름/모양 — 2026-08-06 후속 세션에서 getter 자체를 안 만들기로 확정(위 "4. Setter는..." 절 참고), 더 이상 열린 질문 아님.
  • Modifier가 컴포넌트 경계를 어떻게 통과하는지는 [정정] 이미 base/component-composition-plan.md에서 해소됨(named parameter로 전달, "다중 루트" 개념 자체는 폐기) — 더 이상 열린 질문 아님, 이 문서가 다루는 "값 자체의 동작"과는 별개 문제였다는 점만 참고로 남김.