
각 View간 전환을 하거나 UI 요소를 업데이트 할 때 각 UI View 요소가 다른 View를 직접 참조하고 제어하는 구조는 당장 빠른 구현이 될지 몰라도 장기적으로 새로운 View가 추가되거나 View 로직이 변경되는 등의 확장이나 유지보수가 발생하는 경우 이를 매우 어렵게 만든다다.
마침 이번에 타워디펜스 포트폴리오를 진행하면서 좋은 UI 아키텍쳐에 대해 공부하고 적용해볼 기회가 생겨 추상클래스를 통한 상속 구조와 중재자/옵저버 패턴을 도입해 UI 로직을 리팩토링 해보게 되었다.
1. 각 View별 Canvas 분리
불필요한 Canvas Rebuild 방지
유니티에서는 Canvas 위에 있는 Text, Image 등 하나라도 변경되면 해당 Canvas에 대해 다음 프레임에 렌더링을 위해 Canvas 전체의 UI 요소들을 다시 분석하고 정렬하는 드로우콜(Draw Call)을 위한 배치(Batch)를 새로 구성하게 된다.
| 드로우 콜(Draw Call) | CPU가 GPU에게 보내는 렌더링 명령으로 어떤 Mesh, Texture, Material로 그릴지에 대한 모든 정보가 담겨있다. 한 번 발생할 때마다 CPU와 GPU 양쪽에 모두 부하를 유발하기 때문에 횟수가 적을 수록 성능에 좋다. |
| 배치(Batching) | 여러 개의 드로우 콜을 하나로 묶어주는 최적화 기법이다. 유니티는 동일한 Material을 사용하는 여러 오브젝트를 발견하면 하나의 묶음(Batch)으로 만들어 단 한 번의 드로우콜로 GPU에 전달한다. 이는 결과적으로 드로우 콜 횟수를 줄여 CPU의 부담을 덜어주게 된다. |
따라서 자주 업데이트 되는 UI나 거의 바뀌지 않는 UI, 기능별 UI를 개별적인 Canvas로 분리하여 불필요한 Canvas의 Rebuild를 사전에 방지하고 에디터 하이어라키 구조를 상대적으로 덜 복잡하게 만들어 코드 뿐만 아니라 에디터 작업에 있어서의 유지보수성과 확장성을 고려했다.


