---
title: "DOTween 의 트윈은 숨은 오브젝트 하나의 Update 에서 돈다"
description: "DOTween 의 모든 트윈은 [DOTween] 오브젝트의 Update·LateUpdate·FixedUpdate 가 배열 하나를 훑으며 돌립니다. 실행 순서를 정하지 않았고, 콜백에서 만든 트윈은 다음 프레임부터 돌며, 1.3.030 은 unscaled 시간에서 자기 갱신 시간을 뺍니다."
author: "유니얼"
pubDate: 2026-09-25
updatedDate: 2026-10-18
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "트윈"]
canonical: https://blog.unialgames.com/posts/unity-dotween-update/
---
```csharp
transform.DOMoveX(5f, 1f);                            // Update 에서 돈다
transform.DOMoveX(5f, 1f).SetUpdate(UpdateType.Late); // LateUpdate 에서 돈다
transform.DOMoveX(5f, 1f).SetUpdate(true);            // Time.timeScale 을 무시한다
```

트윈을 만든 스크립트에는 `Update` 가 없는데 값이 매 프레임 바뀝니다. 그러면 내 `Update` 에서 읽은 위치가 이번 프레임 값인지, 일시정지 화면의 `SetUpdate(true)` 트윈이 왜 느리게 끝나는지가 이 구동 방식에 달려 있습니다.

DOTween 은 **첫 호출 때 만든 `[DOTween]` 오브젝트의 `DOTweenComponent` 가 `Update`, `LateUpdate`, `FixedUpdate` 에서 활성 트윈 배열 하나를 훑어 모든 트윈을 돌립니다.** 기준은 설치본 DOTween 1.3.030(GitHub 커밋 `4846808`)입니다.

## 트윈은 다른 스크립트의 Update 사이 어딘가에서 돈다

`DOTweenComponent` 에는 `DefaultExecutionOrder` 도, 실행 순서를 정하는 코드도 없습니다. 같은 순서값끼리의 `Update` 순서는 유니티가 정합니다. 실행 순서를 정하지 않은 스크립트의 `Update` 에서 `DOMoveX` 를 만들고 첫 갱신이 언제 오는지 30회씩 봤습니다.

| 만드는 스크립트의 실행 순서 | 첫 갱신 |
| --- | --- |
| 정하지 않음 (`[DOTween]` 이 이미 있거나, 그 호출로 막 생기거나) | 30회 모두 다음 프레임 |
| `[DefaultExecutionOrder(-100)]` | 30회 모두 같은 프레임 |
| `[DefaultExecutionOrder(100)]` | 30회 모두 다음 프레임 |

이 환경에서는 `[DOTween]` 이 늘 먼저 돌았습니다. 하지만 같은 순서값끼리의 차례는 유니티가 약속한 값이 아니므로, **트윈과의 앞뒤가 중요하면 순서값을 직접 정해야 합니다.**

## SetUpdate(true) 가 피하는 것은 Time.timeScale 하나다

`TweenManager.Update` 는 `Time.timeScale` 이 곱해진 시간과 곱해지지 않은 unscaled 시간을 함께 받고, 트윈마다 `isIndependentUpdate` 로 하나를 고른 뒤 트윈의 `timeScale` 을 곱합니다. 일시정지 화면에서 자주 만나는 세 경우입니다.

```csharp
void Update()
{
    if (TweenManager.hasActiveDefaultTweens)
        TweenManager.Update(Normal,
            Time.deltaTime * DOTween.timeScale,                                 // 스케일 시간
            unscaledDelta * DOTween.unscaledTimeScale * DOTween.timeScale);     // unscaled 시간에도 DOTween.timeScale 이 곱해진다
}

void FixedUpdate()
{
    if (TweenManager.hasActiveFixedTweens && Time.timeScale > 0)   // Time.timeScale 이 0 이면 돌지 않는다
        TweenManager.Update(Fixed, ...);
}

// TweenManager.Update(t): 트윈 하나
float tDelta = (t.isIndependentUpdate ? independentTime : deltaTime) * t.timeScale;   // SetUpdate(true) 면 unscaled

if (tDelta < 0.000001f && tDelta > -0.000001f)
    return false;                              // 사실상 0 이면 건너뛴다
```

`Time.timeScale = 0` 인 일시정지 화면, 프레임 16 ms 에서 트윈이 받는 시간입니다.

| 트윈 | `DOTween.timeScale` | 받는 시간 |
| --- | --- | --- |
| `SetUpdate(true)` | 1 | 16 ms. 움직임 |
| `SetUpdate(true)` | 0 | 0. **멈춤** |
| `SetUpdate(UpdateType.Fixed, true)` | 1 | Fixed 루프가 돌지 않아 **멈춤** |

`DOTween.timeScale` 은 unscaled 시간에도 곱해지고, `Fixed` 루프는 `Time.timeScale` 이 0 이면 아예 돌지 않습니다. `SetUpdate(true)` 는 **`Time.timeScale` 만 피합니다.**

## 루프 길이는 시작할 때 정한다

