---
title: "LitMotion 은 모션마다 드는 할당을 없앤 대신 러너마다 고정 비용을 낸다"
description: "LitMotion 의 Zero Allocation 은 모션을 만들고 돌리는 동안 GC 할당이 0 이라는 뜻입니다. 대신 타입과 타이밍마다 하나인 러너가 모션이 없어도 매 프레임 일해서, 모션이 적을수록 하나당 비용이 커집니다."
author: "유니얼"
pubDate: 2026-09-26
updatedDate: 2026-10-18
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "트윈", "GC"]
canonical: https://blog.unialgames.com/posts/unity-litmotion-alloc/
---
```json
"description": "Lightning-fast and Zero Allocation Tween Library for Unity."
```

코루틴 연출은 부를 때마다 GC 할당을 남깁니다. LitMotion 은 패키지 설명에 "Zero Allocation" 을 걸었고, 모션을 올바른 경로로 만들면 실제로 0 이 나옵니다. 그런데 프로파일러에는 모션이 몇 개 없어도 LitMotion 몫이 보입니다.

LitMotion 은 **모션마다 드는 할당을 없앤 대신, (값 타입 × 타이밍) 마다 하나인 러너가 매 프레임 고정 비용을 냅니다.** 할당은 처음 쓰는 순간과 배열이 커지는 순간에 몰리고, 고정 비용은 모션이 많을수록 잘게 나뉩니다. 기준 판본은 2.0.2 입니다.

## 정상 상태의 모션은 할당이 없다

`Bind` 에 `static` 람다와 state 를 넘기면 모션을 만들 때 0 B 이고, 모션 1만 개가 도는 동안의 프레임 할당도 빈 프레임과 같았습니다. 할당이 남는 것은 다른 기능을 쓸 때입니다.

| 자리 | 언제 | 크기 |
| --- | --- | --- |
| 대상을 붙잡는 람다 | 모션마다 | 152 B |
| 구조체 state (`int` -1~9, `bool` 밖) | 모션마다 | Mono 20 B, IL2CPP 24 B |
| `WithOnComplete` 등 콜백 | 호출마다 | 넘긴 델리게이트, 두 번째부터 결합 델리게이트 |
| 커스텀 `AnimationCurve` | 모션마다 | `keys` 복사본, 키 3개 116 B |
| `MonoBehaviour` 안의 `AddTo(this)` | 호출마다 | 약 890 B |
| 새 (값 타입 × 타이밍) 조합 | 그 조합의 첫 모션 | 약 7.4~7.8 KB |
| 저장소 배열 성장 | 동시 모션 수가 용량을 넘을 때 | `float` 32 → 64칸에서 약 11 KB |

## 할당은 첫 사용과 배열 성장에 몰린다

저장소는 그 조합의 첫 모션 때 생기고, 칸이 모자라면 배열 넷을 두 배로 새로 만듭니다. 32칸 저장소에 32번째와 33번째 모션이 들어올 때입니다.

```csharp
public MotionHandle Create(ref MotionBuilder builder)
{
    EnsureCapacity(tail + 1);      // 슬롯, 역색인, 데이터, 관리 데이터 배열 넷 모두 확인한다

    // ArrayHelper.EnsureCapacity(ref array, minimumCapacity)
    if (array.Length >= minimumCapacity)
        return;                    // 칸이 충분하면 아무것도 하지 않는다

    while (l < minimumCapacity)
        l *= 2;                    // 두 배씩 키운다
    Array.Resize(ref array, l);    // 새 배열을 만들어 옮긴다. 옛 배열은 쓰레기

    unmanagedDataArray[tail] = ...;
    managedDataArray[tail] = ...;
    tail++;
}
```

| 모션 | 필요한 칸 | 용량 | 할당 |
| --- | --- | --- | --- |
| 32번째 | 32 | 32 | 없음 |
| 33번째 | 33 | 32 → 64 | 64칸 배열 넷 (float 모션에서 약 11 KB) |

용량은 32, 64, 128 처럼 두 배로만 자라고 줄어드는 코드는 없습니다.

모션 하나를 만드는 프레임의 순할당을 쟀습니다.

| 경우 | Mono | IL2CPP |
| --- | --- | --- |
| 32번째 (성장 없음) | 440 B | 442 B |
| 33번째 (배열 넷 32 → 64) | 11,576 B | 11,578 B |
| `EnsureStorageCapacity(64)` 뒤 33번째 | 457 B | 442 B |
| 처음 쓰는 `Color` 의 첫 모션 | 7,768 B, 호출 5.41 ms | 7,440 B, 호출 0.88 ms |
| `EnsureStorageCapacity(64)` 뒤 처음 쓰는 `Vector4` 의 첫 모션 | 854 B, 호출 4.06 ms | 962 B, 호출 0.81 ms |

`EnsureStorageCapacity` 는 배열 성장을 없애고 첫 사용의 할당도 대부분 로딩으로 옮기지만, 첫 모션의 시간은 조금밖에 줄이지 못했습니다. 이 API 는 러너를 만들지 않아서, 첫 모션이 러너를 만들고 그 조합의 생성 경로를 처음 밟는 일이 그대로 남기 때문입니다.

