상태 모델과 UI 계층의 분리 설계

목차

1. 요구 사항

2. 설계 목표

3. 흐름도

4. 구현

        4.1. NGUI 기반 UI 클래스 구조

       4.2. 초기화와 참조 확보

       4.3. Update를 통한 실시간 동기화

       4.4. 데이터 조회와 표현 분리

       4.5. 강화 그래프 표시

       4.6. 전체 강화 종합 게이지

5. 개발 의도

1. 시스템 요구 사항

플레이어 스탯 시스템은 이미 PlayerStatus가 계산 규칙을 담당하고, StatusManager가 런타임 상태를 관리하는 구조로 분리되어 있다.

그러나 플레이어는 내부 수치를 직접 보지 않는다.

플레이어가 체감하는 것은 화면에 표시되는 값이며, 따라서 계산된 스탯을 정확하고 일관되게 표현하는 UI 계층이 필요하다.

본 프로젝트는 Unity 기본 UGUI가 아니라 NGUI 기반 GUI 시스템으로 제작되었다.

NGUI는 자동 데이터 바인딩 구조를 제공하지 않기 때문에, 스탯 변경 시 UI는 수동으로 값을 갱신해야 한다.

이 기술적 전제를 고려하여 UI는 현재 스탯 수치(HP/BT/SP/ATK/GSP)를 즉시 표시해야 하고, 강화 포인트 투자 정도를 퍼센트와 슬라이더 그래프로 표현해야 한다.

또한, 다섯 스탯의 합산 진행도를 원형 게이지로 시각화해야 하며, 강화나 치트 기능 등으로 값이 변경되면 UI는 항상 최신 값을 반영해야 한다.

마지막으로, UI는 계산 규칙이나 상태를 직접 수정하지 않고, 읽기 전용 계층으로 동작해야 한다.

즉, StatusUi_는 게임 로직이 아니라 표현 전담 계층(View)이다.

2. 설계  목표

- NGUI 기반 환경에 맞춘 수동 갱신 구조 채택

- PlayerStatus / StatusManager와의 책임 분리 유지

- 스탯 값, 포인트, 그래프, 종합 게이지의 일관된 동기화

- 항상 최신 상태를 보장하는 안정적 갱신 방식 선택

- 단순성과 유지보수성을 우선한 구조 구성

3. 흐름도

[StatusUi_ (View)]

│ (매 프레임 조회)

[StatusManager (Runtime State Owner)]

[PlayerStatus (Rule / Calculation Model)]

[NGUI Widget] UILabel / UISprite

의존성은 항상 'StatusUi_ → StatusManager → PlayerStatus' 방향으로만 흐른다.

UI가 PlayerStatus를 직접 참조하지 않고 StatusManager를 경유하는 이유는, 런타임 상태 접근 경로를 단일화하기 위함이다.

스탯 계산 규칙은 PlayerStatus가 소유하지만, 현재 상태와 시스템 연결 지점은 StatusManager이기 때문에, UI는 상태 관리자만 참조한다.

이렇게 하면 상태 접근 경로가 일관되게 유지되고, 향후 상태 관리 방식이 변경되더라도 UI 수정 범위를 최소화할 수 있다.

4. 구현

4.1. NGUI 기반 UI 클래스 구조
public class StatusUi_ : MonoBehaviour
{
    StatusManager statusManager;

    private UILabel hpLabel;
    private UILabel btLabel;
    private UILabel spLabel;
    private UILabel atkLabel;
    private UILabel gspLabel;

    private UILabel hpPercent;
    private UILabel btPercent;
    private UILabel spPercent;
    private UILabel atkPercent;
    private UILabel gspPercent;

    private UISprite hpBar;
    private UISprite btBar;
    private UISprite spBar;
    private UISprite atkBar;
    private UISprite gspBar;

    private UISprite circle;
    private UILabel upgradeLevel;
}

