---
title: "LitMotion 의 빌더는 풀에서 빌린 버퍼를 가리키는 구조체다"
description: "LMotion.Create 부터 Bind 까지 사슬 어디에서도 힙 객체가 생기지 않는 것은 빌더가 풀에서 빌린 버퍼를 가리키는 작은 구조체이기 때문입니다. 버전 번호가 옛 복사본을 막고, state 인자가 클로저를 없앱니다."
author: "유니얼"
pubDate: 2026-09-26
updatedDate: 2026-10-18
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "트윈"]
canonical: https://blog.unialgames.com/posts/unity-litmotion-builder/
---
```csharp
LMotion.Create(0f, 1f, 0.3f)
    .WithEase(Ease.OutQuad)
    .WithDelay(0.1f)
    .Bind(group, static (x, g) => g.alpha = x);
```

`Create` 로 시작해 `With...` 를 잇고 `Bind` 로 끝나는 사슬입니다. 빌더를 클래스로 만들면 모션마다 객체가 생기고, 설정을 전부 구조체에 담으면 고리마다 설정 전체가 복사됩니다.

LitMotion 의 `MotionBuilder` 는 **풀에서 빌린 버퍼 참조 하나와 `ushort` 버전 하나만 든 구조체**입니다. 설정은 버퍼에 쌓이고, `Bind` 하면 버퍼가 풀로 돌아가며 버전이 오릅니다. 기준 판본은 2.0.2 입니다.

## 설정은 풀에서 빌린 버퍼에 쌓인다

```csharp
// Runtime/MotionBuilder.cs
public struct MotionBuilder<TValue, TOptions, TAdapter> : IDisposable
{
    internal ushort version;
    internal MotionBuilderBuffer<TValue, TOptions> buffer;   // 시작값, 끝값, 이징, 콜백, 스케줄러 전부

    public readonly MotionBuilder<TValue, TOptions, TAdapter> WithEase(Ease ease)
    {
        CheckBuffer();
        buffer.Ease = ease;
        return this;   // 복사본이지만 같은 버퍼를 가리킨다
    }
}
```

`With...` 가 돌려주는 복사본들은 모두 **같은 버퍼**를 가리키므로 어느 복사본으로 설정해도 한곳에 모입니다. 복사하는 것은 참조와 숫자 하나뿐입니다.

버퍼 풀에는 컨테이너가 없습니다. 버퍼 클래스 자신의 `NextNode` 필드로 엮인 스택이고, 정적 필드 `PoolRoot` 가 머리입니다. 빌더 둘을 겹쳐 쓰다가 차례로 `Bind` 하고 셋째를 만드는 경우입니다.

```csharp
// Rent(): LMotion.Create 가 부른다
if (PoolRoot == null)
{
    result = new();                    // 풀이 비었을 때만 new
}
else
{
    result = PoolRoot;                 // 머리를 꺼낸다
    PoolRoot = PoolRoot.NextNode;
    result.NextNode = null;
}

// Return(buffer): Bind 가 끝나면 부른다
buffer.Version++;                      // 버전을 올리고 필드를 전부 기본값으로

if (buffer.Version != ushort.MaxValue)
{
    buffer.NextNode = PoolRoot;        // 머리에 끼운다
    PoolRoot = buffer;
}
```

풀에 버퍼 A 하나가 있을 때 빌더 둘을 겹쳐 쓰다가 차례로 `Bind` 하고 셋째를 만들면 이렇습니다.

| 일 | 풀 | 빌더가 받은 버퍼 |
| --- | --- | --- |
| 첫째 `Create` | 비었음 | 빌더1 → A (v0) |
| 둘째 `Create`. 빌더1 은 아직 `Bind` 전 | 비었음 | 빌더2 → B (v0). **여기서 `new`** |
| 빌더1 `Bind` | A (v1) | |
| 빌더2 `Bind` | B (v1) → A (v1) | |
| 셋째 `Create` | A (v1) | 빌더3 → B (v1) |

`new` 는 동시에 들고 있는 빌더 수가 풀 크기를 넘을 때만 생깁니다.

보통은 `Create` 부터 `Bind` 까지 한 줄이라 동시에 살아 있는 빌더가 하나이고, 정적 초기화로 만든 버퍼 하나가 계속 돕니다. 풀은 `<TValue, TOptions>` 마다 따로이고 잠금이 없어 메인 스레드 전용입니다.

## 버전이 옛 복사본을 막는다

모든 `With...` 와 `Bind` 는 첫 줄에서 버퍼의 버전과 빌더가 베껴 둔 버전을 비교합니다.

```csharp
// Runtime/MotionBuilder.cs
readonly void CheckBuffer()
{
    if (buffer == null || buffer.Version != version) throw new InvalidOperationException("MotionBuilder is either not initialized or has already run a Build (or Bind).");
}
```

