---
title: "DOTween 의 Kill 은 갱신 도중이면 표시만 하고 나중에 치운다"
description: "DOTween 트윈은 어떤 오브젝트에도 묶여 있지 않아 수명을 코드가 챙깁니다. 모든 파기는 Despawn 하나로 모이고, 갱신 도중의 Kill 은 표시만 한 뒤 루프 끝에 치웁니다. DOKill 은 전체를 훑고 SetLink 는 매 차례 묻습니다."
author: "유니얼"
pubDate: 2026-09-25
updatedDate: 2026-10-18
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "트윈"]
canonical: https://blog.unialgames.com/posts/unity-dotween-lifetime/
---
```csharp
Tween _blink;

void OnEnable()  => _blink = icon.DOFade(0f, 0.5f).SetLoops(-1, LoopType.Yoyo);
void OnDisable() => _blink.Kill();
```

코루틴은 그것을 돌리는 컴포넌트가 꺼지면 같이 멈춥니다. DOTween 의 트윈은 누구에게도 묶여 있지 않아서, 트윈을 건 오브젝트가 꺼지든 파괴되든 모릅니다. 끝내는 방법이 여럿이고, 콜백 안에서 부른 `Kill` 이 언제 효과를 내는지도 정해져 있습니다.

DOTween 에서 트윈이 죽는 길은 **모두 `Despawn` 하나로 모이고, 갱신 루프 도중에 부른 `Kill` 은 `active` 만 끈 뒤 루프 끝에 몰아서 치웁니다.** 기준은 DOTween 1.3.030(GitHub 커밋 `4846808`)입니다.

## 갱신 도중의 Kill 은 표시만 한다

`Kill`, `DOKill(target)`, 완료 뒤 자동 파기, `SetLink`, SafeMode 가 모두 `TweenManager.Despawn` 으로 갑니다. `Despawn` 은 `OnKill` 을 먼저 부르고, 활성 배열에서 빼고, 재활용이면 풀에 넣은 뒤 `Reset` 합니다. `Kill` 의 갈림길은 끝에 있습니다.

```csharp
// DOTween/TweenExtensions.cs (Kill 끝부분)
if (TweenManager.isUpdateLoop) {
    // Just mark it for killing, so the update loop will take care of it
    t.active = false;
} else TweenManager.Despawn(t);
```

콜백은 대부분 루프 안에서 불리므로 `OnComplete` 나 `OnUpdate` 안의 `Kill` 은 표시만 합니다. A 가 끝나며 `OnComplete` 에서 `C.Kill()` 을 부르는 프레임입니다.

```csharp
isUpdateLoop = true;                       // 이제부터 부르는 Kill 은 표시만 한다
int len = _maxActiveLookupId + 1;

for (int i = 0; i < len; ++i)
{
    Tween t = _activeTweens[i];

    // 표시된 트윈은 자기 차례에 명단에 오른다. 값은 갱신하지 않는다
    if (!t.active)
    {
        MarkForKilling(t);                 // active = false, _KillList.Add(t)
        willKill = true;
        continue;
    }

    // 끝나고 autoKill 이면 명단에 오른다
    if (Tween.DoGoto(t, ...))
    {
        MarkForKilling(t);
        willKill = true;
    }
}

if (willKill)
    DespawnActiveTweens(_KillList);        // 명단을 뒤에서부터 치운다

_KillList.Clear();
isUpdateLoop = false;
```

활성 배열이 `[A, B, C]` 이고, A 가 끝나며 `OnComplete` 에서 `C.Kill()` 을 부르는 프레임입니다.

1. A 가 끝나 `OnComplete` 가 불립니다. 그 안의 `C.Kill()` 은 루프 도중이라 `C.active` 만 끕니다. A 는 autoKill 이라 명단에 오릅니다. 명단은 `[A]` 입니다.
2. B 는 아직 도는 중이라 그대로 갱신됩니다.
3. C 의 차례에 꺼진 것을 보고 명단에 올립니다. 명단은 `[A, C]` 입니다.
4. 루프가 끝나고 명단을 뒤에서부터 치웁니다. C 의 `OnKill`, A 의 `OnKill` 순서로 불리고, 활성 배열은 `[_, B, _]` 가 됩니다.

루프를 도는 동안 배열의 칸은 움직이지 않습니다. 빼는 일은 모두 루프가 끝난 뒤에 몰려 있습니다.

순회하는 배열에서 원소를 빼면 인덱스가 흔들리니, 표시만 해 두고 **자기 차례에 명단에 올린 뒤 루프 끝에 명단의 역순으로 치웁니다.** 그래서 이미 지나간 칸의 트윈은 한 프레임 늦습니다. `Normal` 트윈 A, B, C 에서 B 의 `OnUpdate` 가 A 와 C 를 죽이자, C 의 `OnKill` 은 그 프레임에, A 의 `OnKill` 은 다음 프레임에 불렸습니다. 루프 밖(버튼 처리, `OnDisable`)의 `Kill` 은 그 자리에서 바로 `Despawn` 합니다.