2. 공통 기능 재사용 : UIView 추상 클래스와 상속
현재 개발중인 타워디펜스 게임 속의 모든 View는 나타나고(Show), 사라지는(Hide) 공통된 기능을 가진다. 이러한 공통된 기능을 각 View마다 따로 구현하고, 만약 어떤 View에서 특정한 상황에 다른 View를 사라지게 만들어야 하거나 나타나게 해야하는 경우 View에서 View를 참조하고 직접 제어하는 구조가 되므로 결합도가 높아지고 코드의 수정 발생 시 연쇄적으로 모든 코드에서 수정사항을 반영해줘야하는 최악의 상황이 발생할 수 있어 반복적인 코드를 제거하고 일관된 동작을 보장하고 최소한의 View만의 개별 기능을 수행할 수 있도록 UIView라는 추상 클래스를 만들었다.
추상 클래스 (abstract class) : 인터페이스는 규칙만 정의하고 실제 구현을 포함할 수 없다. 나는 모든 View를 개별적인 Canvas로 분리했고 각 View에게 CanvasGroup을 사용한 공통 Fade 애니메이션 코드를 적용시켜야 했기 때문에 추상 클래스를 사용하게 되었다. 이는 코드의 중복사용을 원천적으로 방지하도록 해준다.
선택적인 재정의 (virtual과 override) : UIView의 Show, Hide 메서드는 virtual로 선언해 자식 클래스가 부모의 기본기능을 그대로 사용하거나 base.Show(), Time.timeScale 변경 처럼 View만의 개별 로직을 override해 덧붙일 수 있는 구조를 만들었다.
[RequireComponent (typeof(CanvasGroup))] // canvasGroup이 없으면 자동 추가
public abstract class UIView : MonoBehaviour
{
[Header("Fade 옵션")]
[SerializeField] protected float fadeDuration = 0.25f;
protected CanvasGroup canvasGroup;
private Tweener _fadeTween;
protected virtual void Awake()
{
canvasGroup = GetComponent<CanvasGroup>();
}
/// <summary>
/// View를 부드럽게 표시 (Fade In)
/// </summary>
public virtual void Show()
{
_fadeTween?.Kill(); // 이전 페이드 애니메이션 중지
gameObject.SetActive(true);
_fadeTween = canvasGroup.DOFade(1, fadeDuration)
.SetEase(Ease.OutQuad) // Ease: 애니메이션의 속도 변화 곡선 설정
.OnStart(() => // OnStart: 트윈 애니메이션이 시작될 때 한 번 호출될 함수(콜백) 등록
{
canvasGroup.interactable = true;
canvasGroup.blocksRaycasts = true;
})
.SetUpdate(true) // SetUpdate(true): Time.timeScale 값에 영향을 받지 않고 항상 정상 속도로 실행되도록 설정
.OnKill(() => _fadeTween = null); // OnKill: 트윈이 종료될 때 호출될 콜백을 등록. 트윈이 끝나면 참조를 null로 만들어 메모리 누수를 방지하고 상태를 관리
}
/// <summary>
/// View를 숨기기만 하고 아무런 콜백이 필요 없는 경우에 대한 오버로딩(이름 같고 파라미터 다름)
/// </summary>
public virtual void Hide()
{
Hide(null);
}
/// <summary>
/// View를 숨긴 후 실행될 콜백이 필요한 경우
/// </summary>
/// <param name="onHideComplete">숨김 애니메이션 완료 후 실행될 콜백</param>
public virtual void Hide(Action onHideComplete = null)
{
_fadeTween?.Kill();
_fadeTween = canvasGroup.DOFade(0, fadeDuration)
.SetEase(Ease.InQuad)
.OnComplete(() =>
{
canvasGroup.interactable = false;
canvasGroup.blocksRaycasts = false;
gameObject.SetActive(false);
onHideComplete?.Invoke();
})
.SetUpdate(true)
.OnKill(() => _fadeTween = null);
}
}
public class StagePauseView : UIView
{
[Header("Buttons")]
[SerializeField] private Button btnResume;
[SerializeField] private Button btnSettings;
[SerializeField] private Button btnBackToLobby;
public event Action OnHideComplete;
public event Action OnBackToLobbyClicked;
protected override void Awake()
{
base.Awake(); // 부모클래스인 UIView의 Awake 실행
btnResume.onClick.RemoveAllListeners();
btnResume.onClick.AddListener(Hide);
btnBackToLobby.onClick.RemoveAllListeners();
btnBackToLobby.onClick.AddListener(() => OnBackToLobbyClicked?.Invoke());
}
public override void Show()
{
Time.timeScale = 0; // 게임 시간 정지
base.Show(); // 부모의 Show 로직 수행
}
public override void Hide()
{
base.Hide(() =>
{
Time.timeScale = 1;
OnHideComplete?.Invoke();
});
}
}
UIView는 모든 View 요소의 공통 뼈대로 작용하고, CanvasGroup을 이용한 Fade 애니메이션을 기본 기능으로 제공한다.
UIView를 상속받은 View 중 하나인 StagePauseView는 부모늬 Show() 기능을 재사용(base.Show()) 하면서 Time.timeScale을 제어하는 자신만의 로직을 override해 덧붙였다.
3. 복잡한 참조 관계 정리 : 중재자/옵저버 패턴
View간의 직접 참조는 결합도(Coupling)을 높이게 된다. 따라서 중재자(Mediator) 패턴을 도입해 StageUIManager라는 중앙 관제 클래스를 만들었다.
| 응집도(Cohesion) | - 한 클래스 나 모듈이 단 하나만의 명확한 책임, 기능에 얼마나 집중하는지에 대한 척도 - 높은 응집도는 클래스가 하나의 역할만 수행해 코드가 명확하고 이해하기 쉬운 상태를 의미 |
| 결합도(Coupling) | - 한 클래스나 모듈이 다른 모듈의 내부 구현에 얼마나 의존하고 얽혀있는지에 대한 척도 - 낮은 결합도는 모듈 간의 의존성이 낮아 한 모듈의 변경이 다른 모듈에 미치는 영향이 적은 이상적인 상태를 의미 |
| 의존성(Dependency) | - 한 모듈이 다른 모듈의 기능이나 코드를 사용하는 관계나 그 자체 |
| 결론 | - 각 모듈의 응집도를 높이고 모듈 간의 결합도는 낮추는 것이 좋음 |
중재자(LobbyUIManager, StageUIManager) : 모든 View간의 상호작용은 Lobby 씬에서는 LobbyUIManager, 각 스테이지 씬에서는 StageUIManager를 통해서만 이루어진다. 스테이지에서 PauseView를 표시해야 할 때 PauseView가 직접 HUDView를 숨기는 대신 UIManager가 책임을 대신 맡아 흐름을 정리한다. 따라서 각 View들은 다른 View의 존재를 알 필요가 없어져 적절히 분리된다.
옵저버(Observer) : 각각의 View는 자신의 상태 변화(버튼 클릭 등)를 이벤트(event Action)로만 외부에 알린다. 중재자 역할을 하는 UIManager는 이 이벤트를 구독하고 있다가 이벤트 발생 시 적절한 제어 로직을 수행한다. 이로 이벤트 발생 클래스와 처리 클래스를 분리해 이벤트 발생 이후 어떤 일이 일어나는지 알 수 없게 하고 느슨한 결합을 유지하게 된다.
public class StageHUDView : UIView
{
[SerializeField] private Button btnPause;
public event Action OnPauseClicked;
public void InitStagePause()
{
btnPause.onClick.AddListener(() => OnPauseClicked?.Invoke());
}
// 이하 생략
}
public class StageUIManager : MonoBehaviour
{
[SerializeField] private StageHUDView hudView;
[SerializeField] private StagePauseView pauseView;
// ...
private void Awake()
{
// HUDView가 보내는 방송(OnPauseClicked)을 구독
hudView.OnPauseClicked += ShowPauseView;
}
private void ShowPauseView()
{
HideBackgroundViews(); // 다른 View들을 제어
pauseView.Show(); // PauseView를 제어
}
// 이하생략...
}
StageUIManager는 HUDView의 이벤트를 구독하고 있다가 이벤트 발생 시 배경이 되는 View들을 숨기고 일시정지 View를 표시하는 중재 역할을 수행한다.
4. 참조 정리 : 싱글톤, 씬 생명주기 관리
DontDetroyOnLoad를 사용하는 싱글톤 클래스인 GameManager와 로비->스테이지, 스테이지->로비 씬 전환이 발생할 때 생기는 참조 오류들이 있었다.
단일 인스턴스 보장 : 씬 전환 후 Awake에서 중복 인스턴스가 생성이 될 때 다른 객체에 영향을 주기 전 즉시 자기 자신을 파괴하고 return시켜 항상 씬에서 단일 인스턴스가 보장되도록 한다.
명확한 생명주기 : GameManager가 씬이 전환되기 전에 CleanupStageScene()을 호출해 모든 이벤트 구독을 해제하고 참조를 정리한다. 씬 단위로 존재하는 싱글톤인 WaveManager같은 경우는 OnDestroy 시점에 자신의 static Instance를 null로 정리하도록 해서 씬에 재진입할 경우 발생하던 참조 오류 문제를 해결했다.
public class GameManager : MonoBehaviour
{
public static GameManager Instance { get; private set; }
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return; // 즉시 실행을 중단해 인스턴스 복제 이후 새 인스턴스가 덮어씌워지지 않도록 코드를 막는다.
}
Instance = this;
DontDestroyOnLoad(gameObject);
// 이하 생략...
}
private void BackToLobby()
{
Time.timeScale = 1;
CleanupStageScene(); // 씬을 로드하기 전에 정리
SceneManager.LoadScene(0);
}
public void CleanupStageScene()
{
StopAllCoroutines();
// ... 모든 이벤트 구독 해지 및 참조 null 처리 ...
}
}
씬 전환 후 GameManager는 Instance가 이미 존재하는 것을 확인한 후 다른 로직을 실행하기 전에 return으로 즉시 자신을 파괴해 static Instance를 오염(?) 시키는 것을 방지한다.
또, 씬을 이동하기 직전 CleanupStageScene을 호출해 파괴될 오브젝트에 대한 모든 참조, 이벤트 구독을 안전하게 정리한다.
'개발 > Unity 3D' 카테고리의 다른 글
| [Unity] 타워 디펜스 스킬 시스템 구현(스킬 사용 및 쿨다운, 강화 UI 연동) (0) | 2025.09.02 |
|---|---|
| [Unity] 타워 디펜스 타워 구매 및 배치 시스템 (8) | 2025.08.11 |
| [Unity] MVC 관점에서의 책임 분리와 이벤트 기반 통신 흐름 (5) | 2025.07.28 |
| [Unity] Layer Collision Matrix - 투사체 충돌 통과 & 충돌 로직 (1) | 2025.07.21 |
| [Unity] 이동하는 몬스터의 체력바가 항상 카메라를 바라보도록 하기 (1) | 2025.07.17 |