콘텐츠로 이동

quad v1에서 오는 분께 — 없어진 것, 생긴 것, 옮기는 길

대상 독자: Quad.Init(id) / Class "Frame" / Store.GetStore(...)로 쓰는 quad v1 코드베이스를 지금도 굴리고 있는 분. 하는 일: v1의 무엇이 없어졌고, 무엇이 새로 생겨 어떤 문제가 풀렸으며, 이관을 어떤 틀로 잡을 수 있는지를 큰 맥락에서 정리합니다. 안 하는 일: 관용구별 치환 절차가 아닙니다 — “이건 뭐로 바꿔 쓰나”의 정본은 08. quad v1에서 v2로 옮기기이고, 이 문서는 판단에 필요한 만큼만 말하고 거기로 보냅니다.


1. 먼저 — 당장 옮기지 않아도 됩니다

섹션 제목: “1. 먼저 — 당장 옮기지 않아도 됩니다”

v2는 v1의 다음 버전이 아니라 처음부터 다시 짠 별개의 라이브러리입니다. 진입 방식부터 다릅니다 — v1의 require(path).Init(id) 하나가 v2에서는 모듈 둘(quad-base·quad-roblox)과 Quad:UseProvider(QuadRoblox) 한 줄이 됩니다. 자동 변환 도구는 없고, 만들 계획도 없습니다. 문법이 비슷해 보이는 자리도 실행 모델이 달라서, 겉모양만 치환하면 대개 타입 검사에서 막힙니다.

그래서 v1 코드는 v2를 들여와도 손대지 않는 한 그대로 남습니다. 옮길 값어치는 아래 2·3절에서 판단하시고, 옮기기로 했다면 4절의 틀 중 하나를 고르면 됩니다. 설치는 [2026-09-10 기준] pesde 경로만 열려 있습니다(v1처럼 .rbxm 하나를 내려받는 경로는 아직 없음 — 00. 설치 및 환경 구축). 도입 자체를 아직 저울질 중이라면 왜 Quad인가의 “맞지 않습니다” 목록을 먼저 보시는 편이 빠릅니다.


2. 없어진 것 — 그리고 왜 없앴나

섹션 제목: “2. 없어진 것 — 그리고 왜 없앴나”

v1에서 자주 쓰이던 기능 중 v2가 의도적으로 되살리지 않은 것들입니다. 세 번째 열은 대체 관용구가 아니라 그렇게 결정한 이유입니다(무엇으로 바꿔 쓰는지는 08. 이관 가이드 §6이 표로 정리합니다).

v1에 있던 것 v2 왜 없앴나
Frame "id" {} / Store.GetObject(id) / GetObjects 없음 — 분류는 Tag, 참조는 그 자리에서 Ref id를 전역에 매핑하는 모델 자체를 현실적이지 않다고 봤습니다. 충돌을 피해야 하는 단위는 대개 컴포넌트 단위로 나오는데, 그건 그 자리에서 참조를 잡으면 풀리는 문제였습니다.
Style "Child" {} (이름 타겟 매칭) Modifier — 배열 부분에 놓는 값 v1의 스타일은 이름 매칭 기반이고 선언 순서에 의존해서, 실행 순서가 꼬이면 스타일이 조용히 안 먹는 함정이 있었습니다.
명령형 :Tween{} / RunTween / StopTween / 함수 이징 / 스텝 콜백 선언형 q.Tween{} / q.Animate{} v1의 :Tween은 register 메타테이블 체인의 한 고리라 같은 메소드를 두 번 부르면 합성되지 않고 마지막 것만 남았습니다. 애니메이션을 반응 그래프 밖 “프로퍼티 자리에 꽂는 값”으로 옮긴 이유는 왜 Quad인가 §3에 있습니다.
Class.Extend()Getter / Setter / UpdateTriggers / :Update() 없음 — 컴포넌트는 평범한 함수 갱신이 프로퍼티 단위라 “전체를 다시 그린다”는 개념 자체가 없습니다. OOP 스타일은 타입을 붙이는 난이도가 급격히 커진다는 것도 이유였습니다.
register 체이닝(:With:Add:Tween 누적) :Compute / :Apply — 매 호출이 새 노드 체이닝은 호출마다 테이블과 메타테이블을 새로 쌓으면서도 정작 합성은 되지 않았고, Register/Observe만 반대로 루트 스토어를 직접 변경하는 순수·불순 혼합이었습니다.
커스텀 Signal / Disconnecter 없음 — 정리는 인스턴스 수명에 묶임 이벤트 바인드 뒤에 함수를 넣는 것으로 충분하다고 판단했습니다. v1의 커스텀 Signal 구현은 정리 경로에 연결돼 있지도 않았습니다.
Mount(parent, obj) / mounts:Add / :Unmount .Parent 대입 + Slot v1의 mount 모듈이 부모 부기·자식 레지스트리·생명주기 파괴를 한꺼번에 지고 있었습니다. 자식 CRUD는 Slot이 전담하고, 단일 부착은 엔진의 .Parent가 그대로 가져갑니다.
Quad.Lang 없음 — 라이브러리 밖 로케일 상태가 모듈 지역 변수라 Init(id)의 인스턴스 스코프를 무시했습니다(Store·Style은 스코프되는데 Lang만 전역 공유). 애초에 렌더러가 질 책임이 아니라고 봤습니다.
tracker.lua(핫리로드 감시) 없음 v1에서도 공개 API에 연결되지 않은 죽은 코드였습니다. 그 자리는 스토리북 도구가 대신합니다.
Init(QuadId) 네임스페이스 공유 기본 인스턴스 + 필요할 때만 Quad.New() require가 이미 만들어진 기본 인스턴스를 돌려주므로, 파일마다 각자 인스턴스를 만들다 스코프가 조용히 갈라지는 사고가 구조적으로 없습니다. 공유는 모듈 export나 q.Context로 명시합니다.

