---
title: "LitMotion 은 끝난 모션을 표시만 바꿔 두고 다음 프레임에 지운다"
description: "코루틴은 StopCoroutine 하면 그 자리에서 사라집니다. LitMotion 의 Cancel 과 Complete 는 상태 값만 바꾸고, 배열에서 빼는 일은 다음 프레임의 Job 이 합니다. 그 한 프레임과 거절 조건, 콜백 예외, 기다리는 방법이 수명 API 의 모양을 정합니다."
author: "유니얼"
pubDate: 2026-09-26
updatedDate: 2026-10-18
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "트윈"]
canonical: https://blog.unialgames.com/posts/unity-litmotion-lifetime/
---
```csharp
var handle = LMotion.Create(0f, 5f, 0.5f)
    .WithOnComplete(() => Debug.Log("끝"))
    .WithOnCancel(() => Debug.Log("취소"))
    .BindToPositionX(transform);

handle.Cancel();   // 이 줄 뒤에 handle 은 어떤 상태인가
```

코루틴은 `StopCoroutine` 을 부르면 그 자리에서 사라집니다. LitMotion 의 모션은 Job 이 여러 스레드에서 나눠 계산하고 메인 스레드가 번호 순서로 콜백을 도는 배열의 한 칸이라, 도는 도중에 칸을 지울 수 없습니다.

그래서 **`Cancel` 과 `Complete` 는 칸의 상태 값만 바꾸고, 칸을 지우는 일은 다음 러너 갱신의 Job 과 `RemoveAll` 이 합니다.** 그 한 프레임이 핸들 판정, 거절 조건, 기다리는 코드의 동작을 정합니다. 기준 판본은 2.0.2 입니다.

## 끝난 모션은 한 프레임 동안 칸에 남는다

상태는 `None`, `Scheduled`, `Delayed`, `Playing`, `Completed`, `Canceled`, `Disposed` 일곱 개입니다. Job 은 **이미 `Completed` 나 `Canceled` 인 칸**만 골라 `Disposed` 로 바꾸고 번호를 모으며, 러너는 콜백 루프가 다 끝난 뒤 그 칸들을 지웁니다.

```csharp
// 사용자 코드
handle.Cancel();   // Status = Canceled. OnCancel 은 여기서 바로 불린다

// UpdateRunner.Update: 프레임마다 러너마다 한 번
job.Schedule(count, 16).Complete();

// MotionUpdateJob.Execute(i)
if (Status is Scheduled or Delayed or Playing || (IsPreserved && Status == Completed))
{
    Update(time);                    // 끝에 닿으면 Status = Completed
}
else if ((!IsPreserved && Status == Completed) || Status == Canceled)
{
    CompletedIndexList.Add(i);       // 지울 목록에 넣는다
    Status = Disposed;
}

// 콜백 루프 (메인 스레드)
if (canUpdate)
    managedData.UpdateUnsafe(output[i]);       // Bind. Disposed 는 canUpdate 가 아니다
if (isCompleted && WasStatusChanged)
    managedData.InvokeOnComplete();

storage.RemoveAll(completedIndexList);         // 칸을 비우고 슬롯 Version++
```

끝까지 재생하는 경우입니다. 프레임 N 직전 상태는 Playing, Time 0.48, 길이 0.5 입니다.

| 프레임 | Job 이 한 일 | 콜백 루프 | 상태 | IsValid | IsActive |
| --- | --- | --- | --- | --- | --- |
| N | 0.48 + 0.02 로 끝에 닿음 | `Bind(5)`, OnComplete | Completed | 참 | 거짓 |
| N+1 | 보존하지 않은 Completed 라 지울 목록에 넣음 | 없음 | Disposed → 칸 제거, Version 1 → 2 | 거짓 | 거짓 |

도중에 `Cancel()` 을 부르면 이렇게 됩니다.

| 때 | 일 | 상태 | IsValid | IsActive |
| --- | --- | --- | --- | --- |
| `Cancel()` 호출 | OnCancel 을 그 자리에서 부름. 값은 되돌리지 않음 | Canceled | 참 | 거짓 |
| 다음 러너 갱신 | 지울 목록에 넣고 칸 제거, Version 1 → 2 | 칸 없음 | 거짓 | 거짓 |

끝난 모션은 한 번의 러너 갱신 동안 칸을 차지한 채 남습니다. 그 사이 IsValid 는 참, IsActive 는 거짓입니다.

