---
title: "LitMotion 의 핸들은 슬롯의 버전으로 끝난 모션을 걸러 낸다"
description: "끝난 모션의 자리는 곧 새 모션이 씁니다. LitMotion 의 MotionHandle 은 움직이지 않는 슬롯 번호와 그 슬롯의 버전을 들고 있어서, 옛 핸들로 새 모션을 건드리지 못합니다. 단 Clear 뒤에는 이 보호가 풀립니다."
author: "유니얼"
pubDate: 2026-09-26
updatedDate: 2026-10-18
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "트윈"]
canonical: https://blog.unialgames.com/posts/unity-litmotion-handle/
---
```csharp
MotionHandle h = LMotion.Create(0f, 1f, 0.2f)
    .Bind(group, static (x, g) => g.alpha = x);

// 한참 뒤, 그 자리는 이미 다른 모션이 쓰고 있다
h.Cancel();
```

LitMotion 은 끝난 모션의 자리를 곧바로 다음 모션에 내줍니다. 사용자가 들고 있던 참조가 그 자리를 그대로 가리킨다면, 옛 참조로 부른 `Cancel()` 이 남의 모션을 멈춥니다.

LitMotion 의 `MotionHandle` 은 **슬롯 번호와 그 슬롯의 버전**을 들고 있고, 슬롯을 비울 때마다 버전을 올려 옛 핸들을 걸러 냅니다. 위 `h.Cancel()` 은 새 모션을 건드리지 않고 예외를 던집니다. 기준 판본은 2.0.2 입니다.

## 핸들은 움직이지 않는 슬롯을 가리킨다

```csharp
// Runtime/MotionHandle.cs
public struct MotionHandle
{
    public int StorageId;   // 어느 저장소인가 (전역 목록의 칸 번호)
    public int Index;       // 저장소 안의 슬롯 번호
    public int Version;     // 핸들을 만들 때 그 슬롯의 세대
}
```

모션 데이터는 빈칸 없는 밀집 배열에 살고, 모션이 빠지면 맨 뒤 칸이 그 자리로 옮겨 옵니다. 핸들이 배열 칸 번호를 들고 있으면 옮겨진 모션의 핸들이 깨집니다. 그래서 핸들과 배열 사이에 **움직이지 않는 슬롯 표**를 두고, 슬롯이 지금의 배열 칸 번호를 적어 둡니다. 모션 셋 A, B, C 중 A 가 빠지는 모습입니다.

```csharp
tail--;

data[dense] = data[tail];                  // 빈칸을 맨 뒤 모션으로 메운다
data[tail] = default;

var prev = lookup[dense];                  // 지울 모션의 슬롯
var cur = lookup[dense] = lookup[tail];    // 옮겨 온 모션의 슬롯
lookup[tail] = default;

slots[cur.Index].DenseIndex = dense;       // 옮겨 온 모션의 슬롯이 새 칸을 가리키게 고친다
Free(prev);                                // 지운 모션의 슬롯을 비우고 버전을 올린다
```

모션 A, B, C 중 A 가 빠질 때입니다.

| | 배열 | 슬롯 0 | 슬롯 1 | 슬롯 2 |
| --- | --- | --- | --- | --- |
| 전 | `[A, B, C]` | 칸 0 (v1) | 칸 1 | 칸 2 |
| 후 | `[C, B, _]` | 비었음 (v2) | 칸 1 | **칸 0** |

C 의 핸들은 `(슬롯 2, v1)` 그대로입니다. 슬롯 2 가 칸 0 을 가리키도록 고쳐져서, 핸들은 여전히 C 를 찾습니다.

C 는 배열에서 자리를 옮겼지만 핸들의 슬롯 번호 2 는 그대로이고, 슬롯이 대신 따라갑니다. 핸들은 12바이트 값이라 필드에 담고 복사하고 비교하는 데 할당이 없습니다. 대신 핸들로 무엇을 하든 전역 목록 인덱싱, 인터페이스 호출 한 번, 슬롯 검사를 거칩니다.

## 슬롯을 비울 때 버전이 오른다

빈 슬롯들은 각자의 `Next` 필드로 사슬을 이루는 free list 입니다. 버전은 **슬롯을 비울 때** 오르고, 비운 슬롯은 사슬 머리로 가서 다음 모션에게 제일 먼저 나갑니다. 슬롯 넷으로 줄여 A, B 를 만들고 A 가 끝난 뒤 C 를 만듭니다.