세 메시지가 부르는 루프는 같은 함수입니다. 배열 하나를 처음부터 훑으며 `updateType` 이 다른 칸은 건너뜁니다. 그리고 훑을 길이를 **시작할 때 한 번** 잡습니다.

```csharp
// Core/TweenManager.cs (줄임)
int len = _maxActiveLookupId + 1; // Stored here so if _maxActiveLookupId changed during update loop (like if new tween is created at onComplete) new tweens are still ignored
for (int i = 0; i < len; ++i) {
    Tween t = _activeTweens[i];
    if (t == null || t.updateType != updateType) continue;
    if (Update(t, deltaTime, independentTime, false)) willKill = true;
}
```

콜백 안에서 만든 트윈은 배열 끝, `len` 밖에 붙어 이번 루프에서 돌지 않습니다.

```csharp
int len = _maxActiveLookupId + 1;              // 루프를 시작할 때 한 번만 정한다

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

    if (t == null || t.updateType != updateType)
        continue;                              // 다른 루프의 트윈은 건너뛴다

    if (Update(t, deltaTime, independentTime, false))
        willKill = true;
}
```

활성 배열이 `[A(Normal), B(Late), C(Normal)]` 이고, A 가 끝나며 `OnComplete` 에서 새 트윈 D 를 만드는 프레임입니다.

1. 루프를 시작할 때 `len` 이 3 으로 정해집니다.
2. A 가 끝나고 `OnComplete` 안에서 D 가 생겨 배열 끝 3번 칸에 들어갑니다.
3. B 는 Late 라 건너뛰고, C 는 갱신합니다.
4. 3번 칸의 D 는 `len` 밖이라 이번 루프에서 불리지 않습니다. 다음 프레임부터 움직입니다.

`len` 을 매번 다시 읽었다면 D 가 같은 프레임에 한 걸음 먼저 갔을 것입니다.

`OnComplete` 가 불린 프레임이 7958, 그 안에서 만든 트윈의 첫 `OnUpdate` 는 7959 였습니다. 거르는 비용은 작았습니다. 같은 조건에서 `Late` 1개 + `Normal` 10,000개인 프레임(1.869 ms)과 `Normal` 10,000개만인 프레임(1.914 ms)이 구분되지 않았습니다.

## 1.3.030 의 unscaled 시간은 DOTween 자신의 갱신 시간을 뺀다

1.3.030 은 unscaled 시간을 `Time.realtimeSinceStartup` 두 번의 차이로 재는데, 기준점을 **트윈 갱신이 끝난 뒤에** 찍습니다. `realtimeSinceStartup` 은 부를 때마다 실제 시각이라, 트윈 갱신에 걸린 구간이 어느 프레임에도 들어가지 않습니다. 16 ms 프레임에 갱신이 4 ms 걸린다고 두고 1.3.045(GitHub 커밋 `bfb02b0`)와 나란히 봅니다.

