---
title: "LitMotion 은 모션을 빈칸 없는 배열에 모으고 맨 끝 원소로 구멍을 메운다"
description: "수천 개 모션을 Job 이 한 번에 훑으려면 배열에 구멍이 없어야 합니다. LitMotion 은 모션을 꼬리에 붙이고 빠질 때 맨 끝 원소로 메웁니다. 그 대가로 순서가 바뀌고, 한 번 커진 배열과 곡선 메모리는 줄지 않습니다."
author: "유니얼"
pubDate: 2026-09-26
updatedDate: 2026-10-18
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "트윈"]
canonical: https://blog.unialgames.com/posts/unity-litmotion-storage/
---
```csharp
var a = LMotion.Create(0f, 1f, 1f).Bind(static x => Debug.Log(x));
var b = LMotion.Create(0f, 5f, 2f).BindToPositionX(transform);
var c = LMotion.Create(Vector3.zero, Vector3.one, 1f).BindToPosition(transform);
```

`a` 와 `b` 는 같은 배열의 이웃 칸에, `c` 는 다른 배열에 들어갑니다. 무엇을 움직이는지가 아니라 **값의 타입**이 자리를 가릅니다. 매 프레임 Job 이 이 배열을 0번부터 훑으려면 중간에 빈칸이 없어야 합니다.

LitMotion 의 `MotionStorage` 는 모션을 **빈칸 없는 밀집 배열**에 두고, 새 모션은 꼬리에 붙이고, 빠질 때는 맨 끝 원소로 구멍을 메웁니다(swap-remove). 기준 판본은 2.0.2 입니다.

## 저장소는 타입 조합과 타이밍마다 하나다

저장소는 `<TValue, TOptions, TAdapter>` 조합 × PlayerLoop 타이밍마다 하나씩, 처음 쓰일 때 생깁니다. 같은 `float` 모션이라도 `Update` 와 `PostLateUpdate` 는 다른 저장소입니다. 모션 하나는 두 배열의 **같은 칸**에 나뉘어 삽니다.

| 배열 | 담는 것 | 누가 만지나 |
| --- | --- | --- |
| `unmanagedDataArray` | 상태, 시간, 길이, 이징, 루프, 시작값, 끝값, 옵션 | Burst Job 과 메인 스레드 |
| `managedDataArray` | `Bind` 델리게이트, state, 완료·취소 콜백 | 메인 스레드 |

가르는 기준은 **Burst 가 읽을 수 있느냐**입니다. 둘 다 `NativeArray` 가 아니라 평범한 C# 배열이고, Job 에 넘길 때만 `fixed` 로 고정해 포인터를 줍니다. 필드마다 배열을 나누는 모양이 아니라 모션 하나가 구조체 하나로 한 칸에 들어갑니다.

## 새 모션은 꼬리에 붙고 배열은 두 배로 자란다

```csharp
EnsureCapacity(tail + 1);                       // 모자라면 네 배열을 모두 두 배로

ref var data = ref unmanagedDataArray[tail];    // 꼬리 칸을 잡는다
ref var managed = ref managedDataArray[tail];   // 관리 배열도 같은 번호 칸

data.Status = Scheduled;                        // 시간, 길이, 시작값, 끝값 복사
data.Time = 0;
managed.UpdateAction = buffer.UpdateAction;     // 델리게이트와 state

if (buffer.ImmediateBind && buffer.UpdateAction != null)
    managed.UpdateUnsafe(시작값);               // tail 을 올리기 전에 시작값을 한 번 쓴다

var sparseIndex = sparseSetCore.Alloc(tail);    // 빈 슬롯을 꺼내 칸 번호를 적는다
sparseIndexLookup[tail] = sparseIndex;          // 칸 → 슬롯도 거꾸로 적는다
tail++;

return new MotionHandle { Index = sparseIndex.Index, Version = sparseIndex.Version, StorageId = Id };
```

자리가 있을 때입니다. 밀집 배열 `[A, B, _, _]`, tail 2, 빈 슬롯 목록 `[1(v2), 3(v1)]` 에서 C 를 넣습니다.