id 조회가 없다는 게 실제로 뜻하는 것

섹션 제목: “id 조회가 없다는 게 실제로 뜻하는 것”

v1에서 가장 많이 쓰이던 관용구이자, 직역이 불가능한 유일한 자리입니다. Ref는 그 대체재가 아닙니다 — Ref의 용도는 “이름으로 아무 데서나 찾아오는 것”이 아니라 “이미 관리되고 있는 인스턴스의 참조를 그 자리에서 얻는 것”입니다. Store.GetObject("id")로 멀리 있는 요소를 집어오던 코드는, 그 요소를 만드는 자리에서 Ref를 놓고 그 Ref를 값으로 넘기는 모양으로 다시 써야 합니다. 즉 데이터 흐름을 위에서 아래로 다시 그리는 작업이 되고, 이게 4절 (a)를 주 경로로 두는 가장 큰 이유입니다.

v1에는 통일된 정리 모델이 없었습니다. 여러 모듈이 각자 PropertyChangedSignal에 연결해 참조를 붙잡아두는 “GC 방지 핫팩”을 중복해서 갖고 있었고, 대칭되는 해제 경로가 없었으며, 이벤트 연결은 해제 추적 없이 fire-and-forget이었습니다. v2에는 정리할 것을 담아 들고 다니는 물건이 아예 없습니다 — 인스턴스에 걸어둔 엔진 연결로 생존을 판정하고, 인스턴스가 죽으면 거기 묶인 구독과 트윈도 멈춥니다.

대가도 같이 알아두셔야 합니다. quad가 만든 인스턴스는 참조를 놓는 것만으로 회수되지 않고 Destroy가 유일한 절단면입니다. 화면을 버릴 때 Destroy()를 부르지 않는 코드 스타일이라면 이 모델은 맞지 않습니다. 자세한 갈래는 왜 Quad인가 §3의 수명 항목에 있습니다.


3. 새로 생긴 것 — v1의 어떤 자리를 메우나

섹션 제목: “3. 새로 생긴 것 — v1의 어떤 자리를 메우나”