```csharp
// Alloc(dense): 모션을 만들 때
if (freeSlot == -1)
    EnsureCapacity(slots.Length * 2);   // 빈 슬롯이 없을 때만 키운다

var i = freeSlot;                       // 사슬 머리의 슬롯을 꺼낸다
freeSlot = slots[i].Next;
slots[i].Next = -1;
slots[i].DenseIndex = dense;            // 이 슬롯이 가리킬 밀집 칸
return new SparseIndex(i, slots[i].Version);   // 버전은 그대로 적는다

// Free(index): 모션이 빠질 때
slots[i].Next = freeSlot;               // 사슬 머리로 되돌린다
slots[i].Version++;                     // 세대를 올린다
freeSlot = i;
```

처음 상태는 `freeSlot = 0`, 사슬 0→1→2→3, 버전 `[1, 1, 1, 1]` 입니다.

| 단계 | 일 | freeSlot · 사슬 | 버전 | 결과 |
| --- | --- | --- | --- | --- |
| 1 | A 를 만든다 | 1 · 1→2→3 | [1, 1, 1, 1] | A = (0, v1) |
| 2 | B 를 만든다 | 2 · 2→3 | [1, 1, 1, 1] | B = (1, v1) |
| 3 | A 가 끝나 빠진다 | 0 · 0→2→3 | [2, 1, 1, 1] | 슬롯 0 반납 |
| 4 | C 를 만든다 | 2 · 2→3 | [2, 1, 1, 1] | C = (0, v2) |

방금 비운 슬롯 0 이 가장 먼저 다시 나옵니다. A 와 C 는 같은 슬롯 0 을 썼고, 둘을 가르는 것은 버전 1 과 2 뿐입니다.

A 와 C 는 같은 슬롯 0 을 썼고, 둘을 가르는 것은 버전 1 과 2 뿐입니다. 버튼을 누를 때마다 짧은 모션을 하나 만들고 끝내는 코드라면 매번 같은 슬롯에 버전만 오른 핸들이 나옵니다. 새 슬롯은 버전 1 로 시작해서, `default` 핸들의 버전 0 은 어느 슬롯과도 맞지 않습니다.

## Cancel 은 범위, 버전, 상태 순으로 거른다

같은 핸들 `(0, v1)` 로 네 가지 때에 `Cancel()` 을 부릅니다.

```csharp
ref var slot = ref slots[handle.Index];

// 1. 범위: 슬롯이 가리키는 칸이 지금 배열 안에 있는가
if (slot.DenseIndex < 0 || slot.DenseIndex >= tail)
    return 1;   // "Motion has been destroyed or no longer exists."

// 2. 버전: 슬롯이 그사이 다른 모션에게 넘어가지 않았는가
if (slot.Version <= 0 || slot.Version != handle.Version)
    return 1;

// 3. 상태: 취소할 것이 남았는가
ref var state = ref data[slot.DenseIndex].Core.State;
if (state.Status is None or Canceled || (state.Status is Completed && !state.IsPreserved))
    return 2;   // "Motion has already been canceled or completed."

if (checkIsInSequence && state.IsInSequence)
    return 3;   // 시퀀스 안의 자식은 직접 취소할 수 없다

state.Status = Canceled;   // 상태만 바꾼다. 배열에서 빼는 것은 다음 러너 갱신의 Job
InvokeOnCancel();
return 0;
```

| 언제 | 슬롯 0 의 상태 | 걸리는 검사 | 결과 |
| --- | --- | --- | --- |
| 재생 중 | 칸 0, v1, Playing | 모두 통과 | 취소됨, OnCancel 호출 |
| 끝난 그 프레임 | 칸 0, v1, Completed (아직 배열에 있음) | 3. 상태 | `Motion has already been canceled or completed.` |
| 빠지고 슬롯이 빈 뒤 | 옛 DenseIndex 0, v2, tail = 0 | 1. 범위 (0 >= 0) | `Motion has been destroyed or no longer exists.` |
| 슬롯을 새 모션 C 가 쓰는 중 | 칸 0, v2, C 가 Playing | 2. 버전 (2 != 1) | `Motion has been destroyed or no longer exists.` |

빈 슬롯은 DenseIndex 를 지우지 않아 옛 값이 남지만, 배열이 줄어 범위 검사에 걸립니다. 슬롯이 재사용됐으면 버전 검사에서 막혀 C 는 건드려지지 않습니다. 범위는 빈 슬롯을, 버전은 재사용된 슬롯을, 상태는 끝났지만 아직 배열에 있는 모션을 걸러 냅니다.

