| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
- Shimon Schocken
- unity6
- 메타버스
- 데이터 통신과 컴퓨터 네트워크
- 박기현
- JavaScript
- 밑바닥부터 만드는 컴퓨팅 시스템 2판
- hanbit.co.kr
- 이득우의 게임수학
- BOJ
- 이득우
- C
- 잡생각 정리글
- https://insightbook.co.kr/
- 김진홍 옮김
- 게임 수학
- 백준
- 주우석
- 전공자를 위한 C언어 프로그래밍
- 입출력과 사칙연산
- HANBIT Academy
- 알고리즘
- Noam Nisan
- C++
- The Elements of Computing Systems 2/E
- booksr.co.kr
- 생능출판
- (주)책만
- 일기
- C#
- Today
- Total
cyphen156
스컬 모작 프로젝트 @1 UI 개발과 리팩터링 고민_1 본문
UI 위젯 구조 리팩터링 기록: 컨테이너 기반 UI 아키텍처로의 전환 고민
최근 옵션 UI와 컨트롤 UI를 개발하면서, 가능한 한 설계 원칙을 해치지 않으면서 기능 확장성을 유지하는 방향을 계속 고민하고 있다.
그 과정에서 진짜 자주 드는 생각이 나는 항상 디자인패턴을 공부했으니 이걸 적용해보자! 라는 흐름으로 가지 않는다.
코드를 구성하고 시스템을 설계하다보니 이미 만들어둔 원칙을 해치지 않고 기능을 확장하는 방법에 대해 고민하고 그 해답을 시스템에 녹여내고 난 뒤, 웹 검색으로 찾아보다보면 자연스럽게 이런 구조를 사용하게 되었구나!가 느껴진다.
대표적으로 가장 내가 사랑한다고 볼수 잇는것이 컴포지트와 퍼사드, 그리고 중재자이고,
이번에 새로 도입하게 된게 프록시와 DI 컨테이너다.
그 과정에서 반복적으로 구조적 리팩터링을 수행하게 되는데, 이번 글은 그중 가장 핵심적인 이슈를 기록해두기 위한 목적이다.
오늘 발생한 리팩터링 이슈는 다음과 같다.
1. Options에 달린 Widget의 OnSubmit 함수 내부 구현이 특정 기능을 명시적으로, 고정 호출하고 있음
Options UI 내부의 Widget 클래스는 원래 어디에든 붙을 수 있는 범용 Node 컴포넌트로 생각하고 설계되었다.
그런데 다음 코드가 문제가 되었다.
버전 브랜치와 참조 코드는 다음과 같다.
branch : 설정창 UI 개발
Code : Common/Widget.cs
protected override void OnSubmit()
{
// 값을 전달하고 내부에서 참조가 있으니 조회할 수 있음
GameManager.instance.ApplyUserOptionSetting(this);
}
Widget.cs가 특정 옵션 변경 함수(ApplyUserOptionSetting)를 직접 호출하고 있다는 점은, 곧바로 다음 제약을 만들어낸다.
- Widget은 Options UI에만 사용되는 컴포넌트가 된다.
- Widget은 어느 UI에서도 독립적으로 재사용될 수 없다.
- 더 나아가 인게임 월드 오브젝트에 붙는 구조도 불가능해진다.
즉, 범용성을 기반으로 설계된 컴포넌트가 특정 UI 로직을 “내부에 박아넣은 상태”가 되어 버린 것이다.
원래 Widget이 가져야 했던 의도는 다음과 같다.
- UI 하위 노드일 수도 있고
- 인게임 월드 오브젝트일 수도 있으며
- 특정 동작은 외부에서 주입되거나 상위에 의해 추론되어야 하고
- 필요하다면 메인 UI로도 쓰일 수 있어야 한다
따라서 Widget 내부에서 특정 매니저를 직접 호출하는 구조는 처음 목표와 뚜렷이 충돌한다.
여기서 다시 한 번 내 원칙을 상기했다.
“컴포넌트가 상속을 요구하게 되는 설계 자체가 잘못된 것이다.”
“상속은 필요한 최소 범위에서만 허용해야 한다.”
Widget은 상속 트리의 한 분기점이 아니라, 다양한 문맥에서 활용될 수 있는 일종의 Node 데이터 구조에 가깝다.
그렇다면 더욱더 고정된 메소드 호출을 넣어서는 안 된다.
2. UI는 반드시 Dumb해야 한다
UI는 단순해야 한다.
Button, Slider, Widget, Panel 같은 UI 요소들은 입력을 받고 화면에 보여주는 역할에만 집중해야 한다.
다시 말해 UI는:
- “무엇을 눌렀는지”만 알아야 하고
- “왜 눌러야 하는지”, “무엇을 해야 하는지”는 모르는 것이 원칙이다.
그런데 Widget이 특정 기능을 직접 호출하면 다음 문제가 즉시 발생한다.
- UI가 Dumb 하지 않게 된다.
- 특정 UI 구조에 종속된다.
- 옵션·컨트롤·인벤토리·스킬·상점 등 UI 확장성이 즉시 무너진다.
- GameManager는 결국 모든 UI의 내부 기능을 알게 되고, 이는 “역방향 의존성”을 초래한다.
이 흐름은 UI–GameManager 경계를 지키고 싶어 하는 나의 전체 아키텍처와 어긋난다.
따라서 결론은 명확하다.
Widget은 단지
“Submit 이벤트가 발생했다”라는 사실만 상위로 통지(Notify)
해야 한다.이후 어떤 도메인 로직을 실행할지는
UIManager 또는 관리 UI → GameManager 방향으로 흘러야 한다.
Widget은 입력·포커스·슬라이더 드래그 등 UI 기반 입력 해석기 역할에 집중하고,
“무엇을 해야 하는지”는 절대로 내부에서 판단하지 않아야 한다.
3. Unity UI의 한계를 인정하고, 입력/출력을 분리하되 한 컴포넌트 안에 유지하기
모바일·2D UI 환경에서 입력과 출력이 완벽히 분리된 구조를 만드는 것은 이론적으로는 좋지만, 실제로는 오히려 관리 포인트가 늘어나고 코드 복잡도가 증가한다.
그래서 나는 다음과 같은 절충을 유지하고 있다.
- 입력과 출력은 논리적으로 분리하되
- 실제 구현은 하나의 컴포넌트(UIWidgetContainer)에서 처리
- 단, 컴포넌트 자체는 Dumb함을 유지
- 보여줄 데이터, 적용할 기능은 항상 외부에서 받는다
즉, UI 컴포넌트는 “무엇을 보여주고 눌렸는지”까지만 알고,
“그 결과로 무엇을 해야 하는지”는 절대로 모른다.
이 기준을 지키면 Widget은 언제든지 옵션 UI를 떠날 수 있고,
인게임 오브젝트에 붙어도, 다른 UI에 붙어도, 메인 UI가 되어도 문제가 없다.
4. UI Input Module을 사용하지 않는 이유
Unity의 이벤트 시스템과 UI Input Module을 활용하면, UI 프리팹에 스크립트 하나만 달아서 하위 버튼들의 OnSubmit 이벤트를 간편하게 연결할 수 있다.
하지만 현재 내가 구축한 구조는 Unity New Input System 기반이며, 모든 입력은 반드시
InputManager → UIManager → Widget
순서로 통제되도록 설계되어 있다.
Unity 이벤트 시스템을 그대로 사용하면 다음 문제가 발생할 수 있다.
- UI가 활성화되어 있고
- 화면에 떠 있고
- GraphicRaycaster가 켜져 있으며
- CanvasGroup이 blocksRaycasts = true 상태일 때
현재 포커스와 관계 없이, 해당 위치에 있는 버튼의 OnClick이 모두 실행될 수 있다.
즉, 실제 포커스는 다른 UI에 있어도,
화면에 겹쳐 떠 있는 버튼이 그대로 눌릴 수 있는 구조다.
이는 다음과 같은 상황과 유사하다.
한 UI를 조작 중인데, 그 뒤에 숨겨진(그러나 여전히 활성화된) 버튼이
마우스 클릭/터치 이벤트를 가로채서 실행되는 경우.
물론 Unity는 이를 제어하기 위한 CanvasGroup과 GraphicRaycaster 같은 장치를 제공한다.
나 역시 이 장치들을 활용해 레이캐스트를 차단하거나 Raycaster 우선순위를 관리할 수 있다는 점을 알고 있다.
그러나 문제는 이것이 “UI 계층이 복잡해질수록 관리 비용이 비약적으로 증가한다”는 데 있다.
레이캐스트 차단은 UI의 상태 관리에 추가적인 의존성을 만들고:
- 어떤 UI가 열리면 A를 비활성화해야 하고
- 어떤 UI가 닫히면 B의 레이캐스트를 다시 켜야 하며
- UI 겹침 구조가 깊어질수록 예외와 관리 항목이 계속 늘어난다
이러한 이유로 이벤트 시스템은 입력 포인트를 수집하기 위한 최소한의 용도로만 사용하고,
실제 실행 여부와 흐름 제어는 InputManager가 명확하게 담당하는 구조를 유지하고 있다.
5. Widget 클래스와 IWidget 구조에 대한 근본적 재고찰
위의 문제를 해결하려면 “Widget이 지나치게 많은 책임을 갖고 있다”는 사실을 인정하는 순간이 온다.
지금까지 나는 Widget을 UI 하위 오브젝트 묶음 + 입력 처리기 + 출력 렌더러를 모두 포함한 일종의 작은 UI 단위로 사용해왔다.
하지만 실제로 내부 코드를 점검해보니 Widget은 이미 다음과 같은 형태로 바뀌어 있었다.
- 버튼 그룹, 슬라이더 등 ‘UI 요소’를 들고 있는 단순 컨테이너
- 입력 해석(좌/우 이동, 포커스 등)은 InteractiveUIBehaviour가 이미 처리
- UI 표시/값 변경은 IWidget 구현체가 처리
즉, Widget 내부는 사실상 UI 오브젝트 컨테이너(Container) 역할을 하고 있으며
더 이상 “행위(behavior)”를 갖는 객체가 아니게 되어 있었다.
그렇다면 구조를 다시 정의해야 한다.
6. Widget은 더 이상 ‘위젯’이 아니다: 컨테이너(Container)로 재정의
물론 게임오브젝트의 이름은 그대로 위젯이다. 하지만 내부에 붙는 스크립트는 이제 컨테이너다
여기서 중요한 전환점이 생겼다.
위젯이라는 이름 때문에 “행위를 가져야 한다”는 선입견이 생기고 있었다.
하지만 실질적으로 위젯이 하는 일은 다음과 같다.
- 자신 안에 들어 있는 Button / Slider / Text 등 UI 요소를 보관
- IWidget 구현체(StepperWidget, SliderWidget…)를 보관
- 메타데이터(그룹, 이름)를 외부에서 주입받음
- 입력 이벤트가 들어오면 해석해서 하위 UI 요소에 전달
즉 “행동”은 하지 않는다.
결정도 하지 않는다.
임의 로직도 수행하지 않는다.
그렇다면 이름을 명확하게 재정리해야 한다.
- UIWidget → UIWidgetContainer (정확한 역할 반영)
- 내부의 실제 UI 동작 단위는 IWidget 구현체로 분리
이렇게 해야 Widget이 “무엇을 하는지”에 대한 오해가 사라지고
역할도 더 정확하게 나뉜다.
7. 컨테이너가 메타데이터를 가져야 하는 이유, 그리고 인터페이스 재설계
Widget은 외부 UI(Options, Control 등)가 다음과 같은 정보를 알아야 한다.
- 어떤 그룹에 속하는가? (Audio / Data / Graphic…)
- 어떤 옵션 이름인가?
- 어떤 종류의 위젯인가? (Stepper / Slider / OneShot…)
이건 Widget이 아니라 UIWidgetContainer가 알아야 할 정보다.
왜냐하면 Widget은 UI 출력 역할만 수행해야 하고
데이터 구조적 해석은 컨테이너가 담당해야 하기 때문이다.
그래서 컨테이너의 역할은 다음과 같다.
| UIWidgetContainer | 입력·포커스 처리, 메타데이터 보관, 하위 UI 요소 바인딩 |
| IWidget 구현체 | UI 표시·값 조정 등 순수 렌더링 동작 |
| UIManager | 위젯 선택, Proxy 운용, 이벤트 라우팅 |
| GameManager | 도메인 로직 실행 |
하지만 여기서 질문이 생긴다.
그럼 IWidgetContainer 같은 인터페이스를 도입해야 하나?
처음엔 아래처럼 만들려 했다.
public interface IWidgetContainer
{
Enum GroupKey { get; }
string ParentName { get; }
WidgetType WidgetType { get; }
IWidget Widget { get; }
}
하지만 문제는
- 이 인터페이스는 너무 구체적이다.
- Container라는 개념은 UI뿐만 아니라 다양한 도메인에 확장될 수 있다.
- 필드 계약을 강제하면 구조가 또 UI 특화로 고정된다.
그래서 최종 결론이 바뀌었다.
8. 최종 결정: IWidgetContainer가 아니라 IContainer(마커 인터페이스)를 사용한다
컨테이너라는 개념은 본질적으로 “내부 구조를 적재하는 객체”일 뿐이다.
- UI 위젯 컨테이너
- 인게임 월드 오브젝트 위젯
- 슬롯 컨테이너
- HUD 컴포넌트 컨테이너
이건 전부 개념적으로 “Container”다.
그래서 인터페이스를 이렇게 단순화했다.
public interface IContainer { }
이 마커 인터페이스는 강제 사항은 없지만
외부에서 “이 객체는 컨테이너 역할을 한다”고 식별하는 데 사용된다.
이는 Unity UI의 구조적 고민을 하면서 자연스럽게 도달한 결론이었다.
9. 내부 구성 재정립: UIWidgetContainer는 어댑티브 구조
정리해보면 현재 구조는 이렇게 나뉜다.
✔ UIWidgetContainer
- 하위 UI 오브젝트(Button / Slider / Text 등)를 수집·관리
- IWidget 구현체를 보관
- Execute를 통해 입력(Click / Navigate / Point 등)을 해석
- 메타데이터(groupKey, parentName)를 보관
- 실제 값을 변경하거나 렌더링하는 로직은 직접 하지 않는다
즉, UIWidgetContainer는 한 줄로 요약하면:
“위젯 단위 입력·포커스·메타데이터를 관리하며, IWidget 구현체로 연결해 주는 컨테이너 중재자”
✔ IWidget + StepperWidget / SliderWidget / OneShotWidget
- UI 업데이트/표시를 전담한다.
- UIWidgetContainer가 바인딩해 준 Button/Slider/TMP_Text를 사용해
화면에 값을 표현하고 갱신한다. - 입력 컨텍스트나 포커스 상태는 알지 못하고,
컨테이너에게서 이미 가공된 값(인덱스, delta, point 등)을 전달받아 처리한다.
한 줄로 정리하면:
“컨테이너가 바인딩한 하위 UI 오브젝트를 이용해 화면을 갱신하는 작은 UI 렌더링 객체”
이러한 역할 분리는:
- UI가 추가되거나
- 위젯 종류가 늘어나거나
- 같은 위젯 구조를 인게임 오브젝트에 붙이더라도
기존 구조를 깨지 않고 그대로 재사용 가능하게 만든다.
최종 정리 및 결론
이번 리팩터링 과정에서 얻은 가장 중요한 생각은 다음과 같다.
✔ Widget은 Dumb해야 하고
✔ 판단은 UIManager / GameManager가 해야 하며
✔ UIWidgetContainer는 단순 UI 컨테이너여야 한다
✔ 확장을 위해 IContainer라는 마커 인터페이스를 사용한다
✔ Widget 내부에서 도메인 로직을 호출하는 것은 절대 금지한다
✔ 입력 흐름은 InputManager → UIManager → Container → IWidget로 흘러야 한다
이 모든 과정은 디자인 패턴 책을 보고 의도적으로 만든 것이 아니라,
구조를 유지하고 확장성을 해치지 않기 위해 고민하다 보니 자연스럽게 필요해져서 쓰게되었다.
참고 파일
- InputManager.cs
- UIManager.cs
- Control / Menu / Options / UIProxy … 등 관련 스크립트 일체
'프로젝트 > Skul 모작' 카테고리의 다른 글
| ResourceManager 리팩토링 중간 정리 (0) | 2026.02.04 |
|---|---|
| 스컬 모작 프로젝트 @2 도메인 시스템 개발 과정중 GPT와의 설계 논의 (0) | 2025.11.21 |
| 스컬 모작 프로젝트 #999 Extra 게임 서버 구축에 대한 고민글 (0) | 2025.10.13 |
| 스컬 모작 프로젝트 #3 메뉴 UI 만들기 (0) | 2025.10.09 |
| 스컬 모작 프로젝트 #2 프로젝트 버전 마이그레이션 (0) | 2025.10.01 |