v1에 없던 것들입니다. 각 항목이 무엇을 푸는지 한 줄로 적고, 설계 근거는 링크로 넘깁니다.

  • Slot — 형제 여럿과 자리의 소유권. mounts:Add/:Unmount로 손수 하던 목록 관리가, 자리 부기를 스스로 들고 있는 값이 됩니다. 키가 같은 항목은 인스턴스를 재활용하고 사라진 키만 파괴하며, 이미 마운트된 것을 다시 마운트하면 조용히 두 벌이 되는 대신 즉시 에러가 납니다. → Slot 레퍼런스, 03. 긴 목록 다루기
  • 열린 디스패치 — 특수 키를 라이브러리 밖에서 추가. v1에서 새 특수 키를 하나 넣으려면 중앙의 하드코딩된 if/elseif 디스패처를 직접 고쳐야 했습니다. v2는 값의 종류마다 핸들러가 등록되고 우선순위 축이 열려 있어, 라이브러리를 고치지 않고 끼어들 수 있습니다. → Dispatch·Handler 계약
  • Modifier — 값이 된 스타일. 이름 매칭 대신 배열 부분에 놓인 순서가 곧 우선순위이고, 디스패치 이전에 정적으로 평탄화되는 불변 값이라 런타임 캐스케이드 계산이 없습니다. → 03. 컴포넌트 합성, 05. 테마와 동적 스타일링
  • Claim — 이미 그려진 트리를 넘겨받기. v1의 Apply(myFrame){props}(이미 있는 인스턴스 재바인드)가 제한된 형태로 돌아왔습니다. Studio에서 만든 프리팹을 통째로 quad 소유로 넘기는 용도이고, 계약 셋(한 번만 claim / 그려지는 직계 자식 전부 매핑 / PlayerGui류 공동 소유 컨테이너는 대상 밖)이 붙습니다. → 07. Studio UI 바인딩과 Claim
  • Context — 계층을 건너 명시적으로 넘기는 가방. Init(id) 네임스페이스로 암묵적으로 공유하던 것을 명시적 전달로 바꿉니다. 다만 트리를 거슬러 올라가 조회하지는 않습니다 — 중간 계층이 손으로 넘겨야 합니다. → Context 레퍼런스
  • Debounce / Throttle — 시간 기반 전파 게이트. v1 공개 표면에 대응하는 것이 없던 기능입니다. 전파를 묶는 Blocker·:Gate 위에 얹힌 슈거이고, state:Apply(...)로 붙입니다. → Debounce·Throttle 레퍼런스
  • grep 가능한 에러. 메시지는 주어: 이유 (got X) 한 모양이고, 라이브러리 안쪽이 아니라 그걸 부른 사용자 줄을 blame합니다. 로그에 찍힌 문장을 그대로 들고 소스로 되돌아갈 수 있습니다(한계도 함께 문서화돼 있습니다). → 01. 디버깅과 문제 해결
  • strict 타입 검사가 이관 체크리스트를 상당 부분 대신합니다. v1의 props는 타입 없는 가방이었지만, v2는 생성된 프로퍼티 타입과 입력 자리의 공변 마커(StateMarker/SlotMarker) 덕에 직역이 대부분 타입 검사에서 막힙니다. 단, 이건 공짜가 아닙니다 — luau 플래그 넷을 켠 환경이 필수입니다. → 08 §7 strict 체크리스트, Quadnomicon Vol. 4 — 공변 마커
  • 엔진 없이 도는 코어. quad-base는 백엔드 op를 주입받는 순수 코어라, Store/State만 소비하도록 짜둔 로직은 Roblox 없이 상태 전이를 검증할 수 있습니다. → 06. 헤드리스 테스트

셋 다 화면 단위로 움직인다는 점은 같습니다. 코드베이스를 통째로 멈춰 세우고 한 번에 넘기는 경로는 없고, 권하지도 않습니다 — 당장 가능한 화면부터 차근차근 옮기고, 옮긴 화면마다 타입 검사와 스모크를 돌리는 쪽이 실제로 제일 빠릅니다.

(a) 화면 단위로 다시 씁니다 — 주 경로

섹션 제목: “(a) 화면 단위로 다시 씁니다 — 주 경로”

v2 위에 v1 관용구를 얹는 게 아니라, 화면 하나를 골라 다시 쓰는 방식입니다. 권하는 순서는 상태 → 컴포넌트 → 스타일 → 애니메이션입니다 — 상태를 먼저 옮겨두면 컴포넌트를 옮길 때 바인딩 자리가 이미 준비돼 있습니다. 한 화면을 옮길 때마다 타입 검사를 돌리세요; v2에서는 그게 체크리스트의 대부분을 대신합니다.

절차·대응표·strict 진단 목록은 08. quad v1에서 v2로 옮기기에 있습니다. AI 코딩 에이전트에게 이 작업을 맡긴다면 quad는 에이전트용 스킬을 함께 제공합니다 — v1 이관 레퍼런스(영문, 레포의 docs/skills/quad-ui-dev/)가 대응표·제거된 기능·strict 블로커를 에이전트가 읽기 좋은 모양으로 담고 있습니다.

