공용 스크립트 제작기🎈 - UI 시스템 편

Unity 프로젝트의 공용으로 사용할 수 있는 스크립트를 제작하면서 어떻게 만들었는지, 어떤 점에 신경썼는지 설명하는 글입니다.
Unity에서 UI를 관리하다 보면 늘 느끼는 점이 있는데… 매번 UI를 생성하고 파괴하는 과정이 복잡하고, 레이어 관리가 뒤죽박죽이 된다는 점이다. 따라서 이를 중앙에서 관리할 수 있는 UI 시스템을 만들기로 했다.
레이어 기반으로 UI를 관리하고, 오브젝트 풀링, 애니메이션까지 통합된 UI 시스템을 원했다. 너무 초반부터 방대한가 싶긴 한데 나중에 결국 추가하게 될거라서 초반부터 빡세게 잡기도 했다.
UIManager가 모든 UI의 생명주기를 관리하고, 레이어별로 깔끔하게 정리되며, 풀링을 통해 성능을 최적화하고, 애니메이션까지 지원하는 시스템. 와 꿈의 시스템이다~ (참고로 애니메이션은 고민하다가 DoTween을 사용하기로 했다.)
💡예시
게임에 설정 UI가 필요하다고 해보자. 그럴 때 이렇게 먼저 UI 클래스를 만든다.
[PoolAddress("UI/SettingsUI")] // Addressable Asset 주소
public class SettingsUI : UIBase
{
public override UILayer Layer => UILayer.PopUp; // 레이어 어디로 할 지 결정
// 페이드 인 애니메이션
public override UIAnimation ShowAnimation =>
new UIFadeAnimation(0f, 1f, 0.3f);
// 페이드 아웃 애니메이션
public override UIAnimation HideAnimation =>
new UIFadeAnimation(1f, 0f, 0.3f);
public override async UniTask OnShowAsync(CancellationToken ct)
{
// UI 표시 시 초기화 로직
Debug.Log("설정 UI 표시");
}
public override async UniTask OnHideAsync(CancellationToken ct)
{
// UI 숨김 시 정리 로직
Debug.Log("설정 UI 숨김");
}
}
이후 사용할 때는 다음과 같이 간단하게 사용한다.
// UI 표시 (Dim 효과와 함께)
var settingsUI = await UIManager.Instance
.ShowAsync<SettingsUI>(UILayer.PopUp, useDim: true, ct);
// UI 숨김
UIManager.Instance.Hide<SettingsUI>();
타입 안전한 데이터 전달도 가능하다.
public class CharacterData
{
public string Name;
public int Level;
}
public class CharacterUI : UIBase<CharacterData>
{
public override UILayer Layer => UILayer.Overlay;
public override void OnInitialize(CharacterData data)
{
// 타입 안전한 데이터 초기화
Debug.Log($"캐릭터: {data.Name}, 레벨: {data.Level}");
}
}
// 사용은 이렇게
var data = new CharacterData { Name = "전사", Level = 50 };
await UIManager.Instance
.ShowAsync<CharacterUI, CharacterData>(UILayer.Overlay, data, ct);
📊 클래스 다이어그램
UIManager (싱글톤)
├─ UICanvas: 레이어별 관리
├─ UIResolutionHandler: 해상도 변경 감지
└─ UIStack: 뒤로가기 기능
IPoolable (인터페이스)
↑ implements
UIBase / UIBase<TData>(추상 클래스)
└─ 생명주기 메서드 (OnShowAsync, OnHideAsync)
Effect (이펙트)
├─ UIAnimation: 페이드, 슬라이드, 스케일, 시퀀스
└─ UIDimController: 레이어별 Dim 효과
Utilities (유틸리티)
├─ UIInputBlocker (싱글톤) : 입력 차단 (중첩 지원)
├─ UICoordinateConverter (static): 좌표 변환
└─ UISafeAreaHandler (컴포넌트): Safe Area 대응
UI 레이어는 이렇게 두기로 했다
UILayer (Enum)
├─ Background (0)
├─ HUD (1)
├─ Overlay (2)
├─ PopUp (3)
├─ System (4)
└─ Transition (5)
📁 시스템 아키텍처
핵심 컴포넌트
UIManager (싱글톤 매니저)
- 역할: 모든 UI의 생명주기를 중앙에서 관리
- 주요 기능: 사용자에게 제공할 public 함수들
- UI 생성/표시/숨김
- PoolManager와 통합 (UI 재사용 관련)
- 레이어별 Canvas 관리
- 뒤로가기 스택 관리
- 씬 전환 시 자동 정리
- 초기화 방법:
// 게임 시작 시 명시적 초기화 await UIManager.CreateAsync(cancellationToken);
UIBase / UIBase(제네릭 TData)
- 역할: 모든 UI의 베이스 클래스
- 주요 기능:
- IPoolable 구현 (풀링 지원)
- 생명주기 메서드 (OnShowAsync, OnHideAsync)
- 애니메이션 지원 (ShowAnimation, HideAnimation)
- 타입 안전한 데이터 초기화 (제네릭 버전)
- CancellationToken으로 생명주기 관리
- 생명주기:
- OnInitialize (최초 1회)
- OnShowAsync (표시 시마다)
- ShowAnimation
- OnHideAsync (숨김 시)
- HideAnimation
UICanvas
- 역할: MainCanvas 기반 레이어별 GameObject 관리
- 구조:
- MainCanvas (Canvas 컴포넌트)
- Background (RectTransform): 배경 UI
- HUD (RectTransform): 게임플레이 중 항상 표시되는 정보
- Overlay (RectTransform): 메인 메뉴, 인벤토리 등
- PopUp (RectTransform): 팝업 창 (뒤로가기 스택 지원)
- System (RectTransform): 토스트 메시지, 알림
- Transition (RectTransform): 로딩 화면, 씬 전환
- MainCanvas (Canvas 컴포넌트)
- 특징:
- 모든 레이어가 하나의 Canvas를 공유 (Canvas 중복X)
- 레이어별 GameObject로 깊이 관리
- Addressable로 MainCanvas 로드
UIStack
- 역할: 뒤로가기 기능 (PopUp 레이어)
- 주요 기능:
- List 기반 Stack (Remove 연산 효율적)
- Push/Pop/Peek
- 중복 방지
- ESC 키 처리
UIDimController
- 역할: 배경 어둡게 하기
- 주요 기능:
- 레이어별 독립 Dim 관리
- UI Stack 지원 (중첩 팝업)
- 페이드 애니메이션
- Dim GameObject 동적 생성
- 동작 방식:
- ShowDimAsync: 검은색 Image를 UI 뒤에 생성
- HideDimAsync: 페이드 아웃 후 제거
- UI Stack 추적하여 중첩 팝업 지원
UIAnimation (추상 클래스)
DOTween 기반 애니메이션 시스템.
- UIFadeAnimation: 페이드 인/아웃 (CanvasGroup 알파)
- UISlideAnimation: 슬라이드 (Up, Down, Left, Right)
- UIScaleAnimation: 스케일 애니메이션
- UISequenceAnimation: 여러 애니메이션 순차 재생
- 모든 애니메이션은 CancellationToken 지원
UIInputBlocker (싱글톤)
- 역할: UI 전환 중 입력 차단
- 주요 기능:
- 중첩 차단 지원 (Block/Unblock 카운트)
- 전체 화면을 덮는 투명한 패널
- 최상위 레이어 (Transition)에 배치
- LazyMonoSingleton으로 자동 생성
🔄 데이터 흐름
초기화 단계
- 게임 시작 시 UIManager.CreateAsync() 호출
↓ - MainCanvas를 Addressable에서 로드
- UICanvas 초기화 (레이어별 RectTransform 매핑)
- UIDimController, UIStack 생성
- UIInputBlocker 최상위 Canvas 설정
- 씬 전환 이벤트 등록
↓ - [완료] UIManager 사용 가능
UI 표시 프로세스
- UIManager.ShowAsync() 호출 ↓
- [UIInputBlocker] 입력 차단 (다 만들때까지 입력차단)
- [PoolManager] UI 인스턴스 획득 (없으면 Addressable 로드)
- [UICanvas] 해당 레이어의 Transform으로 이동
- [activeUIs] Dictionary에 등록
↓ - [UIDimController] Dim 표시 (useDim=true인 경우)
- 검은색 Image를 UI 뒤에 생성
- 페이드 인 애니메이션 (0 → 0.7)
↓
- [UIBase] ShowInternalAsync() 호출
- OnInitialize() (최초 1회만)
- SetActive(true)
- OnShowAsync() (커스텀 로직 실행)
- ShowAnimation 재생 (페이드/슬라이드/스케일 등)
↓
- [UIStack] PopUp 레이어면 Stack에 추가
- [UIInputBlocker] 입력 차단 해제
↓ - [완료] UI 인스턴스 반환
UI 숨김 프로세스
- UIManager.Hide() 호출
↓ - [UIInputBlocker] 입력 차단 (다 사라질때까지 입력차단)
- [UIStack] Stack에서 제거
- [UIBase] HideInternalAsync() 호출
- OnHideAsync() (정리 로직)
- HideAnimation 재생 (즉시 숨김이 아닌 경우)
- SetActive(false)
↓
- [activeUIs] Dictionary에서 제거
- [PoolManager] 풀로 반환 (재사용 대기)
- [UIDimController] Dim 숨김
- UI Stack 확인
- 다른 UI가 있으면 Dim 이동
- 없으면 페이드 아웃 후 제거
↓
- [UIInputBlocker] 입력 차단 해제
- [완료] UI 숨김 완료
🎯 신경 쓴 부분
레이어 시스템
6개의 레이어로 UI 깊이를 명확하게 관리
- Enum으로 타입 안전
- 숫자가 클수록 상위 레이어
- 하나의 Canvas에 레이어별 GameObject
public enum UILayer { Background = 0, // 배경 HUD = 1, // 게임플레이 정보 Overlay = 2, // 일반 UI PopUp = 3, // 팝업 System = 4, // 알림 Transition = 5 // 씬 전환 }
UI Stack (뒤로가기 기능)
PopUp 레이어에서 ESC 키로 뒤로가기를 지원
- List 기반 Stack (중간 제거 효율적)
- Pop() 시 최상단 UI 반환
- UIManager.HandleBackKey()로 간단하게 사용
public void HandleBackKey() { UIBase ui = uiStack.Pop(); if (ui != null) { HideUIAsync(ui, false, CancellationToken.None).Forget(); } }
Dim 효과 (중첩 팝업 지원)
UIDimController가 UI Stack을 추적하여 중첩된 팝업에서도 올바르게 동작
// UI Stack 관리
private readonly Dictionary<UILayer, List<UIBase>> dimUIStacks;
// UI 닫을 때 스택 확인
if (stack.Count > 0)
{
// 이전 UI 아래로 Dim 이동
UIBase prevUI = stack[stack.Count - 1];
dimTransform.SetSiblingIndex(prevUI.transform.GetSiblingIndex() - 1);
}
else
{
// 스택이 비었으면 Dim 숨김
await HideDimCompletelyAsync(layer, ct);
}
입력 차단 (중첩 차단 지원)
UIInputBlocker가 중첩 차단을 지원하여 여러 UI가 동시에 전환되어도 안전
private int blockCount = 0;
public void Block()
{
blockCount++;
if (blockCount == 1)
{
CreateBlocker(); // 최초 1회만 생성
}
}
public void Unblock()
{
if (blockCount > 0)
{
blockCount--;
}
if (blockCount == 0)
{
DestroyBlocker(); // 모든 차단 해제 시 제거
}
}
타입 안전한 제네릭 API
UIBase 제네릭으로 타입 안전한 데이터 전달을 지원
// 제네릭 버전
public abstract class UIBase<TData> : UIBase where TData : class
{
public virtual void OnInitialize(TData data) { }
// object 버전을 sealed로 막고 제네릭 버전으로 변환
public sealed override void OnInitialize(object data)
{
if (data is TData typedData)
{
OnInitialize(typedData);
}
}
}
결론
구현할 부분이 커서 애먹었다. 이정도면 쓸만하겠지!? 복잡해진 것 같긴하지만 필요했다고 생각한다.