---
title: "DOTween 의 시퀀스는 넣은 트윈을 빼내 시간표대로 돌린다"
description: "DOTween 의 Sequence 는 넣은 트윈을 매니저의 활성 목록에서 빼 자기 시간표에 적고, 매 프레임 자식마다 로컬 시각으로 Goto 합니다. 그래서 자식을 따로 Kill 하거나 DOKill(target) 로 찾을 수 없고, 되감을 때는 뒤에서부터 돕니다."
author: "유니얼"
pubDate: 2026-09-25
updatedDate: 2026-10-18
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "트윈"]
canonical: https://blog.unialgames.com/posts/unity-dotween-sequence/
---
```csharp
Sequence s = DOTween.Sequence();
s.Append(panel.DOScale(1f, 0.3f))
 .Join(group.DOFade(1f, 0.3f))
 .AppendInterval(0.5f)
 .Append(title.DOMoveY(0f, 0.4f));
```

코루틴이었다면 `yield return` 을 사이사이 끼우고, `Animator` 였다면 타임라인에 키를 찍었을 연출입니다. 시퀀스는 이것을 트윈 하나로 만들어, 멈추면 전부 멈추고 되감으면 전부 되감깁니다. 그런데 그 뒤로 `transform.DOKill()` 이 자식을 못 찾고, 자식에게 `Kill()` 을 불러도 아무 일도 일어나지 않습니다.

DOTween 의 시퀀스는 **넣은 트윈을 매니저의 활성 목록에서 빼내 자기 시간표에 적고, 매 프레임 자식마다 로컬 시각을 새로 정해 `Goto` 합니다.** 기준은 DOTween 1.3.030(GitHub 커밋 `4846808`)입니다.

## 시퀀스는 자리를 적은 시간표다

시퀀스의 항목마다 시간축 위의 구간 `[sequencedPosition, sequencedEndPosition]` 이 있고, 시퀀스 길이는 가장 늦게 끝나는 자리입니다. 만드는 API 는 전부 "어디에 적을까" 를 정합니다. `Append` 는 지금 길이에, `Join` 은 직전에 넣은 자리에, `Insert` 는 지정한 시각에 적고, `AppendInterval` 은 적지 않고 길이만 늘립니다.

```csharp
var s = DOTween.Sequence();
s.Append(a);            // a: 1초.   지금 길이 0 에 적는다         → a [0 ~ 1]
s.Join(b);              // b: 0.5초. 직전에 넣은 자리 0 에 겹친다  → b [0 ~ 0.5]
s.AppendInterval(0.5f); // 적지 않고 길이만 1 → 1.5 로 늘린다. 직전 자리는 인터벌 시작 1 로
s.Join(c);              // c: 1초.   직전 자리 1 에 겹친다          → c [1 ~ 2]
s.Append(d);            // d: 1초, SetDelay(0.5f). 지금 길이 2 에 딜레이만큼 밀어 → d [2.5 ~ 3.5]
```

| 항목 | 시간표의 자리 | 그 뒤 시퀀스 길이 |
| --- | --- | --- |
| a | 0 ~ 1 | 1 |
| b | 0 ~ 0.5 | 1 |
| 인터벌 | 적지 않음 | 1.5 |
| c | 1 ~ 2 | 2 |
| d | 2.5 ~ 3.5 | 3.5 |

- 시퀀스의 길이는 따로 정하지 않습니다. 가장 늦게 끝나는 항목이 곧 길이입니다.
- 인터벌 뒤의 `Join` 은 인터벌이 끝난 1.5 가 아니라 시작한 1 에 붙습니다. 소스 주석이 밝혀 둔 동작입니다.
- d 의 딜레이 0.5 는 자리를 미는 데 쓰이고, d 자신의 딜레이는 0 이 됩니다.

항목은 시퀀스가 시작할 때 안정 정렬로 한 번 정렬돼서, 같은 시각의 항목은 넣은 순서대로 처리됩니다. 인터벌 뒤의 `Join` 은 인터벌이 끝난 자리가 아니라 시작한 자리에 붙습니다.

## 넣는 순간 자식은 매니저 손을 떠난다

`DoInsert` 의 첫 줄 `AddActiveTweenToSequence` 는 이름과 달리 `RemoveActiveTween` 한 줄입니다. 자식은 활성 배열에서 지워지고, 그 뒤로 자식을 부르는 길은 부모 시퀀스뿐입니다. 함께 딜레이는 자리로 바뀌고(`delay = 0`), `creationLocked` 와 `autoKill = false` 가 걸리고, 무한 루프는 `int.MaxValue` 로 바뀝니다.

