---
title: "DOTween 은 시작값을 딜레이가 끝난 첫 갱신에서 읽는다"
description: "DOTween 트윈의 시작값은 만들 때가 아니라 딜레이가 끝난 첫 갱신에서 getter 로 읽고, 상대값과 변화량과 속도 기반 길이도 그때 한 번에 정합니다. From 만은 붙이는 줄에서 곧바로 값을 씁니다."
author: "유니얼"
pubDate: 2026-09-25
updatedDate: 2026-10-18
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "트윈"]
canonical: https://blog.unialgames.com/posts/unity-dotween-startup/
---
```csharp
transform.DOMoveX(10f, 1f).SetDelay(1f);   // 출발점은 언제 정해질까
```

끝값만 있고 시작값이 없습니다. 코루틴이라면 `var from = transform.position;` 을 `yield` 위에 적었는지 아래에 적었는지가 답이지만, DOTween 에서는 라이브러리가 그 줄을 대신 부릅니다. 딜레이 동안 다른 코드가 대상을 옮기면 어디서 출발하는지가 여기에 달려 있습니다.

DOTween 은 **시작값을 트윈을 만들 때가 아니라 딜레이가 끝난 첫 갱신에서 읽습니다.** 상대 끝값, 변화량, 속도 기반 길이도 그때 `Startup` 한 번에 정합니다. 기준은 설치본 DOTween 1.3.030(GitHub 커밋 `4846808`)입니다.

## 딜레이가 끝나야 시작값을 읽는다

트윈 하나의 갱신은 딜레이가 남아 있으면 `return false` 로 빠지고, 딜레이가 끝난 뒤에야 `Startup` 을 부릅니다. x = 0 에서 만든 트윈의 대상을 딜레이 도중 x = 4 로 옮기는 경우입니다(한 프레임 0.4초, `Linear`).

```csharp
if (!t.isPlaying)
    return false;

t.creationLocked = true;                    // 첫 갱신에서 잠긴다. 이 뒤로 SetRelative, From 같은 설정은 무시된다
float tDelta = dt * t.timeScale;

if (!t.delayComplete)
{
    tDelta = t.UpdateDelay(t.elapsedDelay + tDelta);   // 딜레이가 끝나면 남은 시간을 돌려준다
    if (tDelta <= 0)
        return false;                       // 아직 딜레이 중
}

if (!t.startupDone)
    t.Startup();                            // 여기서 getter 로 시작값을 읽는다

// 목표 위치 계산 → DoGoto → setter
```

x = 0 에서 `DOMoveX(10, 1).SetDelay(1)` 을 만들고, 딜레이 도중 다른 스크립트가 대상을 x = 4 로 옮기는 경우입니다. 한 프레임은 0.4초, `Linear` 입니다.

| 프레임 | 기다린 시간 | 하는 일 | x |
| --- | --- | --- | --- |
| 1 | 0.4 | 딜레이 중. 설정은 잠김 | 0 |
| 2 | 0.8 | 딜레이 중. 이 프레임 뒤에 대상이 옮겨짐 | 4 |
| 3 | 1.2 | 딜레이가 끝나고 0.2초가 남음. 시작값을 4 로 읽음 | 4 + 6 × 0.2 = 5.2 |

트윈을 만든 순간의 x = 0 은 어디에도 쓰이지 않았습니다.

재 보니 위 표 그대로 시작값이 4 로 읽히고 x = 5.2 가 됐습니다. 시작하기 전의 트윈에 `Goto`, `Rewind`, `Complete`, `ForceInit` 을 부르면 **그 호출 순간** 시작값이 읽힙니다.

## Startup 은 시작값, 상대 끝값, 변화량, 길이를 차례로 정한다

순서가 곧 의존 관계입니다. 시작값이 있어야 상대 끝값을 더하고, 끝값이 확정돼야 변화량이 나오고, 변화량이 있어야 속도를 길이로 바꿉니다. x = 2 에서 `DOMoveX(5, 2)` 를 세 가지로 불러 봅니다.

```csharp
t.startupDone = true;                          // 맨 먼저 켠다. 실패해도 다시 하지 않는다

if (!t.hasManuallySetStartValue)
    t.startValue = t.getter();                 // 대상의 지금 값

if (t.isRelative)
    t.endValue += t.startValue;                // 넘긴 값은 끝값이 아니라 이동량이었다

t.changeValue = t.endValue - t.startValue;     // 여기서 한 번만 계산한다

if (t.isSpeedBased)
    t.duration = Mathf.Abs(t.changeValue) / t.duration;   // duration 칸의 값을 속도로 읽고 길이로 덮어쓴다

t.fullDuration = t.loops > -1 ? t.duration * t.loops : Mathf.Infinity;

if (t.duration <= 0)
    t.easeType = Ease.INTERNAL_Zero;
```

