프로젝트 회고 / / 2025. 12. 3. 00:18

PlayerController가 입력을 처리하는게 적절한가?

반응형

🤔 초기 설계 의도

"Controller의 역할은 입력을 처리하는 것이 아닌가?"

PlayerController의 본연 기능이 입력 처리라고 생각했기에, 모든 입력을 Controller에서 받도록 설계했습니다:

// SoulPlayerControllerBase.cpp
void ASoulPlayerControllerBase::SetupInputComponent()
{
    // 모든 입력을 Controller에 바인딩
    EnhancedInputComp->BindAction(MoveAction, ETriggerEvent::Triggered, this, &ThisClass::Move);
    EnhancedInputComp->BindAction(AttackAction, ETriggerEvent::Started, this, &ThisClass::Attack);
    EnhancedInputComp->BindAction(RollingAction, ETriggerEvent::Started, this, &ThisClass::Rolling);
    // ... 30개 이상의 입력
}

책임 소재가 명확해 보였습니다:

  • Controller: 입력 받기
  • Character: 행동 실행


🚨 문제 인식

Controller는 단순 중계자일 뿐

그러나 실제 코드를 보면 Controller는 아무것도 하지 않습니다:

// SoulPlayerControllerBase.cpp - 반복되는 패턴
void ASoulPlayerControllerBase::Attack()
{
    if (ASoulPlayerCharacter* SoulCharacter = Cast<ASoulPlayerCharacter>(GetCharacter()))
    {
        SoulCharacter->Attack();  // 그냥 전달만
    }
}

void ASoulPlayerControllerBase::Rolling()
{
    if (ASoulPlayerCharacter* SoulCharacter = Cast<ASoulPlayerCharacter>(GetCharacter()))
    {
        SoulCharacter->Rolling();  // 그냥 전달만
    }
}

void ASoulPlayerControllerBase::HeavyAttack()
{
    if (ASoulPlayerCharacter* SoulCharacter = Cast<ASoulPlayerCharacter>(GetCharacter()))
    {
        SoulCharacter->HeavyAttack();  // 그냥 전달만
    }
}

// ... 30개 함수가 모두 동일한 패턴 ❌

실제 입력 흐름:

입력 → Controller::Attack() 
       ↓ (캐스팅 + 전달)
     Character::Attack()
       ↓ (다시 전달)
     CombatComponent::Attack()
       ↓ (실제 로직)
     DoAttack()


3단계를 거쳐야 실제 로직 도달

관찰한 문제점:

  1. Controller는 입력 토스하기 바쁨
    • 이동 → CharacterMovementComponent
    • 공격 → CombatComponent
    • 인벤토리 → InventoryUI
    • UI 모드 전환 → WidgetManager
  2. Controller가 모든 클래스를 알아야 함
  3. // Controller가 알아야 하는 것들 ASoulPlayerCharacter* Character; UCombatComponent* CombatComp; UAttributeComponent* AttributeComp; UStateComponent* StateComp; UInventoryComponent* InventoryComp; UWidgetManagerComponent* WidgetManager; // ... 계속 증가
  4. 결합도 폭발
    • CombatComponent 수정 → Controller도 수정 필요
    • 새 기능 추가 → Controller에 함수 추가
    • "책임 소재 명확"하지만 실제론 God Class


💭 고민의 흐름

고민 1: "Controller의 결합도를 낮추려면?"

Character로 위임했지만, Character도 같은 고민:

// SoulPlayerCharacter.cpp
void ASoulPlayerCharacter::Attack()
{
    if (CombatComponent)
    {
        CombatComponent->Attack();  // 또 전달
    }
}

결국 "입력 → Controller → Character → Component" 3단계 불필요한 중계



고민 2: "무기별로 다른 입력을 어떻게 처리할까?"

Q키를 눌렀을 때:

  • 한손검: 즉발 특수 공격
  • 대검: 차징 공격
  • 폴암: 원거리 투척


시도했던 방법들:


A안: Character에서 Switch 문

void ASoulPlayerCharacter::SpecialAttack()
{
    ECombatType CombatType = CombatComponent->GetWeapon()->GetCombatType();

    switch (CombatType)
    {
    case ECombatType::Sword:
        CombatComponent->SpecialAttack();
        break;
    case ECombatType::GreatSword:
        CombatComponent->ChargeAttack();
        break;
    case ECombatType::Polearm:
        CombatComponent->ThrowWeapon();
        break;
    }
}

❌ Character가 모든 무기 타입을 알아야 함
❌ 무기 추가 시마다 Switch 문 수정



B안: Delegate로 태그만 전달?

// Controller
void ASoulPlayerControllerBase::SpecialAttack()
{
    OnInputReceived.Broadcast(SoulGameplayTag::Input_SpecialAttack);
}

// Character
void ASoulPlayerCharacter::OnInputReceived(FGameplayTag InputTag)
{
    switch (InputTag)
    {
    case Input_SpecialAttack:
        // 무기 타입에 따라 분기
        break;
    }
}

❌ 결국 Character의 Switch 문은 동일
❌ 추상화했지만 근본적 해결 아님



C안: 무기가 IMC를 가지고 장착 시 전달?

// 상상 속 코드
void ASoulWeapon::EquipItem()
{
    Character->AddMappingContext(WeaponSpecificIMC);  
    // Q키 → 차징 공격으로 자동 매핑
}

✅ 무기마다 다른 입력 가능
❌ 구현 복잡도 높음, Enhanced Input 이해 필요
"이게 과연 올바른 방법일까?" → 막막함



💡 깨달음: "입력의 소비자에게 책임을"

ACharacter::SetupPlayerInputComponent()가 존재하는가?


언리얼 공식 문서와 커뮤니티를 찾아보며 알게 된 사실:

