Unity C# Fluent API 트윈(Tween) 라이브러리 만들기: 무할당(Zero-Allocation) 애니메이션 엔진 설계

Unity에서 코루틴 가비지 생성을 방지하고 DOTween 스타일의 메서드 체이닝을 제공하는 경량 무할당 C# Fluent Tween 라이브러리를 직접 설계하고 구현하는 가이드입니다.

✓ 복사 완료!
챕터애니메이션 & 리깅
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Unity C# Fluent API 트윈(Tween) 라이브러리 만들기: 무할당(Zero-Allocation) 애니메이션 엔진 설계

핵심 요약

  • 문제: 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)의 본질은 시작점(start\text{start})에서 목표점(end\text{end})까지 지정된 시간(duration\text{duration}) 동안 상태 값을 점진적으로 변경하는 것입니다.

경과 시간(telapsedt_{\text{elapsed}})을 총 지속 시간(DD)으로 나눈 정규화된 시간 비율을 tt라고 할 때, 보간 공식은 다음과 같습니다.

t=clamp(telapsedD,0,1)t = \text{clamp}\left(\frac{t_{\text{elapsed}}}{D}, 0, 1\right)

선형 보간(Linear Interpolation)은 이 정규화 시간 tt를 그대로 값의 변화율로 적용하지만 현실감 있고 다채로운 연출을 위해서는 비선형 가감속을 부여하는 이징 함수(Easing Function) f(t)f(t)를 거쳐야 합니다.

Value(t)=start+(end−start)×f(t)\text{Value}(t) = \text{start} + (\text{end} - \text{start}) \times f(t)

트윈 애니메이션의 정규화 시간 진행과 이징 커브 곡선 비교

자주 사용하는 대표적인 이징 수식을 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("배너 오픈 완료!");
            });
    }
}

유니티 인스펙터 및 씬 뷰에서 Fluent API 트윈이 부드럽게 보간되는 동작 화면

성능 최적화: GC Alloc 0B를 달성하는 핵심 테크닉

  1. 익명 람다식의 외부 변수 캡처(Closure) 방지: 람다식 내부에서 로컬 변수를 캡처하면 컴파일러가 힙에 클로저 클래스를 생성합니다. 확장 메서드 레벨에서 대상 객체를 직접 넘기거나 상태를 고정하도록 설계해야 합니다.
  2. 역순 순회(Reverse Loop)로 Swap 없이 안전한 제거: Update 루프 내에서 활성 목록을 역순(for (int i = list.Count - 1; i >= 0; i--))으로 순회하여 리스트 중간 요소 제거 시 발생하는 인덱스 밀림 및 불필요한 배열 복사 비용을 방지합니다.
  3. 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# 확장 메서드를 결합하여 게임의 프레임 드랍을 원천 차단하는 견고한 클라이언트 기반 구조를 완성해 보시기 바랍니다.