Unity C# Fluent API 트윈(Tween) 라이브러리 만들기: 무할당(Zero-Allocation) 애니메이션 엔진 설계
Unity에서 코루틴 가비지 생성을 방지하고 DOTween 스타일의 메서드 체이닝을 제공하는 경량 무할당 C# Fluent Tween 라이브러리를 직접 설계하고 구현하는 가이드입니다.

핵심 요약
- 문제: Unity
Coroutine을 활용한 단순 보간 연산은 호출마다IEnumerator인스턴스 힙 할당(GC Alloc)을 유발하며 체계적인 생명주기 제어가 어렵습니다. - 해결책: 단일
MonoBehaviour의Update루프에서 관리되는 정적 객체 풀(Object Pool) 기반 트윈 엔진을 구축하여 가비지 생성을 0B로 억제합니다. - 설계: C# 확장 메서드(Extension Methods)를 통해
transform.DoMove(target, 1f).SetEase(EaseType.OutQuad).OnComplete(...)형태의 직관적인 Fluent API를 구현합니다.
왜 코루틴 대신 자체 트윈(Tween) 엔진이 필요한가?
Unity 게임 개발에서 오브젝트의 이동, 회전, 스케일 변화나 UI 페이드 인·아웃을 구현할 때 흔히 StartCoroutine을 사용합니다. 하지만 수많은 UI 요소와 게임플레이 오브젝트가 빈번하게 애니메이션을 실행하는 환경에서 코루틴 기반 보간은 심각한 성능 저하를 초래합니다.
// 매 호출마다 IEnumerator 객체 및 YieldInstruction 힙 할당 발생
private IEnumerator MoveCoroutine(Vector3 target, float duration)
{
Vector3 start = transform.position;
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
transform.position = Vector3.Lerp(start, target, elapsed / duration);
yield return null; // GC Alloc 및 프레임 분산 오버헤드
}
}
이러한 코루틴 방식은 다음과 같은 명확한 한계를 가집니다.
| 비교 항목 | Unity Coroutine | 전용 Fluent Tween 엔진 |
|---|---|---|
| 메모리 할당 (GC Alloc) | 반복 호출 시 힙 할당 지속 발생 | 객체 풀링(Object Pool)으로 GC Alloc 0B 유지 |
| 업데이트 오버헤드 | 수백 개의 코루틴 개별 순회 오버헤드 | 단일 Update 루프 내 연속 메모리 순회로 캐시 효율 극대화 |
| 코드 가독성 | 보일러플레이트 코루틴 함수 정의 필요 | 단 한 줄의 체이닝 구문(transform.DoMove(...))으로 작성 |
| 생명주기 제어 | 일시정지, 역재생, 이징(Ease), 취소 제어 복잡 | Fluent 체이닝을 통한 직관적인 핸들 제어 제공 |
직접 경량 트윈 엔진을 설계하면 외부 무거운 에셋에 의존하지 않고도 프로젝트 요구사항에 최적화된 고성능 애니메이션 시스템을 구축할 수 있습니다.
트윈 보간의 수학적 원리와 이징(Easing) 함수
트윈(Tweening, In-betweening)의 본질은 시작점()에서 목표점()까지 지정된 시간() 동안 상태 값을 점진적으로 변경하는 것입니다.
경과 시간()을 총 지속 시간()으로 나눈 정규화된 시간 비율을 라고 할 때, 보간 공식은 다음과 같습니다.
선형 보간(Linear Interpolation)은 이 정규화 시간 를 그대로 값의 변화율로 적용하지만 현실감 있고 다채로운 연출을 위해서는 비선형 가감속을 부여하는 이징 함수(Easing Function) 를 거쳐야 합니다.