"PlayerController는 '플레이어의 의지'를 대변하고,
Pawn은 '그 의지를 수행하는 육체'다."


빙의(Possess)의 의미:

  • Controller가 Pawn에 빙의한다 = 의지(Controller)와 육체(Pawn) 분리
  • 입력을 '받는' 곳'소비하는' 곳을 분리하는 설계 철학

핵심 인사이트:

❌ Controller: "공격 입력 → 공격 실행"
✅ Controller: "공격 입력 수신" / Pawn: "공격 실행"

</br>
</br>

🔧 접근 방법

1. Enhanced Input + IMC

무기가 IMC를 소유하는 방식:

// SoulWeapon.h
UCLASS()
class ASoulWeapon
{
    UPROPERTY(EditDefaultsOnly)
    UInputMappingContext* WeaponInputContext;  // 무기별 IMC
};

// SoulWeapon.cpp - 장착 시
void ASoulWeapon::EquipItem()
{
    if (APlayerController* PC = Character->GetController<APlayerController>())
    {
        if (UEnhancedInputLocalPlayerSubsystem* Subsystem = 
            ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PC->GetLocalPlayer()))
        {
            // 무기 전용 입력 추가
            Subsystem->AddMappingContext(WeaponInputContext, 1);
        }
    }
}

// IMC_Sword.asset
// Q키 → IA_ChargeAttack

// IMC_Axe.asset  
// Q키 → IA_QuickAttack

흐름:

1. 검 장착 → IMC_Sword 추가
   Q키 = IA_ChargeAttack 매핑

2. 도끼 장착 → IMC_Sword 제거 + IMC_Axe 추가
   Q키 = IA_QuickAttack 매핑

3. Controller는 "Q가 눌렸다"만 알 뿐, 
   차징인지 즉발인지는 무기의 IMC가 결정

장점:

  • ✅ Controller는 무기 타입을 몰라도 됨
  • ✅ 무기 추가 시 코드 수정 0줄
  • ✅ IMC Asset만 만들면 끝
  • ✅ 결합도 완전 분리


2. GameplayTag + Delegate

입력을 '의도'로 추상화:

// PlayerController
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnInputIntent, FGameplayTag, IntentTag);

void ASoulPlayerController::SpecialAttack()
{
    // 무기/캐릭터를 모름, 태그만 방송
    OnInputIntent.Broadcast(GameplayTag::Input_SpecialAttack);
}

// CombatComponent
void UCombatComponent::BeginPlay()
{
    // 태그 구독
    PlayerController->OnInputIntent.AddDynamic(this, &ThisClass::OnInputReceived);
}

void UCombatComponent::OnInputReceived(FGameplayTag IntentTag)
{
    if (IntentTag == GameplayTag::Input_SpecialAttack)
    {
        // 현재 무기에 맞는 행동 실행
        Weapon->ExecuteSpecialAttack();
    }
}

장점:

  • ✅ Controller는 의도만 전달
  • ✅ 여러 시스템이 동시에 구독 가능


3. Character에서 직접 입력 처리 (가장 단순)

// SoulPlayerCharacter.cpp
void ASoulPlayerCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    UEnhancedInputComponent* EnhancedInput = Cast<UEnhancedInputComponent>(PlayerInputComponent);

    // Controller 거치지 않고 직접 바인딩
    EnhancedInput->BindAction(AttackAction, ETriggerEvent::Started, this, &ThisClass::Attack);
    EnhancedInput->BindAction(RollingAction, ETriggerEvent::Started, this, &ThisClass::Rolling);
}

Controller는 시스템 입력만:

// SoulPlayerController.cpp
void ASoulPlayerController::SetupInputComponent()
{
    // UI/시스템만
    EnhancedInput->BindAction(PauseAction, ETriggerEvent::Started, this, &ThisClass::Pause);
    EnhancedInput->BindAction(InventoryAction, ETriggerEvent::Started, this, &ThisClass::OpenInventory);
}

장점:

  • ✅ 불필요한 중계 함수 제거
  • ✅ 디버깅 용이 (Character만 확인)
  • ✅ AI도 Character 함수 직접 호출


✅ 결론: PlayerController의 진짜 역할

현재 구조의 문제:

❌ PlayerController = "입력 처리자" (God Class)
   → 모든 시스템을 알아야 함
   → 결합도 폭발
   → 중계 함수 30개

올바른 구조:

✅ PlayerController = "입력 관리자" (Input Manager)
   → IMC 추가/제거 관리
   → 의도(Tag)로 변환하여 방송
   → UI/시스템 기능만 직접 처리

책임 분리:

  • Controller: 누가 조종하는가 (Possess/Unpossess), 입력 컨텍스트 관리
  • Character: 무엇을 할 수 있는가 (Move/Attack/Roll), 입력 직접 처리
  • Component: 어떻게 하는가 (로직 구현)

"입력을 '처리'하는 것과 '관리'하는 것은 다르다"


Controller가 모든 입력을 처리하는 것은:

  • ❌ 교과서적이지만 확장성 낮음
  • ❌ 중계 함수만 늘어남
  • ❌ 결합도 문제

Enhanced Input의 IMC나 GameplayTag를 활용하면:

  • ✅ Controller는 "입력 컨텍스트 관리자"
  • ✅ Character/Component는 "입력 소비자"
  • ✅ 무기/스킬 추가 시 코드 수정 불필요

다음 프로젝트에서는:

  1. Character에서 직접 입력 처리 (기본)
  2. 무기별 IMC로 동적 매핑 (확장)
  3. Controller는 UI/시스템만 담당
    ACharacter::SetupPlayerInputComponent()가 존재하는 이유는 "행동의 주체가 직접 입력을 받는 게 자연스럽다"
반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유