# 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<>(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.FontSize`는 `FontSize`가 리터럴 키로 안 박혀있으니 `__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` 같은 조합을 타입 정의 단계에서 거부) — 런타임 가드가 아니라 타입 차단을 우선 검토. ### 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로 전달, "다중 루트" 개념 자체는 폐기) — 더 이상 열린 질문 아님, 이 문서가 다루는 "값 자체의 동작"과는 별개 문제였다는 점만 참고로 남김.