Skip to content

04. RemoteEvent와 엔진 입력을 상태로 브릿징하기

This content is not available in your language yet.

대상 독자: 서버 이벤트·입력·프레임 신호를 UI 상태로 들여오려는 개발자 다루는 개념: Source, Store, Effect, OnDestroyed, 단방향 데이터 흐름


UI는 혼자 있지 않습니다 — 서버의 RemoteEvent, UserInputService의 하드웨어 입력, RunService의 프레임 신호가 전부 UI에 닿습니다. 명령형으로 짜면 리스너 안에서 직접 Frame 위치를 바꾸고 Text를 대입하다가 상태 동기화가 깨집니다.

quad에서 권장하는 모양은 하나입니다 — 외부 신호는 Source로 밀어넣고, UI는 그 상태만 선언적으로 구독합니다(Source의 전체 표면은 레퍼런스: Source에 있습니다).

-- 01장의 설정 모듈: quad_base에 quad_roblox를 설치하고 타입을 다시 내보낸다(시작하기 01 참고)
local q = require("@game/ReplicatedStorage/Client/UI/Quad")

상태 레이어와 UI 레이어를 파일로 나눕니다. 상태 레이어는 Source:Set만 합니다.

q.Store는 시작하기 16에서 봤습니다 — 이름 붙은 Source들을 한 테이블에 담는 묶음이고, 전체 표면은 레퍼런스: Store에 있습니다.

-- PlayerProfileState.luau (상태 레이어)
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local q = require("@game/ReplicatedStorage/Client/UI/Quad") -- 01장의 설정 모듈
local ProfileStore = q.Store({
Gold = q.Source(0),
Level = q.Source(1),
})
local ProfileUpdated = ReplicatedStorage:WaitForChild("ProfileUpdated") :: RemoteEvent
ProfileUpdated.OnClientEvent:Connect(function(newData)
if newData.Gold ~= nil then
ProfileStore.Gold:Set(newData.Gold)
end
if newData.Level ~= nil then
ProfileStore.Level:Set(newData.Level)
end
end)
return ProfileStore

UI 레이어는 RemoteEvent의 존재를 모릅니다.

-- GoldDisplay.luau (UI 레이어)
local q = require("@game/ReplicatedStorage/Client/UI/Quad") -- 01장의 설정 모듈
local ProfileStore = require("./PlayerProfileState")
local function withCommas(n: number): string
local s = tostring(math.floor(n))
local replaced = 1
while replaced > 0 do
s, replaced = string.gsub(s, "^(-?%d+)(%d%d%d)", "%1,%2")
end
return s
end
local function GoldDisplay()
-- 파생 상태는 props 테이블 밖에서(함수 인자 테이블 안의 인라인 :Compute는
-- 에디터 타입 검사에서 무주석 콜백 파라미터를 못 푼다)
local goldText: q.State<string> = ProfileStore.Gold:Compute(function(gold)
return `보유 골드: {withCommas(gold:Get())} G`
end)
return q.Elem "TextLabel" {
Text = goldText,
TextColor3 = Color3.fromRGB(255, 215, 0),
FontFace = Font.fromEnum(Enum.Font.GothamBold),
}
end

글꼴은 FontFace가 현행 API입니다. [2026-09-09] 옛 Font = Enum.Font.GothamBold도 타입 표면에 남겨 두어 타입 검사를 통과하지만 엔진이 Hidden으로 표시한 레거시입니다(필드에 -- @deprecated 주석) — v1 이식 중에만 쓰세요.

⚠️ :Compute 콜백의 첫 인자는 값이 아니라 State 핸들입니다 — gold:Get()으로 꺼내야 하고, 꺼낸 값은 그냥 number입니다. 숫자에 :gsub 같은 문자열 메소드를 바로 부를 수 없습니다.


Tab 키로 인벤토리를 여닫는 예시입니다.

