
게임 개발을 하다 보면 무기, 비행체, 차량, 캐릭터처럼 정체도 다르고, 세팅할 때 필요한 데이터도 제각각인 객체들을 수없이 접하게 된다.
이 녀석들을 생성하거나 가동할 때 클래스마다 일일이 TryGetComponent로 까보면서 세팅하다 보면 코드가 말 그대로 개판이 되기 십상이다. 요구하는거도 다를수도있고 변수가 너무많아진다.. 이걸냅두면 마치 가지처럼 증식하는 문제를 볼수도있고말이지.
그렇다고 공통 부모 클래스의 Init() 인자를 무작정 늘릴 수도 없는 노릇이다 부모와 자식이 일자형 연결구조라면 문제가없겠지만, 가지처럼 퍼저나간다면 요구해야하는것들이 조금씩 변해지며 요구와 작동방식이 달라지고 이걸 부모가 해결하고자하면 부모쪽에서 자식의구조를 알고대응해야하만하는 오히려 문제를 해결할려다가 문제를 더크게만들어버린다.
물론 그러자고 자식을 참조하면 굳이 공통 부모구조를 써서 개선을 한 의미도없어지고 애당초 각 클래스마다 필드를넣어서 관리해야하는 복잡한 구조가될수도있기도하고.
이번 글에서는 부모 코드를 더럽히지 않고, 각기 다른 클래스들의 초기화를 인터페이스로 깔끔하게 통일하는 방법을 알아본다.
👉 이번 글에서 다룰 내용들
1. 제각각인 클래스와 초기화 인자 이질성
• 각각의 클래스의 초기화 문제2. 인터페이스를 통한 초기화 문제해결
• 상속이나 직접대입이 아닌 인터페이스로 초기화 통일3. 제네릭 활용 인터페이스
• 파라미터 이질성을 완벽하게 해결하는 설계4. 인터페이스를 활용한 초기화 구현해보기
• 클래스 정체를 몰라도 단자만 맞춰 데이터를 꽂아 넣는 실제 코드5. 그래서 어떨때 쓰는가?
• 이 방식을 적재적소에 쓰는법
유니티를 만지다보면 스크립팅은 필연적으로 만나게된다. 다만 관건은 모든걸 해결해주는 마법같은방법은없고 각 객체의 정의와 속성 작동이 다를수가있다는거다.
문제는 이런것들은 대개 초기화를 해서 위치를잡든 정리를하든해야한다는거고 문제는 여기서 발생한다

코드예시를보자 아주 간단한코드다
// 1. 자동차
public class Car : MonoBehaviour
{
public void Init(){}
}
// 2. 과일 (식품)
public class Fruit : MonoBehaviour
{
public void Init(){}
}
// 3. 상자 (용기)
public class Box : MonoBehaviour
{
public void Init(){}
}
이런식으로 각각 객체가 있을꺼다 그래야 작동을 구현하니. 다만 문제는 이것들을 조작할려면 참조가필요하고 저장을 해야한다는거다.
공통 부모나 규격이 없다 보니 스포너는 List, List, List처럼 변수를 따로 뚫어야 하고, 생성할때마 객체의 타입을알아야하며 직접 init()를 따로 호출해줘야한다는거다 참 거지같은문제다.
그렇다면 간단하게 공통되는부모를 상속해도된다 다음코드처럼말이지.
public abstract class BaseItem : MonoBehaviour
{
public abstract void Init();
}
public class Car : BaseItem
{
public override void Init() { Debug.Log("자동차 초기화"); }
}
public class Fruit : BaseItem
{
public override void Init() { Debug.Log("과일 초기화"); }
}
public class Box : BaseItem
{
public override void Init() { Debug.Log("상자 초기화"); }
}
물론 이방법도 아주좋은 방법이다. 초기부터 완벽한 설계를 한다면말이지.
이 좋아보이는방식에도 틈이 존재하다는것이 바로 문제이다.
하지만 이 구조는 깨지기가쉽다.
애당초 객체는 서로 다른행동을 하기위해 만든거라 요구하는것 행동하는것들이 다를수있기 때문이다 ,
public class Car : BaseItem
{
// ❌ 부모 BaseItem.Init()과 인자 형태가 달라서 override 불가
public override void Init(string destination, float fuel)
{
...
}
}
public class Fruit : BaseItem
{
// ❌ 이놈도 똑같은문제고
public override void Init(string name, float sweetness)
{
...
}
}
즉 관건은 하나다 하나로 묶고싶은데 우린 왜 부모를 건들여가며 개조를 해야하냐는거다 그냥 자식한테 직접 걸고싶은데말이지.
게다가 부모껄끌어오이게 강제적으로 공통적인 속성을 강요받는다 이또한 오히려 구조를 경직시키도한다.