이 클래스는 UnityEngine.UI가 아니라 NGUI 위젯을 사용한다는 점이 핵심이다.

UILabel은 텍스트 표시용 위젯이며, UISprite는 fillAmount를 통해 원형 게이지 표현이 가능하다.

NGUI는 자동 바인딩 시스템이 없기 때문에, 값 변경 시 UI를 직접 갱신해야 한다. 이 프로젝트는 그 특성을 그대로 반영한 구조다.

4.2. 초기화와 참조 확보
private void Awake()
{
    Initialize();
}

void Initialize()
{
    statusManager = StatusManager.instance;

    hpLabel = GameObject.Find("NowHpLabel").GetComponent<UILabel>();
    ...
    
    hpPercent = GameObject.Find("HpPercentage").GetComponent<UILabel>();
    ...
    
    hpBar = GameObject.Find("HPFilled").GetComponent<UISprite>();
    ...
    
    circle = GameObject.Find("CircleValue").GetComponent<UISprite>();
    upgradeLevel = GameObject.Find("UpgradeLabel").GetComponent<UILabel>();
}

Awake에서 Initialize를 호출하는 이유는, UI 참조를 가능한 빨리 확보하기 위함이다.

이후 Update에서 반복적으로 사용할 레퍼런스를 캐싱해두는 단계다.

UI 참조는 이후 모든 갱신 함수에서 사용되므로, 이 시점에서 바인딩을 완료해두는 것이 안전하다.

GameObject.Find는 문자열 기반 탐색 API다.

이름 변경에 취약하다는 단점이 있지만, 현재 프로젝트 규모에서는 빠른 구성과 명확성을 우선했다.

중요한 점은 이 탐색을 Awake에서 단 한 번만 수행하고, 이후에는 캐싱된 레퍼런스를 사용한다는 것이다.

4.3. Update를 통한 실시간 동기화
void Update()
{
    VariableSetting();
    TextUpdate();
    PercentageUpdate();
    StatLevelGraph();
    CircleValueUpdate();
}

이 UI는 이벤트 기반 갱신이 아니라 Polling 방식(Update)을 선택했다.

스탯 포인트는 강화 UI, 치트 기능, 기타 시스템에서 언제든지 변경될 수 있다.

모든 변경 지점에서 UI 갱신 이벤트를 연결하면 의존성이 복잡해질 수 있다.

대신 UI는 매 프레임 최신 값을 읽도록 설계했다.

Update를 사용한 이유는 UI가 렌더 프레임과 동기화되어야 하기 때문이다.

FixedUpdate는 물리 틱 기반 호출이므로 UI 갱신에는 적합하지 않다.

UI는 물리 연산이 아니라 화면 표현이기 때문에 Update가 더 자연스럽다.

장점은 동기화 누락이 절대 발생하지 않는다는 점이다.

단점은 값이 변하지 않아도 매 프레임 갱신 코드가 실행된다는 점이다.

하지만 여기서 수행되는 연산은 단순 문자열 설정과 슬라이더 값 할당뿐이므로 성능 부담은 매우 낮다.

현재 프로젝트 규모에서는 안정성을 우선한 선택이다.

4.4. 데이터 조회와 표현 분리
float Hp,..., Gsp;
void VariableSetting()
{
    Hp = statusManager.PlayerStatus.HP;
    ...    
    // Bt, Sp, Atk, Gsp, HpPoint, BtPoint, SpPoint, AtkPoint, GspPoint도 동일 구조
}

VariableSetting은 UI가 사용할 데이터를 한 번에 모아오는 단계다.

여기서 중요한 점은 UI가 단순 저장값을 읽는 것이 아니라, PlayerStatus의 계산형 프로퍼티를 직접 호출한다는 점이다.

HP, BT, SP, ATK, GSP는 모두 초기값과 포인트 증가량을 기반으로 계산된 결과이며, UI는 그 계산 결과만을 사용한다.

