---
title: "DOTween 은 타입마다 하나인 플러그인에 값 계산을 맡긴다"
description: "DOTween 의 트윈은 시간만 알고, 값은 만들 때 타입 조합으로 고른 플러그인이 매 프레임 시작값에서 계산해 덮어씁니다. 축 제약 트윈만 지금 값을 읽고, 이징은 초 단위로 받아 Elastic 의 period 도 초입니다."
author: "유니얼"
pubDate: 2026-09-25
updatedDate: 2026-10-18
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "트윈"]
canonical: https://blog.unialgames.com/posts/unity-dotween-plugin/
---
```csharp
float hp = 0f;
DOTween.To(() => hp, x => hp = x, 10f, 1f);
```

이 한 줄에는 0 에서 10 사이 값을 **무엇으로 계산할지**가 없습니다. `float` 이면 더하고 곱하면 되지만 `Quaternion` 이나 `string` 이면 방법이 다릅니다. 같은 `Transform` 에 트윈 둘을 걸었을 때 누가 이기는지, `SetEase` 에 넘긴 숫자가 무슨 뜻인지도 이 계산에 달려 있습니다.

DOTween 의 트윈은 시간과 루프만 알고, **값 계산은 트윈을 만들 때 타입 조합으로 고른 플러그인 하나가 매 프레임 `시작 + 변화량 × 이징` 으로 합니다.** 기준은 DOTween 1.3.030(GitHub 커밋 `4846808`)입니다.

## 플러그인은 만들 때 타입 조합으로 한 번 고른다

`Tweener.Setup` 이 `PluginsManager.GetDefaultPlugin<T1, T2, TPlugOptions>()` 을 부릅니다. 딕셔너리가 아니라 `typeof` 를 차례로 비교하는 if 체인입니다.

```csharp
Type t1 = typeof(T1);
Type t2 = typeof(T2);

// 딕셔너리가 아니라 typeof 를 차례로 비교한다
if (t1 == typeof(Vector3) && t1 == t2)
    plugin = _vector3Plugin;
else if (t1 == typeof(Vector3) && t2 == typeof(Vector3[]))
    plugin = _vector3ArrayPlugin;
else if (t1 == typeof(Quaternion))
{
    if (t2 == typeof(Quaternion))
        LogError("Quaternion tweens require a Vector3 endValue");   // 거부. plugin 은 null
    else
        plugin = _quaternionPlugin;        // 끝값을 오일러각(Vector3)으로 받는 회전
}
else if (t1 == typeof(Vector2))
    plugin = _vector2Plugin;
else if (t1 == typeof(float))
    plugin = _floatPlugin;                 // 처음이면 여기서 하나 만들어 정적 필드에 둔다
// ... Color, int, Vector4, Rect, RectOffset, uint, string, Color2, long, ulong, double

return plugin as ABSTweenPlugin<T1, T2, TPlugOptions>;
```

| 트윈 | 타입 | 고른 플러그인 |
| --- | --- | --- |
| float 값 트윈 | `<float, float, FloatOptions>` | 다섯 번째 비교에서 `FloatPlugin` |
| `DORotate` | `<Quaternion, Vector3, QuaternionOptions>` | `QuaternionPlugin` |
| Quaternion 을 Quaternion 으로 | `<Quaternion, Quaternion, NoOptions>` | 없음. 오류를 남기고 트윈을 버림 |

조회는 트윈을 만들 때 한 번뿐입니다. 매 프레임에는 트윈의 `tweenPlugin` 필드에 담아 둔 참조를 그대로 씁니다.

각 분기는 정적 필드가 비어 있을 때만 `new` 해서, `float` 트윈이 만 개여도 `FloatPlugin` 은 하나입니다. 그래서 **플러그인은 상태를 갖지 않고**, 트윈마다 다른 값은 트윈 객체에 있다가 매 프레임 인자로 들어옵니다. 조회는 만들 때 한 번이고, 매 프레임에는 `tweenPlugin` 필드의 참조를 씁니다.