<x-wiring>
<script type="application/json">
{
  "label": "시퀀스에 넣은 DOMove 가 불리는 길과 DOKill 이 찾는 길",
  "width": 730,
  "height": 380,
  "nodes": [
    {
      "id": "user",
      "title": "사용자 코드",
      "x": 0,
      "y": 20,
      "w": 150,
      "h": 48,
      "aside": true
    },
    {
      "id": "arr",
      "title": "활성 배열",
      "sub": "TweenManager._activeTweens",
      "x": 260,
      "y": 20,
      "w": 170,
      "h": 54
    },
    {
      "id": "seq",
      "title": "Sequence s",
      "sub": "시간표 _sequencedObjs",
      "x": 260,
      "y": 160,
      "w": 170,
      "h": 54
    },
    {
      "id": "mv",
      "title": "DOMove m",
      "sub": "target = transform",
      "x": 260,
      "y": 300,
      "w": 170,
      "h": 54
    },
    {
      "id": "kill",
      "title": "transform.DOKill()",
      "sub": "활성 배열만 훑는다",
      "x": 580,
      "y": 20,
      "w": 150,
      "h": 54,
      "aside": true
    }
  ],
  "edges": [
    {
      "from": "user",
      "to": "arr",
      "label": "DOMove 가 등록"
    },
    {
      "from": "arr",
      "to": "seq",
      "label": "Append: m 을 빼고 s 에 적는다",
      "bend": 30
    },
    {
      "from": "arr",
      "to": "seq",
      "label": "매 프레임 s 만 갱신",
      "bend": -30
    },
    {
      "from": "seq",
      "to": "mv",
      "label": "Goto(m, 로컬 시각)"
    },
    {
      "from": "kill",
      "to": "arr",
      "label": "target == transform 인가"
    }
  ],
  "steps": [
    {
      "edge": 0,
      "why": "DOMove 는 만들어지는 즉시 활성 배열에 들어갑니다. 이 순간에는 매니저가 직접 돌릴 트윈입니다."
    },
    {
      "edge": 1,
      "why": "Append 가 m 을 활성 배열에서 지우고 시퀀스의 시간표에 적습니다. 배열의 그 칸은 null 이 됩니다."
    },
    {
      "edge": 2,
      "why": "매니저는 활성 배열을 돌며 s 만 만납니다. m 은 배열에 없으니 매니저가 부르지 않습니다."
    },
    {
      "edge": 3,
      "why": "s 가 자기 시각을 m 의 로컬 시각으로 바꿔 Goto 합니다. m 이 움직이는 길은 이것뿐입니다."
    },
    {
      "edge": 4,
      "why": "DOKill 은 활성 배열에서 target 이 같은 트윈을 찾습니다. 배열에는 s 만 있고 s 의 target 은 비어 있습니다."
    },
    {
      "node": "mv",
      "why": "m 은 살아서 끝까지 움직입니다. DOKill 의 반환값은 0 입니다."
    }
  ],
  "note": "시퀀스에 넣은 트윈을 대상으로 잡으려면 시퀀스 자체에 target 을 걸어야 합니다."
}
</script>
</x-wiring>

`DOMove` 를 `Append` 한 뒤 `transform.DOKill()` 의 반환값은 0 이었고 자식은 계속 활성이었습니다. 자식에게 직접 `Kill()`, `Complete()`, `Pause()` 를 불러도 첫머리의 `isSequenced` 검사에서 돌아가고, 무시했다는 경고는 로그 수준이 `Verbose` 일 때만 찍힙니다. 넣기 전에 걸어 둔 `SetLink` 도 이때 지워집니다.

이미 재생 중인 트윈도 경고 없이 들어가고, **처음부터 다시 재생됩니다.** 0.5초 돌아 x = 5 에 온 `DOMoveX(10, 1)` 을 새 시퀀스에 넣자 시퀀스의 첫 갱신에서 0 근처로 돌아가 1초 동안 다시 10 으로 갔습니다.

## 매 프레임 자식마다 로컬 시각으로 Goto 한다

시퀀스의 갱신은 "시퀀스 시각 `from` 에서 `to` 로" 한 가지이고, 시간표를 훑어 구간이 걸친 자식마다 `TweenManager.Goto(자식, to - 자식 시작)` 을 부릅니다. 지난 프레임에서 흐른 시간을 더해 주는 것이 아니라 "너는 지금 몇 초 지점이다" 를 매번 알려 줍니다. 0.8초에서 1.2초로 넘어가는 한 프레임입니다.