`Bind` 는 끝에서 자기 변수의 `buffer` 를 `null` 로 만들지만, 미리 떠 둔 복사본은 여전히 버퍼를 가리킵니다. 그 버퍼는 곧바로 다음 빌더에게 나가므로, 옛 복사본이 가리키는 것은 "지나간 버퍼" 가 아니라 "지금 남이 쓰는 버퍼" 입니다.

<x-compare>
<script type="application/json">
{
  "label": "빌더를 복사해 둔 변수로 설정할 때",
  "tabs": [
    {
      "label": "Bind 전에 복사본으로 설정",
      "code": "var b = LMotion.Create(0f, 1f, 1f);   // 버퍼 A, 버전 0\nvar c = b.WithEase(Ease.OutQuad);     // c 도 같은 A 를 가리킨다\nc.WithDelay(0.5f);                    // A.Version 0 == c.version 0. 통과\nb.Bind(x => Debug.Log(x));            // 설정은 A 한곳에 모여 있어 둘 다 들어간다",
      "text": "복사본이어도 같은 버퍼를 가리키고 버전이 같으니 설정이 그대로 들어갑니다.",
      "stats": [
        [
          "결과",
          "OutQuad, 0.5초 딜레이로 생성"
        ]
      ],
      "tone": "good"
    },
    {
      "label": "Bind 뒤 옛 복사본을 씀",
      "code": "var b = LMotion.Create(0f, 1f, 1f);   // 버퍼 A, 버전 0\nvar c = b;                            // c 도 A, 버전 0\nb.Bind(x => Debug.Log(x));            // A 를 돌려주고 A.Version = 1\nvar d = LMotion.Create(5f, 9f, 2f);   // 같은 A 를 꺼낸다. 이제 A 는 d 의 것\nc.WithEase(Ease.OutQuad);             // A.Version 1 != c.version 0 → 예외",
      "text": "버전 검사가 없었다면 c 가 d 의 이징을 바꿨습니다.",
      "stats": [
        [
          "결과",
          "InvalidOperationException"
        ]
      ],
      "tone": "bad"
    }
  ]
}
</script>
</x-compare>

**`Dispose` 에는 이 검사가 없습니다.** `buffer` 가 `null` 이 아니면 그대로 반납합니다. 그래서 `Bind` 한 빌더의 복사본을 `Dispose` 하면 같은 버퍼가 풀에 두 번 들어가고(`NextNode` 가 자기 자신을 가리킴), 다음 `Create` 두 번이 같은 버퍼와 같은 버전을 받습니다. 둘째 빌더에 적은 설정이 첫째 모션에 섞이고, 첫째가 `Bind` 한 뒤에야 둘째가 예외를 냅니다.

## state 인자가 클로저를 없앤다

`Bind` 는 델리게이트를 `object` 필드 하나에, state 를 `State0` 부터 차례로 넣습니다. 매 프레임에는 `UnsafeUtility.As` 로 `Action<TValue, object>` 로 다시 읽어 부릅니다. 참조끼리는 넘기는 방식이 같아서 이 재해석이 성립하고, 그래서 state 는 `where TState : class` 입니다.

람다가 대상을 붙잡지 않고 인자로 받으면 컴파일러가 델리게이트를 한 번만 만들어 재사용합니다. `BindToPosition` 같은 확장 메서드도 안에서 `Bind(transform, static (x, t) => t.position = x)` 를 부릅니다.