그 한 프레임 동안 세 판정이 갈립니다.

| 칸의 상태 | `IsValid` | `IsActive` | `IsPlaying` |
| --- | --- | --- | --- |
| `Scheduled`, `Delayed`, `Playing` | 참 | 참 | 참 |
| `Completed`, `Canceled` (지워지기 전) | 참 | 거짓 | 거짓 |
| `Completed` + `Preserve()` | 참 | **참** | 거짓 |
| 지워짐, 또는 `default` 핸들 | 거짓 | 거짓 | 거짓 |

실제로도 끝난 프레임에 `IsValid()` 참, `IsActive()` 거짓, 한 프레임 뒤 둘 다 거짓이었습니다. `IsValid` 는 칸이 아직 있느냐만 묻고, 조작할 일이 남았는지는 `IsActive` 가 답합니다.

## Cancel 과 Complete 는 받아 주는 조건이 다르다

| 모션 | `Cancel()` | `Complete()` |
| --- | --- | --- |
| 재생 중 | 상태만 `Canceled`, `OnCancel`. 값은 되돌리지 않음 | 최종값 `Bind` 한 번, `OnLoopComplete`, `OnComplete` |
| 끝남 (보존 안 함) | `Motion has already been canceled or completed.` | 같은 예외 |
| 끝남 + `Preserve()` | **받아 줌**, `OnCancel` | 같은 예외 |
| 무한 루프 | 받아 줌 | `...loops infinitely. If you want to end the motion, call Cancel() instead.` |

`Preserve()` 는 Job 의 제거 조건 하나를 끄는 표시입니다. 보존한 모션은 끝난 뒤에도 칸에 남아 매 프레임 다시 계산되고 `Bind` 를 받습니다(끝난 시점 47회에서 10프레임 뒤 57회). 상태가 `Completed` 에서 `Completed` 로 가므로 `OnComplete` 는 다시 불리지 않습니다. 슬라이더로 `handle.Time` 을 긁는 스크러빙을 위한 장치이고, 푸는 방법은 `Cancel()` 하나입니다.

`TryCancel()`, `TryComplete()` 는 같은 검사를 하고 예외 대신 `false` 를 돌려줍니다.

## 콜백 예외는 기본으로 로그만 남긴다

`Bind` 가 예외를 던지면 러너는 전역 핸들러(기본 `Debug.LogException`)에 넘기고, `CancelOnError` 가 켜져 있을 때만 모션을 취소합니다. 빌더의 기본값은 `false` 입니다.

<x-compare>
<script type="application/json">
{
  "label": "Bind 대상 오브젝트가 파괴된 뒤",
  "tabs": [
    { "label": "기본 (CancelOnError = false)", "code": "LMotion.Create(0f, 5f, 2f)\n    .BindToPositionX(transform);\n// 1초 뒤 Destroy(gameObject)", "text": "Bind 가 MissingReferenceException 을 던지지만 모션은 Playing 그대로입니다. 남은 1초 동안 러너가 매 프레임 Bind 를 부르고, 매 프레임 같은 예외가 콘솔에 쌓입니다. 끝나면 OnComplete 까지 불립니다.", "stats": [["모션", "끝까지 재생"], ["예외 로그", "매 프레임 하나"]], "tone": "bad" },
    { "label": "WithCancelOnError()", "code": "LMotion.Create(0f, 5f, 2f)\n    .WithCancelOnError()\n    .BindToPositionX(transform);\n// 1초 뒤 Destroy(gameObject)", "text": "첫 예외에서 상태를 Canceled 로 바꾸고 OnCancel 을 부릅니다. 다음 러너 갱신에서 Job 이 칸을 지웁니다.", "stats": [["모션", "첫 예외에서 취소"], ["예외 로그", "한 번"]], "tone": "good" }
  ]
}
</script>
</x-compare>

`WithCancelOnError` 경로에는 틈이 있습니다. 이때 부르는 `OnCancelAction?.Invoke()` 는 `try` 밖이라, `OnCancel` 이 또 던지면 예외가 콜백 루프를 빠져나갑니다. 앞 칸 모션 A 의 `Bind` 와 `OnCancel` 이 모두 던지게 했더니, 뒤 칸 모션 B 의 `Bind` 가 그 프레임 한 번 빠졌습니다. 같은 프레임에 끝난 칸을 치우는 `RemoveAll` 도 그 프레임에는 돌지 않습니다.

