---
title: "DOTween 은 매 프레임 위치와 루프 수를 새로 정해 그 자리로 간다"
description: "DOTween 은 경과 시간을 쌓지 않고 매 프레임 목표 위치와 완료 루프 수를 새로 계산해 DoGoto 로 놓습니다. 그래서 넘긴 루프는 빠짐없이 세고, 딜레이의 남는 시간을 버리지 않으며, Yoyo 는 시간만 뒤집습니다."
author: "유니얼"
pubDate: 2026-09-25
updatedDate: 2026-10-18
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "트윈"]
canonical: https://blog.unialgames.com/posts/unity-dotween-goto/
---
```csharp
elapsed += Time.deltaTime;
if (elapsed >= duration) { elapsed -= duration; loop++; forward = !forward; }   // 한 프레임에 루프 둘을 넘으면?
float p = elapsed / duration;
```

손으로 쓴 `Update` 보간은 시간을 쌓고, 루프나 Yoyo, 딜레이가 붙을 때마다 분기를 더합니다. 로딩 직후처럼 한 프레임이 길어 루프를 여러 번 넘으면 넘친 시간을 버리거나 루프 하나만 넘긴 것으로 치기 쉽습니다.

DOTween 은 **시간을 쌓지 않고, 매 프레임 "몇 번째 루프의 몇 초 지점" 을 새로 계산해 `DoGoto` 로 트윈을 그 자리에 놓습니다.** 정상 재생도 `Goto` 도 `Complete` 도 같은 `DoGoto` 를 거칩니다. 기준은 설치본 DOTween 1.3.030(GitHub 커밋 `4846808`)입니다.

## 넘긴 루프는 while 로 하나씩 센다

트윈의 시간은 두 칸입니다. 루프 안의 시간 `position`(0 ~ `duration`)과 끝낸 루프 수 `completedLoops` 입니다. 매 프레임 매니저는 `position` 에 `dt` 를 더하고, `duration` 을 넘는 동안 한 루프씩 빼며 셉니다.

```csharp
float toPosition = t.position;                   // 지금 루프 안의 시간
bool wasEndPosition = toPosition >= t.duration;
int toCompletedLoops = t.completedLoops;         // 지금까지 끝낸 루프 수

toPosition += tDeltaTime;                        // 이번 프레임에 흐른 시간을 더한다

// duration 을 넘는 동안 한 루프씩 빼며 센다
while (toPosition >= t.duration && (t.loops == -1 || toCompletedLoops < t.loops))
{
    toPosition -= t.duration;
    toCompletedLoops++;
}

if (wasEndPosition)
    toCompletedLoops--;

// 유한 루프를 다 썼으면 끝에 고정한다
if (t.loops != -1 && toCompletedLoops >= t.loops)
    toPosition = t.duration;

return Tween.DoGoto(t, toPosition, toCompletedLoops, UpdateMode.Update);
```

길이 1초, `loops = 3` 인 트윈이 0.5초 지점에 있을 때 프레임이 크게 늦어 2.7초가 한꺼번에 들어오면 이렇게 셉니다.

| 단계 | `toPosition` | `toCompletedLoops` |
| --- | --- | --- |
| 시간을 더함 | 3.2 | 0 |
| 첫 루프를 뺌 | 2.2 | 1 |
| 둘째 루프를 뺌 | 1.2 | 2 |
| 셋째 루프를 뺌 | 0.2 | 3 |
| 루프를 다 써서 끝에 고정 | 1.0 | 3 |

`DoGoto(1.0, 3)` 이 불립니다. 프레임이 크게 늦어도 넘긴 루프 수가 정확히 남습니다.

넘긴 루프 수가 그대로 `DoGoto` 에 가고, `DoGoto` 는 그 차이만큼 `OnStepComplete` 를 부릅니다. `DOTween.To(...).SetLoops(3)` 을 v = 0.75 까지 보낸 뒤 `DOTween.ManualUpdate(2.7f, 2.7f)` 를 한 번 부르자 그 호출 안에서 `OnStepComplete` 가 세 번, `OnComplete` 가 한 번 불렸습니다.

## 딜레이의 남는 시간은 버리지 않는다

목표를 계산하기 전에 딜레이가 먼저 시간을 가져갑니다.

```csharp
// Tweener.cs (DoUpdateDelay 줄임)
if (elapsed > tweenDelay) {
    t.elapsedDelay = tweenDelay;
    t.delayComplete = true;
    return elapsed - tweenDelay;   // 남는 시간을 이번 프레임 진행에 넘긴다
}
t.elapsedDelay = elapsed;
return 0;
```

