quad/.claude/research/v1-compat-plan.md
qwreey 24c82a299e
v1-compat-plan.md: 병행 사용 + 경계 리졸브 브리지로 방향 수렴
사용자가 "v1 런타임을 v2 위에 재구현" 대신 "v1을 그대로 병행 실행하고
경계에서만 값을 리졸브해 넘기는 브리지"를 제안 — 검토 결과 기존에 설계된
state:Observer()(무인자="계속 관측" 유틸)와 v1의 공개 프로퍼티 재대입
API만으로 조립 가능함을 확인, DOMless/엔진값 원칙 덕에 구조적 합성도
이미 공짜라 3-2("얇게 안 됨") 문제를 재구현이 아니라 회피로 해결하는
유력 방향으로 수렴. 조사 중 target()/Linker를 "양방향 바인딩"으로
서술한 이전 오류도 정정(실제로는 named child 등록 + 시그널 중계).

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

12 KiB

v1 하위호환(compat) 레이어 타당성 검토

상태: research — 신규 조사(2026-08-06 세션, 사용자 질문으로 착수). 설계 확정 아님, "얇은 래퍼가 가능한가"에 대한 타당성 평가만 담음.

배경: 사용자가 "quad v1에 대한 하위호환 레이어를 v2가 얇은 래퍼로 제공할 수 있을지" 질문. 폐기된 재작성 시도 quad2-tryquad-compat이라는 서브패키지가 있어서 "이미 한 번 시도했다 실패한 것"으로 짐작했으나, 조사 결과 아래처럼 사실이 아니었음 — 완전히 새로 검토할 만한 주제.

1. 선행 조사: quad2-try의 quad-compat은 실제로 시도된 적 없음

base/bind-system-plan.md:715에서 quad2-try의 서브패키지 9개(quad-docs, quad-debug, quad-compat, quad-2, quad-roblox, quad-lang, quad-gtk, quad-core 등)를 나열하며 "quad-core 밖엔 참고할 게 없다"고 기록돼있는데, 직접 확인한 결과 out/quad-compat/파일이 0개인 완전히 빈 디렉토리. compat.lua나 어댑터 코드는 전혀 없고, README/커밋 메시지에도 "왜 포기했는지" 단서가 없음 — 애초에 착수된 적이 없다는 뜻.

question.md:110이 "OOP 상속/커스텀 파서/Slot 스텁/Pipe COW는 확인된 죽은 접근"이라고 명시한 목록에 compat은 포함돼 있지 않음(정확한 서술). 즉 CLAUDE.md의 "반복 조사 금지"는 compat에는 적용되지 않는다 — 이 문서를 쓰는 게 규칙 위반이 아님.

2. v1 공개 API 표면 — 두 계층으로 나뉨

v1(.claude/initreq/quad/src) 조사 결과, API는 성격이 다른 두 계층으로 나뉜다:

(a) 표면 문법 — 개별 함수/헬퍼로 비교적 독립적:

  • 이벤트 핸들러가 첫 인자로 self(or this)를 받는 관습(event.lua:81-83)
  • 프로퍼티 테이블의 특수 키(RoundSize/Corner/PaddingAll/Scale, class.lua:134-213)
  • target()(정확히는 컴포넌트 내부 self("이름") 호출)을 통한 named child 등록 + 시그널 중계(Linker, class.lua:112-131,352-358,511-521) — 정정(2026-08-06): 최초 조사 때 "양방향 바인딩"으로 잘못 서술했음. 실제로는 데이터 동기화가 아니라, Linker 값을 숫자 키(자식 위치)에 놓으면 생성된 자식을 target[name]에 한 번 등록(LinkindexType=="number" 분기, rawset)하고, 문자열 키(이벤트 값)에 놓으면 자식 이벤트 발생마다 target:GetPropertyChangedSignal(name)을 대신 Fire하는 시그널 중계일 뿐 — "이름 있는 자식 참조 등록"에 더 가까움.
  • store.GetObjects("a,b&c") 쿼리 문법의 오브젝트 태그 저장소(store.lua:103-190)

(b) 핵심 런타임 — v1 컴포넌트 모델 그 자체:

  • Class.Extend()가 반환하는 단일 메타테이블이 상속 체인을 대신 (class.lua:361)
  • 인스턴스화 시 생성자 인자를 자동으로 store로 감싸고(class.lua:367), 이후 comp.Text = "hi"처럼 프로퍼티를 재대입하면 __newindex가 자동으로 내부 store에 위임 + UpdateTriggers에 걸리면 자동 재렌더까지 발생 (class.lua:524-566) — CLAUDE.md에 이미 "이 자동 위임/재렌더 매직은 v2에서 폐기하기로 확정"이라 기록된 바로 그 메커니즘.

3. 계층별 실현 가능성