| 단계 | 밀집 배열 | tail | 슬롯 |
| --- | --- | --- | --- |
| 용량 확인 | 4 ≥ 3, 그대로 | 2 | |
| 칸 2 에 값 복사 | [A, B, C, _] | 2 | |
| `Alloc(2)` | [A, B, C, _] | 2 | 슬롯 1 → 칸 2 (v2), 빈 목록 [3(v1)] |
| `tail++` | [A, B, C, _] | 3 | |

돌려주는 핸들은 `{ Index = 1, Version = 2 }` 입니다. 슬롯 1 은 예전에 한 번 쓰고 돌려받아 버전이 2 입니다.

꽉 찼을 때는 첫 줄이 다릅니다. `[A, B, C, D]`, 용량 4 에서 5칸이 필요하니 네 배열을 모두 8칸으로 새로 만들어 복사합니다(`Array.Resize`). 옛 배열은 버려지고, E 는 칸 4 에 들어가 tail 이 5 가 됩니다.

표는 용량을 4 로 줄였습니다. 실제 초기 용량은 32 이고 두 배씩 늘어나며, 줄어드는 코드는 없습니다.

중간에 빈칸이 없으니 새 모션은 언제나 `tail` 칸입니다. 핸들에는 밀집 칸 번호가 아니라 슬롯 번호가 들어갑니다. 배열이 차면 네 배열을 모두 두 배짜리로 새로 만들어 복사하고, 줄이는 코드는 없습니다. 초기 용량은 32 입니다.

## 빠질 때는 맨 끝 원소가 구멍을 메운다

지운 칸을 맨 끝 원소로 채우면 몇 개가 있든 비용이 같습니다. 옮겨 온 원소의 슬롯은 새 칸 번호로 고치고, 지운 모션의 슬롯은 버전을 올려 돌려받습니다. 네 모션 A, B, C, D 에서 B 를 뺍니다.

```csharp
tail--;                                         // 맨 끝 원소를 지울 칸으로 옮긴다
data[dense] = data[tail];
data[tail] = default;
managed[dense] = managed[tail];
managed[tail] = default;                        // 참조를 끊어 GC 가 거둘 수 있게

var prev = lookup[dense];                       // 지워지는 원소의 슬롯
var cur = lookup[dense] = lookup[tail];         // 옮겨 온 원소의 슬롯
lookup[tail] = default;

if (cur.Version != 0)
    slots[cur.Index].DenseIndex = dense;        // 옮겨 온 원소의 슬롯에 새 칸 번호
if (prev.Version != 0)
    sparseSetCore.Free(prev);                   // 빈 슬롯 목록에 넣고 Version++
```

밀집 `[A, B, C, D]`, lookup `[s0, s3, s1, s2]`, tail 4 에서 시작합니다.

| 지우는 것 | 밀집 배열 | lookup | 슬롯 변화 |
| --- | --- | --- | --- |
| 가운데 B (칸 1) | [A, D, C, _] | [s0, s2, s1, _] | 슬롯 2 → 칸 1, 슬롯 3 v1 → v2 |
| 맨 끝 D (칸 3) | [A, B, C, _] | [s0, s3, s1, _] | 슬롯 2 → 칸 3 (tail 밖), 슬롯 2 v1 → v2 |

B 를 지우면 D 가 B 자리로 옵니다. D 의 핸들은 슬롯 2 를 통해 그대로 쓰이고, B 의 옛 핸들 (Index 3, Version 1) 은 버전이 안 맞습니다. 맨 끝을 지울 때는 자기 자신을 자기 자리에 복사하고 지우는 셈이라, 옛 핸들은 버전으로도 칸 범위로도 걸립니다. B 가 빠진 자리에 D 가 들어오면서 배열 안 순서가 A, D, C 로 바뀐다는 점도 기억해 둡니다.

B 가 빠진 자리에 D 가 들어오면서 **배열 순서가 A, D, C 로 바뀌었습니다.** 러너는 이 배열을 0번부터 돌며 콜백을 부르므로, 같은 저장소 모션들의 콜백 순서는 만든 순서를 따르지 않습니다.