이 구조의 장점은 명확하다.

강화 포인트가 변경되면 다음 프레임에서 VariableSetting이 다시 호출되고, 계산형 프로퍼티가 즉시 최신 값을 반환한다.

별도의 스탯 재계산 호출이나 캐시 무효화 처리가 필요하지 않다.

조회 시점이 곧 최신성 보장 지점이 된다.

또한 HpPoint 등 포인트 값도 함께 읽어 오는데, 이는 이후 퍼센트 계산과 슬라이더 정규화, 원형 게이지 계산에 사용된다.

여기서 포인트를 읽는 행위는 규칙을 침범하는 것이 아니다.

계산은 PlayerStatus가 담당하고, UI는 단지 표현을 위해 필요한 입력 값을 받아오는 역할을 수행한다.

void TextUpdate()
{
    hpLabel.text = "HP : " + Hp.ToString();
    ...
    // btLabel, spLabel, atkLabel, gspLabel도 동일 구조
}

TextUpdate는 데이터 계층에서 가져온 수치를 플레이어가 읽을 수 있는 문자열로 변환하는 단계다.

ToString은 C#의 기본 형 변환 메서드이며, UI 텍스트 출력은 문자열 기반이므로 필수적인 과정이다.

이 함수의 의미는 단순히 글자를 바꾸는 것이 아니다.

숫자 데이터를 게임 시스템 내부 값에서 사용자 인터페이스 표현으로 변환하는 책임을 수행한다.

또한 모든 텍스트 출력 형식이 이 함수에 집중되어 있기 때문에, 향후 소수점 자리 제한, 천 단위 콤마 처리, 로컬라이징 대응 같은 요구가 발생하더라도 수정 지점이 명확하다.

UI는 계산하지 않는다.

대신 계산된 값을 사람이 이해하기 쉬운 형태로 가공한다.

TextUpdate는 그 역할을 담당하는 표현 전용 함수다.

int HpPoint, ..., GspPoint;
void PercentageUpdate()
{
    hpPercent.text = (HpPoint * 20).ToString() + "%";
    ...
    // btPercent, spPercent, atkPercent, gspPercent도 동일 구조
}

PercentageUpdate는 강화 포인트를 시각적으로 직관적인 퍼센트 값으로 변환한다.

현재 설계에서 maxPoint는 5이므로, 1포인트는 20%에 해당한다.

따라서 포인트 값에 20을 곱해 퍼센트로 표현한다.

이 계산은 전투 밸런스와 무관하다.

능력치 공식은 PlayerStatus가 소유한다.

여기서 수행하는 연산은 표시 형식 변환이다.

즉, 강화 정도를 사용자에게 직관적으로 전달하기 위한 UI 표현 로직이다.

만약 maxPoint가 변경된다면 하드코딩된 20 대신 PlayerStatus.MAXPOINT를 기준으로 환산하도록 개선할 수 있다.

현재 구현은 설계 가정을 전제로 단순성과 가독성을 우선한 선택이다.

4.5. 강화 그래프 표시
void StatLevelGraph()
{
    hpBar.fillAmount = (float)HpPoint / 5.0f;
    ...
    // btBar, spBar, atkBar, gspBar 모두 동일 구조
}

StatLevelGraph 함수는 현재 스탯 포인트 값을 UI 게이지에 반영하는 역할을 한다.

NGUI에서 UISprite.fillAmount는 스프라이트가 채워지는 비율을 0~1 범위의 값으로 제어하는 속성으로, 원형 게이지나 진행 바와 같은 UI를 구현할 때 사용된다.

fillAmount 값이 0이면 게이지가 비어 있고, 1이면 완전히 채워진 상태가 된다.

현재 스탯 포인트는 정수 값으로 관리되기 때문에, 게이지에 반영하기 위해 '현재 포인트 / 최대 포인트' 형태로 값을 정규화하여 사용하였다.