## 러너는 모션이 없어도 매 프레임 일한다

러너는 자기 타이밍마다 `TempJob` 네이티브 배열 둘을 잡고, Job 을 예약해 기다리고, 콜백 루프를 **배열 길이만큼** 돕니다. 모션이 0개여도 같습니다.

```csharp
var count = storage.Count;

// 타이밍마다 네이티브 배열 둘을 잡는다. 모션이 0개여도 잡는다
using var output = new NativeArray<TValue>(count, Allocator.TempJob);
using var completed = new NativeList<int>(count, Allocator.TempJob);

job.Schedule(count, 16).Complete();       // 할 일이 없어도 예약하고 기다린다

var managedDataSpan = storage.GetManagedDataSpan();   // 모션 수가 아니라 배열 길이(용량)

for (int i = 0; i < managedDataSpan.Length; i++)
{
    if (canUpdate)
        managedData.UpdateUnsafe(output[i]);          // Bind. 빈 칸은 상태가 None 이라 넘어간다
}

storage.RemoveAll(completed);
```

용량 8 인 저장소로 줄여 보면 이렇습니다.

| 모션 수 | 네이티브 배열 | Job 예약 | 콜백 루프가 보는 칸 |
| --- | --- | --- | --- |
| 2 | 둘 잡음 | 함 | 8칸 (Bind 2번, 빈 칸 6) |
| 0 | 둘 잡음 | 함 | 8칸 (모두 빈 칸) |

러너 하나의 고정 비용은 모션 수와 상관없이 나가고, 콜백 루프는 용량만큼 돕니다.

그래서 모션이 적을수록 하나당 비용이 커집니다. `Vector3` 위치 모션 N개를 돌린 값입니다.

| 동시 모션 | 에디터 프레임 증가 | Mono 단계 | IL2CPP 단계 |
| --- | --- | --- | --- |
| 100 | +0.074 ms (740 ns) | 0.245 ms (2,431 ns) | 0.176 ms (1,747 ns) |
| 1,000 | +0.169 ms (169 ns) | 0.326 ms (324 ns) | 0.267 ms (265 ns) |
| 10,000 | +1.169 ms (117 ns) | 0.968 ms (97 ns) | 0.759 ms (76 ns) |
| 0 (모두 취소, 러너는 남음) | | 0.161 ms | 0.091 ms |

괄호 안은 모션 하나당 값이고, 에디터의 기준 프레임은 0.130 ms 입니다. 플레이어 값은 `Vector3` 저장소를 16,384칸으로 미리 키운 뒤 LitMotion `Update` 단계 앞뒤를 200프레임 잰 중앙값입니다.

마지막 줄이 표를 읽는 열쇠입니다. 모션이 없어도 16,384칸 배열을 도는 콜백 루프가 남아, 모션 100개일 때 Mono 단계 시간의 3분의 2 가 모션과 무관한 몫이었습니다. 32칸짜리 빈 러너 하나는 Mono 4.3 µs, IL2CPP 1.6 µs, 에디터 약 4.4 µs 였습니다. 러너는 한 번 생기면 목록에서 빠지지 않으므로, 한 번이라도 쓴 조합은 그 뒤로 매 프레임 이 값을 냅니다. 모션을 하나 더할 때의 비용(1,000개와 10,000개 행의 차이)은 에디터 111 ns, Mono 71 ns, IL2CPP 55 ns 였습니다.

## MotionDebugger 는 끝난 모션을 놓지 않는다

2.0.2 에는 옛 `MotionTracker` 대신 `MotionDebugger` 와 `Window/LitMotion Debugger` 창이 있습니다. `Enabled` 는 실행 중에 읽는 `bool` 이고, 켜져 있으면 모션을 만들 때마다 추적 객체를 목록에 넣고 끝날 때 뺍니다. 빼는 조건이 문제입니다.