<x-compare>
<script type="application/json">
{
  "label": "16 ms 프레임에서 트윈 갱신이 4 ms 걸릴 때 unscaled 트윈이 받는 시간",
  "tabs": [
    {
      "label": "1.3.030",
      "code": "void Update()\n{\n    // 지난 프레임 트윈 갱신이 끝난 시각부터 잰다\n    _unscaledDeltaTime = Time.realtimeSinceStartup - _unscaledTime;\n\n    if (TweenManager.hasActiveDefaultTweens)\n        TweenManager.Update(UpdateType.Normal, dt, _unscaledDeltaTime);   // 여기서 4 ms 가 걸린다\n\n    // 기준점을 갱신이 끝난 뒤에 찍는다\n    _unscaledTime = Time.realtimeSinceStartup;\n}",
      "text": "Time.realtimeSinceStartup 은 부를 때마다 실제 시각입니다. 기준점을 트윈 갱신이 끝난 뒤에 찍으니, 갱신에 걸린 4 ms 가 어느 프레임에도 들어가지 않습니다.",
      "stats": [
        [
          "실제 프레임",
          "16 ms"
        ],
        [
          "트윈이 받는 시간",
          "12 ms"
        ]
      ],
      "tone": "bad"
    },
    {
      "label": "1.3.045",
      "code": "void Update()\n{\n    // 유니티가 프레임마다 한 번 정한 값을 읽는다\n    _unscaledDeltaTime = Time.unscaledDeltaTime;\n\n    if (TweenManager.hasActiveDefaultTweens)\n        TweenManager.Update(UpdateType.Normal, dt, _unscaledDeltaTime);\n}",
      "text": "갱신에 4 ms 가 걸려도 이미 읽은 값은 바뀌지 않습니다.",
      "stats": [
        [
          "실제 프레임",
          "16 ms"
        ],
        [
          "트윈이 받는 시간",
          "16 ms"
        ]
      ],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

`Time.timeScale = 0` 에서 `SetUpdate(true)` 트윈이 센 시간을 실제 경과와 비교해 빠진 비율을 쟀습니다.

| 부하 트윈 | 상한 없음, Mono | 상한 없음, IL2CPP | 60 fps, Mono | 60 fps, IL2CPP |
| --- | --- | --- | --- | --- |
| 0 | 0.37 % | 0.35 % | 0.02 % | 0.02 % |
| 1,000 | 29.02 % | 21.25 % | 0.92 % | 0.65 % |
| 5,000 | 65.75 % | 58.15 % | 4.69 % | 2.98 % |

빠지는 양은 DOTween 이 한 프레임에 돈 시간이고 비율은 그것을 프레임 시간으로 나눈 값이라, 트윈이 많고 프레임이 짧을수록 커집니다. 렌더링 없는 에디터 배치모드에서는 트윈 5,001개에서 71.56 % 가 사라졌습니다. 영향을 받는 것은 독립 시간을 쓰는 `Normal`, `Late` 트윈입니다.

## 주의할 점

### 1. 일시정지 화면의 unscaled 트윈은 1.3.045 이상에서 쓴다

**안 지키면 생기는 문제.** `Time.timeScale = 0` 으로 게임을 멈추고 메뉴를 `SetUpdate(true)` 트윈으로 띄우면, 1.3.030 에서는 트윈이 많은 장면일수록 메뉴 연출이 느리게 끝납니다. 60 fps 로 묶은 플레이어에서도 트윈 5,000개면 3~5 % 가 사라졌습니다.

**이렇게 쓴다.** 1.3.045 이상으로 올립니다. 올릴 수 없으면 그 트윈을 `SetUpdate(UpdateType.Manual, true)` 로 두고 내 `Update` 에서 `DOTween.ManualUpdate(Time.deltaTime, Time.unscaledDeltaTime)` 를 부릅니다. 같은 플레이어의 60 fps, 부하 5,000개에서 이 경로의 누락은 Mono 0.000 %, IL2CPP 0.001 % 였고, 대조한 `SetUpdate(true)` 트윈은 4.390 %, 3.302 % 였습니다.

### 2. timeScale 0 일시정지에 Fixed 트윈과 DOTween.timeScale 을 섞지 않는다

**안 지키면 생기는 문제.** `SetUpdate(UpdateType.Fixed, true)` 트윈은 `Time.timeScale` 이 0 인 동안 `FixedUpdate` 쪽 조건에 막혀 움직이지 않습니다. 게임을 멈추려고 `DOTween.timeScale` 을 0 으로 두면 unscaled 시간에도 곱해져 일시정지 메뉴 트윈까지 멈춥니다.

**이렇게 쓴다.** 일시정지 중에 움직일 트윈은 `Normal` 이나 `Late` 에 `SetUpdate(true)` 로 둡니다. 게임 쪽 트윈만 멈추려면 `Time.timeScale` 을 쓰거나, 게임 트윈에 id 를 달아 `DOTween.Pause(id)` 로 멈춥니다.

### 3. 트윈이 옮기는 값을 Update 에서 읽지 않는다

**안 지키면 생기는 문제.** `[DOTween]` 의 `Update` 가 내 스크립트보다 먼저 돌지는 DOTween 이 정하지 않습니다. 트윈이 옮기는 오브젝트의 위치를 내 `Update` 에서 읽어 카메라나 UI 를 맞추면, 스크립트에 따라 이번 값과 지난 값을 섞어 읽어 한 프레임 어긋납니다.

**이렇게 쓴다.** 트윈이 쓴 값을 따라가는 코드는 `LateUpdate` 에서 읽습니다. 값 자체가 필요하면 값을 쓴 직후에 불리는 `OnUpdate` 콜백에서 받습니다.

### 4. 콜백 안에서 만든 트윈은 다음 프레임부터 돈다고 보고 짠다

**안 지키면 생기는 문제.** `OnComplete` 에서 다음 트윈을 만들면 한 프레임 동안 아무 값도 쓰지 않아, 이어지는 움직임에 턱이 보일 수 있습니다. 같은 콜백에서 이미 돌던 트윈을 시퀀스에 넣어 앞쪽 칸이 비면, 새 트윈을 넣을 때의 압축이 순회를 흔들어 아직 안 읽은 트윈 하나가 그 프레임을 건너뛰었습니다.

**이렇게 쓴다.** 끊김 없이 이어질 움직임은 `Sequence` 에 `Append` 로 넣습니다. 이미 재생 중인 트윈을 콜백 안에서 시퀀스에 넣지 않습니다.

## 정리

DOTween 의 트윈은 **`[DOTween]` 오브젝트 하나의 `Update`, `LateUpdate`, `FixedUpdate` 가 배열 하나를 훑어** 돌립니다.

- 실행 순서를 정하지 않아 다른 스크립트의 `Update` 와의 앞뒤는 보장되지 않습니다. 순서값을 정하지 않은 스크립트보다 늘 먼저 돌았습니다.
- `SetUpdate(true)` 는 `Time.timeScale` 만 피하고, 루프 길이는 시작할 때 고정돼 콜백에서 만든 트윈은 다음 프레임부터 돕니다.
- 1.3.030 은 unscaled 시간에서 자기 갱신 시간을 빼먹습니다. 1.3.045 가 `Time.unscaledDeltaTime` 으로 고쳤고, 1.3.030 에서는 `Manual` 갱신이 우회로입니다.