## 매 프레임은 시작값에서 계산해 덮어쓴다

```csharp
// DOTween/Plugins/FloatPlugin.cs (EvaluateAndApply 줄임)
if (t.loopType == LoopType.Incremental) startValue += changeValue * (t.isComplete ? t.completedLoops - 1 : t.completedLoops);
setter(startValue + changeValue * EaseManager.Evaluate(t.easeType, t.customEase, elapsed, duration, t.easeOvershootOrAmplitude, t.easePeriod));
```

**getter 를 부르지 않습니다.** 시작할 때 저장한 시작값과 변화량에서 계산해 setter 로 덮어씁니다. 증가 루프(`LoopType.Incremental`)도 트윈이 따로 기억하지 않고 플러그인이 끝낸 루프 수만큼 시작값을 밀어서 만듭니다. SafeMode 가 켜져 있으면 이 호출 전체가 `try` 안에 있어서, 대상이 파괴돼 setter 가 예외를 던지면 트윈이 죽습니다.

## 축 제약 트윈만 지금 값을 읽는다

`DOMoveX` 는 `DOMove` 와 같은 `Vector3` 트윈에 `AxisConstraint.X` 만 붙은 것입니다. 축 제약이 있으면 `Vector3Plugin` 이 **getter 로 지금 값을 읽어 한 축만 바꿔 씁니다.** 원점의 `Transform` 에 같은 프레임에 두 트윈을 걸고 첫 갱신에서 0.5초를 민 경우입니다(`Linear`, 길이 1초).

<x-compare>
<script type="application/json">
{
  "label": "원점에 두 트윈을 걸고 첫 갱신에서 0.5초 민 위치",
  "tabs": [
    {
      "label": "DOMoveX + DOMoveY",
      "code": "// X 트윈: 축 제약이 있어 지금 값을 읽고 x 만 바꾼다\nVector3 resX = getter();                 // (0, 0, 0)\nresX.x = startValue.x + changeValue.x * easeVal;\nsetter(resX);                            // (5, 0, 0)\n\n// Y 트윈: X 트윈이 방금 쓴 값을 읽는다\nVector3 resY = getter();                 // (5, 0, 0)\nresY.y = startValue.y + changeValue.y * easeVal;\nsetter(resY);                            // (5, 5, 0)",
      "text": "서로 다른 축만 바꾸므로 두 트윈이 공존합니다. 대각선으로 움직입니다.",
      "stats": [
        [
          "결과",
          "(5, 5, 0)"
        ]
      ],
      "tone": "good"
    },
    {
      "label": "DOMove + DOMove",
      "code": "// 트윈 A: 첫 갱신에 시작값을 읽는다\nstartValue = getter();                   // (0, 0, 0)\nchangeValue = endValue - startValue;     // (10, 0, 0)\nsetter(startValue + changeValue * easeVal);   // (5, 0, 0)\n\n// 트윈 B: 같은 갱신에서 A 다음 차례. 이제야 시작값을 읽는다\nstartValue = getter();                   // (5, 0, 0). A 가 방금 옮긴 자리\nchangeValue = endValue - startValue;     // (0, 10, 0) - (5, 0, 0) = (-5, 10, 0)\nsetter(startValue + changeValue * easeVal);   // (2.5, 5, 0)",
      "text": "축 제약이 없으면 뒤 차례가 세 축을 모두 덮어씁니다. 그 시작값은 앞 트윈이 첫 갱신에서 옮겨 둔 자리입니다.",
      "stats": [
        [
          "결과",
          "(2.5, 5, 0)"
        ]
      ],
      "tone": "bad"
    }
  ]
}
</script>
</x-compare>