3-1. (a)는 얇게 재현 가능 — opt-in 서브패키지로 격리하면 근거 문제도 해소됨

  • 이벤트 self 관습: 클로저 한 겹으로 재현 가능. base/bind-system-plan.md "이벤트 핸들러는 self를 받지 않는다" 절이 든 반대 근거 4가지(Ref 중복 채널, Modifier 정적 flatten과 경쟁, quad-debug 추적 밖 mutate 경로, 클로저 비용)는 코어에 넣을 때 문제가 되는 것들 — 별도 opt-in 패키지 (quad-compat 부활)로 격리하면 비용은 compat 사용자만 부담하고 코어 KV 핸들러 분기도 안 생김. 단, "quad-debug 추적 밖 mutate 경로가 생긴다"는 근거(4번)는 격리해도 남는 문제라 별도 검토 필요.
  • RoundSize 등 특수 키: Corner/PaddingAll/Scale은 이미 research/ui-shorthand-plan.md에서 네이티브 포팅 확정됨 — 별도 compat 작업 불필요. RoundSize(이미지 9-slice 라운드 트릭)만 UICorner 없던 시절 워크어라운드라 재현 자체가 불필요하다고 이미 결론남.
  • target()/Linker: 위 정정대로 "이름 있는 자식 등록 + 시그널 중계"일 뿐이라, v2 쪽에서 굳이 흉내낼 이유가 약함 — v2엔 이미 Ref가 있고(컴포넌트 경계로 참조를 넘기는 표준 경로), 시그널 중계는 아래 4번 브리지 메커니즘이 흡수함.
  • 오브젝트 태그 조회: v2엔 대응 개념이 아예 없음 — CollectionService 태그로 유사 구현은 가능하나 새 서브시스템에 가까워 "얇다"고 하기 애매.

3-2. (b)는 얇게 안 됨 — 컴포넌트 정체성 모델 자체가 충돌

Class.Extend() 자동-store 위임 + 자동 재렌더는 v1 컴포넌트 작성 경험의 본질인데, v2는 정확히 이 매직("자기 store 자동 소유")을 이미 폐기하기로 확정한 상태(base/component-composition-plan.md §1, 사용자 확정 발언 "마법 안쓴다 그것도 동의함"). 이유는 이름 문제가 아니라 컴포넌트 정체성을 다르게 정의하기 때문:

  • v1: 컴포넌트는 렌더 후에도 "살아있는 오브젝트"로 남아 .Text = ... 재대입을 전제 — mutate 기반.
  • v2: 반응형 소스(Store/State)를 갈아끼우는 방식, 컴포넌트는 "특정 상태의 store를 받는 함수"(architecture.md) — 만들어진 후의 컴포넌트 인스턴스를 밖에서 mutate하는 접점 자체가 없음.

이 격차를 메우려면 compat 레이어가 컴포넌트마다 "가짜 OOP 인스턴스"를 만들어 내부적으로 v2 Store/State를 대신 조작해주는 shim을 새로 설계해야 함 — 몇 줄짜리 어댑터가 아니라 사실상 v1 런타임을 v2 위에 재구현하는 것. 참고 사례로 Vue 2→3의 @vue/compat이 있으나, 그것도 별도 빌드 모드 + 다수의 호환 플래그 + 성능 오버헤드 경고가 딸린 규모라 "얇다"고 부르기 어려움.

4. 사용자 제안 — v1/v2 병행 사용 + 경계 리졸브 브리지 (2026-08-06 후속, 유력 방향)

사용자가 3-2의 "얇게 안 됨" 결론에 대한 대안으로 제시한 방향: v1 런타임을 v2 위에 재현하려 하지 말고, v1을 그대로, 수정 없이 계속 돌리면서 v2와 병행 사용하고, 두 시스템의 경계(v2가 만든 반응형 값을 v1 쪽에 넘겨야 하는 지점)에서만 작은 브리지를 둔다는 아이디어. 검토 결과 이쪽이 3-1/3-2보다 분명히 나은 방향 — 아래 근거.