```csharp
for (int i = 0; i < len; ++i)
{
    var o = _sequencedObjs[i];

    // 이번 구간 (fromPos, toPos] 에 걸치지 않는 항목은 건너뛴다
    if (o.sequencedPosition > toPos || o.sequencedEndPosition <= fromPos)
        continue;

    // 콜백은 구간 안에 들었으면 한 번 부른다
    if (o is SequenceCallback)
    {
        OnTweenCallback(o.onStart, s);
        continue;
    }

    float gotoPos = toPos - o.sequencedPosition;   // "너는 지금 몇 초 지점이다"

    if (toPos >= o.sequencedEndPosition)
        gotoPos = o.fullDuration;                  // 끝을 넘었으면 끝으로 맞춘다

    TweenManager.Goto(o, gotoPos);
}
```

시간표 `[a 0~1, b 0~0.5, 콜백 @1, c 1~2]` 에서 0.8초에서 1.2초로 넘어가는 프레임입니다.

| 항목 | 판정 | 하는 일 |
| --- | --- | --- |
| a | 걸침. 1.2 가 끝 1 을 넘음 | 끝(1초)에 맞춰 놓음. a 의 `OnComplete` 가 여기서 불림 |
| b | 0.5 에 이미 끝남 | 건너뜀 |
| 콜백 @1 | 0.8 < 1 ≤ 1.2 | 실행 |
| c | 걸침 | 로컬 0.2초 지점에 놓음. c 의 첫 갱신이라 시작값도 여기서 읽힘 |

지난 프레임에서 흐른 시간을 더해 주는 것이 아니라 매번 "지금 몇 초 지점이다" 를 알려 줍니다. 그래서 끝을 지난 자식도 끝에 정확히 놓입니다.

끝을 지난 자식은 끝으로 맞춰 `OnComplete` 를 빠뜨리지 않고, 시퀀스를 어디로 점프시켜도 자식 상태가 어긋나지 않습니다. 대신 **이미 끝난 항목도 매 프레임 한 번씩 검사합니다.** 시퀀스 100개에서 마지막 항목 하나만 진행 중일 때의 프레임 증가분입니다.

| 시퀀스 100개 × 항목 M | Mono | IL2CPP |
| --- | --- | --- |
| M = 10 | +0.010 ms | +0.058 ms |
| M = 100 | +0.116 ms | +0.103 ms |
| M = 1000 | +2.486 ms | +1.805 ms |

움직이는 트윈은 셋 다 100개인데 비용은 항목 수를 따라 늘었습니다. 끝난 항목 하나가 Mono 에서 매 프레임 10~25 ns 입니다.

## 되감을 때는 뒤에서부터 돈다

`to < from` 이면 같은 시간표를 끝에서 앞으로 훑습니다. 같은 대상을 이어서 옮기는 자식들이 그 시각의 올바른 값을 남기려면, 앞으로 갈 때는 뒤 항목이, 되감을 때는 앞 항목이 마지막에 써야 하기 때문입니다.

<x-compare>
<script type="application/json">
{
  "label": "x = 15 에서 0.5초로 되감기. a[0~1] 0→10, b[1~2] 10→20",
  "tabs": [
    {
      "label": "뒤에서부터 (실제 코드)",
      "code": "for (int i = len - 1; i > -1; --i)   // 되감을 때는 끝에서 앞으로\n{\n    var t = _sequencedObjs[i];\n    if (t.sequencedEndPosition < toPos || t.sequencedPosition > fromPos)\n        continue;\n\n    float gotoPos = Math.Max(0, toPos - t.sequencedPosition);\n    TweenManager.Goto(t, gotoPos);\n}\n\n// b: gotoPos = max(0, 0.5 - 1) = 0  → x = 10\n// a: gotoPos = 0.5                  → x = 5  (마지막에 씀)",
      "text": "되감을 때는 앞 항목이 마지막에 써야 그 시각의 올바른 값이 남습니다.",
      "stats": [
        [
          "결과",
          "x = 5 (맞음)"
        ]
      ],
      "tone": "good"
    },
    {
      "label": "앞에서부터라면",
      "code": "for (int i = 0; i < len; ++i)       // 가정: 앞에서부터\n{\n    var t = _sequencedObjs[i];\n    if (t.sequencedEndPosition < toPos || t.sequencedPosition > fromPos)\n        continue;\n\n    float gotoPos = Math.Max(0, toPos - t.sequencedPosition);\n    TweenManager.Goto(t, gotoPos);\n}\n\n// a: gotoPos = 0.5 → x = 5\n// b: gotoPos = 0   → x = 10 (자기 시작값을 나중에 써서 a 를 덮음)",
      "text": "b 가 자기 시작값 10 을 나중에 써서 a 가 맞춰 둔 값을 덮습니다.",
      "stats": [
        [
          "결과",
          "x = 10 (틀림)"
        ]
      ],
      "tone": "bad"
    }
  ]
}
</script>
</x-compare>