x = 2 에서 `DOMoveX(5, 2)` 를 세 가지로 부르면 이렇습니다.

| 호출 | 시작값 | 끝값 | 변화량 | 길이 |
| --- | --- | --- | --- | --- |
| 기본 | 2 | 5 | 3 | 2초 |
| `.SetRelative()` | 2 | 7 | 5 | 2초 |
| `.SetSpeedBased()` | 2 | 5 | 3 | 1.5초 (초당 2) |

순서가 곧 의존 관계입니다. 시작값이 있어야 상대 끝값을 더하고, 끝값이 확정돼야 변화량이 나오고, 변화량이 있어야 속도를 길이로 바꿉니다.

`startupDone` 이 맨 먼저 켜지므로 getter 가 예외를 던져 실패하면 다시 시도하지 않고 트윈을 파기합니다. `changeValue` 는 여기서 한 번만 계산되고, 매 프레임은 `start + change × ease` 만 합니다. `From()` 이나 `ChangeStartValue` 로 시작값을 직접 준 트윈은 getter 호출을 건너뜁니다.

속도 기반 트윈의 길이는 `Startup` 전에는 없습니다. x = 2 에서 만든 `DOMoveX(5f, 2f).SetSpeedBased()` 의 `Duration()` 은 만든 직후 2(넘긴 속도), 첫 갱신 뒤 1.5(거리 3 / 속도 2)였습니다(같은 세 환경). 시퀀스에 넣으면 속도 기반이 꺼지고 넘긴 숫자를 초로 씁니다.

## From 은 붙이는 줄에서 곧바로 값을 쓴다

`From()` 기본형은 `Startup` 을 기다리지 않고, 붙이는 순간 getter 와 setter 를 부릅니다.

```csharp
// .From() 을 체인에 붙이는 순간 (setImmediately = true)
if (t.creationLocked || !t.isFromAllowed)
    return t;                                  // 한 번이라도 갱신된 트윈에는 안 먹는다

t.isFrom = true;
var prevEnd = t.endValue;                      // 넘긴 값
t.endValue = t.getter();                       // 지금 값이 끝값이 된다
t.startValue = isRelative ? t.endValue + prevEnd : prevEnd;
t.setter(t.startValue);                        // 대상을 그 자리에서 옮긴다
t.hasManuallySetStartValue = true;             // Startup 이 시작값을 다시 읽지 않는다
```

x = 2 에서 부르면 이렇습니다.

| 호출 | 시작값 | 끝값 | `From()` 직후 x |
| --- | --- | --- | --- |
| `DOMoveX(5, 1).From()` | 5 | 2 | 5 |
| `DOMoveX(5, 1).From(true)` | 2 + 5 = 7 | 2 | 7 |

시작값을 정하는 getter 와 대상에 값을 쓰는 setter 가 둘 다 `From()` 을 부른 그 줄에서 실행됩니다. 첫 갱신을 기다리지 않습니다.

즉시 쓰는 경로는 `Startup` 과 달리 SafeMode 의 `try` 안에 있지 않습니다. 대상을 `Destroy` 한 다음 프레임에 들고 있던 `Transform` 으로 트윈을 만들어 봤습니다(SafeMode 켬).