<x-compare>
<script type="application/json">
{
  "label": "MotionDebugger 를 켰을 때 추적 객체가 목록에서 빠지는가",
  "tabs": [
    {
      "label": "완료",
      "code": "void OnComplete()\n{\n    OriginalOnCompleteCallback?.Invoke();\n\n    // 완료 콜백은 상태를 Completed 로 바꾼 뒤에 불린다\n    // 보존 안 함: IsActive() 가 거짓 → 조건 거짓\n    // Preserve:   IsActive() 는 참이지만 IsPreserved 도 참 → 조건 거짓\n    if (Handle.IsActive() && !MotionManager.GetDataRef(Handle, false).State.IsPreserved)\n        Release();   // trackings.Remove(this)\n}",
      "text": "완료 경로에서 조건이 참이 되는 상태 조합이 없습니다.",
      "stats": [
        [
          "목록에서",
          "빠지지 않음"
        ],
        [
          "0.05초 모션 1,000개 재생 뒤",
          "Items.Count = 1,000"
        ]
      ],
      "tone": "bad"
    },
    {
      "label": "취소",
      "code": "void OnCancel()\n{\n    OriginalOnCancelCallback?.Invoke();\n    Release();   // 조건 없이 뺀다\n}",
      "text": "취소 쪽은 조건 없이 뺍니다.",
      "stats": [
        [
          "목록에서",
          "빠짐"
        ],
        [
          "Cancel() 로 끝낸 1,000개 뒤",
          "Items.Count = 0"
        ]
      ],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

`Enabled` 를 켜고 0.05초 모션 1,000개를 끝까지 재생하자 `MotionDebugger.Items.Count` 가 1,000 이었고, `Cancel()` 로 끝낸 1,000개는 0 이었습니다. 켜 두면 생성마다 Mono 544.7 B, IL2CPP 552.5 B 가 들었습니다. 창은 `IsActive()` 인 항목만 그리므로 쌓이는 것이 화면에 보이지 않고, 켬 상태는 `EditorPrefs` 에 남아 다음에 창이 열릴 때 되살아납니다.

`WithDebugName(string)` 의 이름 필드는 릴리스 빌드에서 사라지지만, `$"enemy {id}"` 같은 보간 문자열은 호출하기 전에 만들어지므로 릴리스에서도 할당됩니다.

## 주의할 점

### 1. 모션이 적은 곳에서는 러너 수를 줄인다

**안 지키면 생기는 문제.** 화면 연출 몇 개에 `float`, `Vector2`, `Vector3`, `Color` 를 섞고 타이밍도 나누면, 모션이 다 끝난 뒤에도 러너 여럿이 매 프레임 `TempJob` 할당 둘과 Job 대기를 합니다. 모션 100개일 때 하나당 비용이 만 개일 때의 여섯 배(에디터)에서 스물다섯 배(Mono)였던 것이 이 고정 비용입니다.

**이렇게 쓴다.** 소수의 UI 연출에서는 LitMotion 을 "할당이 없고 API 가 짧다" 는 이유로 쓰고 속도 이득은 기대하지 않습니다. 값 타입과 타이밍 가짓수를 줄입니다. 위치 X 만 움직이면 `float` 에 `BindToPositionX`, x·y·z 를 같이 움직이면 `Vector3` 하나로 묶고, 스케줄러는 기본 `Update` 에 둡니다.

### 2. 처음 쓰는 조합은 로딩 중에 한 번 만들고 취소해 둔다

**안 지키면 생기는 문제.** 게임 도중 처음 쓰는 값 타입의 첫 모션에서 저장소와 러너가 생기며 할당과 시간이 한 프레임에 몰립니다. Mono 플레이어에서 첫 `Color` 모션 호출이 5.41 ms, 그 프레임 간격이 2.07 ms 에서 6.16 ms 로 튀었습니다.

**이렇게 쓴다.** 로딩 중에 쓸 조합마다 `LMotion.Create(...).RunWithoutBinding().Cancel()` 로 모션 하나를 만들고 바로 취소합니다. 러너는 그때 생겨 사라지지 않습니다. 동시 최대치를 알면 `EnsureStorageCapacity` 로 배열 성장도 로딩으로 옮깁니다.

### 3. MotionDebugger 는 볼 때만 켜고 Clear 로 비운다

**안 지키면 생기는 문제.** 켜 두면 모션마다 550 B 안팎이 할당되고, 끝까지 재생된 모션의 추적 객체가 목록에 계속 쌓입니다. 켠 채로 프로파일링하면 이 몫이 측정에 섞입니다.

**이렇게 쓴다.** 모션을 찾아야 할 때만 창에서 Enable 을 켜고 다 보면 끕니다. 긴 세션에서는 창의 Clear 나 `MotionDebugger.Clear()` 로 목록을 비웁니다.

### 4. 디버그 이름에 보간 문자열을 넘기지 않는다

**안 지키면 생기는 문제.** 릴리스 빌드에서 `WithDebugName` 의 본문이 비어도 넘길 문자열은 호출하는 쪽에서 만들어져, 모션마다 문자열 하나가 할당됩니다.

**이렇게 쓴다.** 디버그 이름은 상수 문자열로 주거나, 호출하는 쪽을 `#if DEVELOPMENT_BUILD || UNITY_EDITOR` 로 감쌉니다.

## 정리

LitMotion 은 **모션마다 드는 할당을 없앤 대신 러너마다 고정 비용을 냅니다.**

- `static` 람다와 state 로 만든 모션은 만들 때도 도는 동안에도 0 B 입니다. 할당은 새 조합의 첫 사용과 배열 성장에 몰립니다.
- 러너는 모션이 없어도 매 프레임 네이티브 할당 둘, Job 대기, 배열 길이만큼의 콜백 루프를 돕니다. 모션 100개에서 하나당 740 ns, 만 개에서 117 ns 였습니다(에디터).
- `MotionDebugger` 를 켜면 모션마다 할당이 생기고 끝난 모션이 목록에 남습니다.