딜레이 0.5 에 0.45 를 기다린 상태에서 0.1 이 들어오면, 딜레이를 끝내고 남는 0.05 를 같은 프레임의 진행에 씁니다. 딜레이와 본 재생을 합친 길이가 프레임 경계와 무관하게 맞습니다. 트위너의 딜레이는 **처음 한 번만** 기다립니다. 루프마다 다시 기다리지 않고, 다시 기다리게 하는 것은 `Restart()` 입니다. 시퀀스에 건 `SetDelay` 는 맨 앞에 빈 구간을 끼우는 방식이라 루프마다 반복됩니다.

## Yoyo 는 시간만 뒤집는다

Yoyo 는 홀수 번째 루프 동안 `duration - position` 을 플러그인에 넘깁니다. 시작값과 끝값은 그대로이고 **시간만 거꾸로** 흐릅니다.

```csharp
t.position = toPosition;
t.completedLoops = toCompletedLoops;

// 루프를 끝낸 직후 0 에 있으면 끝 위치로 본다
if (t.position <= 0 && (t.completedLoops > 0 || t.isComplete))
    t.position = t.duration;

// Yoyo 의 홀수 번째 루프면 시간을 뒤집는다
bool useInverse = t.loopType == LoopType.Yoyo
    && (t.position < t.duration ? t.completedLoops % 2 != 0 : t.completedLoops % 2 == 0);

float updatePosition = useInverse ? t.duration - t.position : t.position;

plugin.EvaluateAndApply(..., updatePosition, ...);   // x = 0 + 10 * updatePosition
```

`DOMoveX(10, 1).SetLoops(2, LoopType.Yoyo)` 를 x = 0 에서 `Linear` 로 돌리면 이렇습니다.

| 누적 시간 | `position` | `completedLoops` | 뒤집나 | 넘기는 시간 | x |
| --- | --- | --- | --- | --- | --- |
| 0.5 | 0.5 | 0 | 아니오 | 0.5 | 5 |
| 1.2 | 0.2 | 1 | 예 | 0.8 | 8 |
| 2.0 | 1.0 | 2 | 예 | 0 | 0 |

마지막 줄은 위치가 끝(1.0)이라 오른쪽 식 `2 % 2 == 0` 을 씁니다. 왼쪽 식을 썼다면 뒤집지 않고 x = 10 에서 끝났을 것입니다.

`ManualUpdate` 로 0.5, 0.7, 0.8 초를 밀며 적은 x 는 5, 8, 0 이었습니다. 시간만 뒤집으므로 이징도 거울상이 됩니다. 같은 트윈을 `OutQuad` 로 바꿔 0.1초씩 밀자 가는 루프의 변화량은 1.9, 1.7 ... 0.1 로 줄고, 돌아오는 루프는 -0.1, -0.3 ... -1.9 로 커졌습니다. 돌아오는 길은 가는 길을 거꾸로 튼 모양이라 **천천히 출발해 빠르게 도착**합니다.

## 콜백은 값을 쓴 뒤 정해진 순서로 불린다

`DoGoto` 는 값을 적용한 다음 콜백을 부릅니다. `loops = 3` 트윈의 첫 갱신과, 한 프레임에 끝까지 간 경우입니다.

```csharp
// 처음 갱신되는 순간에만
if (!t.playedOnce)
{
    t.playedOnce = true;
    onStart?.Invoke();
    onPlay?.Invoke();
}

t.completedLoops = toCompletedLoops;
t.isComplete = t.completedLoops == t.loops;
int newCompletedSteps = t.completedLoops - prevCompletedLoops;   // 이번 프레임에 넘긴 루프 수

t.ApplyTween(...);                         // setter 로 값을 쓴다
onUpdate?.Invoke();                        // 값을 쓴 다음에 부른다

if (t.position <= 0 && t.completedLoops <= 0 && !wasRewinded)
    onRewind?.Invoke();

// 넘긴 루프 수만큼 부른다. 도중에 죽으면 멈춘다
for (int i = 0; i < newCompletedSteps; i++)
{
    onStepComplete();
    if (!t.active) break;
}

if (t.isComplete && !wasComplete)
    onComplete?.Invoke();

// autoKill 로 끝나는 트윈에는 OnPause 를 부르지 않는다
if (!t.isPlaying && wasPlaying && (!t.isComplete || !t.autoKill))
    onPause?.Invoke();

return t.autoKill && t.isComplete;         // true 면 루프 끝에서 OnKill
```

`loops = 3` 인 트윈에서 불리는 콜백입니다.