<x-compare>
<script type="application/json">
{
  "label": "파괴된 대상으로 트윈을 만들 때 (SafeMode 켬)",
  "tabs": [
    {
      "label": "DOMoveX(5f, 1f).From()",
      "code": "try { t.DOMoveX(5f, 1f).From(); }\ncatch (Exception e) { /* 여기로 들어온다 */ }",
      "stats": [
        [
          "예외가 가는 곳",
          "호출한 코드의 catch"
        ],
        [
          "예외 종류, 에디터 플레이 모드",
          "MissingReferenceException"
        ],
        [
          "예외 종류, Mono·IL2CPP 플레이어",
          "NullReferenceException"
        ]
      ],
      "tone": "bad"
    },
    {
      "label": "DOMoveX(5f, 1f)",
      "code": "t.DOMoveX(5f, 1f);   // 만들 때는 예외가 없다",
      "stats": [
        [
          "예외가 가는 곳",
          "첫 갱신의 Startup 에서 SafeMode 가 잡음"
        ],
        [
          "남는 것",
          "Tween startup failed (NULL target/property ...) 경고"
        ],
        [
          "두 프레임 뒤 IsActive()",
          "false, 파기됨"
        ]
      ],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

## 상대값은 Startup 에서 더하고, 순서를 바꾸면 두 번 더한다

`SetRelative()` 는 플래그만 켜고, `end += start` 는 `Startup` 에서 합니다. 상대 이동의 기준도 딜레이가 끝난 순간의 위치입니다. From 트윈에는 `SetRelative` 가 막히지만, 순서를 거꾸로 쓴 `.SetRelative().From()` 은 막히지 않습니다. `From()` 이 x = 5 로 옮기고(start 5, end 2), `Startup` 이 켜져 있던 플래그로 end 에 start 를 한 번 더 더해 end = 7 이 됩니다.

| x = 2 에서 `DOMoveX(5f, 1f)` 에 | 만든 직후 x | 끝난 뒤 x |
| --- | --- | --- |
| `.SetRelative().From()` | 5 | **7** |
| `.From(true)` | 7 | 2 |

시퀀스에 건 `SetRelative` 도 주석은 "Has no effect on Sequences" 라고 하지만, 시퀀스의 `Startup` 이 자식 전부의 `isRelative` 를 켜서 x = 2 에서 `DOMoveX(5f, 1f)` 하나를 넣은 시퀀스가 7 에서 끝났습니다.

## 주의할 점

### 1. 딜레이 동안 대상이 움직이면 그 자리에서 출발한다고 본다

**안 지키면 생기는 문제.** 딜레이가 있는 트윈의 시작값은 딜레이가 끝나는 첫 갱신에서 읽힙니다. 딜레이 동안 물리나 다른 트윈, 애니메이터가 대상을 옮기면 트윈은 옮겨진 자리에서 출발해 출발점이 튀거나 거리가 달라집니다.

**이렇게 쓴다.** 출발점이 정해져 있어야 하면 `From(시작값, setImmediately: false)` 로 시작값을 인자로 못 박습니다. `Startup` 이 시작값을 다시 읽지 않습니다.

### 2. From 은 대상이 살아 있는 것을 확인하고 붙인다

**안 지키면 생기는 문제.** `.From()` 의 즉시 경로에는 SafeMode 의 `try` 가 없어서, 대상이 파괴됐으면 예외가 호출한 코드로 올라와 그 메서드의 나머지가 실행되지 않습니다. 예외 종류도 에디터에서는 `MissingReferenceException`, 플레이어에서는 `NullReferenceException` 이라 에디터에서 본 종류만 `catch` 하면 빌드에서 빠져나갑니다.

**이렇게 쓴다.** `From()` 을 부르기 전에 `if (target == null) return;` 으로 거릅니다. 깜빡임이 문제되지 않으면 `From(false, false)` 로 미뤄 SafeMode 가 감싸는 `Startup` 경로를 탑니다.

### 3. From 에 상대값을 줄 때는 From(true) 를 쓴다

**안 지키면 생기는 문제.** `.From().SetRelative()` 는 조용히 무시되고, `.SetRelative().From()` 은 끝값에 시작값이 한 번 더 더해져 5 에서 2 가 아니라 5 에서 7 로 갑니다. 어느 쪽이든 경고가 없습니다.

**이렇게 쓴다.** "지금 위치에서 얼마만큼 떨어진 곳에서 출발" 은 `From(true)` 로만 씁니다.

### 4. 속도 기반 트윈의 길이는 첫 갱신 뒤에 읽는다

**안 지키면 생기는 문제.** `SetSpeedBased()` 트윈의 `Duration()` 은 `Startup` 전에는 초가 아니라 속도를 돌려줍니다. 그 값으로 다른 연출의 타이밍을 맞추면 어긋납니다. 이미 시작한 트윈에 길이 없이 `ChangeEndValue(newEnd, true)` 를 부르면 길이를 다시 계산하지 않아서, 속도 2 로 시작한 트윈이 초당 3.9 로 움직였습니다(같은 세 환경).

**이렇게 쓴다.** 길이가 필요하면 `ForceInit()` 을 먼저 불러 `Startup` 을 끝낸 뒤 `Duration()` 을 읽습니다. 속도 기반 트윈의 끝값을 바꿀 때는 `ChangeEndValue(newEnd, 속도, true)` 처럼 속도를 다시 넘깁니다.

## 정리

DOTween 은 **시작값을 딜레이가 끝난 첫 갱신에서 읽고**, 그때 상대 끝값, 변화량, 길이를 한 번에 정합니다.

- 딜레이 동안 대상이 옮겨지면 옮겨진 자리에서 출발합니다. `Goto` 같은 호출이 먼저 오면 그 순간에 읽습니다.
- `From()` 은 붙이는 줄에서 getter 와 setter 를 부르고, 이 경로는 SafeMode 가 감싸지 않습니다.
- `SetRelative` 와 `SetSpeedBased` 는 플래그이고 실제 계산은 `Startup` 에서 합니다. 순서를 바꾸거나 시작 뒤에 값을 바꾸면 결과가 어긋납니다.