(b) v1과 v2가 화면을 나눠 가집니다 — 점진 교체

섹션 제목: “(b) v1과 v2가 화면을 나눠 가집니다 — 점진 교체”

전면 재작성을 한 번에 하기 어려울 때 쓰는 틀이고, 지원되는 경로입니다. v1과 v2가 서로 다른 ScreenGui(또는 서로 겹치지 않는 서브트리)를 각각 소유하게 하고, 공유 데이터는 브릿지 한 겹으로 잇습니다. 화면 하나씩 넘겨가며 가능한 것부터 차근차근 옮기는 것이 실제로 권하는 방식입니다.

지켜야 할 규칙은 하나입니다 — 한 인스턴스는 한 쪽만 만진다. 경계는 인스턴스 단위가 아니라 ScreenGui/서브트리 단위로 그으세요. 브릿지는 데이터만 건너가고 인스턴스는 건너가지 않습니다.

브릿지는 양방향이되, 넘어가는 것은 항상 “값”입니다

섹션 제목: “브릿지는 양방향이되, 넘어가는 것은 항상 “값”입니다”

브릿지는 양쪽 다 놓을 수 있습니다.

  • v1 → v2: v1 Store가 바뀌는 시그널에서 v2의 source:Set(값)을 부릅니다.
  • v2 → v1: v2 상태가 바뀔 때 v1 Store에 값을 써 넣는 콜백을 한 겹 둡니다. 콜백은 state:Observer(fn)으로 만드는데, Observer의 콜백에는 값이 실려 오지 않습니다 — 안에서 targetState:Get()으로 읽어 v1 쪽에 씁니다. 그 구독을 v2 화면이 소유한다면 props의 배열 부분에 그 Observer를 넣어 인스턴스 수명에 매달고, v1 쪽이 소유한다면 :Subscribe()로 전역 구독시킨 뒤 그 핸들을 v1 소유자가 들고 있다가 소유자가 사라질 때 직접 :Unsubscribe() 하세요.

경계를 넘는 값은 예외 없이 평범한 값(그 시점의 실측값)입니다. State/Source 핸들이나 Store 객체 자체를 반대편에 넘기지 마세요 — 넘기는 순간 두 반응 시스템이 같은 노드를 공유하게 되고, 그때부터는 (c)와 같은 이중 소유 문제가 됩니다. 이 브릿지의 모양 자체는 RemoteEvent나 엔진 입력을 상태로 격리할 때 쓰는 것과 같아서, 04. 외부 시그널 브릿징의 패턴을 그대로 가져다 쓰면 됩니다.

⚠️ 이 틀의 가장 큰 주의점은 수명(GC)입니다. v2는 인스턴스에 묶이지 않은 구독을 스스로 붙잡아주지 않습니다 — observer:Subscribe()(강한 구독)를 쓰거나 그 핸들을 직접 들고 있어야 살아 있고, :WeakSubscribe()로 걸어두고 참조를 놓으면 수거되면서 발화도 멈춥니다. 반대 방향, 즉 v1 쪽 객체가 수거됐을 때 v2 쪽 구독이 그것을 알아채고 자동으로 멈춰주는지는 [2026-09-10 기준] 아직 실측하지 않았습니다. 그러니 브릿지 핸들에는 반드시 명시적인 주인을 정해 두세요 — v2 인스턴스가 주인이면 그 인스턴스의 props 배열 부분에 넣어 수명을 맡기고(화면을 버릴 때 Destroy가 같이 정리합니다), 주인이 v1 쪽이면 :Subscribe()로 살린 핸들을 v1 소유자가 강하게 들고 있다가 그 소유자가 사라지는 자리에서 손으로 해제하세요. 주인 없이 떠도는 강한 구독을 남기면 안 됩니다. 구독의 강·약 계약은 Observer·Effect 레퍼런스에 있습니다.

(c) v1이 그린 트리를 Claim으로 넘겨받기 — 권장하지 않습니다

섹션 제목: “(c) v1이 그린 트리를 Claim으로 넘겨받기 — 권장하지 않습니다”

