프로젝트와 문제
CH2 Text RPG는 여섯 명이 C++17로 만든 콘솔 게임입니다. 플레이어는 재료 몬스터를 처치해 골드와 식재료를 얻고, 요리를 완성해 능력치를 높입니다. 일반 요리 열 개를 완성하면 궁극의 햄버거와 보스전이 열리고, 최종 요리를 만들면 엔딩에 도달합니다.
개발을 시작할 때 저는 BattleManager, RecipeManager, ShopManager, UIManager 등 기능별 매니저 파일을 빈 골격으로 먼저 생성했습니다. 시스템의 경계를 파일과 클래스 단위로 제시한 뒤 팀원들이 역할에 따라 각 영역의 내부 구현을 채우는 방식으로 작업을 나눴습니다.
팀원들의 구현이 모인 초기 통합본에는 각 클래스와 함수가 존재했지만, 메뉴 선택은 실제 동작으로 이어지지 않았고 전투 보상은 인벤토리에 들어가지 않았습니다. 레시피와 상점 역시 화면과 로직이 분리된 채였기 때문에, 처음에 나눈 시스템 경계를 실제 게임의 실행 흐름으로 다시 연결해야 했습니다.
핵심 문제는 클래스의 부재가 아니라 객체 사이의 계약과 상태 전이의 부재였습니다. “전투 기능이 있다”와 “전투를 마친 뒤 보상을 받고 메뉴로 돌아갈 수 있다”는 서로 다른 완성 조건입니다.
내 역할과 설계 기준
저는 개발 초기에 기능별 매니저 파일과 클래스 골격을 만들고 팀의 작업 영역을 나눴습니다. 이후 프로그램의 진입점과 GameManager, 전체 진행 흐름, 팀 코드 통합을 맡았습니다. 플레이어 초기화부터 튜토리얼, 메인 루프, 스킬 선택, 전투 보상, 요리, 상점, 보스전과 엔딩까지 하나의 유스케이스로 연결했습니다.
즉 제 역할은 완성된 시스템을 마지막에 이어 붙이는 데서 시작하지 않았습니다. 먼저 시스템별 책임이 놓일 자리를 만들고, 팀원들이 구현한 코드를 그 구조 안에서 조율한 다음, 시스템 사이의 인터페이스와 상태 전이를 완성하는 작업까지 포함했습니다.
통합 기준은 “매니저끼리 직접 참조하지 않고 GameManager가 조정한다”였습니다. UI가 상점을 직접 호출하거나 전투가 화면에 직접 출력하면 한 시스템의 인터페이스 변경이 다른 시스템으로 번지고 순환 의존성이 생기기 쉽기 때문입니다.
UIManager ── 선택값 ──▶ GameManager ── 명령 ──▶ 각 도메인 시스템
UIManager ◀── 출력 데이터 ─ GameManager ◀── 실행 결과 ─ 각 도메인 시스템
각 시스템은 자신의 규칙을 처리하고, GameManager는 호출 순서와 데이터 전달을 담당하도록 경계를 잡았습니다.
GameManager로 흐름 연결하기
막연한 “전투”를 실행 가능한 단계로 풀었습니다. 몬스터 생성 → 스킬 선택 → 자동 전투 → 로그 출력 → 승패 확인 → 보상 지급 → 체력·마나 복구 → 메뉴 복귀 순서입니다. 메인 루프는 UI가 반환한 선택값만 해석합니다.
while (isRunning && !recipe.IsFinalRecipeCompleted())
{
switch (ui.PrintMenu())
{
case 1: RunNextIngredientBattle(); break;
case 2: ui.PrintInventory(player.GetInventory()); break;
case 3: HandleRecipeMenu(); break;
case 4: HandleShopMenu(); break;
case 0: isRunning = false; break;
}
}
상점도 같은 방향으로 연결했습니다. ShopManager가 상품 목록을 제공하면 UI는 이를 표시하고 선택한 ID만 반환합니다. 구매 가능 여부, 아이템 지급, 골드 차감은 PurchaseItem()이 처리하며, 성공 여부를 다시 UI에 전달합니다.
const int itemId = ui.PrintShop(shop.GetShopItems());
if (itemId <= 0) return;
const bool purchased = shop.PurchaseItem(player, itemId);
ui.PrintShopPurchaseResult(purchased, player);
UI가 입력과 상태 변경을 함께 처리하지 않기 때문에 취소, 구매 실패, 메뉴 복귀 같은 전이를 한곳에서 추적할 수 있게 됐습니다.
전투 로직과 출력 분리
전투는 자동 진행되지만 시작 전에 스킬 하나를 선택합니다. GameManager가 선택 ID를 Skill 포인터로 변환해 전달하면, BattleManager는 턴 주기나 HP 비율 같은 발동 조건과 MP를 검사합니다. 조건이 맞지 않으면 기본 공격으로 대체합니다.
중요한 결정은 전투 중 콘솔을 직접 출력하지 않고 BattleInfo 목록을 반환한 것입니다. 각 항목에는 턴, 사용 스킬, 몬스터 ID와 이름, 양측 피해량, 남은 HP가 담깁니다.
const auto result = battle.StartBattle(player, monster, selectedSkill);
ui.PrintBattleLog(result);
if (result.first == BattleResult::Win)
{
ApplyBattleReward(monster);
}
이 구조에서 전투는 규칙만 알고 UI는 표현만 압니다. 콘솔 출력 형식을 바꾸더라도 피해 계산을 수정할 필요가 없고, 같은 로그 데이터로 리플레이나 다른 화면을 구현할 여지도 생깁니다.
상태와 소유권 정리
Player와 Monster의 공통 능력치는 Character에 두고, 아이템은 Item에서 Potion과 Ingredient로 나눴습니다. 반면 플레이어와 인벤토리는 “is-a”가 아니므로 상속 대신 합성을 사용했습니다.
struct InventorySlot
{
std::unique_ptr<Item> ItemPtr;
int Count;
};
class Player : public Character
{
Inventory PlayerInventory;
};
unique_ptr는 슬롯이 아이템을 단독 소유한다는 사실을 타입으로 표현합니다. 슬롯이 사라질 때 아이템도 함께 정리되어 수동 해제와 이중 해제 위험을 줄입니다.
상태 변경 역시 데이터를 가진 객체에 두었습니다. 골드는 Player::AddGold()와 SpendGold(), 아이템 수량은 Inventory::AddItem()과 ReduceItem(), 레시피 완료 상태는 RecipeManager::CompleteRecipe()가 담당합니다. 외부에서 멤버를 직접 수정하는 것보다 “부족하면 차감하지 않는다”, “수량은 음수가 되지 않는다” 같은 불변식을 지키기 쉽습니다.
통합 과정에서 해결한 문제
레시피와 실제 인벤토리 연결
RecipeManager는 제작 가능 여부를 판단할 수 있었지만 플레이어 인벤토리를 직접 알지 못했습니다. 따라서 GameManager가 슬롯을 RecipeIngredient 목록으로 변환해 전달하고, 성공하면 레시피 상태 변경과 음식 능력치 적용을 차례로 호출했습니다. 독립된 두 모델 사이에 명시적인 변환 경계를 둔 셈입니다.
취소값을 상태 전이로 취급
레시피 상세 화면에서 취소를 눌러도 제작되는 버그는 반환값을 무시한 것이 원인이었습니다. 모든 UI 입력에서 0을 취소 또는 이전 화면으로 통일하고, 유효 범위를 검사한 뒤에만 도메인 동작을 호출하도록 수정했습니다. 콘솔 게임에서 입력값은 단순 숫자가 아니라 다음 상태를 결정하는 이벤트입니다.
인코딩과 저장소 설정
UTF-8과 CP949가 섞인 파일에서는 한글이 깨지고 문자열 리터럴이 잘못 해석되었습니다. 파일별 인코딩을 확인해 필요한 파일을 변환했고, Git에 이미 추적된 빌드 산출물은 .gitignore만 추가해도 사라지지 않는다는 점도 확인했습니다. 인코딩·줄바꿈·무시 규칙은 통합 단계의 잡일이 아니라 사전에 합의할 빌드 계약이었습니다.
검증과 남은 한계
기능을 연결할 때마다 Visual Studio의 x64 Debug 빌드로 컴파일과 링크를 확인하고, 콘솔 입력을 전달해 튜토리얼 전투, 보상, 메뉴 복귀, 스킬 선택, ASCII 아트 출력까지 실행했습니다. 마지막에는 git diff --check로 공백과 줄바꿈 문제를 점검했습니다.
그러나 빌드 성공은 실행 흐름의 정확성을 보장하지 않습니다. 자동화된 테스트가 없어 메뉴 경로를 수동으로 검증했고, 후반 콘텐츠를 빠르게 확인하기 위해 재료 차감 코드가 임시로 비활성화된 상태도 소스에 남았습니다. 이는 제품 규칙이 아니라 테스트 편의를 위한 기술 부채이며, 발행 시점의 코드 한계로 명확히 구분해야 합니다.
또한 모든 조정 책임을 모은 GameManager는 약 300줄 규모로 성장했습니다. 작은 프로젝트에서는 의존 흐름을 한눈에 볼 수 있었지만, 콘텐츠가 늘면 전투 진행, 메뉴 상태, 보상 처리 같은 흐름별 컨트롤러로 분리해야 합니다.
배운 점과 다음 개선
- 클래스를 나누는 것보다 객체가 주고받을 입력, 결과, 실패 조건을 먼저 합의해야 합니다.
- UI는 선택을 반환하고 상태 변경은 해당 데이터를 소유한 객체가 맡아야 흐름을 추적하기 쉽습니다.
- DTO 역할의 BattleInfo처럼 로직과 표현 사이의 데이터 계약을 두면 변경 범위가 줄어듭니다.
- 중앙 조정자는 직접 참조를 줄이지만, 커지기 시작하면 유스케이스 단위로 다시 분리해야 합니다.
- 컴파일뿐 아니라 실제 사용자 입력 경로를 실행해야 통합이 끝납니다.
다음 팀 프로젝트에서는 구현 전에 공개 인터페이스와 공통 ID, 반환값과 실패 조건을 문서화하고, 인벤토리 수량 변경·구매 실패·레시피 제작 조건부터 단위 테스트를 추가할 계획입니다. PR에는 빌드 결과와 실행한 시나리오를 기록하고 UTF-8과 줄바꿈 규칙도 저장소 생성 시점에 고정해야 합니다.
이번 프로젝트에서 얻은 결론은 단순합니다. 객체지향은 클래스를 많이 만드는 기술이 아니라, 변경의 책임을 어디에 두고 의존성이 어느 방향으로 흐르게 할지 결정하는 과정입니다.
'내배캠_Unreal10기' 카테고리의 다른 글
| [TIL] Unreal 리플렉션과 GC, CDO의 동작 흐름 (0) | 2026.08.14 |
|---|---|
| [TIL] 언리얼 라이브 코딩 (1) | 2026.08.11 |
| [TIL] 이분 탐색과 탐욕법 문제 풀이 (0) | 2026.07.22 |
| [TIL] 방의 개수 / 이분 탐색과 탐욕법 (0) | 2026.07.21 |
| [과제 회고] 내일배움캠프 Unreal 10기 CH2 과제 회고 - C++ 텍스트 RPG 구현하기 (0) | 2026.07.08 |