재 보니 `DOMoveX` + `DOMoveY` 는 (5, 5, 0), `DOMove` 둘은 **(2.5, 5, 0)** 이었습니다. 제약 없는 뒤 트윈이 세 축을 모두 덮어쓰되, 그 시작값은 앞 트윈이 첫 갱신에서 옮겨 둔 자리입니다. 축 제약의 대가는 getter 한 번입니다.

| `Transform` 1만 개, 트윈 하나의 매 프레임 비용 | `DOMoveX` (축 제약) | `DOMove` | 차이 |
| --- | --- | --- | --- |
| Mono 플레이어 | 240 ns | 201 ns | 39 ns |
| IL2CPP 플레이어 | 169 ns | 132 ns | 37 ns |

(200프레임 PlayerLoop 중앙값의 증가분 ÷ 1만.)

## 이징은 초 단위로 받고, 두 인자의 뜻은 이징마다 다르다

`EaseManager.Evaluate` 는 0~1 진행률이 아니라 **`(time, duration)` 초 단위 쌍**을 받는 `switch` 하나이고, 결과를 자르지 않습니다(`OutBack` 은 1 을 넘습니다). `SetEase(ease, a, b)` 의 두 숫자는 트윈의 필드 두 개에 들어가고 뜻은 이징이 정합니다.

| 이징 | `overshootOrAmplitude` | `period` |
| --- | --- | --- |
| Back | 지나치는 정도. 기본 1.70158 | 쓰지 않음 |
| Elastic | 진폭. 1 보다 작으면 1 로 올림 | 흔들림 한 번의 길이, **초 단위**. 0 이면 `duration × 0.3` |
| Flash | 깜빡임 횟수. 정수로 자름 | 약해지거나 세지는 정도 |

Elastic 의 `sin` 한 바퀴가 `period` 초라서, 트윈 전체에서 흔들리는 횟수는 `duration ÷ period` 입니다.

<x-calc>
<script type="application/json">
{
  "label": "OutElastic 이 트윈 전체에서 흔들리는 횟수",
  "inputs": [
    {
      "id": "d",
      "label": "트윈 길이",
      "options": [
        0.5,
        1,
        2,
        3
      ],
      "default": 3,
      "suffix": "초"
    },
    {
      "id": "p",
      "label": "period",
      "options": [
        [
          "비워 둠 (0)",
          0
        ],
        [
          "0.3",
          0.3
        ],
        [
          "0.5",
          0.5
        ],
        [
          "1",
          1
        ]
      ],
      "default": 0.3
    }
  ],
  "bars": [
    {
      "label": "이 설정",
      "expr": "d / (p == 0 ? d * 0.3 : p)",
      "unit": "번",
      "accent": true,
      "decimals": 1
    },
    {
      "label": "period 를 비웠을 때",
      "expr": "d / (d * 0.3)",
      "unit": "번",
      "decimals": 1
    }
  ],
  "verdict": {
    "expr": "Math.abs(bars[0] - bars[1]) < 0.5",
    "yes": "기본값과 같은 모양으로 흔들린다",
    "no": "기본값과 흔들리는 횟수가 다르다"
  },
  "note": "횟수 = duration ÷ period. 진폭은 2^(-10t) 로 줄어들어 뒤쪽 흔들림은 거의 보이지 않습니다."
}
</script>
</x-calc>

`SetEase(Ease.OutElastic, 1f, 0.3f)` 로 v 가 1 을 지나는 횟수를 세자 1초 트윈은 7번, 3초 트윈은 20번이었고, `period` 를 0 으로 비우면 둘 다 7번이었습니다.

Flash 는 `SetEase` 가 필드를 정수로 자르고 되돌리지 않습니다. 인자 없는 `SetEase(Ease.Flash)` 는 기본값 1.70158 을 1 로 만들고, 같은 트윈에 이어서 인자 없는 `SetEase(Ease.OutBack)` 를 부르면 지나치는 정도가 1 로 남아 최대값이 1.100 이 아니라 1.037 이었습니다(같은 세 환경).

## 주의할 점