| 프레임 | 불리는 콜백 (순서대로) |
| --- | --- |
| 첫 갱신 (0 → 0.3초) | `OnStart`, `OnPlay`, `OnUpdate` |
| 한 프레임에 끝까지 (루프 0 → 3) | `OnUpdate`, `OnStepComplete` × 3, `OnComplete`, 루프 끝에서 `OnKill` |

순서는 **OnStart → OnPlay → 값 적용 → OnUpdate → OnRewind → OnStepComplete × n → OnComplete → OnPause** 이고 `OnKill` 은 루프 끝입니다. 같은 프레임에 A, B 가 함께 끝나면 기록은 `A.OnComplete, B.OnComplete, B.OnKill, A.OnKill` 이었습니다. `OnComplete` 는 배열 순서, `OnKill` 은 그 반대입니다.

사용자가 위치를 옮기는 API 도 같은 `DoGoto` 를 부르되 `UpdateMode` 로 콜백을 뺍니다. `Goto`, `Rewind`, `Complete` 는 `UpdateMode.Goto` 라서 `OnStart`, `OnPlay`, `OnStepComplete` 가 빠지고, 건너뛴 루프의 `OnStepComplete` 가 필요하면 `GotoWithCallbacks` 를 씁니다.

## 주의할 점

### 1. 한 프레임에 한 루프만 넘어간다고 가정하지 않는다

**안 지키면 생기는 문제.** 프레임이 크게 늦으면 짧은 루프 트윈은 한 프레임에 루프 여러 개를 넘기고, `OnStepComplete` 가 그 수만큼 연달아 불립니다. 그 안에서 사운드를 틀거나 이펙트를 만드는 코드가 같은 프레임에 여러 번 실행됩니다.

**이렇게 쓴다.** `OnStepComplete` 에서 하는 일은 같은 프레임에 여러 번 불려도 괜찮게 만들거나 `Time.frameCount` 로 프레임당 한 번만 하게 거릅니다. 루프 수로 상태를 셀 때는 콜백 횟수보다 `CompletedLoops()` 를 읽습니다.

### 2. 딜레이가 루프마다 반복된다고 기대하지 않는다

**안 지키면 생기는 문제.** 트위너에 `SetDelay(0.5f).SetLoops(3)` 을 걸면 처음 한 번만 기다리고 세 루프는 쉬지 않고 이어집니다. 루프마다 쉬는 깜빡임을 기대한 연출이 한 번만 쉽니다.

**이렇게 쓴다.** 시퀀스에 `AppendInterval` 과 트윈을 넣고 시퀀스에 `SetLoops` 를 겁니다.

### 3. Yoyo 로 돌아오는 길의 이징을 따로 기대하지 않는다

**안 지키면 생기는 문제.** `OutQuad` 로 부드럽게 도착하는 트윈을 Yoyo 로 되돌리면, 돌아올 때는 처음에 느리고 도착할 때 빨라져서 출발점에서 툭 끊깁니다.

**이렇게 쓴다.** `InOutQuad` 처럼 양끝이 대칭인 이징을 쓰거나, 가는 트윈과 오는 트윈을 시퀀스로 따로 만들어 각자 이징을 줍니다.

### 4. 콜백 안에서 Kill 해도 같은 호출의 남은 콜백은 불린다

**안 지키면 생기는 문제.** `OnStart`, `OnPlay`, `OnStepComplete` 뒤에는 트윈이 죽었는지 보는 검사가 있지만 `OnUpdate`, `OnRewind`, `OnComplete` 뒤에는 없습니다. 마지막 프레임의 `OnUpdate` 에서 `Kill()` 을 불러도 이어서 `OnComplete` 가 한 번 불렸습니다. "죽였으니 완료 처리는 없다" 고 가정한 코드가 완료 처리를 합니다.

**이렇게 쓴다.** 끝났을 때 할 일은 `OnComplete` 한 곳에 모으고 `OnUpdate` 에서는 값을 읽기만 합니다. `OnUpdate` 에서 멈춰야 하면 내 쪽 플래그를 두고 `OnComplete` 에서 봅니다.

## 정리

DOTween 은 **매 프레임 목표 위치와 루프 수를 새로 계산해 `DoGoto` 로 그 자리에 놓습니다.**

- 넘긴 루프는 `while` 로 하나씩 세어 `OnStepComplete` 를 빠짐없이 부릅니다.
- 딜레이가 끝나는 프레임의 남는 시간은 진행에 넘기고, 트위너의 딜레이는 처음 한 번뿐입니다.
- Yoyo 는 시간만 뒤집어 이징도 거울상이 되고, 콜백은 값을 쓴 뒤 정해진 순서로 불립니다.