전역 핸들러는 등록이 아니라 교체입니다. `RegisterUnhandledExceptionHandler(null)` 을 넣으면 `?.Invoke` 라서 콜백 예외가 소리 없이 사라집니다.

## AddTo 는 게임오브젝트의 이벤트에서 목록을 돈다

`AddTo(gameObject)` 는 대상에 `MotionHandleLinker` 를 한 번 붙이고 핸들을 목록에 넣습니다. 유니티가 `OnDestroy`(기본 `CancelOnDestroy`)나 `OnDisable`(`CancelOnDisable`, `CompleteOnDisable`)을 부를 때 목록을 돌며 `IsActive()` 인 핸들에 `Cancel()` 이나 `Complete()` 를 부릅니다. 목록에서 빼는 코드는 없습니다.

`IsActive()` 가 참인데 `Complete()` 가 거절하는 두 경우가 걸립니다. `CompleteOnDisable` 로 B(재생 중), C(보존한 완료 또는 무한 루프), D(재생 중)를 묶고 `SetActive(false)` 했더니 B 는 완료, C 에서 예외 한 줄, D 는 5프레임 뒤에도 재생 중이었습니다. 예외가 `OnDisable` 을 빠져나가 D 는 검사조차 받지 못한 것입니다.

`MonoBehaviour` 안에서 `AddTo(this)` 라고 쓰면 더 구체적인 오버로드가 골라져 링커 대신 `destroyCancellationToken` 에 클로저를 등록합니다. 호출당 Mono 887 B, IL2CPP 891 B 였고 `AddTo(gameObject)` 는 28~31 B 였습니다(목록 배열이 커진 몫이 나뉜 값).

## 기다리는 길은 넷이고 취소를 알리는 방식이 다르다

<x-compare>
<script type="application/json">
{
  "label": "모션이 도중에 Cancel 됐을 때 기다리던 쪽은",
  "tabs": [
    { "label": "ToYieldInstruction", "code": "yield return handle.ToYieldInstruction();\n// 내부: while (handle.IsActive()) yield return null;", "text": "IsActive 가 거짓이 되면 끝납니다. 끝났는지 취소됐는지는 알 수 없습니다. 보존한 모션은 완료해도 IsActive 가 참이라 Cancel 할 때까지 돌아오지 않습니다.", "stats": [["감지", "매 프레임 IsActive 폴링"], ["취소되면", "정상 종료"], ["할당", "반복자 객체 하나"]] },
    { "label": "await handle", "code": "await handle;\n// MotionAwaiter.UnsafeOnCompleted:\n// OnCompleteAction += continuation;\n// OnCancelAction += continuation;", "text": "같은 continuation 을 두 슬롯에 덧붙입니다. GetResult 가 비어 있어서 취소돼도 예외 없이 다음 줄로 넘어갑니다.", "stats": [["감지", "두 슬롯에 += 로 덧붙임"], ["취소되면", "예외 없이 다음 줄"], ["할당", "결합 델리게이트 둘"]], "tone": "bad" },
    { "label": "ToValueTask", "code": "try { await handle.ToValueTask(token); }\ncatch (OperationCanceledException) { /* 취소 */ }", "text": "두 슬롯의 원래 델리게이트를 보관하고 자기 델리게이트로 바꿔 끼웁니다. 불리면 원래 것을 먼저 부르고 결과를 정합니다. 끝나면 원래 것을 되돌려 놓고 풀로 돌아갑니다.", "stats": [["감지", "슬롯을 바꿔치기, 끝나면 복원"], ["취소되면", "OperationCanceledException"], ["할당", "풀에서 꺼냄"]], "tone": "good" },
    { "label": "ToAwaitable", "code": "await handle.ToAwaitable(token);   // 유니티 2023.1 이상", "text": "ToValueTask 와 같은 부모 클래스라 결과 규칙이 같습니다. 소스에 '// TODO: use object pool' 이 남아 있고, 호출마다 소스 객체와 AwaitableCompletionSource 를 새로 만듭니다.", "stats": [["감지", "슬롯을 바꿔치기"], ["취소되면", "취소된 Awaitable"], ["할당", "호출마다 새로"]] }
  ]
}
</script>
</x-compare>

`await handle` 의 continuation 은 모션의 `OnCompleteAction` 에 붙어 있어서, 이어지는 코드는 다른 곳에 예약되지 않고 **러너의 콜백 루프 안에서 곧바로** 돕니다. 스택에 `UpdateRunner<...>.Update` 가 있었고 `frameCount` 도 `OnComplete` 가 불린 프레임과 같았습니다.