왜 이게 작동하는가

  1. 구조적 합성은 이미 공짜architecture.md:11,18의 DOMless 원칙상 v1/v2 둘 다 렌더 결과가 그냥 평범한 Roblox Instance라, v1이 만든 Instance를 v2 트리 안에 자식으로 두거나 그 반대나 특별한 어댑터 없이 Roblox 부모-자식 관계만으로 합성됨. 3-2가 문제 삼은 "컴포넌트 정체성 충돌"은 v1 컴포넌트 자체를 v2로 재구성하려 할 때만 발생하는 문제고, "v1 컴포넌트를 그대로 두고 옆에 놓기"에는 애초에 적용되지 않음.
  2. v2→v1 값 전달(사용자가 든 예시)도 이미 있는 재료로 충분히 얇음:
    • v2 쪽: state:Observer()를 인자 없이 호출하면 "이 State를 계속 능동 관측 상태로 유지"하는 유틸로 동작(base/bind-system-plan.md:441) — 이걸로 lazy를 포기하고 항상 최신값이 계산되게 강제하는 부분이 이미 설계돼 있음. 사용자가 말한 "포기하고 전부 관측된 값으로" 정확히 이 API.
    • v1 쪽: 만들어진 v1 인스턴스에 instance.Text = value처럼 그냥 재대입하면 v1의 진짜 공개 API(class.lua:543-566__newindex)를 타고 v1 자신의 업데이트 파이프라인(UpdateTriggers, 재렌더)이 정상 작동함 — v1 내부를 뜯어 흉내낼 필요 없이 v1이 원래 하던 일을 밖에서 호출만 하는 것.
    • 합치면: state:Observer(function() v1Instance.Text = state:Get() end) 한 줄 수준의 브리지로 "v2 State가 바뀔 때마다 v1 인스턴스 프로퍼티에 써주기"가 됨 — 3-2에서 우려한 "v1 런타임 재구현"이 전혀 필요 없음.
  3. 정반대 방향(v1→v2)도 필요하다면 대칭적으로 얇음(미검증, 방향성만): v1은 GetPropertyChangedSignal/EmitPropertyChangedSignal (class.lua:407-437)을 이미 공개 API로 노출하므로, 그 시그널을 구독해서 매번 v2 Source:Set()(또는 clone 불가 값이면 :Emit())을 호출해주는 것도 같은 패턴 — 다만 사용자가 예시로 든 건 v2→v1 한 방향뿐이라, 실제로 양방향이 필요한지는 아래 열린 질문으로 남김.
  4. 경계 코드의 라이프사이클 정리도 새로 설계할 필요 없음 — 브리지용 Observer 구독을 v1 인스턴스(진짜 Roblox Instance)의 Destroying에 묶으면 됨, 이미 채택된 rbvm Connected+GC 관용구(base/ lifecycle-pattern.md)를 그대로 재사용.

3-1(문법 설탕 compat)과의 관계

이 방향은 3-1의 "이벤트 self 관습, 프로퍼티 특수 키" 같은 v1 쪽 표현을 v2 문법으로 흉내내는 작업 자체를 없앰 — v1 코드는 그냥 v1 문법 그대로 남아있고, v2는 v1을 흉내낼 필요가 없음. 즉 "compat 레이어가 v1처럼 보이게 만드는" 문제가 "v1이 원래 하던 일을 그대로 하게 두고 데이터만 새 파이프로 갈아끼우는" 훨씬 좁은 문제로 축소됨.

5. 결론 / 권장

  • 1순위(신규 권장): 4번의 "병행 사용 + 경계 리졸브 브리지" — v1을 그대로 두고 v2와 나란히 돌리되, 반응형 값이 경계를 넘는 지점만 각 쪽의 기존 공개 API(v2 state:Observer(), v1 프로퍼티 재대입/시그널)로 잇는 얇은 글루 코드. 3-2가 지적한 "컴포넌트 정체성 모델 충돌"을 재구현이 아니라 회피로 해결 — 사실상 strangler-fig식 점진 마이그레이션 패턴.
  • 2순위(보조): 3-1의 문법 설탕 어댑터(이벤트 self 등) — 위 1순위로 충분하다면 불필요할 수 있음, "v1 문법 자체를 v2 컴포넌트 함수 안에서 쓰고 싶다"는 별도 니즈가 있을 때만 검토.
  • "v1 코드를 완전히 무수정으로 v2 런타임 위에서 돌리는 것"(3-2, OOP mutate 재구현)은 여전히 비권장 — 1순위 방향이 그 문제 자체를 안 만들기 때문에 불필요.
  • 확정된 소스 트리(base/architecture.md "구현 착수" 절)는 quad-base/ quad-roblox 두 패키지뿐 — 경계 브리지 글루 코드를 어디 둘지(별도 quad-compat 패키지 신설 vs 필요한 프로젝트마다 로컬 유틸)는 아래 열린 질문.

6. 열린 질문 (사용자 판단 필요)

  • 양방향이 실제로 필요한가, 한쪽 방향(v2→v1, 데이터 새로 짜고 v1 UI는 유지)만으로 충분한가 — 사용자 예시는 v2→v1 한 방향. 반대 방향까지 필요하면 4번의 3번 항목(v1 시그널 구독 → v2 Source 갱신)을 실제로 설계해야 함.
  • 브리지 글루 코드를 별도 패키지(quad-compat, 소스 트리에 아직 없음)로 뺄지, 아니면 정식 패키지 없이 "필요할 때 짜는 유틸 패턴" 정도로 문서화만 해둘지.
  • v1 store의 registerClass 체이닝 기능(:Tween/:Default 등)까지 브리지가 흡수해야 하는 케이스가 있는지 — 단순 프로퍼티 재대입만으로 충분한 범위인지 실사용 예시로 확인 필요.
  • (3-1을 실제로 병행 채택할 경우) 이벤트 self 관습을 compat에서 되살릴 때, base/bind-system-plan.md가 명시한 반대 근거 4번(quad-debug 추적 밖 mutate 경로)을 어떻게 처리할지 — quad-debug는 어차피 후순위라 지금 결정 불필요할 수도 있음.

착수 시점

지금 당장 설계/착수 불필요 — CLAUDE.md "지금 할 일" 1번(구현 착수, ROADMAP M0)이 최우선. 이 문서는 타당성 평가 결과만 남겨두고, 실제 설계는 사용자가 방향(4번 병행+브리지의 세부 범위)을 정한 뒤 진행.