
개발을 하고 리팩토링을 과정에서는 기존의 코드를 재사용하거나 수정해야하는 경우가 자주 발생한다. 하지만 코드가 복잡하거나 작성한지 오래되어 레거시 코드가 되어버린 경우 코드의 의도를 알아보기 힘들고 섣불리 수정할 수 없을 수도 있을 것이다. 이 경우 기존의 코드를 변경시키지 않으면서도 인터페이스를 활용하여 여러 객체를 다룰 수 있어야하고 이를 위해 사용할 수 있는 것이 어댑터 패턴이다.
서로 호환되지 않는 인터페이스를 가진 클래스들이 같이 동작할 수 있도록 중간에서 연결해주는 패턴이다. 기존 코드를 변경하지않고 새로운 코드와 호환되게 만드는 것을 목표로 한다.
즉, 어댑터를 사용하여 기존 코드의 A를 B로 만들어 호환성을 갖도록 만드는 것이다.
기존의 코드를 수정하지 않기 때문에 변경 불가능하거나 업데이트 될 때 대응이 가능하다.
최소한의 변경으로 기존 코드를 재사용할 수 있다.
interfacepublic class Bullet : MonoBehaviour
{
public int attackPoint;
private OnCollisionEnter(Collision collision)
{
IDamagable damagable = collision.gameObject.GetComponent<IDamagable>();
if(damagable != null)
{
Attack(damagable);
}
}
private bool Attack(IDamagable damagable)
{
damagable.TakeDamage(gameObject, attackPoint);
}
}
public class Monster : MonoBehaviour, IDamagable
{
private int hp;
public int HP { get { return hp;} private set { hp = value; } }
public void TakeDamage(GameObject dealer, int damage)
{
HP -= damage;
}
}
public interface IDamagable
{
public GameObject gameObject { get; } ;
public void TakeDamage(GameObject dealer, int damage);
}
인터페이스를 구현하는 것은 Unity에서 두 객체 간의 상호작용을 정의하는 방식 중 가장 흔히 사용되는 경우이다. 위에서는 IDamagable 이라는 인터페이스를 선언하고 Bullet과 Monster의 각각의 행동을 정의하여 두 객체 간 상호작용이 가능하도록 구현했다.
UnityEvent 사용public class Switch : MonoBehaviour
{
[SerializeField] GameObject door;
private bool isDoorOpened => !door.ActiveSelf;
// 에디터에서 실행해볼 수 있게 만든다.
[ContextMenu("TestDoorAction")]
public void SwitchAction()
{
if(door.Activate)
{
Open();
}
else Close();
}
public void Open()
{
door.SetActive(false);
}
public void Close()
{
door.SetActive(true);
}
}
이미 구현되어 있는 Switch에 총알에 맞으면 스위치가 작동하도록 하는 상호작용을 추가하고 싶은 상황을 가정해보자. IDamagable을 상속하여 상호작용을 구현하려했지만, 스위치는 hp를 가지고 있지 않기 때문에 호환이 되지 않는데 이때 아래와 같은 adapter를 만들어 적용시키면 문제를 해결할 수 있다.
public class DamageAdapter : MonoBehaviour, IDamagable
{
public Switch switch;
public UnityEvent<GameObject, int> Ondamaged;
public void Start()
{
if(switch != null)
{
Ondamaged.AddListener((dealer, damage) => switch.SwitchAction());
}
}
public void TakeDamage(GameObject dealer, int damage)
{
Ondamaged?.Invoke(dealer, damage);
}
}
UnityEvent 사용xpublic interface IInteractable
{
public void Interact();
}
public class InteractAdapter : MonoBehaviour, IInteractable
{
public NPC npc;
public void Interact()
{
npc?.Talk();
}
}
public class Player : MonoBehaviour
{
private void Update()
{
if(Input.GetKeyDown())
{
TryInteract();
}
}
private void TryInteract()
{
if(Physics.Raycast())
{
IInteractable interactable = hitInfo.collider.gameObject.GetComponent<IInteractable>();
if(interactable != null)
{
interactable.Interact();
}
}
}
}
public class NPC : MonoBehaviour
{
public void Talk()
{
Debug.Log("안녕하세요");
}
}
정리하자면, Adapter는 기존 클래스의 코드 수정없이 인터페이스 내에서 자연스럽게 동작하는 것을 가능하게 한다. 따라서 Unity 컴포넌트 구조에서는 Adapter를 통해 다양한 MonoBehaviour를 하나의 인터페이스로 감싸 다양한 시스템과 유연하게 연결이 가능한 것이다.
Singleton PatternMonoBehaviourpublic class GameManager : MonoBehaviour
{
private static GameManager instance;
public static GameManager Instance
{
get
{
if(instance == null)
{
GameObject gameObject = new GameObject("GameManager");
instance = gameObject.AddComponent<GameManager>();
}
return instance;
}
}
private void Awake()
{
CreateInstance();
}
private void CreateInstance()
{
if(instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject)
}
else
{
Destroy(gameObject);
}
}
}
Unity에서는 RunTimeInitializeOnLoadMethod를 통해 특정 클래스가 씬 로드 전에 초기화되도록 설정하는 것이 가능하다. 초기화 시점은 RuntimeInitializeLoadType으로 조절할 수 있다.
public static class Manager
{
public static GameManager Game => GameManager.GetInstance();
// 로딩하기 전에 초기화되어 게임 시작 전에 호출 [RunTimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Initialize()
{
GameManager.CreateInstance();
}
}
씬에 포함하지 않고 Manager 스크립트에서 시작하자마자 manager 싱글톤 구현을 다음과 같이 진행할 수 있다. 이 경우 씬에 Manager를 넣지 않아도 자동으로 생성이 되고, 정상적으로 작동하는 것을 확인할 수 있다.
public class GameManager : MonoBehaviour
{
public event Action Onpaused;
private static GameManager instance;
public static GameManager Instance => instance;
private static GameManager CreateInstance()
{
if(instance == null)
{
GameManager prefab = Resources.Load<GameManager>("GameManager")
instance = Instantiate(prefab);
DontDestroyOnLoad(instance.gameObject);
}
}
private static void ReleaseInstance()
{
if(instance != null)
{
Destroy(intance.gameObject)
instance = null;
}
}
public static GameManager GetInstance()
{
return instance;
}
위와 같은 방식의 장점은 다음과 같다.
1. 씬에 직접 프리팹을 배치하지않아도 되기에 실수를 방지할 수 있다.
2. 코드 차원으로 관리가 되어 더 명확한 초기화 흐름을 갖는다.
Observer Pattern게임 오브젝트의 상태나 데이터를 감지하기 위해 주기적으로 검사를 실행하는 방식을 활용할 경우 연산을 낭비하게 된다. 하지만 콜백 기반의 옵저버 패턴을 활용하여 이를 구현할 경우 데이터 변경 시에만 이벤트를 발생시켜 리소스를 효울적으로 활용할 수 있다.
옵저버 패턴은 주시 대상이 되는 객체(Subject)가 자신의 데이터 변경시 등록된 관찰자들(Observer)에게 알려주는 디자인 패턴이다. 이는 주기적으로 확인하지 않아도 데이터 변화에 대응이 가능해 게임 최적화에 효과적이다.
public class Subject : MonoBehaviour
{
public event Action OnChanged;
public void ChangeState()
{
OnChanged?.Invoke();
}
}
public class Observer : Monobehaviour
{
public void Subscribe(Subject subject)
{
subject.OnChanged += RespondToChange;
}
public void Unsubscribe(Subject subject)
{
subject.OnChanged -= RespondToChange;
}
public void RespondToChange()
{
Debug.Log("반응");
}
}
발행 - 구독(pub - sub) 패턴은 이벤트를 발행하는 주체(Publisher)와 그 이벤트를 수신하고 처리하는 주체(Subscriber)를 중간 매개체(Event Channel 등)를 통해 연결하는 패턴이다.
발행 - 구독 패턴은 직접 참조 기반인 옵저버 패턴과 해당 부분에서 차이를 보이며, 중간 매개체가 발행자와 구독자를 이어주기 때문에 직접 참조하지 않는 느슨한 결합을 갖는다.
발행 - 구독 패턴은 느슨한 결합 구조로 확장성, 유지보수성, 테스트 용이성이 뛰어나다. 복잡한 시스템에서는 모듈간 의존성을 줄이기 위한 패턴으로 많이 쓰이며 주제가 다수인 경우 옵저버 패턴보다는 발행 - 구독 패턴을 활용하는 것이 좋다.
[CreateAssetMenu]
public class GameEvent : ScriptableObject
{
private event Action OnEvent;
public void Raise() => OnEvent?.Invoke();
public void Subscribe(Action listener) => OnEvent += listener;
public void UnSubscribe(Action listener) => OnEvent -= listener;
}
public class Player : MonoBehaviour
{
public GameEvent onPlayerDeath;
public void Die()
{
onPlayerDeath.Raise();
}
}
public class UIManager : MonoBehaviour
{
public GameEvent onPlayerDeath;
public void OnEnable() => onPlayerDeath.Subscribe(UpdateUI);
public void OnDisable() => onPlayerDeath.UnSubscribe(UpdateUI);
public void UpdateUI()
{
Debug.Log("플레이어 사망 UI 표기");
}
}
GetComponent<T>게임 오브젝트에 같은 타입 컴포넌트가 여러 개 있는 경우, 가장 먼저 추가된(맨 위) 컴포넌트를 반환한다. 또한 성능상 자주 호출 되면 캐싱을 하는 것이 좋다.
Scenepublic class SceneChanger : MonoBehaviour
{
private void Update()
{
if(Input.KeyConde)
{
SceneManager.LoadScene("TitleScene")
}
}
}
씬 전환시에 오브젝트를 유지하기 위해 사용하는 키워드로, 주로 매니저가 이에 해당하기 때문에 싱글턴 패턴과 함께 사용한다.
public class GameManager : MonoBehaviour
{
public static GameManager instance;
private void Awake()
{
CreateInstance();
}
private void CreateInstance()
{
if(instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject)
}
}
}
게임에 반드시 포함되는 리소스를 모아둔 폴더로 새폴더를 만들고 폴더의 이름을 Resources로 지으면 폴더 내부의 리소스를 언제든 호출하는 것이 가능하다.
Ressources.Load<T>("경로") // 런타임 중 로드 가능
단, Resources 폴더는 빌드시 전체가 포함되기에 많아지면 메모리 사용량이 증가하고 유지보수에 어려움이 생긴다.
대규모 프로젝트에서는 Addressable을 사용하는 것이 권장된다.
public class GameManager : MonoBehaviour
{
public static GameManager Instance;
public event Action Onpaused;
private void Awake()
{
Instance = this;
}
public void Pause()
{
Time.timeScale = 0f;
OnPaused?.Invoke();
}
}
public class Tank
{
// Start()에서 수행해도 무관
private void OnEnable()
{
Manager.Game.OnPaused += HandlePause;
}
private void Disable()
{
Manager.Game.OnPaused -= HandlePause;
}
private void HandlePause()
{
Debug.Log("탱크가 멈춤");
}
}