상황에 따라 여러 UI를 변경해야 할 때, 어떻게 설계해야 할까?
게임 클라이언트를 개발하다 보면 특정 상황에서 여러 UI의 상태를 한꺼번에 변경해야 할 때가 있다.
예를 들어 A 서버로 이동한 뒤 B 상황이 시작되었다고 가정해 보자.
B 상황에서는 다음과 같은 변경이 필요할 수 있다.
- 인벤토리의 특정 아이템 슬롯 비활성화
- 미니맵의 특정 버튼 숨김
- 채팅 입력창 비활성화
- 특정 체크박스의 체크 상태 변경
- 프로그레스바 표시
- 일부 아이콘 이미지 변경
이때 B 상황을 관리하는 클래스가 UI를 어떤 방식으로 변경해야 할까?
처음 생각해 볼 수 있는 방법은 크게 세 가지다.
- 각 Dialog에 B 상황 전용 함수를 만든다.
- B 상황 클래스가 각 Dialog와 Control에 직접 접근한다.
- 변경할 Control 정보를 목록으로 만들어 공통 처리한다.
이번 글에서는 각 방법의 장단점을 살펴보고, 현재 사용 중인 DialogUI + Control ID 구조에 적합한 설계를 정리해 본다.
1. 현재 UI 구조
프로젝트의 UI는 Dialog 단위로 구성되어 있다.
인벤토리를 예로 들면 인벤토리 전체가 하나의 Dialog이며, 내부에는 다음과 같은 Control들이 존재한다.
- 버튼
- 아이콘
- 체크박스
- 프로그레스바
- 에디트 박스
- 아이템 슬롯
모든 Dialog는 공통 기반 클래스인 DialogUI를 상속받는다.
class DialogUI
{
public:
UIControl* GetControl(int controlID);
protected:
std::map<int, UIControl*> m_Controls;
};
인벤토리, 미니맵, 채팅창은 각각 독립적인 Dialog다.
class InventoryDialog : public DialogUI
{
};
class MiniMapDialog : public DialogUI
{
};
class ChatDialog : public DialogUI
{
};
각 Dialog는 자신이 가진 Control들을 Control ID로 관리한다.
UIControl* control = inventoryDialog->GetControl(IDC_ITEM_SLOT_3);
이러한 구조에서 B 상황이 시작되면 여러 Dialog의 Control을 동시에 변경해야 한다.
2. 여러가지 방법들.
방법 2-1. 각 Dialog에 B 상황 전용 함수 만들기
가장 먼저 생각할 수 있는 방법은 각 Dialog에 B 상황 전용 함수를 만드는 것이다.
class InventoryDialog : public DialogUI
{
public:
void ApplySituationB();
};
class MiniMapDialog : public DialogUI
{
public:
void ApplySituationB();
};
class ChatDialog : public DialogUI
{
public:
void ApplySituationB();
};
B 상황 클래스에서는 각 Dialog의 함수를 호출한다.
void SituationB::OnEnter()
{
inventoryDialog->ApplySituationB();
miniMapDialog->ApplySituationB();
chatDialog->ApplySituationB();
}
구조가 단순하고 각 Dialog가 자신의 Control을 직접 변경하므로 구현하기도 쉽다.
하지만 상황이 계속 추가되면 중복되는 상황 설정도 늘어 날것이다.
void ApplySituationB();
void ApplySituationC();
void ApplyPVP();
void ApplyTournament();
void ApplyEventPVP();
각 Dialog가 게임에 존재하는 모든 상황을 알아야 한다.
인벤토리 Dialog는 원래 인벤토리의 표시와 동작을 책임져야 하지만, 이 구조에서는 서버 상태, 전투 상태, 토너먼트 상태와 같은 외부 상황까지 알게 된다.
상황이 늘어날수록 Dialog 내부에 상황별 함수와 조건문이 계속 추가된다.
따라서 상황 전용 함수는 처음에는 편하지만, 상황이 많아지면 Dialog와 게임 상황 사이의 결합도가 높아진다는 단점이 있다.
방법 2-2. B 상황 클래스가 Control을 직접 변경하기
두 번째 방법은 B 상황 클래스가 글로벌 Dialog 관리자에 접근하여 Control을 직접 변경하는 것이다.
void SituationB::OnEnter()
{
DialogUI* inventory =
DialogManager::GetDialog(EDialogID::Inventory);
UIControl* slot =
inventory->GetControl(IDC_ITEM_SLOT_3);
slot->SetVisible(false);
}
이 방법은 변경 사항을 B 상황 클래스 한 곳에서 확인할 수 있다는 장점이 있다.
하지만 B 상황 클래스가 다음 내용을 모두 알아야 한다.
- 어떤 Dialog가 존재하는가?
- 각 Dialog의 Control ID는 무엇인가?
- Control의 실제 타입은 무엇인가?
- 어떤 순서로 상태를 변경해야 하는가?
- 상황 종료 시 무엇을 복구해야 하는가?
결국 B 상황 클래스가 UI 내부 구조에 지나치게 의존하게 된다.
예를 들어 인벤토리의 Control ID가 변경되거나 UI 구조가 개편되면, 인벤토리 코드뿐만 아니라 B 상황 클래스도 수정해야 한다.
void SituationB::OnEnter()
{
InventoryDialog* inventory =
DialogManager::GetInventoryDialog();
MiniMapDialog* miniMap =
DialogManager::GetMiniMapDialog();
ChatDialog* chat =
DialogManager::GetChatDialog();
inventory->GetControl(IDC_ITEM_SLOT_3)->SetVisible(false);
miniMap->GetControl(IDC_TELEPORT_BUTTON)->SetEnabled(false);
chat->GetControl(IDC_CHAT_EDIT)->SetVisible(false);
}
상황이 복잡해질수록 B 상황 클래스는 여러 Dialog의 내부 구현을 직접 조작하는 거대한 함수가 될 가능성이 높다.
따라서 상황 클래스가 개별 Control에 직접 접근하는 방식은 가능하면 피하는 것이 좋다.
방법 2-3. Control 변경 명령을 목록으로 관리하기
세 번째 방법은 변경할 Dialog, Control, 속성, 값을 하나의 명령으로 표현하는 것이다.
예를 들면 다음과 같다.
* 인벤토리 Dialog의 3번 슬롯을 숨긴다.
이를 문자열로 표현할 수도 있다.
"Inventory", "Slot", "3", "Visible", "false"
하지만 문자열 기반 명령에는 문제가 있다.
"Visble"
"Inventoryy"
"Flase"
오타가 있어도 컴파일러가 잡아주지 못한다. 실행 중에 명령 해석에 실패해야 문제를 발견할 수 있다.
따라서 문자열 대신 enum과 구조체를 사용한다.
enum class EDialogID
{
Inventory,
MiniMap,
Chat
};
enum class EControlProperty
{
Visible,
Enabled,
Checked
};
하나의 Control 변경 명령을 구조체로 표현한다.
struct FUIControlCommand
{
EDialogID DialogID;
int ControlID;
EControlProperty Property;
bool Value;
};
B 상황에서 적용할 명령 목록을 만든다.
const std::vector<FUIControlCommand> SituationBCommands =
{
{
EDialogID::Inventory,
IDC_ITEM_SLOT_3,
EControlProperty::Visible,
false
},
{
EDialogID::MiniMap,
IDC_TELEPORT_BUTTON,
EControlProperty::Enabled,
false
},
{
EDialogID::Chat,
IDC_CHAT_EDIT,
EControlProperty::Visible,
false
}
};
명령을 실제 UI에 적용하는 역할은 별도의 SituationUIController가 담당한다.
class SituationUIController
{
public:
void ApplyCommands(
const std::vector<FUIControlCommand>& commands);
};
void SituationUIController::ApplyCommands(
const std::vector<FUIControlCommand>& commands)
{
for (const FUIControlCommand& command : commands)
{
DialogUI* dialog =
DialogManager::GetDialog(command.DialogID);
if (dialog == nullptr)
continue;
UIControl* control =
dialog->GetControl(command.ControlID);
if (control == nullptr)
continue;
switch (command.Property)
{
case EControlProperty::Visible:
control->SetVisible(command.Value);
break;
case EControlProperty::Enabled:
control->SetEnabled(command.Value);
break;
case EControlProperty::Checked:
{
CheckBox* checkBox =
dynamic_cast<CheckBox*>(control);
if (checkBox != nullptr)
checkBox->SetChecked(command.Value);
break;
}
}
}
}
B 상황 클래스는 구체적인 Control을 직접 조작하지 않고, UI 컨트롤러에 B 상황의 설정을 적용해 달라고 요청한다.
void SituationB::OnEnter()
{
SituationUIController::ApplyCommands(
SituationBCommands
);
}
이 구조는 단순한 Control 속성 변경을 데이터처럼 관리할 수 있다는 장점이 있다.
3. (2-3의 방법)인 모든 변경을 명령 목록으로 처리해도 될까?
Control 명령 방식은 다음과 같은 단순한 속성 변경에 잘 맞는다.
- Visible 변경
- Enabled 변경
- 체크 상태 변경
- 텍스트 변경
- 이미지 변경
- 위치 변경
- 크기 변경
하지만 모든 UI 변경을 Control ID 명령으로 처리하는 것은 적절하지 않다.
예를 들어 B 상황에서 인벤토리의 모든 아이템을 사용할 수 없게 해야 한다고 가정해 보자.
이를 Control 명령으로 처리하면 슬롯마다 명령을 만들어야 한다.
{
EDialogID::Inventory,
IDC_ITEM_SLOT_1,
EControlProperty::Enabled,
false
},
{
EDialogID::Inventory,
IDC_ITEM_SLOT_2,
EControlProperty::Enabled,
false
},
{
EDialogID::Inventory,
IDC_ITEM_SLOT_3,
EControlProperty::Enabled,
false
}
슬롯이 동적으로 생성되거나 개수가 많다면 관리하기 어려워진다.
더 큰 문제는 Control의 Enabled만 끄는 것으로 실제 아이템 사용 로직까지 차단된다는 보장이 없다는 것이다.
마우스 클릭은 막았지만 단축키나 더블클릭 처리에서는 아이템이 사용될 수도 있다.
이 경우에는 인벤토리 Dialog에 기능의 의미를 나타내는 함수를 제공하는 것이 좋다.
class InventoryDialog : public DialogUI
{
public:
void SetItemInteractionEnabled(bool enabled);
private:
bool m_bItemInteractionEnabled = true;
};
void InventoryDialog::SetItemInteractionEnabled(bool enabled)
{
m_bItemInteractionEnabled = enabled;
for (ItemSlot* slot : m_ItemSlots)
{
slot->SetEnabled(enabled);
}
}
아이템 사용 처리에서도 실제 상태를 검사한다.
void InventoryDialog::UseItem(ItemSlot* slot)
{
if (!m_bItemInteractionEnabled)
return;
// 실제 아이템 사용 처리
}
미니맵이나 채팅창도 마찬가지다.
miniMapDialog->SetMarkerMode(EMarkerMode::Restricted);
chatDialog->SetChannelEnabled(
EChatChannel::Trade,
false
);
중요한 점은 함수 이름을 상황 이름으로 만들지 않는 것이다.
// 상황에 종속된 함수
inventoryDialog->ApplySituationB();
miniMapDialog->ApplySituationB();
chatDialog->ApplySituationB();
대신 해당 Dialog가 제공하는 기능을 기준으로 이름을 정한다.
// 기능을 나타내는 함수
inventoryDialog->SetItemInteractionEnabled(false);
miniMapDialog->SetMarkerMode(EMarkerMode::Restricted);
chatDialog->SetChannelEnabled(EChatChannel::Trade, false);
이렇게 하면 같은 기능을 다른 상황에서도 재사용할 수 있다.
4. 단순 속성과 기능 변경을 분리하자
결론적으로 한 가지 방식만 사용하기보다는 두 가지 방식을 함께 사용하는 것이 적합하다.
4-1. 단순 Control 속성 변경
Control ID 기반 명령 목록으로 처리한다.
ApplyCommands(SituationBCommands);
4-2. Dialog의 동작이나 데이터 변경
개별 Dialog가 제공하는 기능 기반 함수를 호출한다.
inventoryDialog->SetItemInteractionEnabled(false);
miniMapDialog->SetMarkerMode(EMarkerMode::Restricted);
chatDialog->SetChannelEnabled(EChatChannel::Trade, false);
5. 결론 정리
DialogUI가 Control을 Control ID로 관리하는 구조라면, 상황에 따른 UI 변경을 다음과 같이 나누는 것이 좋다.
- 상황 클래스가 각 Control을 직접 변경하지 않는다.
- 여러 Dialog의 변경을 조정하는 SituationUIController를 둔다.
- Visible, Enabled 같은 단순 변경은 타입이 있는 명령 목록으로 관리한다.
- 문자열 명령보다 enum과 구조체를 사용한다.
- UI의 동작이나 데이터가 변경된다면 개별 Dialog의 기능 함수를 호출한다.
- ApplySituationB() 같은 상황 전용 함수보다 SetItemInteractionEnabled() 같은 기능 기반 함수를 만든다.
- 상황 종료 시 이전 상태를 어떻게 복구할지도 함께 설계한다.
결국 핵심은 다음과 같다.
상황 클래스는 UI 내부 구조를 직접 조작하지 않고, 어떤 UI 정책이 필요한지만 요청한다. 단순한 외형 변경은 Control 명령으로 처리하고, 의미 있는 기능 변경은 각 Dialog가 책임진다.
이렇게 역할을 분리하면 새로운 상황이 추가되어도 기존 Dialog에 상황별 함수가 계속 늘어나는 것을 막을 수 있고, UI 구조가 변경되었을 때 수정 범위도 줄일 수 있다.
'👨🏻💻 programming > ◽ C, C++, C#' 카테고리의 다른 글
| [C++/ 코드 추천] 클래스 멤버 선언 순환문제 (전방선언의 한계) (5) | 2026.10.17 |
|---|---|
| [C++/코드 추천] 가변 결과 수를 받을 때 vector vs 동적할당 vs array (0) | 2026.10.17 |
| [c++20] requires (0) | 2026.07.29 |
| (c++) 레퍼런스/reference 정리 (0) | 2025.05.26 |
개발을 하는 이유는 단순합니다. 살면서 성취감을 처음 느껴봤습니다.