[Unity] 제네릭과 함정
들어가며
이전 글로부터 기억해야할 점은 아래와 같다.
- 정적 타입과 런타임 타입은 구분된다.
- 힙 객체의 런타임 타입은 헤더에 있다.
- 가변변환이 헤더를 바꾸지 않는다.
Foreach, IEnumerator 함정
public static class TargetFinder
{
public static Unit FindNearestFor(IReadOnlyList<Unit> candidates, Vector3 origin)
{
Unit nearest = null;
for (int i = 0; i < candidates.Count; i++)
{
Unit unit = candidates[i];
//...
}
return nearest;
}
public static Unit FindNearestForeach(IReadOnlyList<Unit> candidates, Vector3 origin)
{
Unit nearest = null;
foreach (var unit in candidates)
{
//...
}
return nearest;
}
}
코드를 짠 사람은 Unit을 상속받은 애들도 FindNearest를 쓸 수 있게 하고 싶어서 IReadOnlyList를 썼다고 한다. List는 불공변이고 IReadOnlyList는 공변이니 합리적인 판단같다.
근데 함수 두 개가 정의되어있다. 하나는 for버전, 다른 하나는 foreach버전인데 하는 일은 완전히 같다. 그러나 둘 중 하나는 박싱이 생기고 다른 하나는 박싱이 없다. 이건 다음 글로 정리할 예정.
in T와 비교자 함정
public sealed class DistanceComparer : IComparer<Unit>
{
public Vector3 Origin;
public int Compare(Unit a, Unit b)
{
float da = (a.transform.position - Origin).sqrMagnitude;
float db = (b.transform.position - Origin).sqrMagnitude;
return da.CompareTo(db);
}
}
public class SkillCaster : MonoBehaviour
{
private readonly List<Enemy> _enemiesInRange = new();
private readonly List<Ally> _alliesInRange = new();
private readonly DistanceComparer _byDistance = new();
private void SortTargets()
{
_byDistance.Origin = transform.position;
_enemiesInRange.Sort(_byDistance); // Sort(IComparer<Enemy>)에 IComparer<Unit> 전달
_alliesInRange.Sort(_byDistance); // Sort(IComparer<Ally>)에 IComparer<Unit> 전달
}
}
DistanceComparer를 구조체가 아닌 클래스로 만들어서 캐싱해서 쓰고 있다. 만약 구조체였다면, IComparer로 박싱되어서 Sort에 전달되므로 Sort호출마다 박싱이 생겼을 것이다.
Action의 += 함정
public class AllyManager : MonoBehaviour
{
public event Action<Ally> AllyDied; // class Ally: Unit
}
public class EnemyManager : MonoBehaviour
{
public event Action<Enemy> EnemyDied; // class Enemy: Unit
}
public class CombatLogPanel : MonoBehaviour
{
[SerializeField] private AllyManager _allyManager;
[SerializeField] private EnemyManager _enemyManager;
private Action<Unit> _onUnitDied;
private void Awake()
{
_onUnitDied = unit => Debug.Log(unit + " died.");
}
private void OnEnable()
{
_allyManager.AllyDied += _onUnitDied;
_enemyManager.EnemyDied += _onUnitDied;
}
private void OnDisable()
{
_allyManager.AllyDied -= _onUnitDied;
_enemyManager.EnemyDied -= _onUnitDied;
}
}
먼저, 위 코드는 컴파일 될까? 된다! OnEnable과 OnDisable에서 반공변변환을 통해 매니저 클래스의 이벤트에 동일한 인스턴스를 넣었다. 덕분에 onUnitDied를 타입 각각에 대해 구현하지 않고 Unit에 대해서만 구현할 수 있다.
그런데, 위 코드는 런타임에 안전할까? 안전하지 않다!
누군가 같은 프로젝트에서 아래와 같은 코드를 작성했다고 생각해보자...
_enemyManager.EnemyDied += (e) => Debug.Log(e);
- 이벤트 EnemyDied는
Action<Enemy>타입이고, 인스턴스로는 onUnitDied를 아까 가변변환해서 넣었다. - 누군가 람다로 이벤트를 구독했다. 둘 다
Action<Enemy>이므로 아무런 문제가 없어보인다. - 컴파일 된다. 빌드도 된다. 근데 런타임에 구독이 늦은 쪽에서
ArgumentException(Incompatible Delegate Types)이 터진다.
원인은 +=의 내부 동작에 있다. 이 연산자는 컴파일러에 의해 Delegate.Combine(a, b) 메소드로 치환되는데, 이 메소드는 두 인자의 헤더가 완전히 동일해야한다.
가변변환이 헤더를 바꾸지 않는다는 점을 기억하자. 이 경우에 하나는 헤더가 Action<Unit>이고, 다른 하나는 Action<Enemy>이므로 터진 것이다.
그럼 어떻게 할까? 아래와 같은 방법도 있다.
private void OnEnable()
{
_allyManager.AllyDied += HandleUnitDied;
_enemyManager.EnemyDied += HandleUnitDied;
}
private void OnDisable()
{
_allyManager.AllyDied -= HandleUnitDied;
_enemyManager.EnemyDied -= HandleUnitDied;
}
private void HandleUnitDied(Unit unit) => Debug.Log(unit + " died.");
메서드 이름(메서드 그룹)으로 넣으면, 대상 이벤트의 타입에 맞는 대리자 인스턴스를 컴파일러가 생성해서 넣어준다.
다만 그 때문에, 위 코드는 구독과 해제시에 매번 할당이 생긴다. 그래서 아래와 같은 처리도 가능하다.
private Action<Ally> _onAllyDied;
private Action<Enemy> _onEnemyDied;
private void Awake()
{
_onAllyDied = HandleUnitDied;
_onEnemyDied = HandleUnitDied;
}
private void OnEnable()
{
_allyManager.AllyDied += _onAllyDied;
_enemyManager.EnemyDied += _onEnemyDied;
}
private void OnDisable()
{
_allyManager.AllyDied -= _onAllyDied;
_enemyManager.EnemyDied -= _onEnemyDied;
}
위의 코드는 enemy와 ally 각각의 매니저 이벤트에 그 타입이 맞는 대리자 인스턴스를 +=, -=하고 있다.
대리자 인스턴스를 미리 캐싱해두었으므로, 구독과 해제시에는 새로운 대리자 인스턴스를 만들지 않아 그만큼 할당이 없다!
참고
- 람다를 이벤트에 넣으면, 컴파일러는 대상 대리자 타입과 동일한 타입의 대리자 인스턴스를 생성한다. Roslyn은 캡처가 없는 람다를 정적 필드에 캐싱한다.
- Delegate.Combine은 새 인스턴스를 반환하는 방식이라 할당이 있다. -=연산에 해당하는 Delegate.Remove도 마찬가지. 다만 Combine은 두 인자 중 하나가 null이면 할당이 없고, Remove는 반환값이 null이면 할당이 없다. 즉, 최초 +=와 최후 -=시점에 할당이 없다.
끗.

![[Unity] PC 세이브 데이터 Android로 옮기기](https://data.develog.develrocket.com/upload/develog/muchan/1792206352286-hd4xz4/_____2026-10-17_114953.png)
![[Unity] 제네릭 박싱 예방](https://data.develog.develrocket.com/upload/develog/gmumdev/1792123312021-bhxx2i/_____.png)