local UserInputService = game:GetService("UserInputService")
local isInventoryOpen = q.Source(false)
UserInputService.InputBegan:Connect(function(input, gameProcessed)
if gameProcessed then
return
end
if input.KeyCode == Enum.KeyCode.Tab then
isInventoryOpen:Set(not isInventoryOpen:Get())
end
end)
local function InventoryModal()
return q.Elem "Frame" {
Size = UDim2.fromOffset(500, 400),
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.fromScale(0.5, 0.5),
Visible = isInventoryOpen,
q.Elem "TextButton" {
Text = "X",
MouseButton1Click = function()
isInventoryOpen:Set(false)
end,
},
}
end

끌어다 놓으면 제자리로 돌아가는 카드

섹션 제목: “끌어다 놓으면 제자리로 돌아가는 카드”

엔진이 직접 프로퍼티를 바꾸는 입력(UIDragDetector의 드래그)과 애니메이션을 한 프로퍼티에 같이 걸 때는 원천을 둘로 가릅니다. 드래그 중 위치는 q.Out으로 받아 읽기만 하고, 놓은 뒤 돌아갈 목표는 애니메이션이 걸린 다른 Source에 둡니다(같은 Source에 둘 다 걸면 트윈 중간값이 q.Out으로 되쓰여 애니메이션이 첫 프레임에 멈춥니다 — q.Out의 주의).

local HOME = UDim2.fromScale(0.5, 0.5)
local home = q.Source(HOME) -- 놓으면 돌아갈 목표 — 애니메이션이 걸린 원천
local dragged = q.Source(HOME) -- 드래그 중 위치 — 읽기 전용으로 받아 둠
local card = q.Elem "Frame" {
Size = UDim2.fromOffset(120, 160),
AnchorPoint = Vector2.new(0.5, 0.5),
Position = home:Apply(q.Animate { Time = 0.25, Dedup = false }),
q.Out "Frame" "Position" (dragged),
q.Elem "UIDragDetector" {
DragEnd = function()
home:Set(HOME) -- 같은 목표로 다시 — Dedup = false라 매번 돌아간다
end,
},
}

Dedup = false가 핵심입니다. home은 늘 같은 HOME을 들고 있으므로, 놓을 때의 home:Set(HOME)은 그 자리에 기록된 목표와 같은 값입니다. 기본값(Dedup 켜짐)이면 quad는 “이미 그 목표”로 보고 건너뛰는데, 실제 프로퍼티는 드래그로 엔진이 옮겨 놓은 뒤라 카드가 제자리로 돌아오지 않습니다 — Dedup은 기록된 목표만 보고 실제 프로퍼티 값은 읽지 않기 때문입니다(Tween 레퍼런스의 Dedup 절). [2026-10-01 mock 실측 — 엔진의 드래그 쓰기를 대입 한 번으로 흉내 냄; 실기기 드래그는 미실측]


4. 컴포넌트 수명에 리스너 묶기

섹션 제목: “4. 컴포넌트 수명에 리스너 묶기”

컴포넌트가 살아 있는 동안에만 엔진 신호를 듣고 싶다면, 연결을 q.Effect 안에서 만들고 cleanup 함수로 돌려주면 됩니다. quad가 그 Effect를 인스턴스 수명에 묶고, 인스턴스가 파괴될 때 cleanup을 부릅니다.

local function CrosshairOverlay()
local mousePos = q.Source(Vector2.zero)
local dotPosition: q.State<UDim2> = mousePos:Compute(function(pos)
local p = pos:Get()
return UDim2.fromOffset(p.X, p.Y)
end)
return q.Elem "Frame" {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
-- 숫자 키 자리에 둔다 — 마운트 시 연결, 파괴 시 해제
q.Effect(function()
local conn = UserInputService.InputChanged:Connect(function(input)
if input.UserInputType == Enum.UserInputType.MouseMovement then
mousePos:Set(Vector2.new(input.Position.X, input.Position.Y))
end
end)
return function()
conn:Disconnect()
end
end),
q.Elem "Frame" {
Size = UDim2.fromOffset(8, 8),
Position = dotPosition,
BackgroundColor3 = Color3.fromRGB(255, 0, 0),
},
}
end