## DOKill(target) 은 활성 배열 전체를 훑는다

`transform.DOKill()` 은 대상별 색인 없이 **활성 배열 전체를 뒤에서부터** 보며 `Equals` 로 target 과 id 를 비교합니다. 아무 트윈도 걸리지 않은 `Transform` 에 `DOKill()` 을 부른 한 번의 비용입니다.

| 활성 트윈 수 | Mono | IL2CPP |
| --- | --- | --- |
| 100 | 2.1 µs | 1.4 µs |
| 1,000 | 22 µs | 16 µs |
| 10,000 | 236 µs | 160 µs |

활성 트윈 하나에 Mono 약 22 ns, IL2CPP 약 15 ns 로 거의 정확히 비례합니다. 트윈 1만 개가 떠 있을 때 `DOKill` 100번이면 Mono 에서 약 24 ms 입니다. `string` 이나 `int` id 로 부르면 그 필드만 비교합니다. 단, `intId` 의 기본값이 `-999` 라서 `DOTween.Kill(-999)` 는 int id 를 안 건 트윈 전부와 맞습니다(같은 세 환경에서 id 없는 트윈 3개와 string id 트윈 1개가 함께 죽었습니다).

## SetLink 는 트윈 차례마다 묻는다

`SetLink(gameObject)` 는 이벤트를 구독하지 않고, 갱신 루프가 트윈 하나를 갱신할 때마다 가장 먼저 링크된 오브젝트를 들여다봅니다. `PauseOnDisablePlayOnEnable` 로 건 트윈이 세 상황을 지나는 모습입니다.

```csharp
if (!_TweenLinks.TryGetValue(t, out tLink))
    return;                                // 링크가 없는 트윈

// 필드 타입이 GameObject 라 유니티의 == 가 파괴된 오브젝트를 null 로 본다
if (tLink.target == null)
{
    t.active = false;                      // 파괴됨. 곧바로 명단에 오른다
    return;
}

bool goActive = tLink.target.activeInHierarchy;
bool justEnabled = !tLink.lastSeenActive && goActive;    // 지난번엔 꺼져 있었다
bool justDisabled = tLink.lastSeenActive && !goActive;   // 지난번엔 켜져 있었다
tLink.lastSeenActive = goActive;

// case LinkBehaviour.PauseOnDisablePlayOnEnable:
if (justDisabled)
    Pause(t);
else if (justEnabled)
    Play(t);
```

`PauseOnDisablePlayOnEnable` 로 건 트윈이 만나는 세 상황입니다.

| 상황 | 판정 | 결과 |
| --- | --- | --- |
| 켜진 채 | 바뀐 것 없음 | 그대로 재생 |
| 방금 꺼짐 | `justDisabled` | `Pause` |
| 파괴됨 (일시정지 중이어도) | `target == null` | 죽음 |

`KillOnDestroy` 에는 따로 처리하는 코드가 없습니다. 모든 행동에 공통인 `target == null` 검사가 그 일을 합니다.

링크 검사는 `isPlaying` 검사보다 앞이라 일시정지된 트윈도 파괴되면 죽습니다. 반응은 파괴한 순간이 아니라 그 뒤 트윈의 차례에 옵니다.

| 없앤 방법 (DOTween 보다 먼저 도는 스크립트) | `OnKill` |
| --- | --- |
| `Update` 나 `LateUpdate` 에서 `Destroy(go)` | 다음 프레임 |
| `Update` 에서 `DestroyImmediate(go)` | 같은 프레임 |

`Destroy` 는 프레임 끝에야 오브젝트를 지우므로 링크 트윈은 한 프레임 더 돕니다. 그리고 링크가 **하나라도** 있으면 모든 트윈이 딕셔너리를 한 번씩 조회합니다. IL2CPP 플레이어에서 `DOMove` 1만 개에 링크 하나를 더하자 PlayerLoop 이 1.72 ms 에서 2.49 ms 로, 트윈 하나에 약 74 ns 늘었습니다. Mono 에서는 트윈당 5 ns 로 흔들림에 묻혔습니다.

## 재활용을 켜면 옛 참조가 남의 트윈이 된다

재활용은 기본 꺼짐입니다. 켜면 죽은 트윈 객체가 풀에 들어가 같은 타입의 다음 트윈으로 **그대로** 다시 쓰이는데, 사용자 코드의 변수는 여전히 그 객체를 가리킵니다.

```csharp
_fade = image.DOFade(0f, 1f);              // 트윈 객체 X
// 1초 뒤 완료 → autoKill → Despawn(X)
var other = otherImage.DOFade(1f, 1f);     // 같은 타입의 새 트윈
_fade.Kill();
```

| 줄 | 재활용 켬 | 재활용 끔 (기본) |
| --- | --- | --- |
| `_fade = ...` | `_fade` 가 X | `_fade` 가 X |
| 완료 뒤 | X 가 풀로 들어감. `_fade` 는 여전히 X | X 는 버려짐. `_fade` 는 여전히 X |
| `other = ...` | 풀에서 X 를 꺼내 씀. `other` 도 X | 새 객체 Y. `other` 는 Y |
| `_fade.Kill()` | X 는 지금 살아 있음. **`other` 의 트윈이 죽음** | X 는 죽어 있어 아무 일 없음 |

