본문 바로가기
유니티 공부/Unity

Unity - 제네릭이 꼭 좋은 건 아니였다.

by 코딩하는 돼징 2025. 5. 12.
반응형

제네릭(Generic)

제네릭 타입에서는 데이터 형식을 확정하지 않고 런타임 또는 컴파일 타임에 타입을 지정할 수 있도록 만드는 타입이다. 일반적으로 <T>로 작성한다. 

public class Example<T>
{
    public T Value;
}

Where이란?

특정 조건에만 대응되는 데이터 타입이 필요한 경우 where키워들르 사용하여 제약 조건을 추가할 수 있다. 제약 조건이 만족하지 않을 경우 컴파일 에러가 발생한다. 이걸 통해 타입 파라미터가 특정 인터페이스를 반드시 구현해야 한다거나 클래스에서 파생되어야 한다는 식의 제약을 걸 수 있다.

public class Pool<T> where T : MonoBehaviour, IPoolable
{
    public void ReturnToPool(T obj) { ... }
}

그러면 모든 데이터 형식을 쓸 수 있으니까 그냥 모든 클래스를 제네릭 타입으로 만들면 확장성이 좋지 않을까? 라고 생각해서 제네릭타입을 사용한 바 많은 문제들이 생겼다

 

1. 제네릭 제약이 복잡해질 수 있다.

부끄럽지만 점점 코드를 적다가 제약이 많아지는 경우가 생겼다. 이러한 경우 너무 낳은 인터페이스와 제약이 추가되면 제네릭 타입이 지나치게 엄격해지고 유연성이 사라지는 문제가 생긴다.

public class BaseEnemy<T> : MonoBehaviour, IEnemy, IStateMachineOwner<T>, IPoolable
    where T : MonoBehaviour, IEnemy, IStateMachineOwner<T>, IPoolable

 

 

위의 구조는 매우 많은 제약을 받아야 하므로 상속받는 클래스들이 일일이 그 제약 조건을 맞춰야하고 그 결과 상속 계층에서의 호환성 문제가 발생한다.


 

2. 제네릭 오버로드 우선순위 문제

Pool과 관련하여 아래와 같이 2가지의 Return 메서드가 있었다.

public void Return(string key, FleeEnemy enemy) { }
public void Return<T>(string key, T enemy) where T : IPoolable { }

그리고 아래와 같이 호출을 하게 된다면

PoolManager.Instance.Return(poolKey, obj);

 

obj는 T타입이고 T는 FleeEnemy가 아닐 수 있다. 그런데 컴파일러는 제네릭 메서드 오버로드가 일반 메서드보다 우선순위가 높다고 판단하므로 타입이 불일치해서 컴파일 오류가 발생할 수 있다.


해결 방법

01. 명시적 캐스팅

타입을 명확히 하여 일반 오버로드를 강제로 선택한다 하지만 null일 경우 런타임 문제가 발생할 수 있다.

PoolManager.Instance.Return(poolKey, obj as FleeEnemy);

 

 

02. BaseEnemy에서 제네릭 제거

상태 머신또는 풀링 시스템이 필요하다면 인터페이스나 런타임 타입을 분기 사용한다.

public abstract class BaseEnemy : MonoBehaviour, IEnemy, IPoolable

 

03 구체 타입으로 감싸기(제네릭 내부 사용 유지)

public class BaseEnemy<T> : MonoBehaviour where T : BaseEnemy<T>
public class FleeEnemy : BaseEnemy<FleeEnemy>, IEnemy, IPoolable, IStateMachineOwner<FleeEnemy>

결론

제네릭을 사용한다고 해서 무조건 좋은 도구가 아니다. 너무 남용하면 오히려 복잡도를 증가시킬 수 있다.

 

그럼 언제 사용하는게 좋을까?

1. 타입에 관계없이 동작하지만 타입 안전성을 유지하고 싶을 때(컬렉션, 컨테이너)

ist<T>, Dictionary<TKey, TValue>

2. 코드 재사용성을 높이고 싶을때 - 중복을 줄이고 타입별로 똑같은 로직을 하나로 통합 가능하다.

ObjectPool<T> where T : MonoBehaviour, IPoolable

3. 타입에 따라 다른 동작이 필요하지만 구조는 동일 할 때 - 상태 머신, 전략 패턴

StateMachine<T>, IState<T>

4. 함수나 유틸리티에서 여러 타입을 지원해야 할 때

5. 인터페이스 또는 추상 캘래스를 제네릭으로 할 때 - 리포지토리 패턴, 서비스 패턴

 

그럼 언제 사용하지 않는게 좋을까?

1. Unity MonoBehaviour 상속 클래스 - 인스펙터/ 직렬화에서 제네릭 지원 안됨

2. 상태가 복잡한 상속 구조 - 제약 조건이 과도하게 복잡해 질수 있다.

3. ScritableObject

4. 단순하게 인터페이스로 충분할 때

 

반응형

댓글