12. 함수로 묶기 — 팩토리·클로저·커링
This content is not available in your language yet.
대상 독자: 11. 컴포넌트로 쪼개기를 끝낸 개발자 목표: 지금까지 손이 먼저 익힌 모양들에 이름을 붙이고, 반복되는 반응형 로직을 함수 하나로 묶기
새로 배우는 API가 없는 장입니다. 지금까지 여러 번 써 온 것 — :Compute에 함수를
넘기고, Effect를 만들어 자리에 놓고, 컴포넌트를 부르는 것 — 은 전부 함수를
값처럼 다루는 같은 습관이었습니다. 이 장은 그 습관을 정리하고, 그걸로
반복을 줄이는 법을 봅니다.
Lua의 함수는 숫자나 테이블과 똑같은 값입니다. 변수에 담을 수도, 인자로 넘길 수도, 함수에서 돌려줄 수도 있습니다. quad의 API가 대부분 그 성질 위에 서 있어서, 이 장을 지나면 앞 장들의 모양이 한꺼번에 정리됩니다.
1. 함수를 인자로 — 콜백
섹션 제목: “1. 함수를 인자로 — 콜백”가장 많이 한 것입니다. 어떻게 할지를 함수로 적어 넘기면, 언제 부를지는 받는 쪽이 정합니다.
-- 지금까지 세 번 했습니다count:Compute(function(c) return `카운트: {c:Get()}` end) -- 값이 필요할 때 quad가 부른다count:Observer(function(t) print(t:Get()) end) -- 값이 바뀔 때 quad가 부른다D.TextButton { Activated = function() count:Set(1) end } -- 눌렸을 때 엔진이 부른다셋 다 “지금 실행해서 결과를 넘기는 것”이 아니라 함수 자체를 넘기는 것입니다. count:Compute(f())처럼 괄호를 붙이면 결과값이 넘어가 버립니다 — 괄호가 없는 것이 핵심입니다.
2. 바깥을 기억하는 함수 — 클로저
섹션 제목: “2. 바깥을 기억하는 함수 — 클로저”-- (04장에서 만든 그 버튼입니다)const count = q.Source(0)
const button = D.TextButton { Text = "+ 1", Activated = function() count:Set(count:Get() + 1) -- ← 바깥의 count를 기억한다 end,}Activated에 넘긴 함수는 count를 인자로 받지 않았는데도 count를 씁니다. 함수가 만들어진 자리의 바깥 변수를 계속 붙들고 있기 때문이고, 이것을 클로저라고 부릅니다.
quad에서 이 성질은 계속 나옵니다 — 컴포넌트 안에서 만든 Source를 그 안의 이벤트 핸들러가 쓰는 것(11장), Effect가 인자 대신 클로저로 값을 읽는 것(07장)이 전부 클로저입니다. 그래서 컴포넌트를 두 번 부르면 상태도 둘입니다. 각 호출이 자기 Source를 만들고, 그 호출 안에서 만들어진 함수들이 각자 그것을 붙들기 때문입니다.
3. 값을 돌려주는 함수 — 팩토리
섹션 제목: “3. 값을 돌려주는 함수 — 팩토리”q.Ref(...)·q.Effect(...)·D.Modifier.Frame {...}·컴포넌트는 전부 부르면 값이 나오는 함수입니다. 그렇다면 그 호출을 내 함수로 한 겹 감싸는 것도 당연히 됩니다. 그런 함수를 팩토리라고 부릅니다.
quad에서 팩토리가 쓰이는 자리는 둘입니다 — 값을 만들어 돌려주는 것, 그리고 그 팩토리 자체를 컴포넌트에 넘기는 것.
값을 돌려주는 팩토리
섹션 제목: “값을 돌려주는 팩토리”07장 3절에서 버튼 색을 만든 파이프는 “기준을 넘으면 노랑, 아니면 파랑”이라는 규칙 하나였습니다. 그 규칙에 이름을 붙입니다.
-- 새 예시: 별도 스크립트(또는 Counter.luau 위쪽)-- "이 State가 기준을 넘었는지에 따라 두 색 중 하나를 내놓는다"를 함수 하나로local function highlightColor(state, threshold) return state:Compute(function(c) return if c:Get() >= threshold then Color3.fromRGB(255, 190, 0) else Color3.fromRGB(0, 162, 255) end)end돌려주는 것이 State이므로, 쓰는 쪽은 그 호출을 문자 키의 값 자리에 놓습니다. 11장으로 옮겨 오면서 색 리터럴로 돌아가 있던 버튼을 다시 파이프로 바꿉니다.
-- … (Counter.luau) 버튼의 BackgroundColor3을 이렇게 고칩니다 D.TextButton { -- …나머지 프로퍼티 생략… BackgroundColor3 = highlightColor(count, 10), -- ← 문자 키의 값 자리 Text = "+ 1", Activated = function() count:Set(count:Get() + 1) end, },실행하면 07장과 똑같이 카운트가 10 이상일 때 버튼이 노란색이 됩니다. 달라진 것은 그 규칙이 이제 이름을 가졌다는 것뿐입니다 — 버튼이 열 개여도 highlightColor(...) 열 줄이면 되고, 여러 화면에서 쓸 것이면 이 함수만 모듈 하나로 빼면 됩니다.
컴포넌트에 팩토리를 넘기기
섹션 제목: “컴포넌트에 팩토리를 넘기기”11장의 Counter는 count를 자기 안에 숨깁니다. 부른 쪽은 그 Source를 손에 쥘 수 없습니다. 그런데 바깥에서 “이 카운터가 셀 때마다 로그를 남기고 싶다”처럼 안쪽 상태에 무언가를 걸고 싶은 경우가 생깁니다.
이럴 때 상태를 밖으로 내주는 대신 팩토리를 받습니다. 컴포넌트가 자기 count를 그 함수에 건네고, 돌려받은 것을 자기 숫자 키 자리에 놓습니다.
-- … (Counter.luau) 돌려주는 D.Frame의 끝, props.Children 뒤에 한 줄을 더합니다 props.Children or q.None,
if props.Watch then props.Watch(count) else q.None, }end부르는 쪽은 그 자리에서 count를 받아 무언가를 만들어 돌려줍니다. 로그처럼 뒤처리가 따라오는 일이면 Effect가 됩니다.
-- … (Main.client.luau) 부르는 쪽Counter { Label = "왼쪽", Start = 0, Watch = function(count) return q.Effect(function() print("왼쪽 카운터:", count:Get()) return function() print("왼쪽 카운터 정리") end end, count) end,},실행하면 왼쪽 카운터가 만들어질 때 왼쪽 카운터: 0이 찍히고, 버튼을 누를 때마다 왼쪽 카운터 정리 뒤에 왼쪽 카운터: 1이 이어집니다. 그 카운터를 파괴하면 마지막 정리가 한 번 더 찍힙니다. Watch를 안 넘긴 카운터는 그대로입니다.
if props.Watch then … else q.None은 11장 2절의 props.Children or q.None과 같은 이유입니다 — 안 넘어왔을 때 그 자리가 구멍이 되면 안 되니까요. 더구나 여기서는 Children 뒤에 원소가 하나 더 붙었으니, 11장이 “뒤에 원소를 하나라도 더 붙이는 순간”이라고 예고한 상황이 바로 이것입니다.
돌려주는 것이 Effect여야 할 이유는 없습니다. Observer든 Modifier든, 컴포넌트는 받은 것을 자기 숫자 키 자리에 놓기만 합니다 — 07장 4절의 Effect(Ref와 파이프를 의존성으로 건 것)도 그대로 이 자리에 넘길 수 있습니다.
컴포넌트도 사실 이 팩토리의 한 종류입니다. 다른 점은 돌려주는 값이 Effect가 아니라 인스턴스라는 것뿐입니다.
4. 함수를 돌려주는 함수 — 커링
섹션 제목: “4. 함수를 돌려주는 함수 — 커링”한 걸음만 더 가 봅니다. 팩토리가 값을 돌려줬다면, 함수를 돌려주는 함수도 만들 수 있습니다.
:Compute에 넘기는 “10을 더한다”를 재사용하고 싶다고 해 봅시다. 더하는 수가 매번 다르니 그 수를 먼저 받고, 그 다음에 콜백을 돌려주면 됩니다.
-- 새 예시: 별도 스크립트-- n을 받아서, "핸들을 받아 n을 더해 돌려주는 함수"를 만든다local function Sum(n) return function(c) return c:Get() + n endend
const count = q.Source(0)const plusTen = count:Compute(Sum(10))
print(plusTen:Get()) --> 10
count:Set(1)print(plusTen:Get()) --> 11Sum(10)이 만들어 낸 것은 값이 아니라 함수입니다. 그리고 그 함수는 10을 기억하고 있습니다(2절의 클로저입니다). 이렇게 “인자를 나눠서 단계적으로 받는” 모양을 커링이라고 부릅니다.
같은 모양이 관측 콜백에도 그대로 쓰입니다.
-- 새 예시: 별도 스크립트(count를 0에서 새로 만듭니다)const count = q.Source(0)
local function logWith(prefix) return function(t) print(prefix, t:Get()) endend
const label = D.TextLabel { Text = "…", count:Observer(logWith("카운트:")),}실행하면 카운트: 0으로 시작해 값이 바뀔 때마다 같은 접두사로 찍힙니다. 접두사가 다른 관측을 열 개 붙여야 한다면, 열 개의 익명 함수 대신 logWith(...) 열 줄이 됩니다.
흔한 연산은 이미 있습니다 — q.Operator
섹션 제목: “흔한 연산은 이미 있습니다 — q.Operator”방금 손으로 만든 Sum 같은 것은 quad에 이미 들어 있습니다. q.Operator가 그 모음이고, 붙이는 자리는 :Compute가 아니라 **:Apply**입니다.
-- … 이 절 첫 조각(Sum/plusTen을 만든 별도 스크립트)에 이어집니다const plusTenOp = count:Apply(q.Operator.Sum(10)) -- 앞서 손으로 만든 Sum(10)과 같은 결과:Apply(factory)는 “이 팩토리가 만들어 낸 연산을 이 State에 붙인다”는 뜻입니다 — 팩토리가 자기 의존성까지 같이 들고 있어서, 한 번 이름 붙인 연산자를 여러 State에 붙여도 의존성이 따라갑니다.
산술·비교·불리언·비트 연산까지 이름 붙은 것이 열넷 있습니다. 전체 목록과 계약은 레퍼런스: Operator에 있습니다.
--!strict에서는 이 함수들에 뭘 적나요?
이 문서의 화면 코드는 Roblox 기본 모드(--!nonstrict) 기준이라 위 예제들엔 타입 주석이 없습니다. --!strict으로 올릴 때 붙는 것은 넘겨받는 핸들의 타입뿐입니다 — 전부 01장의 설정 모듈이 다시 내보낸 이름입니다.
--!strict-- 콜백이 받는 것은 "값"이 아니라 핸들이므로 q.StateData<T>local function Sum(n: number) return function(c: q.StateData<number>): number return c:Get() + n endend
local function logWith(prefix: string) return function(t: q.StateData<number>) print(prefix, t:Get()) endend
-- 팩토리는 받는 것과 돌려주는 것에 이름만 붙이면 된다local function highlightColor(state: q.State<number>, threshold: number): q.State<Color3> return state:Compute(function(c: q.StateData<number>): Color3 return if c:Get() >= threshold then Color3.fromRGB(255, 190, 0) else Color3.fromRGB(0, 162, 255) end)end
-- :Apply의 결과 타입은 부르는 쪽이 적는다const count = q.Source(0)const total: q.State<number> = count:Apply(q.Operator.Sum(10))q.StateData<T>와 q.State<T>의 차이는 “핸들로 받는 자리”와 “값이 흐르는 노드”입니다. 콜백 파라미터는 앞의 것, 변수에 담아 프로퍼티로 흘려보내는 것은 뒤의 것을 씁니다.
:Apply에는 테이블도 들어갑니다 — __apply
섹션 제목: “:Apply에는 테이블도 들어갑니다 — __apply”q.Operator.Sum(10)이 그랬듯 :Apply에 넘기는 것은 대개 함수입니다. 그런데 팔이 하나 더 있습니다 — __apply 메소드를 가진 테이블도 받습니다. 함수를 넘기면 factory(state)로 불리고, 테이블을 넘기면 factory:__apply(state)로 불립니다.
-- … 위쪽 코드에 이어집니다 — __apply 하나면 :Apply에 들어간다const Doubled = { __apply = function(self, state) return state:Compute(function(c) return c:Get() * 2 end) end,}
const twice = count:Apply(Doubled)둘 중 어느 쪽도 아닌 것을 넘기면 그 자리에서 던집니다.
State: Apply factory must be a function or an object with an __apply method이 팔이 있어서 자기 상태를 가진 것도 :Apply로 붙습니다. 16장에서 만들 q.Blocker()가 바로 이런 값이라 countText:Apply(blocker)처럼 붙고, 그러고도 그 스위치는 손에 남아 있어 나중에 여닫을 수 있습니다(반대로 q.Animate { … }는 함수를 돌려주는 팩토리라 함수 팔입니다). 두 팔의 계약은 레퍼런스: state:Apply(factory)에 있습니다.
5. 이 함수들에는 규칙이 없습니다
섹션 제목: “5. 이 함수들에는 규칙이 없습니다”React를 써 봤다면 여기서 한 번 멈칫하게 됩니다. Hook은 컴포넌트 최상단에서만, 조건문 밖에서, 언제나 같은 순서로 불러야 하니까요.
quad의 이 함수들에는 그런 규칙이 없습니다. 조건문 안에서도, 루프 안에서도, 이름이 use로 시작하지 않아도 됩니다.
-- (컴포넌트 안이라면 이런 것도 됩니다 — 조건식 안에서 만들어도, 이름이 use로 시작하지 않아도 됩니다)const extra = if props.Verbose then q.Effect(function() print(count:Get()) end, count) else q.None-- …그리고 이 값을 숫자 키 자리에 그대로 놓습니다왜 Hook 규칙이 없나요?
Hook 규칙은 컴포넌트 함수가 몇 번이고 다시 실행된다는 전제에서 나옵니다. React는 상태를 호출 순서로 짝지어 기억하기 때문에, 두 번째 실행에서 순서가 달라지면 짝이 어긋납니다.
quad의 컴포넌트는 한 번 실행되는 셋업 함수입니다. 실행되는 동안 실제 인스턴스와 반응형 그래프를 한 번 만들고, 그다음부터 화면을 갱신하는 것은 그 그래프이지 함수의 재실행이 아닙니다. 다시 실행되지 않으니 “두 번째 실행에서의 순서” 자체가 존재하지 않고, 그래서 지킬 규칙도 없습니다.
같은 이유로 Effect나 Observer를 만드는 자리도 자유롭습니다 — 만든 것을 숫자 키 자리에 놓기만 하면 그때부터 그 인스턴스의 수명을 따릅니다.
“한 번 만든 그래프를 다시 그리지 않는다”가 어떤 성질로 이어지는지는 17장에서 한 번에 봅니다.
더 알고 싶다면
섹션 제목: “더 알고 싶다면”- 01. 컴포넌트 경계 규약과 스타일 합성 — 재사용 로직을 함수로 빼는 실전 규약과
--!strict에서 붙이는 타입 주석 - 레퍼런스:
Operator— 콤비네이터 열넷의 시그니처,:Apply의 계약, 에러가 나는 세 시점 - 레퍼런스:
Observer/Effect— 팩토리가 돌려주는 그 값들의 전체 계약