상속이 실패한 근본적인 이유는 객체의 정체성과 객체의 기능을 하나로 묶으려 했기 때문이다.
상속이란건 이객체가 무엇인가를 정의하기때문이라서 그렇다.
초기화는 객체의 본질이 아니라, 씬에 스폰될 때 수행해야 하는 단순한 기능일 뿐이다. 따라서 정의를 조정하는것이 아니라 이 객체가 뭘할수있는가를 달아주면 된다. 마치 추가 부품처럼
그점을 이용하는것이바로 인터페이스다.
인터페이스를 사용하면 기존의 상속 계층 구조를 단 1줄도 수정할 필요가 없다. C#은 클래스 상속은 단 하나만 허용하지만, 인터페이스는 개수 제한 없이 다중 구현이 가능하기 때문이다.
코드를 보자면 아주 간단한데
// 규격(인터페이스) 정의
public interface IInitializable
{
void Init();
}
public class Car : Vehicle, IInitializable
{
public void Init()
{
Debug.Log("Car 초기화");
}
}
public class Fruit : Consumable, IInitializable
{
public void Init()
{
Debug.Log("Fruit 초기화");
}
}
public class Box : Prop, IInitializable
{
public void Init()
{
Debug.Log("Box 초기화");
}
}
이런식으로 깊이 들어갈필요없이 표면에서 조정이가능하다 그리고 이점의 가장 강력한점은 바로 객체를 직접알필요가없다는것이다.
객체 타입에 구애받지 않는 규격이 생겼으니, 이제 이 객체들을 씬에 찍어내고 초기화하는 스포너 입장을 보자.
과거에는 스포너가 Car, Fruit, Box의 존재를 전부 다 알아야 했지만, 이제는 구체적인 클래스 정체를 단 1도 몰라도 된다.
객체의 위치를 참조할필요가없이 우린 인터페이스로 통신하면되기때문이다.
기존에있던 필드덩어리를 단순하게 원큐로 해결가능하다 조건문덩어리도 같이날리면서말이지
public class ObjectSpawner : MonoBehaviour
{
private List<IInitializable> _activeObjects = new List<IInitializable>();
public void SpawnObject(GameObject prefab)
{
GameObject obj = Instantiate(prefab);
if (obj.TryGetComponent<IInitializable>(out var initializable))
{
initializable.Init();
// 필드(List)에 저장해서 추적 및 일괄 관리
_activeObjects.Add(initializable);
}
}
이런식으로 각 클래스의 선언없이 그저 인터페이스로 간단하게 공통된 규약을이용해서 처리가능하다.
문제는 과연 이걸로끝일까? 2번째문제인 위치와 복잡도는 해결했지만.
각 객체가 가진 행동이 다르기에 요구도 다를수있다는 문제를 우린 아직 처리를못했다 즉 이건 초기화시 파라미터가 다를수도있다는걸 의미한다.
전에있던 코드 예시를 봐보자
public class Car : BaseItem
{
// ❌ 부모 BaseItem.Init()과 인자 형태가 달라서 override 불가
public override void Init(string destination, float fuel)
{
...
}
}
public class Fruit : BaseItem
{
// ❌ 이놈도 똑같은문제고
public override void Init(string name, float sweetness)
{
...
}
}
인터페이스로 이걸 이니트묶는다 하더라도 문제는 바로 각 요구하는 매개가 다르다는것이다 물론 오버로딩을통해 구현할수있으나 인터페이스는 사용시 모두 구현해야하기에 구조상 아주 개판이날테고 코드가 더러워질꺼다
IInitializable<T> 구현만약 구현한다면 다음과 같은 코드가 된다.
public interface IInitializable<T>
{
void Init(T param);
}
// Car에 필요한 DTO
public struct CarInitParam
{
public string destination;
public float fuel;
}
public class Car : Vehicle, IInitializable<CarInitParam>
{
public void Init(CarInitParam param)
{
// 자동차 초기화 로직...
}
}
인자 타입 문제를 제네릭으로 해결하면, 또다른 부딪히게 된다.
C#에서 IInitializable<T> 는 가 다르면 완전히 다른 타입으로 취급된다는 점이다.
인터페이스를 도입했던 진짜 목적을 떠올려보자.
Car, Fruit, Box 등 서로 다른 객체들을 하나의 리스트나 매니저에서 일괄 관리하고 초기화하고 싶었다.하지만 제네릭 인터페이스를 쓰면 이런 코드가 불가능해진다.
// ❌ 제너릭을썻기에 사용을 못한다
List<IInitializable> initList = new List<IInitializable>();
// 그렇다고 T를 각각 지정하면 결국 타입별로 리스트를 따로 만들어야 한다.
List<IInitializable<CarInitParam>> carInitList;
List<IInitializable<FruitInitParam>> fruitInitList;
이런식으로쓰고싶어하는사람은 아무도없을것이다 애당초 이런식으로 할꺼였으면 애당초 이니트 문제제기를 하지도않았을테고..
그렇다면 과연 해결법이 없는걸까?
생각을 뒤집어보자. 인터페이스는 고정시켜야한다 그래야 저장하기가 편하니깐 다만 파라미터가 다른걸 해결해야하니 골치이다.
그렇다면 파라미터자체를 제너릭으로 쓰면안되는가? 라는 생각도할수있다.
public interface IInitializable
{
void Init(InitParam param);
}
// 일단은 공통적으로 묶어줄 프레임 생성
public abstract class InitParam
{
}
// 제너릭으로 포장하기
public class InitParam<T> : InitParam
{
public T Data { get; }
public InitParam(T data)
{
Data = data;
}
}
실제 사용 코드는 다음과 같이 작성된다.
public struct CarData
{
public string destination;
public float fuel;
}
public class Car : Vehicle, IInitializable
{
public void Init(InitParam param)
{
// 부모 타입으로 들어온 파라미터를 자신의 제네릭 타입으로 패턴 매칭
if (param is InitParam<CarData> carParam)
{
var data = carParam.Data;
// 자동차 초기화 로직 수행 (data.destination, data.fuel)
}
}
}
물론 InitParam where T : struct 를 통해 좀더 타입안정성을 강화할수있다
하지만 이 강의는 확장성을 구현하고자하는거라 굳이 안넣었다.
이러면 초기화 하는 객체에서 타입검사만 하는것으로 알아서 처리되고, 공통적인 인터페이스 리스트를 참조가능하다.
뭐 간단하게하자면 이런식으로 구현가능하다.
인터페이스
// 1. 단일 리스트 수집을 위한 핵심 인터페이스
public interface IInitializable
{
void Init(InitParam param);
}
// 2. 서로 다른 제네릭 파라미터들을 하나로 묶어줄 공통 부모 클래스
public abstract class InitParam
{
}
// 3. 실제 데이터(T)를 업캐스팅 창구로 전달하기 위한 제네릭 포장 클래스
public class InitParam<T> : InitParam
{
public T Data { get; }
public InitParam(T data)
{
Data = data;
}
}
객체 구현과 DTO생성
using UnityEngine;
// --- 자동차 (Car) ---
public struct CarData
{
public string destination;
public float fuel;
}
public class Car : MonoBehaviour, IInitializable
{
public void Init(InitParam param)
{
// 제네릭 타입 자동 검증 및 데이터 추출
if (param is InitParam<CarData> carParam)
{
var data = carParam.Data;
Debug.Log($"Car 초기화 - 목적지: {data.destination}, 연료: {data.fuel}");
}
}
}
// --- 과일 (Fruit) ---
public struct FruitData
{
public string name;
public float sweetness;
}
public class Fruit : MonoBehaviour, IInitializable
{
public void Init(InitParam param)
{
if (param is InitParam<FruitData> fruitParam)
{
var data = fruitParam.Data;
Debug.Log($"Fruit 초기화 - 이름: {data.name}, 당도: {data.sweetness}");
}
}
}
테스트용 호출자
using System.Collections.Generic;
using UnityEngine;
public class Spawner : MonoBehaviour
{
[SerializeField] GameObject carPrefab;
[SerializeField] GameObject fruitPrefab;
// 이런식으로 클래스의 타입에 구애받지않는 참조리스트를 만들수있다
private List<IInitializable> _initList = new List<IInitializable>();
private void Start()
{
GameObject carObj = Instantiate(carPrefab);
GameObject fruitObj = Instantiate(fruitPrefab);
if (carObj.TryGetComponent<IInitializable>(out var carInit)) _initList.Add(carInit);
if (fruitObj.TryGetComponent<IInitializable>(out var fruitInit)) _initList.Add(fruitInit);
carInit?.Init(new InitParam<CarData>(new CarData
{
destination = "Seoul",
fuel = 100f
}));
fruitInit?.Init(new InitParam<FruitData>(new FruitData
{
name = "Apple",
sweetness = 9.5f
}));
}
}
일단 코드를보면 의문이 하나 들것이다. 왜 리스트로둔거지? 초기화용일텐데라고.
이는 사실 커맨드패턴을 염두에두었기때문이다.
사실은 이런식으로 응용이 가능한데
// 액션 팩 구현
public interface IEntityBehavior : IInitializable, IActionable, ITickable
{
// 추가적인 공통 메서드 선언 가능
bool IsActive { get; }
}
// 하나의 인터페이스만 구현해도 Init, Execute, OnTick을 모두 포함하게 됨
public class allRounder : MonoBehaviour, IEntityBehavior
{
public bool IsActive => gameObject.activeSelf;
public void Init(InitParam param) { ... }
public void Execute(ActionParam param) { ... }
public void OnTick(float deltaTime) { ... }
}
이 구조는 단순 1회성 함수 호출이 아니라 커맨드 패턴(Command Pattern)과 일괄 제어 파이프라인을 염두에 두었기 때문이다.
지연 초기화를 구현하여 여러 객체를 먼저 스폰만 해두고, 특정 이벤트 시점(예: 레벨 로딩 완료, 게임 시작 버튼 클릭)에 List를 순회하며 한 번에 일괄 초기화할 수 있다.
이 구조에서 인터페이스 상속만 더하면 List 하나로 Init뿐만 아니라 Execute(행동), OnTick(프레임 갱신)까지 제어하는 엔티티 프레임워크로 진화한다.
다만 이방식을쓰면서 초기화할때 주의할점도 반드시 존재한다
이 구조를 사용할 때 가장 경계해야 할 것은 초기화 실패 후 리스트에 불완전한 객체가 남아있는 현상이다.
InitParam으로 입구를 하나로 뭉쳤기 때문에, 실수로 다른 DTO 데이터를 넘겨줘도 컴파일 에러가 나지 않는다. 이때 방어 코드에서 단순 return;으로 끝나버리면 치명적인 버그가 터진다.
Init 메서드가 성공/실패 여부(bool)를 반환하거나 릴리즈 콜백을 받아, 초기화에 실패한 객체는 그 즉시 매니저 리스트에서 철수시키고 객체를 파괴/풀 반환해야 한다. 다만 액션을 추천하는데 단순 이니트만쓴다면 불리언이 좋겟지만, 생명주기관점에서 철수할때도 인터페이스를 리스트에서 빼내야하기에 액션을 추천한다
물론 이 방식은 상당히 유용하지만 장단점과 주의사항또한 존재한다.
장점이라면 이와같다.
OCP의 완벽한 준수: 새로운 몬스터, 아이템, DTO가 아무리 늘어나도 스포너나 게임 매니저 코드는 단 한 줄도 건드릴 필요가 없다.
의존성 단절과 안전한 참조 주입: 매니저가 구체 클래스의 정체를 몰라도 되며, 카메라나 매니저 같은 외부 레퍼런스를 InitParam에 담아 안전하게 주입할 수 있다.
단일 창구 생명주기 제어: IEntityBehavior와 Action 콜백을 조합하면 스폰, 프레임 갱신, 사망, 풀 반환까지 단 하나의 리스트에서 깔끔하게 일괄 제어된다.
1회성 GC Alloc 발생: DTO를 new InitParam 포장지로 감싸는 과정에서 필연적으로 힙 메모리 할당이 일어난다.
컴파일 타임 검증 상실: 단일 창구 다형성을 위해 비제네릭 타입을 거치므로, 잘못된 DTO를 넘겼을 때 컴파일 에러가 아닌 런타임 Guard Clause로만 캡처할 수 있다.
물론 단점을 해결할수도있겠지만 이는 원점으로 가는행위일뿐이다.
각각의 트레이드오프가 존재하는것이지 어느것이 안좋고 좋다라곤할수없기때문이다
사용 판단 기준
| 구분 | 적용 대상 | 선택 이유 |
|---|---|---|
| 🟢 적극 권장 | 몬스터, 보스, UI, 아이템, 장비 등 | 스폰 주기가 길고 데이터 구조 및 외부 참조 주입이 복잡하여 소량의 GC Alloc보다 구조적 유연성의 이득이 훨씬 큼 |
| 🔴 사용 금지 | 탄환(Bullet), 파티클, 피격 이펙트 등 | 초당 수십~수백 개씩 풀에서 꺼내 재사용하므로, 매번 포장 객체를 생성하면 GC Alloc 폭탄이 터짐 |
이와같이 상황에 맞게 최선의 선택을 하면 될뿐이다.
소프트웨어 설계에서 모든 상황을 완벽하게 만족하는 만능열쇠는 존재하지 않는다.
단지 내가 다루는 도메인에서 구조적 유연성을 위해 약간의 GC 통행료를 낼 것인가? 아니면 유연함을 다소 포기하더라도 극한 성능을 챙길 것인가?를 명확히 인지하고 능동적으로 선택할 뿐이다.
본 글에서 소개한 제네릭 포장 패턴 InitParam<T> 역시 모든 곳에 들이대야 하는 절대적 정답은 아니다. 하지만 적어도 클래스 상속의 늪에 빠지지 않고, OCP를 지키며 객체 간 의존성을 깔끔하게 주입할 수 있는 매우 강력한 무기"**임은 분명하다.
개발에 정답은없다 생각한다. 그저 최적의 적응을 하고 구현하는것일뿐 절대적과 완벽이란 존재할리가없기때문이다.