되감을 때는 한 번도 시작하지 않은 자식을 건너뜁니다. 콜백도 방향을 탑니다. 0.5초와 1초에 콜백을 둔 시퀀스를 끝까지 재생한 뒤 `PlayBackwards()` 하면 1초 것, 0.5초 것 순으로 불렸고, `Rewind()` 로 한 번에 돌리면 하나도 불리지 않았습니다.

## 주의할 점

### 1. 시퀀스에 넣은 트윈은 시퀀스로 다룬다

**안 지키면 생기는 문제.** 자식 참조로 `Kill()`, `Complete()`, `Pause()` 를 부르거나 `OnDestroy` 에서 `transform.DOKill()` 로 정리해도, 기본 로그 설정에서는 흔적 없이 무시됩니다. 오브젝트가 사라진 뒤에도 시퀀스가 돌며 SafeMode 경고를 남기거나, 풀링된 오브젝트라면 다음 사용 때 옛 움직임이 섞입니다.

**이렇게 쓴다.** 제어는 부모 `Sequence` 에 합니다. `DOTween.Sequence(transform)` 이나 `SetTarget(transform)` 으로 시퀀스에 대상을 걸면 `transform.DOKill()` 이 시퀀스를 잡고 자식도 함께 정리됩니다. 오브젝트 수명에 묶으려면 시퀀스에 `SetLink(gameObject)` 를 겁니다.

### 2. 무한 루프는 시퀀스에 건다

**안 지키면 생기는 문제.** `SetLoops(-1)` 트윈을 `Append` 하면 `int.MaxValue` 루프가 되어 시퀀스 길이가 2,147,484,000초가 됩니다. 그 뒤에 붙인 트윈과 시퀀스의 `OnComplete` 는 오지 않습니다. XML 주석은 "1 로 바뀐다" 고 적어서 더 헷갈립니다.

**이렇게 쓴다.** 반복할 구간만 따로 시퀀스로 만들어 그 시퀀스에 `SetLoops(-1)` 을 겁니다.

### 3. 시퀀스는 만든 프레임 안에서 다 채운다

**안 지키면 생기는 문제.** 시퀀스는 첫 갱신에서 잠기고, 그 뒤의 `Append`, `Join`, `Insert` 는 무시됩니다. 비동기 로드가 끝나면 트윈을 덧붙이는 코드는 로드가 한 프레임만 늦어도 조용히 빠집니다. 자식도 넣는 순간 잠겨서 `Append` 뒤에 부른 `SetLoops`, `SetDelay` 는 먹지 않습니다.

**이렇게 쓴다.** 자식 설정은 `Append` 전에 끝내고 시퀀스 구성은 한 메서드 안에서 끝냅니다. 나중에 붙일 것은 자리만 `AppendInterval` 로 잡고 `AppendCallback` 에서 따로 시작합니다.

### 4. 시퀀스의 SetEase 는 자식 이징 위에 겹친다

**안 지키면 생기는 문제.** 시퀀스에 이징을 걸면 `from` 과 `to` 가 먼저 휘고, 자식은 그 휜 시각 위에 자기 이징(기본 `OutQuad`)을 또 겁니다. 끝부분이 예상보다 훨씬 느려지고 콜백 시점도 휜 시간축을 따라 움직입니다.

**이렇게 쓴다.** 시퀀스 이징은 기본값 `Linear` 로 두고 자식마다 이징을 정합니다. 전체를 한 곡선으로 휘고 싶을 때만 시퀀스에 걸고 자식을 `Ease.Linear` 로 둡니다.

## 정리

DOTween 의 시퀀스는 **넣은 트윈을 활성 목록에서 빼내 시간표에 적고, 매 프레임 자식마다 로컬 시각으로 `Goto` 합니다.**

- 넣는 순간 자식은 매니저 손을 떠나, 자식 직접 제어와 `DOKill(target)` 이 조용히 빗나갑니다.
- 끝을 지난 자식은 끝으로 맞추고, 끝난 항목도 매 프레임 검사해 비용이 항목 수에 비례합니다.
- 되감을 때는 뒤에서부터 돌아 그 시각의 올바른 값을 남기고, 시작하지 않은 자식은 건너뜁니다.