예를 들어 HP 포인트가 3이고 최대 포인트가 5라면 (float)HpPoint / 5.0f 계산을 통해 0.6이 되고, 이는 게이지가 60% 채워진 상태로 표현된다.

여기서 (float) 캐스팅을 사용하는 이유는 C#에서 정수끼리 나눗셈을 수행하면 소수점이 버려지는 정수 나눗셈이 되기 때문이다.

만약 HpPoint / 5 형태로 계산하면 결과가 0 또는 1과 같은 값만 나오게 되어 게이지가 정상적으로 표현되지 않는다.

따라서 실수 나눗셈을 수행하도록 명시적으로 float 형변환을 적용하였다.

이 구현에서는 현재 최대 스탯 포인트가 5라는 설계를 전제로 하고 있기 때문에 5.0f 값이 코드에 직접 사용되어 있다.

향후 시스템이 확장되어 최대 포인트가 변경될 가능성이 있다면, 하드코딩된 값 대신 PlayerStatus.MAXPOINT와 같은 상수를 사용하도록 개선할 수 있다.

또한 이 UI는 사용자 입력을 받는 인터랙티브 요소가 아니라 단순히 스탯 진행도를 시각적으로 표시하기 위한 목적이기 때문에 UISlider 대신 UISprite.fillAmount를 직접 제어하는 방식을 선택하였다.

UISlider는 입력 처리와 이벤트 시스템을 포함하는 UI 컴포넌트이지만, 현재 시스템에서는 이러한 기능이 필요하지 않기 때문에 더 단순한 구조의 게이지 방식이 적합하다고 생각했다.

4.6. 전체 강화 종합 게이지
void CircleValueUpdate()
{
    circle.fillAmount = (float)(HpPoint + BtPoint + SpPoint + AtkPoint + GspPoint)/ 25.0f;
    upgradeLevel.text = (circle.fillAmount * 100).ToString();
}

CircleValueUpdate는 다섯 스탯의 포인트 합을 전체 강화 진행도로 요약한다.

최대 포인트가 스탯당 5이고 스탯이 5개이므로 총합 최대는 25가 된다.

이를 0~1로 정규화해 UISprite.fillAmount에 넣으면 원형 게이지가 진행도를 표현한다.

이 구조가 의미 있는 이유는 플레이어가 각각의 스탯 슬라이더를 보지 않아도 전체적으로 얼마나 성장했는가를 한 눈에 파악할 수 있기 때문이다.

또한 upgradeLevel은 동일한 fillAmount를 0~100 값으로 환산해 숫자 레벨처럼 보여주는데, 이는 시각적 게이지와 정량 수치를 동시에 제공해 UI 정보 전달력을 높인다.

이 종합 수치는 전투 밸런스를 결정하는 값이 아니라 UI가 제공하는 메타 지표이므로, PlayerStatus로 옮기기보다는 UI에서 계산하는 것이 책임 분리 관점에서 더 자연스럽다.

5. 개발 의도

이 게시글에서 핵심적으로 보여주고 싶은 것은, NGUI 기반 프로젝트에서도 계산 계층과 표현 계층을 명확히 분리할 수 있다는 점이다.

StatusUi_클래스는 스탯을 계산하거나 상태를 변경하지 않는다.

오직 읽고, 표현을 담당한다.

Update 기반 동기화는 구조 단순성과 안정성을 우선한 선택이며, 규모 확장 시 이벤트 기반 구조로 전환 가능하다.

현재 설계는 항상 최신 상태를 보장하는 표현 계층이라는 목표에 최적화되어 있다.

결과적으로 이 UI 시스템은 규칙(PlayerStatus), 상태(StatusManager), 표현(StatusUi_)가 명확히 분리된 3계층 구조 위에서 동작하며, NGUI 기반 프로젝트라는 기술적 전제 안에서도 일관된 설계 철학을 유지하고 있다.