<x-compare>
<script type="application/json">
{
  "label": "같은 위치 모션을 만드는 세 가지 Bind",
  "tabs": [
    {
      "label": "붙잡는 람다",
      "code": "var t = transform;\nLMotion.Create(a, b, 1f)\n    .Bind(x => t.position = x);\n\n// 컴파일러가 만드는 것:\n//   t 를 필드로 가진 클로저 객체\n//   그 객체의 메서드를 가리키는 Action<Vector3>\n// 둘 다 Bind 를 부를 때마다 새로",
      "stats": [["StateCount", "0"], ["모션 하나당 할당", "152.48 B"], ["누가 할당하나", "사용자 코드의 람다"]],
      "tone": "bad"
    },
    {
      "label": "state + static 람다",
      "code": "LMotion.Create(a, b, 1f)\n    .Bind(transform, static (x, t) => t.position = x);\n\n// transform 은 State0 에\n// 람다는 아무것도 붙잡지 않으니\n// 컴파일러가 한 번 만들어 둔 델리게이트를 재사용",
      "stats": [["StateCount", "1"], ["모션 하나당 할당", "1.76 B, 배열 성장 몫이 100개에 나뉜 것"], ["누가 할당하나", "없음"]],
      "tone": "good"
    },
    {
      "label": "확장 메서드",
      "code": "LMotion.Create(a, b, 1f)\n    .BindToPosition(transform);\n\n// 안에서 위 탭과 같은 Bind(transform, static ...) 를 부른다",
      "stats": [["StateCount", "1"], ["모션 하나당 할당", "0 B"], ["누가 할당하나", "없음"]],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

붙잡는 람다만 모션마다 152 B 였고 에디터, Mono, IL2CPP 가 같았습니다. 클로저 객체와 `Action<Vector3>` 가 새로 생기는 몫이고, LitMotion 쪽은 어느 탭에서도 할당하지 않았습니다(`Vector3` 위치 모션 100개를 한 번에 만든 순할당을 100 으로 나눔).

state 가 구조체면 확장 오버로드가 `Box<T>` 로 감싸서 부를 때마다 상자를 만듭니다(Mono 20 B, IL2CPP 24 B). 미리 만든 상자를 쓰는 것은 `int` -1~9 와 `bool` 뿐인데, 2.0.2 의 `BoxFalse` 는 `new(true)` 로 만들어져 있어 `Bind(false, ...)` 의 콜백이 `true` 를 받습니다.

## Bind 하는 순간 시작값을 한 번 쓴다

`Bind` 는 버퍼의 값을 저장소 배열로 옮겨 적고 버퍼를 돌려줍니다. 옮기는 도중 `ImmediateBind` 가 켜져 있으면 진행도 0 의 값으로 콜백을 **그 자리에서** 한 번 부릅니다. 모션의 첫 갱신은 다음 PlayerLoop 이라, 이게 없으면 만든 프레임에 대상이 옛 값에 머뭅니다.

이 블록은 딜레이를 보지 않습니다. 위치 (9, 9, 9) 인 Transform 에 `WithDelay(1f).BindToPosition` 을 걸면 같은 프레임에 (0, 0, 0) 으로 가서 딜레이 내내 거기 머뭅니다. `WithImmediateBind(false)` 를 붙이면 (9, 9, 9) 를 지킵니다. 빌더 버퍼의 기본값은 `true` 이고, 직접 `new MotionSettings<...>` 로 만든 설정은 `false` 라 첫 대입이 한 프레임 늦습니다.

## 주의할 점

### 1. 빌더를 담아 두고 다시 쓰거나 Dispose 하지 않는다

**안 지키면 생기는 문제.** `Bind` 한 빌더나 그 복사본으로 다시 `With...` 나 `Bind` 를 부르면 `MotionBuilder is either not initialized or has already run a Build (or Bind).` 예외입니다. 복사본을 `Dispose` 하면 예외도 없이 다음 두 빌더가 한 버퍼를 나눠 씁니다.

**이렇게 쓴다.** `Create` 부터 `Bind` 까지 한 문장으로 끝냅니다. 설정을 재사용하려면 `ToMotionSettings()` 로 뽑은 `MotionSettings` 를 보관하고 `LMotion.Create(settings)` 로 매번 새 빌더를 받습니다. `Dispose` 는 `Bind` 하지 않기로 한 빌더 변수 하나에만 부릅니다.

### 2. 대상을 붙잡는 람다로 Bind 하지 않는다

**안 지키면 생기는 문제.** `Bind(x => t.position = x)` 는 모션마다 152 B 를 할당합니다. 목록 칸마다 모션을 거는 UI 라면 스크롤할 때마다 GC 거리가 쌓입니다.

**이렇게 쓴다.** 확장 메서드가 있으면 그것을, 없으면 `Bind(대상, static (x, 대상) => ...)` 를 씁니다. state 는 셋까지 넘길 수 있고, `static` 을 붙이면 실수로 붙잡았을 때 컴파일이 막습니다.

### 3. 구조체, 특히 bool 을 state 로 넘기지 않는다

**안 지키면 생기는 문제.** `Bind(false, (x, flag) => ...)` 의 `flag` 는 `true` 로 들어옵니다. `int` -1~9 밖의 구조체 state 는 부를 때마다 상자를 할당합니다.

**이렇게 쓴다.** 켜짐, 꺼짐은 `static` 람다 두 개 중 하나를 고르는 식으로 나누고, 값이 필요하면 그 값을 가진 참조 타입(컴포넌트 자신 같은 것)을 state 로 넘깁니다.

### 4. 딜레이 동안 제자리를 지켜야 하면 ImmediateBind 를 끈다

**안 지키면 생기는 문제.** `WithDelay` 를 걸어도 만드는 순간 시작값이 한 번 대입됩니다. 1초 뒤에 날아가야 할 아이콘이 만들자마자 출발점으로 튀어 거기서 기다립니다.

**이렇게 쓴다.** 그런 모션에는 `WithImmediateBind(false)` 를 붙입니다. 반대로 `MotionSettings` 를 직접 만들 때는 `ImmediateBind = true` 를 적어 빌더와 기본값을 맞춥니다.

## 정리

LitMotion 의 빌더는 **풀에서 빌린 버퍼를 가리키는 작은 구조체**입니다.

- 설정은 버퍼에 쌓이고 복사본은 모두 같은 버퍼를 가리킵니다. 풀은 버퍼 자신의 `NextNode` 로 엮인 스택입니다.
- `Bind` 가 버퍼를 돌려주며 버전을 올려 옛 복사본을 막습니다. `Dispose` 만 이 검사를 건너뜁니다.
- 대상을 state 로 넘기고 `static` 람다를 쓰면 모션당 0 B 입니다. 붙잡는 람다는 152 B 였습니다.