Effect의 cleanup 반환은 선택입니다 — 정리할 게 없으면 아무것도 돌려주지 않아도 됩니다. 구독 네 진입점과 cleanup 계약은 레퍼런스: Observer / Effect에 있습니다.

정리만 필요한 자리에는 생명주기 훅 슈가가 더 짧습니다(세 훅이 각각 언제 불리고 무엇을 보장하지 않는지는 레퍼런스: 생명주기 훅).

훅 언제 무엇을 받나
q.OnCreated<<I>>(fn) quad가 이 인스턴스에 무언가 하기 전 인스턴스
q.OnRendered<<I>>(fn) 이 인스턴스의 처리가 끝난 뒤 인스턴스
q.OnDestroyed(fn) 인스턴스가 파괴될 때 —
local function StatusPanel()
-- 컴포넌트를 만들 때 연결하고, 파괴될 때 끊는다
local conn = SomeSignal:Connect(function(...) --[[ … ]] end)
return q.Elem "Frame" {
-- 숫자 키 자리에 둔다 — 돌려주는 Effect이 이 인스턴스의 수명에 묶여야 파괴 때 돈다
q.OnDestroyed(function()
conn:Disconnect()
end),
-- …나머지 자식·프로퍼티…
}
end

인스턴스 자체를 나중에 참조해야 하면 q.Ref<<Frame?>>(nil)을 숫자 키 자리에 두고 ref.Value(또는 ref:Unwrap())로 읽습니다. 초기값의 타입이 곧 Ref의 타입이라, q.Ref(nil)은 Ref<nil>이 되어 그 숫자 키 자리에서 거부됩니다 — 타입 인자로 요소 타입을 명시하세요. Ref 계열 값도 문자 키가 아니라 숫자 키 자리에 놓습니다.


  1. 단방향 주입: 엔진 리스너 안에서 UI 인스턴스를 직접 고치지 말고 항상 Source:Set으로 상태를 바꿉니다.
  2. UI와 네트워크 디커플링: 컴포넌트는 Store/State만 소비하게 두면 헤드리스 테스트가 쉬워집니다 (06. Roblox Studio 없이 헤드리스로 테스트하기).
  3. 수명 동기화: 컴포넌트에 매달린 외부 연결은 Effect의 cleanup이나 OnDestroyed로 반드시 같이 정리합니다.

외부 신호가 UI에 닿는 길

RemoteEvent나 하드웨어 입력을 UI에 반영할 때 권장되는 모양은 무엇인가요?

  • 리스너 안에서는 Source:Set으로 상태만 바꾸고, UI는 그 상태를 선언적으로 구독합니다
  • 리스너 안에서 Frame 위치나 Text를 직접 고쳐 화면을 즉시 맞춥니다
  • UI 컴포넌트가 RemoteEvent를 직접 구독해 값을 받아 씁니다

명령형으로 리스너 안에서 인스턴스를 고치면 상태 동기화가 깨집니다. 상태 레이어와 UI 레이어를 파일로 갈라 두면 UI 레이어는 RemoteEvent의 존재를 모르고, 그래서 헤드리스 검증도 쉬워집니다.

컴포넌트 수명에 연결 묶기

컴포넌트가 살아 있는 동안에만 엔진 신호를 듣게 하려면 어떻게 하나요?

  • q.Effect 안에서 연결을 만들고 해제 함수를 돌려준 뒤, 그 Effect를 숫자 키 자리에 놓습니다
  • q.Effect는 cleanup 함수를 반드시 돌려줘야 하므로 정리할 것이 없어도 빈 함수를 돌려줍니다
  • q.OnDestroyed(fn)은 문자 키에 놓아야 인스턴스 수명에 묶입니다

숫자 키 자리에 두면 quad가 그 Effect를 인스턴스 수명에 묶고 파괴될 때 cleanup을 부릅니다. cleanup 반환은 선택이고, 정리만 필요한 자리에 더 짧은 q.OnDestroyed도 같은 숫자 키 자리에 놓습니다.

Quiz Progress

0 / 0 questions answered (0%)

0 correct