“이미 화면에 떠 있는 v1 트리를 q.Claim으로 v2에 인계하면 되지 않나”는 자연스러운 발상이지만, 권장하지 않습니다. Claim은 Studio에서 만든 정적 프리팹을 넘겨받기 위한 것이라, 한 번만 claim할 수 있고 그려지는 직계 자식을 전부 매핑해야 합니다. 그런데 v1이 그린 트리에는 v1의 바인딩(Store → 프로퍼티 연결)이 아직 살아 있어서, 같은 인스턴스를 두 런타임이 동시에 쓰는 이중 소유가 됩니다. 이 조합은 실측하지 않았으므로 “된다”고도 “안 된다”고도 단정하지 않습니다 — 다만 디버깅 비용이 (a)/(b)보다 확실히 크고, 얻는 것은 인스턴스 재사용뿐입니다.

예외는 하나입니다: v1이 만들어놓고 손을 뗀 정적 트리(바인딩이 하나도 걸려 있지 않은 마크업)라면 Studio 프리팹과 성질이 같으므로 Claim의 정상 사용 범위 안입니다. 계약 셋은 07. Studio UI 바인딩과 Claim에 있습니다.

v1과 v2를 같은 클라이언트에 함께 두기

섹션 제목: “v1과 v2를 같은 클라이언트에 함께 두기”

(b)를 쓰려면 두 라이브러리가 한 클라이언트에 공존해야 합니다. 이건 허용됩니다 — 다만 조건 셋을 지켜야 합니다.

  1. 모듈이 서로 겹치지 않게 둡니다. v1은 Init(id)로 얻는 모듈 하나, v2는 quad-base·quad-roblox 둘과 Quad:UseProvider(QuadRoblox) 한 줄입니다. 설치 위치와 require 경로를 분리해 두세요.
  2. 같은 인스턴스를 두 쪽이 만지지 않습니다. 소유 경계는 ScreenGui/서브트리 단위로 긋습니다((b)의 규칙과 같습니다).
  3. 전역을 통해 섞지 않습니다. v1은 전역 테이블에 상태를 두지 않으므로 두 라이브러리가 서로의 상태를 의도치 않게 건드릴 경로는 없습니다 — 공유는 반드시 위 브릿지 한 겹을 통해서만 하세요.

5. 완전히 새로 짠다면 — 미리 알아둘 전제 여섯

섹션 제목: “5. 완전히 새로 짠다면 — 미리 알아둘 전제 여섯”

이관이 아니라 새 프로젝트를 v2로 시작하는 경우, v1 감각과 가장 크게 어긋나는 자리들입니다.

  1. 타입 검사 환경부터 만듭니다. luau 플래그 넷을 켜지 않으면 quad 소스가 파싱조차 되지 않습니다. 편집기에도 같은 플래그가 필요합니다 — 00. 설치 및 환경 구축의 타입 검사 절.
  2. Destroy가 회수의 전부입니다. 참조를 놓는 것만으로는 회수되지 않습니다. 화면을 버리는 자리에 Destroy()가 반드시 있어야 합니다.
  3. 의존성은 손으로 적습니다. :Compute(fn, ...deps)에 적은 것만 잡히고, 콜백 안에서 그냥 읽는 것은 의존성이 되지 않습니다. 우연한 캡처가 없는 대신 보일러플레이트가 늘어납니다.
  4. props의 배열 부분과 해시 부분은 다른 뜻입니다. 해시 부분은 프로퍼티·이벤트, 배열 부분은 Modifier·자식·Ref·Slot·훅입니다. 선택적으로 넘기는 값은 예외 없이 or q.None을 붙이세요 — 배열 부분의 nil 구멍은 타입 검사를 조용히 통과하고 실행할 때 부기 안쪽에서 죽습니다.
  5. Slot은 자리를 소유합니다. :List/:SingleOwned 기본값은 파괴라, 목록에서 빠진 요소는 파괴됩니다. 파괴하지 않고 떼어두려면 명시적으로 그렇게 적어야 합니다.
  6. 없는 기능은 밖에서 만듭니다. 로케일(Lang), 커스텀 Signal, id 기반 전역 조회, 스타일시트는 v2가 제공하지 않기로 확정된 것들입니다 — 필요하면 라이브러리 밖의 별도 층으로 두세요.

각 항목이 직역 코드에서 어떤 진단으로 나타나는지는 08 §7·§8이 진단 문구까지 붙여 정리합니다.