- 슬롯이 비었으면 범위 검사에서 `Motion has been destroyed or no longer exists.`
- 슬롯을 새 모션이 쓰고 있으면 버전 검사에서 같은 예외. 이 검사가 없으면 옛 핸들이 C 를 취소합니다.
- 끝났지만 아직 배열에 있으면 상태 검사에서 `Motion has already been canceled or completed.`

`TryCancel()` 은 같은 자리에서 예외 대신 `false` 를 돌려줍니다. 한 번도 대입하지 않은 `default` 핸들은 저장소가 아직 없으면 `ArgumentException("Invalid type id.")` 입니다.

## Clear 는 모든 버전을 1 로 되감는다

세대 번호가 지켜지려면 버전이 줄지 않아야 합니다. 그런데 `MotionDispatcher.Clear()` 가 부르는 `SparseSetCore.Reset()` 은 모든 슬롯의 버전을 1 로 되돌립니다. 그러면 `Clear()` 전에 받은 핸들과 뒤에 만든 모션의 핸들이 세 필드 모두 같아질 수 있습니다.

| `Clear()` 전 핸들의 슬롯 | 새 모션과 번호 | 옛 핸들로 `Cancel()` |
| --- | --- | --- |
| 한 번도 비워진 적 없음 (버전 1) | (0, 1) 로 같음, `==` 가 참 | 예외 없이 **새 모션이 취소됨** |
| 한 번 비워졌다 다시 채워짐 (버전 2) | 새 모션은 버전 1 | `has been destroyed or no longer exists.` |

`Clear()` 는 사용자만 부르지 않습니다. `MotionDispatcher` 가 `BeforeSceneLoad` 에서 플레이를 시작할 때마다 부릅니다. 도메인 리로드를 끈 에디터에서 지난 플레이의 핸들을 정적 필드에 남겨 두었더니, 두 번째 플레이의 첫 모션과 둘 다 `(Index 0, Version 1)` 이었고 `saved == fresh` 와 `saved.IsActive()` 가 모두 참이었습니다.

## 주의할 점

### 1. 끝났을 수 있는 핸들은 TryCancel 로 끊는다

**안 지키면 생기는 문제.** 짧은 모션의 핸들을 들고 있다가 버튼을 다시 눌렀을 때 `Cancel()` 을 부르면, 모션이 끝난 시점에 따라 두 가지 예외 중 하나가 납니다. 연타 속도에 따라 가끔만 나서 원인을 찾기 어렵습니다.

**이렇게 쓴다.** 새 모션을 걸기 전에 옛 모션을 이렇게 끊습니다.

```csharp
fade.TryCancel();
fade = LMotion.Create(0f, 1f, 0.2f).Bind(group, static (x, g) => g.alpha = x);
```

### 2. MotionDispatcher.Clear() 를 부른 뒤에는 옛 핸들을 버린다

**안 지키면 생기는 문제.** 버전이 1 로 되감겨, 옛 핸들의 `Cancel()` 이 뒤에 만든 모션을 예외 없이 끊을 수 있습니다. 호출한 쪽에는 아무 신호도 없습니다.

**이렇게 쓴다.** `Clear()` 를 부르는 자리에서 핸들을 들고 있는 필드를 모두 `MotionHandle.None` 으로 비웁니다. 전체 정리가 목적이면 `Clear()` 대신 모션마다 `AddTo(gameObject)` 로 수명을 묶습니다.

### 3. 도메인 리로드를 끈 에디터에서 핸들을 정적 필드에 두지 않는다

**안 지키면 생기는 문제.** 지난 플레이의 핸들이 새 플레이의 첫 모션과 같은 번호가 됩니다. 에디터에서만, 두 번째 플레이부터만 나는 증상이라 빌드에서는 재현되지 않습니다.

**이렇게 쓴다.** 핸들은 인스턴스 필드에 둡니다. 정적 필드에 둬야 하면 `[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]` 초기화 메서드에서 `default` 로 비웁니다.

## 정리

LitMotion 의 핸들은 **움직이지 않는 슬롯 번호와 그 슬롯의 버전**으로 끝난 모션을 걸러 냅니다.

- 모션이 배열에서 자리를 옮겨도 슬롯이 따라가므로 핸들은 그대로 씁니다.
- 슬롯을 비울 때 버전이 올라, 같은 슬롯을 다시 쓴 새 모션과 옛 핸들이 갈립니다.
- `Clear()` 는 버전을 1 로 되감아 이 보호를 풉니다. 그 뒤로는 옛 핸들을 쓰지 않습니다.