## 여러 개를 지울 때는 슬롯 번호로 바꿔 둔다

매 프레임 Job 은 끝난 모션의 **밀집 칸 번호**를 모아 넘깁니다. `RemoveAll` 은 그 번호로 바로 지우지 않고 먼저 슬롯 번호로 바꿔 둡니다. 첫 제거가 맨 끝 원소를 옮기면 뒤의 칸 번호가 낡기 때문입니다.

<x-compare>
<script type="application/json">
{
  "label": "지울 목록 [0, 3] 을 처리하는 두 방식",
  "tabs": [
    {
      "label": "칸 번호 그대로",
      "code": "for (int i = 0; i < list.Length; i++)\n    RemoveAt(list[i]);   // 앞에서 지우면 뒤 원소가 칸을 옮긴다",
      "text": "[A, B, C, D] 에서 칸 0 의 A 를 지우면 D 가 칸 0 으로 옵니다. 다음 칸 3 을 지울 때 D 는 이미 거기 없고, C 가 tail 밖 칸 3 으로 밀려납니다. 남은 것은 D, B 입니다. 지웠어야 할 D 가 살고, C 의 슬롯은 범위 밖 칸을 가리킨 채 돌려받지도 못합니다.",
      "tone": "bad"
    },
    {
      "label": "슬롯으로 바꿔 두고 (실제 코드)",
      "code": "// 지우기 전에 칸 번호를 모두 슬롯으로 바꿔 둔다\nfor (int i = 0; i < list.Length; i++)\n    saved[i] = lookup[list[i]];\n\n// 슬롯이 지금 가리키는 칸을 지운다\nfor (int i = 0; i < saved.Length; i++)\n    RemoveAt(slots[saved[i].Index].DenseIndex);",
      "text": "A 를 지우면 D 가 칸 0 으로 오고, RemoveAt 이 D 의 슬롯도 칸 0 으로 고칩니다. 다음 차례에 D 의 슬롯이 가리키는 칸 0 을 지우니 남은 것은 C, B 입니다. 슬롯은 옮겨 다니지 않는 주소라 옮겨 간 원소를 쫓아갑니다.",
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

바꿔 둔 목록은 `Allocator.Temp` 에서 잡으므로 GC 할당이 아닙니다.

## Reset 은 내용만 비우고 크기는 남긴다

`MotionDispatcher.Clear()` 는 저장소마다 `Reset()` 을 부릅니다. 배열을 0 으로 지우고 `tail` 을 0 으로 돌릴 뿐 **배열 길이는 그대로**입니다. 러너의 콜백 루프는 모션 수가 아니라 배열 길이만큼 돌기 때문에, 남은 길이가 매 프레임 비용으로 남습니다.

<x-compare>
<script type="application/json">
{
  "label": "모션 0개에서 float 스토리지 용량만 바꿨을 때",
  "tabs": [
    { "label": "용량 32", "text": "모션 하나를 만들고 취소해서 러너 하나와 기본 용량 스토리지만 남긴 상태.", "stats": [["프레임", "0.141 ms"], ["러너 없는 기준선 대비", "+0.015 ms"]] },
    { "label": "용량 16,384", "text": "EnsureStorageCapacity 로 16,384 칸까지 늘렸다. 모션은 여전히 0개다.", "stats": [["프레임", "0.315 ms"], ["러너 없는 기준선 대비", "+0.174 ms"]], "tone": "bad" },
    { "label": "그 뒤 Clear()", "text": "MotionDispatcher.Clear() 로 스토리지를 Reset 했다. 내용은 비었지만 배열 길이는 16,384 그대로다.", "stats": [["프레임", "0.314 ms"], ["러너 없는 기준선 대비", "+0.174 ms (그대로)"]], "tone": "bad" }
  ]
}
</script>
</x-compare>

커스텀 `AnimationCurve` 이징의 곡선 메모리도 같습니다. 곡선은 저장소마다 하나인 `RewindableAllocator` 에 복사되는데, 모션이 빠질 때 돌려주는 코드가 없고 되감는 곳은 `Reset()` 하나입니다. 키 3개 곡선 모션 6만 개를 끝까지 재생하며 재 보았습니다.

| 시점 | 할당자 블록 | 블록 크기 합 |
| --- | --- | --- |
| 시작 | 1개 | 128 KB |
| 6만 개를 모두 끝낸 뒤 | 7개 | 16,256 KB |
| 곡선 모션 1만 개를 더 만들고 끝낸 뒤 | 7개 | 16,256 KB |
| `MotionDispatcher.Clear()` 뒤 | 7개 | 16,256 KB |

`RewindableAllocator.Rewind()` 는 이번 주기에 쓴 블록을 "처음부터 다시 쓸 수 있게" 만들 뿐 풀지 않습니다. 한 번 커진 16 MB 는 프로세스가 계속 쥡니다.

## 주의할 점

### 1. 같은 타입 모션들의 콜백 순서에 기대지 않는다

**안 지키면 생기는 문제.** 가운데 모션 하나만 끝나도 맨 끝 모션이 그 자리로 옵니다. A, B, C 순서로 만든 모션의 `Bind` 와 `OnComplete` 가 A, C, B 순서로 불릴 수 있고, 한 콜백이 다른 모션이 이번 프레임에 쓴 값을 전제로 하면 어느 날부터 가끔 틀립니다.

**이렇게 쓴다.** 순서가 필요한 값은 모션 하나에 묶어 한 콜백 안에서 씁니다. 여러 모션의 결과를 합칠 때는 각 콜백이 자기 필드에만 쓰고, 합치는 일은 `LateUpdate` 처럼 모든 갱신이 끝난 뒤에 합니다.

### 2. EnsureStorageCapacity 는 실제 최대치만큼만 잡는다

**안 지키면 생기는 문제.** 로딩 때 배열을 미리 키우면 실행 중 `Array.Resize` 는 사라지지만, 키운 길이는 줄지 않고 매 프레임 훑입니다. 모션 0개에 용량 16,384 인 저장소 하나가 프레임당 0.174 ms 를 더했고 `Clear()` 로도 돌아오지 않았습니다. 이 API 는 쓰지 않을 타이밍의 저장소까지 여덟 개를 모두 만듭니다.

**이렇게 쓴다.** 그 타입으로 동시에 도는 모션의 최대치를 재고 그보다 조금 큰 2의 거듭제곱으로 잡습니다. 한 장면에서만 몰리는 대량 연출은 평소 쓰지 않는 타이밍으로 떼어 평소 저장소가 커지지 않게 합니다.

### 3. 커스텀 곡선 모션을 끝없이 새로 만들지 않는다

**안 지키면 생기는 문제.** 곡선 메모리는 모션 단위로 돌아오지 않아, 곡선 모션 6만 개에 할당자가 128 KB 에서 16 MB 로 커졌고 `Clear()` 뒤에도 그대로였습니다. GC 쪽 수치에는 보이지 않습니다.

**이렇게 쓴다.** 꼭 필요한 연출이 아니면 `Ease` 열거형의 내장 이징을 씁니다. 곡선 모션을 많이 만드는 연출은 프로파일러의 Memory 모듈에서 네이티브 메모리가 계단처럼 오르는지 보고, `Clear()` 로 줄어들 거라고 기대하지 않습니다.

## 정리

LitMotion 은 **모션을 빈칸 없는 배열에 모으고 맨 끝 원소로 구멍을 메웁니다.**

- 저장소는 타입 조합 × 타이밍마다 하나이고, 모션 하나는 Burst 용 구조체 배열과 콜백 배열의 같은 칸에 삽니다.
- 새 모션은 꼬리에 붙고, 빠질 때는 맨 끝이 구멍을 메워 배열 순서가 바뀝니다. 여러 개를 지울 때는 슬롯 번호로 바꿔 두고 지웁니다.
- 배열 길이와 곡선용 할당자는 한 번 커지면 `Clear()` 로도 줄지 않습니다.