자주 사용하는 대표적인 이징 수식을 C# 함수로 구성해 봅니다.
public enum EaseType
{
Linear,
InQuad,
OutQuad,
InOutQuad,
OutBounce
}
public static class EasingUtility
{
public static float Evaluate(EaseType ease, float t)
{
switch (ease)
{
case EaseType.InQuad:
return t * t;
case EaseType.OutQuad:
return t * (2f - t);
case EaseType.InOutQuad:
return t < 0.5f ? 2f * t * t : -1f + (4f - 2f * t) * t;
case EaseType.OutBounce:
return EvaluateOutBounce(t);
case EaseType.Linear:
default:
return t;
}
}
private static float EvaluateOutBounce(float t)
{
const float n1 = 7.5625f;
const float d1 = 2.75f;
if (t < 1f / d1) return n1 * t * t;
if (t < 2f / d1) return n1 * (t -= 1.5f / d1) * t + 0.75f;
if (t < 2.5f / d1) return n1 * (t -= 2.25f / d1) * t + 0.9375f;
return n1 * (t -= 2.625f / d1) * t + 0.984375f;
}
}
시스템 아키텍처: 풀링과 업데이트 루프 설계
매 프레임 생성되고 소멸하는 트윈 객체로 인한 GC 스파이크를 방지하기 위해 엔진의 아키텍처는 단일 업데이트 매니저와 사전 할당된 객체 풀(Object Pool) 구조로 설계합니다.
flowchart TD
A["클라이언트 코드 (확장 메서드)"] -->|"DoMove / DoFade 호출"| B["TweenManager (싱글톤/서비스)"]
B -->|"대여 (Rent)"| C["TweenPool (미리 할당된 큐)"]
C -->|"활성화 노드"| D["Active Tween List (활성 목록)"]
E["Unity Update Loop"] -->|"매 프레임 일괄 갱신"| B
B -->|"시간 누적 및 Easing 계산"| D
D -->|"Getter / Setter 실행"| F["Transform / CanvasGroup 갱신"]
D -->|"종료 조건 만족 시 풀 반환"| C
트윈의 생명주기를 완벽하게 격리하여 연산 주체는 씬에 상주하는 단 하나의 TweenManager 인스턴스에서만 동작하도록 보장합니다.
Fluent API 체이닝은 어떻게 구현할까?
개발자가 간결하게 애니메이션을 정의할 수 있도록 3단계로 코드를 구현합니다.
1단계: 트윈 코어(TweenCore<T>) 및 인터페이스 정의
다양한 타입(Vector3, float, Color 등)을 일관되게 처리할 수 있는 기본 인터페이스와 제네릭 코어 클래스를 구현합니다.
using System;
using UnityEngine;
public interface ITween
{
bool IsActive { get; }
int Id { get; }
bool Update(float deltaTime);
void Reset();
}
public class TweenCore<T> : ITween
{
public int Id { get; private set; }
public bool IsActive { get; private set; }
private Func<T> _getter;
private Action<T> _setter;
private Func<T, T, float, T> _interpolator;
private T _startValue;
private T _endValue;
private float _duration;
private float _elapsed;
private EaseType _easeType;
private Action _onComplete;
public void Initialize(int id, Func<T> getter, Action<T> setter, Func<T, T, float, T> interpolator,
T targetValue, float duration)
{
Id = id;
_getter = getter;
_setter = setter;
_interpolator = interpolator;
_startValue = getter();
_endValue = targetValue;
_duration = Mathf.Max(duration, 0.0001f);
_elapsed = 0f;
_easeType = EaseType.Linear;
_onComplete = null;
IsActive = true;
}
public TweenCore<T> SetEase(EaseType ease)
{
_easeType = ease;
return this;
}
public TweenCore<T> OnComplete(Action callback)
{
_onComplete = callback;
return this;
}
public bool Update(float deltaTime)
{
if (!IsActive) return false;
_elapsed += deltaTime;
float progress = Mathf.Clamp01(_elapsed / _duration);
float easedProgress = EasingUtility.Evaluate(_easeType, progress);
T currentValue = _interpolator(_startValue, _endValue, easedProgress);
_setter(currentValue);
if (_elapsed >= _duration)
{
_onComplete?.Invoke();
IsActive = false;
return false; // 트윈 종료
}
return true; // 계속 진행
}
public void Reset()
{
IsActive = false;
_getter = null;
_setter = null;
_interpolator = null;
_onComplete = null;
}
}
2단계: 글로벌 트윈 매니저(TweenManager) 구현
오브젝트 풀링 관리와 Unity 프레임 루프 연동을 담당합니다.
using System;
using System.Collections.Generic;
using UnityEngine;
public class TweenManager : MonoBehaviour
{
private static TweenManager _instance;
private readonly List<ITween> _activeTweens = new List<ITween>(256);
private readonly Queue<ITween> _pool = new Queue<ITween>(256);
private int _idCounter = 0;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Initialize()
{
if (_instance != null) return;
GameObject go = new GameObject("[FluentTweenManager]");
DontDestroyOnLoad(go);
_instance = go.AddComponent<TweenManager>();
}
public static TweenCore<T> Rent<T>(Func<T> getter, Action<T> setter, Func<T, T, float, T> interpolator,
T targetValue, float duration)
{
TweenCore<T> tween = null;
while (_instance._pool.Count > 0)
{
var pooled = _instance._pool.Dequeue();
if (pooled is TweenCore<T> match)
{
tween = match;
break;
}
}
if (tween == null)
{
tween = new TweenCore<T>();
}
tween.Initialize(++_instance._idCounter, getter, setter, interpolator, targetValue, duration);
_instance._activeTweens.Add(tween);
return tween;
}
private void Update()
{
float dt = Time.deltaTime;
for (int i = _activeTweens.Count - 1; i >= 0; i--)
{
var tween = _activeTweens[i];
if (!tween.Update(dt))
{
_activeTweens.RemoveAt(i);
tween.Reset();
_pool.Enqueue(tween);
}
}
}
}
3단계: 확장 메서드로 Fluent 인터페이스 구축
Unity 엔진 기본 컴포넌트(Transform, CanvasGroup 등)에서 점(dot) 연산자로 직접 체이닝 호출할 수 있도록 확장 메서드를 작성합니다.
using UnityEngine;
public static class TweenExtensions
{
public static TweenCore<Vector3> DoMove(this Transform target, Vector3 targetPosition, float duration)
{
return TweenManager.Rent(
() => target.position,
val => { if (target != null) target.position = val; },
Vector3.LerpUnclamped,
targetPosition,
duration
);
}
public static TweenCore<Vector3> DoScale(this Transform target, Vector3 targetScale, float duration)
{
return TweenManager.Rent(
() => target.localScale,
val => { if (target != null) target.localScale = val; },
Vector3.LerpUnclamped,
targetScale,
duration
);
}
public static TweenCore<float> DoFade(this CanvasGroup target, float targetAlpha, float duration)
{
return TweenManager.Rent(
() => target.alpha,
val => { if (target != null) target.alpha = val; },
Mathf.Lerp,
targetAlpha,
duration
);
}
}
완성된 트윈 라이브러리 실전 활용 예제
이제 인스펙터나 게임플레이 로직 어디서든 직관적인 한 줄 구문으로 애니메이션을 구동할 수 있습니다.
using UnityEngine;
public class UINotificationBanner : MonoBehaviour
{
[SerializeField] private CanvasGroup bannerCanvasGroup;
[SerializeField] private Transform bannerTransform;
public void ShowBanner()
{
// 스케일과 위치를 부드럽게 복합 연출
bannerTransform.localScale = Vector3.zero;
bannerCanvasGroup.alpha = 0f;
bannerTransform.DoScale(Vector3.one, 0.4f)
.SetEase(EaseType.OutBounce);
bannerCanvasGroup.DoFade(1f, 0.25f)
.SetEase(EaseType.OutQuad)
.OnComplete(() =>
{
Debug.Log("배너 오픈 완료!");
});
}
}