### 1. Elastic 의 period 를 비율로 주지 않는다

**안 지키면 생기는 문제.** `period` 는 초 단위입니다. 1초 트윈에서 맞춘 `SetEase(Ease.OutElastic, 1f, 0.3f)` 를 3초 트윈에 그대로 쓰면 열 번 가까이 떨립니다. 진폭은 1 아래로 내려가지 않아 덜 튀게 하려고 0.5 를 줘도 효과가 없습니다.

**이렇게 쓴다.** 길이와 무관하게 같은 모양을 원하면 `period` 를 0 으로 비웁니다. 직접 정해야 하면 `duration × 비율` 로 계산해 넘깁니다.

### 2. 같은 속성에 축 제약 없는 트윈 둘을 걸지 않는다

**안 지키면 생기는 문제.** `DOMove` 는 자기 시작값에서 세 축을 모두 씁니다. 같은 `Transform` 에 X 로 가는 `DOMove` 와 Y 로 가는 `DOMove` 를 걸면 뒤 차례가 세 축을 덮어써서 앞 트윈의 움직임이 사라지고, 첫 갱신이 길면 X 도 엉뚱한 자리에서 출발해 되돌아옵니다. 오류도 경고도 없습니다.

**이렇게 쓴다.** 축을 나눠 움직이면 `DOMoveX`, `DOMoveY` 를 씁니다. 같은 축을 새로 움직이면 먼저 걸린 트윈을 `DOKill` 하고 겁니다. 움직임을 더해야 하면 `DOBlendableMoveBy` 계열을 씁니다.

### 3. AnimationCurve 이징을 트윈마다 새로 걸지 않는다

**안 지키면 생기는 문제.** `SetEase(curve)` 는 부를 때마다 `EaseCurve` 객체와 델리게이트를 새로 만듭니다. `float` 트윈 하나가 `OutQuad` 로 350 B 남짓일 때 커브면 약 150 B 가 더 붙고, 매 프레임 평가도 키 3개짜리 커브에서 트윈 비용이 1.6~1.8배가 됐습니다.

**이렇게 쓴다.** 델리게이트를 한 번만 만들어 두고 `SetEase(EaseFunction)` 로 넘깁니다. 이 오버로드는 넘긴 델리게이트를 필드에 넣기만 합니다.

```csharp
static EaseFunction _popEase;
void Awake() => _popEase ??= new EaseCurve(popCurve).Evaluate;
void Pop() => transform.DOScale(1.2f, 0.3f).SetEase(_popEase);
```

### 4. Flash 는 횟수를 함께 넘긴다

**안 지키면 생기는 문제.** 인자 없는 `SetEase(Ease.Flash)` 는 한 번만 오가서 깜빡이지 않고 스르륵 변하고 끝납니다. 잘린 값은 필드에 남아 같은 트윈에 다시 건 `OutBack` 이 10% 대신 4% 만 지나칩니다.

**이렇게 쓴다.** `SetEase(Ease.Flash, 6)` 처럼 횟수를 적습니다. 한 트윈에 이징을 바꿔 걸 때는 `SetEase(ease, overshoot, period)` 로 두 값을 명시합니다.

## 정리

DOTween 은 **트윈을 만들 때 타입 조합으로 고른 플러그인 하나에 매 프레임 값 계산을 맡깁니다.**

- 플러그인은 타입마다 정적 인스턴스 하나라 상태가 없고, 매 프레임 저장한 시작값에서 `시작 + 변화량 × 이징` 을 계산해 덮어씁니다.
- 축 제약 트윈만 getter 로 지금 값을 읽어 한 축만 바꾸므로 서로 다른 축끼리 공존합니다. 제약 없는 트윈 둘은 뒤 차례가 이깁니다.
- 이징은 초 단위로 계산하고, `overshootOrAmplitude` 와 `period` 의 뜻은 이징마다 다릅니다. Elastic 의 `period` 는 초입니다.