재활용을 켜면 트윈 객체가 다른 트윈으로 다시 쓰여도 변수는 그대로라, `IsActive()` 도 `true` 를 돌려줍니다.

재활용을 켜고 끝난 `a` 의 참조를 둔 채 `b` 를 만들자 같은 인스턴스였고, `a.Kill()` 뒤 `b.IsActive()` 가 `false` 였습니다. `IsActive()` 로 걸러지지도 않습니다. `Despawn` 은 `OnKill` 을 풀에 넣기 전에 부르므로, 소스 주석이 권하는 `.OnKill(() => myTweenReference = null)` 이 참조를 끊는 확실한 자리입니다.

## 주의할 점

### 1. 재활용을 켰으면 OnKill 에서 참조를 끊는다

**안 지키면 생기는 문제.** 필드에 담아 둔 트윈이 끝나 풀로 돌아간 뒤 같은 타입의 다른 트윈이 그 객체를 가져갑니다. 옛 필드로 `Kill()` 이나 `Pause()` 를 부르면 전혀 다른 오브젝트의 연출이 멈추고, "같은 타입이 마침 그 사이에 생성됨" 이 조건이라 가끔만 터집니다.

**이렇게 쓴다.** 트윈을 필드에 담으면 `OnKill(() => _fade = null)` 을 함께 겁니다. 오래 들고 있을 트윈에는 `SetRecyclable(false)` 를 겁니다. 재활용이 꺼진 기본 설정에서는 옛 참조가 `active = false` 로 남아 조작이 전부 무시됩니다.

### 2. 무한 루프 트윈은 Complete 가 아니라 Kill 로 끝낸다

**안 지키면 생기는 문제.** `Complete` 의 첫 줄이 `if (t.loops == -1) return false;` 입니다. `SetLoops(-1)` 트윈에 `Complete()` 를 부르면 예외도 로그도 없이 계속 돌고(`IsComplete()` 는 `false`, `IsActive()` 는 `true`), `OnComplete` 에 걸어 둔 다음 단계도 시작되지 않습니다. `Kill(true)` 도 완료 단계를 건너뜁니다.

**이렇게 쓴다.** 무한 루프는 `Kill()` 로 끝내고, 끝값이나 시작값에 놓여야 하면 `Goto` 나 `Rewind()` 를 먼저 부르거나 값을 직접 넣습니다.

### 3. DOKill(target) 을 대량으로 부르지 않는다

**안 지키면 생기는 문제.** 풀링된 탄환 수백 개가 같은 프레임에 반환되며 각자 `OnDisable` 에서 `transform.DOKill()` 을 부르면, 활성 트윈 수 × 호출 수만큼 비교가 한 프레임에 몰립니다. 활성 트윈 1만 개에서 백 번이면 16~24 ms 입니다.

**이렇게 쓴다.** 자주 끄고 켜는 오브젝트는 트윈 참조를 필드로 들고 `_tween.Kill()` 로 직접 죽입니다. 여러 트윈을 한 번에 치우려면 0 이상의 `int` id 로 묶어 `DOTween.Kill(id)` 를 한 번 부릅니다.

### 4. SafeMode 는 켜 두고 경고를 읽는다

**안 지키면 생기는 문제.** SafeMode 는 시작값 읽기와 값 적용의 예외를 잡아 트윈을 죽이고, 콜백 예외는 경고만 남기고 트윈을 살려 둡니다. 끄면 파괴된 대상 하나의 예외가 갱신 루프를 끊어 그 프레임의 뒤 차례 트윈들이 갱신되지 않고, 다음 갱신이 끝날 때까지 루프 밖의 `Kill` 도 표시만 됐습니다. 켜 둔 채 `safeModeLogBehaviour` 를 `None` 으로 두면 콜백 버그가 흔적 없이 삼켜집니다.

**이렇게 쓴다.** 트윈 1만 개에서 비용이 1.7% 정도라 켜 두고, 로그 수준은 `Warning` 이상으로 둡니다. "Target or field is missing/null" 경고가 보이면 그 트윈에 `SetLink` 가 빠진 것입니다.

## 정리

DOTween 의 트윈은 수명을 코드가 챙기고, **모든 파기는 `Despawn` 하나로 모이며 갱신 도중의 `Kill` 은 루프 끝으로 미뤄집니다.**

- 표시된 트윈은 자기 차례에 명단에 올라 루프 끝에 역순으로 치워지고, 이미 지나간 칸이면 `OnKill` 이 한 프레임 늦습니다.
- `DOKill(target)` 은 활성 트윈 수에 비례하고, `SetLink` 는 트윈 차례마다 묻는 폴링이라 `Destroy` 에 한 프레임 늦게 반응합니다.
- 재활용을 켜면 옛 참조가 남의 트윈을 조작하므로 `OnKill` 에서 참조를 끊습니다.