성능 최적화: GC Alloc 0B를 달성하는 핵심 테크닉
- 익명 람다식의 외부 변수 캡처(Closure) 방지: 람다식 내부에서 로컬 변수를 캡처하면 컴파일러가 힙에 클로저 클래스를 생성합니다. 확장 메서드 레벨에서 대상 객체를 직접 넘기거나 상태를 고정하도록 설계해야 합니다.
- 역순 순회(Reverse Loop)로 Swap 없이 안전한 제거:
Update루프 내에서 활성 목록을 역순(for (int i = list.Count - 1; i >= 0; i--))으로 순회하여 리스트 중간 요소 제거 시 발생하는 인덱스 밀림 및 불필요한 배열 복사 비용을 방지합니다. - Unclamped 보간 함수 활용:
Vector3.Lerp대신Vector3.LerpUnclamped를 사용하여 오버슈트(Overshoot)가 일어나는 Bounce나 Elastic 계열 이징 커브가 0과 1 범위 바깥으로 정상 연산되도록 합니다.
자주 묻는 질문 (FAQ)
Q1. 트윈 동작 도중 게임오브젝트가 파괴(Destroy)되면 NullReferenceException이 발생하지 않나요?
확장 메서드의 setter 람다식 내부에서 if (target != null) 검사를 수행하도록 작성했습니다. 만약 대상 오브젝트가 도중에 파괴되었다면 세터 실행을 생략하고 트윈의 자연스러운 만료 혹은 즉시 반환 처리가 이루어집니다.
Q2. Time.timeScale = 0인 일시정지 상태에서도 UI 트윈을 재생할 수 있나요?
트윈 코어에 SetUpdate(bool isUnscaled) 옵션을 추가하고 TweenManager의 Update 시 Time.unscaledDeltaTime을 선택적으로 전달하도록 분기하면 일시정지 팝업창 연출도 간단히 지원할 수 있습니다.
Q3. Action 콜백 등록 시 가비지 컬렉션이 발생하지 않나요?
정적 메서드나 클래스 멤버 함수를 대리자로 직접 바인딩하면 캐싱되어 가비지가 발생하지 않습니다. 매번 새로운 람다식 () => { ... }을 생성하고 내부에서 로컬 변수를 캡처할 때만 클로저 인스턴스가 할당되므로 극도의 무할당이 필요한 영역에서는 미리 선언된 Action 멤버를 전달하는 방식을 권장합니다.
요약 및 결론
자체 Fluent 트윈 엔진은 코루틴의 힙 메모리 할당 문제를 완벽히 해결하고 가독성 높은 애니메이션 코드를 작성할 수 있게 돕습니다. 단일 관리자 기반의 객체 풀링 기법과 C# 확장 메서드를 결합하여 게임의 프레임 드랍을 원천 차단하는 견고한 클라이언트 기반 구조를 완성해 보시기 바랍니다.