`OnCancelAction` 에 붙은 같은 continuation 은 떼어지지 않습니다. `Preserve()` 한 모션을 `await handle` 로 기다리면 완료 때 한 번, 나중에 `Cancel()` 할 때 한 번 더, 이미 끝난 메서드의 다음 줄이 조용히 다시 돌았습니다. `ToValueTask` 는 끝날 때 원래 델리게이트를 되돌려 놓아 한 번이었습니다.

## 주의할 점

### 1. IsValid 로 재생 중인지 판단하지 않는다

**안 지키면 생기는 문제.** 끝나거나 취소된 프레임에도 칸이 남아 있어 `IsValid()` 가 참입니다. 이를 "재생 중" 으로 읽고 `Complete()` 를 부르면 `Motion has already been canceled or completed.` 예외가 나고, 새 모션을 거는 조건이 한 프레임 늦습니다.

**이렇게 쓴다.** 조작할 수 있는지는 `IsActive()`, 진행 중인지는 `IsPlaying()` 으로 묻습니다. 끝내거나 멈출 때는 `TryComplete()`, `TryCancel()` 을 씁니다.

### 2. 무한 루프와 보존한 모션은 Cancel 로 끝낸다

**안 지키면 생기는 문제.** 두 경우 모두 `IsActive()` 는 참인데 `Complete()` 는 거절합니다. `CompleteOnDisable` 링커나 `CompositeMotionHandle.Complete()` 는 `IsActive()` 만 보고 부르므로, 목록 중간에서 예외가 나면 뒤의 모션은 처리되지 않고 남습니다. 보존한 모션은 대상이 파괴된 뒤에도 매 프레임 `Bind` 를 받습니다.

**이렇게 쓴다.** 무한 루프와 보존한 모션은 `Cancel()` 로 끝내고, 끝값이 필요하면 `OnCancel` 에서 직접 씁니다. `Preserve()` 를 쓴 자리에는 `.AddTo(gameObject)` 를 같이 붙여 파괴에 취소를 맡깁니다.

### 3. 대상이 사라질 수 있는 모션에는 WithCancelOnError 를 붙이고, OnCancel 에서 던지지 않는다

**안 지키면 생기는 문제.** 기본 설정이면 대상이 파괴된 뒤 남은 시간 동안 매 프레임 같은 예외가 콘솔에 쌓이고, 끝나면 `OnComplete` 까지 불립니다. `WithCancelOnError` 를 붙인 모션의 `OnCancel` 이 던지면 같은 러너의 뒤쪽 모션이 한 프레임 건너뜁니다.

**이렇게 쓴다.** 대상이 파괴될 수 있으면 `WithCancelOnError()` 를 붙입니다. `OnCancel` 본문은 `try`/`catch` 로 감싸고 `if (target == null) return;` 으로 시작합니다.

### 4. 끝났는지 끊겼는지가 중요하면 ToValueTask 로 기다린다

**안 지키면 생기는 문제.** `await handle` 은 취소돼도 예외 없이 다음 줄로 넘어갑니다. 연출이 끊겨도 다음 단계로 가고, 대상이 파괴돼 취소된 경우라면 다음 줄이 파괴된 오브젝트를 만집니다.

**이렇게 쓴다.** `await handle.ToValueTask(token)` 을 쓰고 `OperationCanceledException` 을 받습니다. 토큰을 취소한 스레드에서 `Cancel()` 이 돌므로 토큰은 메인 스레드에서 취소합니다. 보존한 모션은 `await handle` 로 기다리지 않습니다.

## 정리

LitMotion 은 **끝난 모션을 표시만 바꿔 두고 다음 프레임에 지웁니다.**

- `Cancel`, `Complete` 는 상태만 바꾸고, 칸은 다음 러너 갱신에서 빠집니다. 그 사이 `IsValid` 는 참, `IsActive` 는 거짓입니다.
- `Cancel` 은 보존한 완료 모션과 무한 루프를 받아 주고, `Complete` 는 둘 다 거절합니다. 보존한 모션을 푸는 것은 `Cancel` 뿐입니다.
- 콜백 예외는 기본으로 로그만 남깁니다. `await handle` 은 취소를 구분하지 못하고 러너 루프 안에서 이어지며, `ToValueTask` 는 취소를 예외로 알립니다.