언리얼 엔진 5

[UE5] 게임플레이 프레임워크 구조 이해하기

언린이 2026. 3. 2. 12:26
반응형

1. 게임플레이 프레임워크가 필요한 이유

게임을 만들다 보면 자연스럽게 다음과 같은 질문이 떠오릅니다. 게임의 규칙은 어디에 작성해야 하는가? 플레이어의 점수는 어떤 클래스가 관리해야 하는가? 입력 처리와 캐릭터 로직은 분리해야 하는가? 이러한 질문에 답하지 못한 채 개발을 시작하면, 하나의 클래스에 모든 로직이 뒤섞이는 이른바 "God Class" 문제가 발생합니다. 규모가 커질수록 유지보수가 어려워지고, 멀티플레이어를 도입하려 할 때 구조적으로 큰 벽에 부딪히게 됩니다.

언리얼 엔진은 이 문제를 해결하기 위해 게임플레이 프레임워크(Gameplay Framework)라는 클래스 구조를 제공합니다. 게임의 규칙, 상태, 플레이어 정보, 입력 처리, 물리적 표현 등을 각각 전담하는 클래스로 분리하여, 역할이 명확하고 확장 가능한 아키텍처를 만들 수 있습니다. 이 프레임워크를 이해하면 싱글플레이어에서 멀티플레이어로 전환할 때에도 구조를 크게 변경하지 않아도 되는 유연한 설계가 가능합니다.

보드 게임에 비유하면 이해하기 쉽습니다. GameMode는 게임 마스터만 가진 규칙서이고, GameState는 모든 플레이어가 볼 수 있는 게임판이며, PlayerController는 플레이어의 의사 결정(두뇌)이고, Pawn은 게임판 위의 물리적인 말입니다. PlayerState는 각 플레이어의 점수판에 해당합니다.

2. 프레임워크의 전체 구조

게임플레이 프레임워크를 구성하는 핵심 클래스는 다음과 같습니다.

클래스 역할 존재 위치 리플리케이션
AGameModeBase 게임 규칙 정의, 클래스 스폰 관리 서버에만 존재 복제되지 않음
AGameStateBase 게임 전체의 공개 상태 관리 서버 + 모든 클라이언트 복제됨
APlayerState 개별 플레이어의 상태 관리 서버 + 모든 클라이언트 복제됨
APlayerController 입력 처리, Pawn 제어 서버 + 해당 클라이언트 부분 복제
APawn / ACharacter 월드에서의 물리적 표현 서버 + 모든 클라이언트 복제됨

이 클래스들은 게임이 시작될 때 일정한 순서로 생성됩니다. 먼저 GameMode가 초기화되고, GameMode가 GameState를 생성합니다. 플레이어가 접속하면 GameMode가 해당 플레이어의 PlayerController를 생성하고, PlayerController에 연결될 PlayerState가 만들어집니다. 마지막으로 GameMode가 기본 Pawn을 스폰하여 PlayerController가 빙의(Possess)합니다. 이 과정에서 서버와 클라이언트 간의 데이터 동기화는 언리얼 엔진의 네트워크 복제(Replication) 시스템이 담당합니다.

3. GameMode — 게임의 규칙을 정의하는 심판

3-1. GameMode의 역할

AGameModeBase는 게임의 규칙과 흐름을 정의하는 클래스입니다. 어떤 PlayerController, Pawn, HUD, GameState, PlayerState 클래스를 사용할지 지정하고, 플레이어의 접속과 퇴장을 관리하며, 게임의 시작과 종료 조건을 제어합니다.

GameMode의 가장 중요한 특성은 서버에만 존재한다는 점입니다. 멀티플레이어 게임에서 클라이언트는 GameMode 인스턴스에 직접 접근할 수 없습니다. 이는 게임 규칙의 권위(authority)가 서버에만 있어야 한다는 설계 철학을 반영합니다.

3-2. GameMode 구현 예제

팀 데스매치 게임의 규칙을 정의하는 GameMode 예제입니다.

// ATeamDeathMatchGameMode.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "TeamDeathMatchGameMode.generated.h"

UCLASS()
class ATeamDeathMatchGameMode : public AGameModeBase
{
    GENERATED_BODY()

public:
    ATeamDeathMatchGameMode();

    // 플레이어 접속 시 호출
    virtual void PostLogin(APlayerController* NewPlayer) override;

    // 플레이어 퇴장 시 호출
    virtual void Logout(AController* Exiting) override;

    // 킬 발생 시 점수 처리
    void OnPlayerKilled(APlayerState* KillerState, APlayerState* VictimState);

protected:
    UPROPERTY(EditDefaultsOnly, Category = "Rules")
    int32 ScoreToWin = 50;

    UPROPERTY(EditDefaultsOnly, Category = "Rules")
    float RespawnDelay = 3.0f;

private:
    // 팀 배정 로직
    int32 AssignTeam();
    void CheckWinCondition();
};

위의 코드에서 GameModeBase를 상속받아 게임 규칙에 필요한 요소를 정의합니다. PostLogin()은 플레이어가 접속할 때 엔진이 자동으로 호출하는 함수이며, 여기서 팀 배정 등의 초기화 로직을 수행합니다.

다음은 구현 파일입니다.

// ATeamDeathMatchGameMode.cpp
#include "TeamDeathMatchGameMode.h"
#include "TeamDeathMatchGameState.h"
#include "TeamPlayerState.h"
#include "GameFramework/PlayerController.h"

ATeamDeathMatchGameMode::ATeamDeathMatchGameMode()
{
    // 사용할 클래스 지정 — GameMode의 핵심 역할
    GameStateClass = ATeamDeathMatchGameState::StaticClass();
    PlayerStateClass = ATeamPlayerState::StaticClass();
    DefaultPawnClass = AMyCharacter::StaticClass();
}

void ATeamDeathMatchGameMode::PostLogin(APlayerController* NewPlayer)
{
    Super::PostLogin(NewPlayer);

    // 접속한 플레이어에게 팀 배정
    if (ATeamPlayerState* PS = NewPlayer->GetPlayerState<ATeamPlayerState>())
    {
        PS->SetTeamId(AssignTeam());
        UE_LOG(LogTemp, Log, TEXT("플레이어 %s → 팀 %d 배정"),
            *PS->GetPlayerName(), PS->GetTeamId());
    }
}

void ATeamDeathMatchGameMode::OnPlayerKilled(
    APlayerState* KillerState, APlayerState* VictimState)
{
    if (ATeamPlayerState* Killer = Cast<ATeamPlayerState>(KillerState))
    {
        Killer->AddScore(1);

        // GameState를 통해 팀 점수 업데이트
        if (ATeamDeathMatchGameState* GS = GetGameState<ATeamDeathMatchGameState>())
        {
            GS->AddTeamScore(Killer->GetTeamId(), 1);
            CheckWinCondition();
        }
    }
}

int32 ATeamDeathMatchGameMode::AssignTeam()
{
    // 인원이 적은 팀에 배정 (0 또는 1)
    ATeamDeathMatchGameState* GS = GetGameState<ATeamDeathMatchGameState>();
    return (GS && GS->GetTeamPlayerCount(0) <= GS->GetTeamPlayerCount(1)) ? 0 : 1;
}

위 코드처럼 GameMode의 생성자에서 GameStateClass, PlayerStateClass, DefaultPawnClass 등을 지정하면, 엔진이 게임 시작 시 해당 클래스의 인스턴스를 자동으로 생성합니다. 이것이 GameMode가 다른 프레임워크 클래스들의 "진입점" 역할을 하는 이유입니다.

3-3. GameModeBase와 GameMode의 차이

언리얼 엔진은 AGameModeBaseAGameMode 두 가지 클래스를 제공합니다. AGameMode는 AGameModeBase를 상속하며, 매치 상태 머신(MatchState), 기본 리스폰 로직 등 추가 기능을 포함합니다. 이 추가 기능은 원래 언리얼 토너먼트에서 사용되던 것으로, 대부분의 프로젝트에서는 AGameModeBase를 상속하는 것이 권장됩니다. 필요한 기능만 직접 구현하는 편이 구조를 더 명확하게 유지할 수 있습니다.

4. GameState — 모두가 볼 수 있는 게임판

4-1. GameState의 역할

AGameStateBase는 게임 전체의 공개 상태를 관리하는 클래스입니다. 경기 시간, 팀 점수, 완료된 미션 목록 등 모든 클라이언트가 알아야 하는 정보를 담습니다. GameMode와 달리 GameState는 서버와 모든 클라이언트에 복제(replicate)되므로, 클라이언트에서도 게임 상태를 읽을 수 있습니다.

GameState는 현재 접속한 모든 플레이어의 PlayerState 목록을 PlayerArray 프로퍼티로 관리합니다. 스코어보드를 구현할 때 이 배열을 순회하면 모든 플레이어의 점수를 표시할 수 있습니다.

4-2. GameState 구현 예제

팀 데스매치의 팀 점수를 관리하는 GameState 예제입니다.

// ATeamDeathMatchGameState.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameStateBase.h"
#include "TeamDeathMatchGameState.generated.h"

UCLASS()
class ATeamDeathMatchGameState : public AGameStateBase
{
    GENERATED_BODY()

public:
    // 팀 점수 추가 (서버에서만 호출)
    void AddTeamScore(int32 TeamId, int32 Score);

    // 팀 점수 조회 (클라이언트에서도 호출 가능)
    UFUNCTION(BlueprintPure, Category = "Score")
    int32 GetTeamScore(int32 TeamId) const;

    // 팀 인원 조회
    int32 GetTeamPlayerCount(int32 TeamId) const;

protected:
    // Replicated — 모든 클라이언트에 자동 복제
    UPROPERTY(Replicated)
    TArray<int32> TeamScores = { 0, 0 };

    virtual void GetLifetimeReplicatedProps(
        TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};

위의 코드에서 UPROPERTY(Replicated) 매크로가 붙은 TeamScores 배열은 서버에서 값이 변경되면 자동으로 모든 클라이언트에 복제됩니다. 이를 위해 GetLifetimeReplicatedProps() 함수에서 복제 규칙을 등록해야 합니다.

다음은 복제 규칙 등록과 함수 구현입니다.

// ATeamDeathMatchGameState.cpp
#include "TeamDeathMatchGameState.h"
#include "Net/UnrealNetwork.h"

void ATeamDeathMatchGameState::GetLifetimeReplicatedProps(
    TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    // TeamScores를 모든 클라이언트에 복제
    DOREPLIFETIME(ATeamDeathMatchGameState, TeamScores);
}

void ATeamDeathMatchGameState::AddTeamScore(int32 TeamId, int32 Score)
{
    if (TeamScores.IsValidIndex(TeamId))
    {
        TeamScores[TeamId] += Score;
    }
}

int32 ATeamDeathMatchGameState::GetTeamScore(int32 TeamId) const
{
    return TeamScores.IsValidIndex(TeamId) ? TeamScores[TeamId] : 0;
}

위 코드처럼 DOREPLIFETIME 매크로를 사용하면 해당 프로퍼티가 값이 변경될 때마다 모든 클라이언트에 자동으로 동기화됩니다. GameState는 이처럼 게임 전체의 공개 정보를 관리하고 복제하는 데 특화된 클래스입니다.

5. PlayerState — 개별 플레이어의 신원 카드

5-1. PlayerState의 역할

APlayerState는 개별 플레이어의 상태 정보를 관리하는 클래스입니다. 플레이어 이름, 점수, 팀 정보, 핑(Ping) 등 플레이어 고유의 데이터를 담습니다.

PlayerState가 필요한 핵심적인 이유는 Pawn과 PlayerController의 수명 차이 때문입니다. Pawn은 캐릭터가 사망하면 파괴되고 리스폰 시 새로 생성됩니다. PlayerController는 해당 클라이언트에서만 접근 가능합니다. 반면 PlayerState는 Pawn이 파괴되더라도 계속 존재하며, 모든 클라이언트에 복제됩니다. 따라서 Pawn의 사망과 리스폰 사이에도 유지되어야 하는 데이터(점수, 킬 수, 장비 정보 등)는 PlayerState에 저장하는 것이 올바른 설계입니다.

5-2. PlayerState 구현 예제

팀 데스매치에서 사용할 PlayerState 예제입니다.

// ATeamPlayerState.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerState.h"
#include "TeamPlayerState.generated.h"

UCLASS()
class ATeamPlayerState : public APlayerState
{
    GENERATED_BODY()

public:
    void SetTeamId(int32 NewTeamId);

    UFUNCTION(BlueprintPure, Category = "Team")
    int32 GetTeamId() const { return TeamId; }

    void AddScore(int32 Amount);

    UFUNCTION(BlueprintPure, Category = "Score")
    int32 GetKillCount() const { return KillCount; }

    UFUNCTION(BlueprintPure, Category = "Score")
    int32 GetDeathCount() const { return DeathCount; }

    void AddDeath();

protected:
    UPROPERTY(Replicated)
    int32 TeamId = 0;

    UPROPERTY(Replicated)
    int32 KillCount = 0;

    UPROPERTY(Replicated)
    int32 DeathCount = 0;

    virtual void GetLifetimeReplicatedProps(
        TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};

위의 코드에서 TeamId, KillCount, DeathCount 모두 Replicated로 선언되어 있어, 서버에서 값을 변경하면 모든 클라이언트에 자동 동기화됩니다. 스코어보드 UI는 GameState의 PlayerArray를 순회하며 각 PlayerState의 정보를 읽어 표시할 수 있습니다.

// ATeamPlayerState.cpp
#include "TeamPlayerState.h"
#include "Net/UnrealNetwork.h"

void ATeamPlayerState::GetLifetimeReplicatedProps(
    TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(ATeamPlayerState, TeamId);
    DOREPLIFETIME(ATeamPlayerState, KillCount);
    DOREPLIFETIME(ATeamPlayerState, DeathCount);
}

void ATeamPlayerState::SetTeamId(int32 NewTeamId)
{
    // 서버에서만 값 변경 — HasAuthority()로 확인
    if (HasAuthority())
    {
        TeamId = NewTeamId;
    }
}

void ATeamPlayerState::AddScore(int32 Amount)
{
    if (HasAuthority())
    {
        KillCount += Amount;
        // APlayerState의 기본 Score 프로퍼티도 활용 가능
        SetScore(GetScore() + Amount);
    }
}

void ATeamPlayerState::AddDeath()
{
    if (HasAuthority())
    {
        DeathCount++;
    }
}

위 코드처럼 PlayerState의 값 변경은 반드시 HasAuthority()를 확인하여 서버에서만 수행합니다. 클라이언트가 직접 PlayerState의 값을 변경하면 복제 시스템과 충돌하여 값이 덮어씌워지거나 일관성이 깨질 수 있습니다.

6. PlayerController — 플레이어의 의지를 전달하는 두뇌

6-1. PlayerController의 역할

APlayerController는 플레이어의 입력을 받아 Pawn에 전달하는 중간 계층입니다. 키보드, 마우스, 게임패드 등의 물리적 입력을 해석하고, 현재 빙의(Possess)한 Pawn에게 행동 명령을 내립니다. 또한 카메라 관리(PlayerCameraManager)와 HUD 표시도 PlayerController의 책임입니다.

PlayerController가 Pawn과 분리되어야 하는 이유는 명확합니다. 플레이어가 캐릭터를 조종하다가 차량에 탑승하면, 기존 캐릭터 Pawn에서 차량 Pawn으로 빙의 대상이 바뀝니다. 이때 입력 처리 로직이 Pawn 안에 있으면 차량마다 입력 코드를 중복 작성해야 합니다. 하지만 PlayerController에 입력 해석 로직이 있으면, 빙의 대상만 바꾸면 됩니다.

6-2. PlayerController 구현 예제

Enhanced Input System을 사용한 PlayerController 예제입니다.

// AMyPlayerController.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "MyPlayerController.generated.h"

class UInputMappingContext;
class UInputAction;

UCLASS()
class AMyPlayerController : public APlayerController
{
    GENERATED_BODY()

public:
    virtual void BeginPlay() override;
    virtual void SetupInputComponent() override;

protected:
    UPROPERTY(EditDefaultsOnly, Category = "Input")
    TObjectPtr<UInputMappingContext> DefaultMappingContext;

    UPROPERTY(EditDefaultsOnly, Category = "Input")
    TObjectPtr<UInputAction> MoveAction;

    UPROPERTY(EditDefaultsOnly, Category = "Input")
    TObjectPtr<UInputAction> JumpAction;

private:
    void HandleMove(const FInputActionValue& Value);
    void HandleJump();

    // 서버에 이동 요청을 보내는 RPC
    UFUNCTION(Server, Reliable)
    void ServerRequestMove(FVector2D MoveInput);
};

위의 코드에서 주목할 점은 입력 처리(HandleMove, HandleJump)는 PlayerController에, 실제 물리적 움직임은 Pawn에 위임하는 구조입니다. 이렇게 분리하면 Pawn이 바뀌어도 입력 로직을 재사용할 수 있습니다.

// AMyPlayerController.cpp
#include "MyPlayerController.h"
#include "EnhancedInputComponent.h"
#include "EnhancedInputSubsystems.h"
#include "GameFramework/Character.h"

void AMyPlayerController::BeginPlay()
{
    Super::BeginPlay();

    // Enhanced Input의 매핑 컨텍스트 등록
    if (UEnhancedInputLocalPlayerSubsystem* Subsystem =
        ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(GetLocalPlayer()))
    {
        Subsystem->AddMappingContext(DefaultMappingContext, 0);
    }
}

void AMyPlayerController::SetupInputComponent()
{
    Super::SetupInputComponent();

    if (UEnhancedInputComponent* EnhancedInput =
        Cast<UEnhancedInputComponent>(InputComponent))
    {
        // 입력 액션과 핸들러 바인딩
        EnhancedInput->BindAction(
            MoveAction, ETriggerEvent::Triggered, this, &AMyPlayerController::HandleMove);
        EnhancedInput->BindAction(
            JumpAction, ETriggerEvent::Started, this, &AMyPlayerController::HandleJump);
    }
}

void AMyPlayerController::HandleMove(const FInputActionValue& Value)
{
    FVector2D MoveInput = Value.Get<FVector2D>();

    // 빙의한 Pawn에게 이동 명령 전달
    if (APawn* ControlledPawn = GetPawn())
    {
        FVector Forward = ControlledPawn->GetActorForwardVector();
        FVector Right = ControlledPawn->GetActorRightVector();
        ControlledPawn->AddMovementInput(Forward, MoveInput.Y);
        ControlledPawn->AddMovementInput(Right, MoveInput.X);
    }
}

void AMyPlayerController::HandleJump()
{
    // Character일 경우 점프 실행
    if (ACharacter* ControlledCharacter = GetCharacter())
    {
        ControlledCharacter->Jump();
    }
}

위 코드처럼 PlayerController는 입력을 해석하여 GetPawn()이나 GetCharacter()를 통해 현재 빙의한 Pawn에게 명령을 전달합니다. Pawn이 교체되더라도 PlayerController의 입력 코드는 수정할 필요가 없습니다.

7. Pawn과 Character — 월드에서의 물리적 존재

7-1. Pawn과 Character의 관계

APawn은 월드에서 플레이어나 AI의 물리적 표현을 담당하는 클래스입니다. PlayerController나 AIController가 빙의(Possess)하여 제어하는 대상이 됩니다.

ACharacter는 APawn을 상속하며, 인간형 캐릭터에 필요한 기능을 추가로 제공합니다. CapsuleComponent(충돌), SkeletalMeshComponent(외형), CharacterMovementComponent(걷기, 달리기, 점프, 수영, 비행 등)를 기본으로 포함합니다.

모든 캐릭터에 Character를 사용할 필요는 없습니다. 차량, 드론, 자유 카메라 등 인간형이 아닌 조작 대상은 Pawn을 직접 상속하는 것이 더 가볍고 적합합니다.

7-2. 빙의(Possess)와 탈빙의(UnPossess)

PlayerController가 Pawn을 제어하기 위해서는 빙의(Possess) 과정이 필요합니다.

// GameMode에서 리스폰 시 새 Pawn에 빙의
void ATeamDeathMatchGameMode::RespawnPlayer(APlayerController* Controller)
{
    if (!Controller) return;

    // 기존 Pawn 정리
    if (APawn* OldPawn = Controller->GetPawn())
    {
        OldPawn->Destroy();
    }

    // 새 Pawn 스폰
    FTransform SpawnTransform = FindSpawnPoint(Controller);
    APawn* NewPawn = GetWorld()->SpawnActor<APawn>(
        DefaultPawnClass, SpawnTransform);

    // 빙의 — PlayerController가 새 Pawn을 제어
    Controller->Possess(NewPawn);
}

위의 코드에서 Possess()를 호출하면 PlayerController와 Pawn이 연결됩니다. 이때 Pawn에서는 PossessedBy() 이벤트가, 이전 Pawn에서는 UnPossessed() 이벤트가 호출됩니다. 이 이벤트들을 오버라이드하여 빙의/탈빙의 시점에 필요한 초기화나 정리 작업을 수행할 수 있습니다.

8. 컴포넌트 — ActorComponent와 SceneComponent의 차이

8-1. 컴포넌트 계층 구조

언리얼 엔진은 액터에 기능을 추가하기 위해 컴포넌트(Component) 시스템을 사용합니다. 컴포넌트의 상속 계층은 다음과 같습니다.

UActorComponentUSceneComponentUPrimitiveComponent

이 계층에서 아래로 갈수록 기능이 추가되고, 그만큼 메모리와 연산 비용도 증가합니다. 따라서 필요한 기능에 맞는 가장 가벼운 컴포넌트를 선택하는 것이 중요합니다.

8-2. UActorComponent — 트랜스폼 없는 순수 로직

UActorComponent는 컴포넌트 계층의 최상위 클래스로, 트랜스폼(위치, 회전, 크기)이 없습니다. 월드 내 물리적 위치가 필요 없는 순수한 로직이나 데이터 관리에 사용합니다.

체력 시스템을 ActorComponent로 구현하는 예제입니다.

// UHealthComponent.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "HealthComponent.generated.h"

DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(
    FOnHealthChanged, float, CurrentHealth, float, MaxHealth);

DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnDeath);

UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent))
class UHealthComponent : public UActorComponent
{
    GENERATED_BODY()

public:
    UHealthComponent();

    // 데미지 적용
    void ApplyDamage(float DamageAmount);

    // 체력 회복
    void Heal(float HealAmount);

    UFUNCTION(BlueprintPure, Category = "Health")
    float GetCurrentHealth() const { return CurrentHealth; }

    UFUNCTION(BlueprintPure, Category = "Health")
    bool IsAlive() const { return CurrentHealth > 0.f; }

    // 외부에서 바인딩 가능한 이벤트
    UPROPERTY(BlueprintAssignable, Category = "Health")
    FOnHealthChanged OnHealthChanged;

    UPROPERTY(BlueprintAssignable, Category = "Health")
    FOnDeath OnDeath;

protected:
    virtual void BeginPlay() override;

    UPROPERTY(EditAnywhere, Category = "Health")
    float MaxHealth = 100.f;

private:
    float CurrentHealth;
};

위의 코드에서 UHealthComponent는 UActorComponent를 상속합니다. 체력 시스템은 월드 내 위치가 필요 없으므로 USceneComponent 대신 더 가벼운 UActorComponent를 사용하는 것이 올바른 선택입니다.

// UHealthComponent.cpp
#include "HealthComponent.h"

UHealthComponent::UHealthComponent()
{
    // 이 컴포넌트는 매 프레임 업데이트가 필요 없음
    PrimaryComponentTick.bCanEverTick = false;
}

void UHealthComponent::BeginPlay()
{
    Super::BeginPlay();
    CurrentHealth = MaxHealth;
}

void UHealthComponent::ApplyDamage(float DamageAmount)
{
    if (DamageAmount <= 0.f || !IsAlive()) return;

    CurrentHealth = FMath::Clamp(CurrentHealth - DamageAmount, 0.f, MaxHealth);
    OnHealthChanged.Broadcast(CurrentHealth, MaxHealth);

    if (CurrentHealth <= 0.f)
    {
        OnDeath.Broadcast();
    }
}

void UHealthComponent::Heal(float HealAmount)
{
    if (HealAmount <= 0.f || !IsAlive()) return;

    CurrentHealth = FMath::Clamp(CurrentHealth + HealAmount, 0.f, MaxHealth);
    OnHealthChanged.Broadcast(CurrentHealth, MaxHealth);
}

위 코드처럼 UActorComponent는 PrimaryComponentTick.bCanEverTick을 false로 설정하여 불필요한 틱 업데이트를 비활성화할 수 있습니다. 이벤트 기반으로 동작하는 컴포넌트는 틱을 비활성화하는 것이 성능에 유리합니다.

8-3. USceneComponent — 트랜스폼이 있는 공간적 컴포넌트

USceneComponent는 UActorComponent를 상속하며, 트랜스폼(FTransform)을 가집니다. 월드 내 위치, 회전, 크기 정보가 필요하고, 다른 컴포넌트에 부착(Attach)할 수 있습니다. 단, 자체적으로 시각적 렌더링이나 충돌 기능은 없습니다.

USceneComponent의 핵심 특징은 계층 구조(Hierarchy)를 형성할 수 있다는 점입니다. 액터 내에서 컴포넌트 트리의 루트(RootComponent)로 설정할 수 있으며, 자식 컴포넌트를 부착하여 부모의 트랜스폼을 자동으로 따라가게 만들 수 있습니다.

투사체 발사 위치를 SceneComponent로 정의하는 예제입니다.

// 생성자에서 컴포넌트 계층 구성
AMyCharacter::AMyCharacter()
{
    // 투사체 발사 위치 — 월드 위치가 필요하므로 SceneComponent 사용
    ProjectileSpawnPoint = CreateDefaultSubobject<USceneComponent>(
        TEXT("ProjectileSpawnPoint"));
    ProjectileSpawnPoint->SetupAttachment(GetMesh(), FName("Muzzle_Socket"));

    // 인벤토리 — 위치가 필요 없으므로 ActorComponent 사용
    InventoryComponent = CreateDefaultSubobject<UInventoryComponent>(
        TEXT("InventoryComponent"));
}

void AMyCharacter::FireProjectile()
{
    // SceneComponent의 트랜스폼으로 정확한 발사 위치와 방향 결정
    FVector SpawnLocation = ProjectileSpawnPoint->GetComponentLocation();
    FRotator SpawnRotation = ProjectileSpawnPoint->GetComponentRotation();

    GetWorld()->SpawnActor<AProjectile>(
        ProjectileClass, SpawnLocation, SpawnRotation);
}

위의 코드에서 ProjectileSpawnPoint는 캐릭터 메시의 소켓에 부착된 SceneComponent입니다. 캐릭터가 움직이거나 회전하면 이 컴포넌트의 월드 위치도 자동으로 업데이트되므로, 항상 정확한 발사 위치를 제공합니다. 반면 InventoryComponent는 위치가 필요 없는 순수 로직이므로 ActorComponent로 만듭니다.

8-4. 컴포넌트 선택 기준 정리

필요한 기능 선택할 컴포넌트 예시
순수 로직, 데이터 관리 UActorComponent 체력, 인벤토리, 능력치, AI 로직
월드 내 위치/회전 필요 USceneComponent 발사 위치, 스프링 암, 오디오 소스
부모에 부착(Attach) 필요 USceneComponent 소켓에 부착하는 이펙트 위치
렌더링 또는 충돌 필요 UPrimitiveComponent 메시, 콜리전 박스, 파티클

실제로 사용해보면 대부분의 게임 로직 컴포넌트는 UActorComponent로 충분하며, 월드 공간에서의 위치가 명확히 필요한 경우에만 USceneComponent를 사용하는 것이 성능과 설계 양면에서 바람직합니다.

9. 프레임워크 클래스 간 통신 패턴

게임플레이 프레임워크에서 각 클래스는 특정한 방식으로 서로 통신합니다. 올바른 접근 경로를 아는 것이 중요합니다.

// Pawn에서 다른 프레임워크 클래스에 접근하는 방법
void AMyCharacter::ExampleAccess()
{
    // Pawn → PlayerController
    APlayerController* PC = Cast<APlayerController>(GetController());

    // Pawn → PlayerState
    ATeamPlayerState* PS = GetPlayerState<ATeamPlayerState>();

    // Pawn → GameState (어디서든 접근 가능)
    ATeamDeathMatchGameState* GS = GetWorld()->GetGameState<ATeamDeathMatchGameState>();

    // Pawn → GameMode (서버에서만 유효)
    ATeamDeathMatchGameMode* GM = GetWorld()->GetAuthGameMode<ATeamDeathMatchGameMode>();
}

// PlayerController에서 접근
void AMyPlayerController::ExampleAccess()
{
    // PlayerController → Pawn
    APawn* MyPawn = GetPawn();

    // PlayerController → PlayerState
    ATeamPlayerState* PS = GetPlayerState<ATeamPlayerState>();

    // PlayerController → HUD
    AMyHUD* MyHUD = GetHUD<AMyHUD>();
}

위의 코드에서 주의할 점은 GetAuthGameMode()는 서버에서만 유효한 값을 반환한다는 것입니다. 클라이언트에서 이 함수를 호출하면 nullptr을 반환하므로, 반드시 nullptr 체크를 수행해야 합니다. 게임 규칙에 접근해야 하는 클라이언트 로직은 GameState를 통해 공개된 데이터를 읽는 방식으로 설계해야 합니다.

10. 일반 C++과의 비교

게임플레이 프레임워크의 핵심 패턴은 일반 C++ 소프트웨어 설계의 원칙과 공통점이 있습니다. 프레임워크의 각 클래스가 하나의 역할만 담당하는 구조는 객체지향 설계의 단일 책임 원칙(Single Responsibility Principle)을 따릅니다. GameMode는 규칙, GameState는 상태, PlayerController는 입력이라는 명확한 책임 분리가 이루어집니다.

또한 컴포넌트 시스템은 일반 C++에서도 사용되는 컴포지션(Composition) 패턴에 해당합니다. 상속으로 기능을 확장하는 대신, 컴포넌트를 조합하여 액터의 기능을 구성합니다.

// 일반 C++ — 상속 기반 (깊은 상속 트리 문제 발생 가능)
class FlyingShootingHealingCharacter
    : public FlyingCharacter, public ShootingCharacter, public HealingCharacter
{ /* 다중 상속의 다이아몬드 문제 */ };

// 언리얼 — 컴포지션 기반 (유연하고 재사용 가능)
AMyCharacter::AMyCharacter()
{
    HealthComp = CreateDefaultSubobject<UHealthComponent>(TEXT("Health"));
    ShootingComp = CreateDefaultSubobject<UShootingComponent>(TEXT("Shooting"));
    FlyingComp = CreateDefaultSubobject<UFlyingComponent>(TEXT("Flying"));
}

위의 코드에서 일반 C++의 다중 상속 방식은 클래스 계층이 복잡해지고 다이아몬드 문제가 발생할 수 있지만, 언리얼의 컴포넌트 방식은 독립적인 기능 모듈을 자유롭게 조합할 수 있어 유연합니다. 비행 기능이 필요 없는 캐릭터에서는 FlyingComponent를 제거하기만 하면 됩니다.

다만, 언리얼의 프레임워크 클래스들은 일반 C++의 설계 패턴을 따르면서도 네트워크 복제(Replication)라는 고유한 계층을 추가합니다. 어떤 클래스가 서버에만 존재하고, 어떤 클래스가 모든 클라이언트에 복제되는지를 이해하는 것은 일반 C++ 설계에서는 고려하지 않는 언리얼만의 아키텍처적 요소입니다.

11. 유니티 엔진과의 비교

유니티 엔진에서도 게임 로직을 구조화하기 위한 패턴이 존재하지만, 언리얼과는 접근 방식에 큰 차이가 있습니다.

유니티에서는 게임 규칙, 상태, 입력 처리 등을 위한 전용 프레임워크 클래스가 엔진 차원에서 제공되지 않습니다. 개발자가 빈 GameObject에 커스텀 스크립트(MonoBehaviour)를 붙여 GameManager, ScoreManager, InputHandler 등을 직접 설계해야 합니다. 이는 유연하지만, 프로젝트마다 구조가 달라지고 팀 간 일관성이 부족해질 수 있습니다.

반면 언리얼 엔진은 GameMode, GameState, PlayerState, PlayerController라는 명확한 역할 분담 구조를 엔진 수준에서 제공합니다. 이 구조의 가장 큰 강점은 멀티플레이어 네트워킹과의 통합입니다. 유니티에서 멀티플레이어를 구현하려면 Netcode for GameObjects나 Mirror 같은 서드파티 솔루션을 사용해야 하고, 어떤 객체를 서버에만 두고 어떤 객체를 복제할지를 개발자가 직접 설계해야 합니다. 하지만 언리얼의 게임플레이 프레임워크는 처음부터 복제(Replication) 구조가 내장되어 있어, GameMode는 서버에만 존재하고 GameState는 자동 복제된다는 규칙이 엔진 차원에서 보장됩니다.

또한 유니티의 컴포넌트는 모두 MonoBehaviour를 상속하며 Transform이 필수적으로 포함됩니다. 언리얼처럼 트랜스폼 없는 순수 로직 컴포넌트(UActorComponent)와 트랜스폼 있는 컴포넌트(USceneComponent)로 세분화되어 있지 않아, 위치가 필요 없는 로직에도 Transform 오버헤드가 존재합니다.

12. 주의사항

12-1. 클라이언트에서 GameMode에 접근하지 않기

GameMode는 서버에만 존재하므로, 클라이언트 코드에서 GetWorld()->GetAuthGameMode()를 호출하면 nullptr이 반환됩니다. 클라이언트에서 게임 규칙 정보가 필요하면, GameMode가 해당 정보를 GameState에 저장하도록 설계하고 클라이언트는 GameState에서 읽어야 합니다.

12-2. 플레이어별 데이터를 GameState에 넣지 않기

팀 점수나 경기 시간처럼 게임 전체의 데이터는 GameState에, 개인 점수나 킬 수처럼 플레이어별 데이터는 PlayerState에 넣어야 합니다. 모든 데이터를 GameState에 몰아넣으면 불필요한 네트워크 트래픽이 발생하고, 데이터 구조가 복잡해집니다.

12-3. Pawn에 영속적 데이터를 저장하지 않기

Pawn은 캐릭터 사망 시 파괴됩니다. 점수, 인벤토리, 장비 정보 등 사망 후에도 유지되어야 하는 데이터를 Pawn에 저장하면 리스폰 시 데이터가 사라집니다. 이러한 데이터는 반드시 PlayerState나 PlayerController에 저장해야 합니다.

12-4. 불필요한 SceneComponent 사용 피하기

트랜스폼이 필요 없는 로직(체력, 인벤토리, 능력치 관리 등)에 USceneComponent를 사용하면, 불필요한 트랜스폼 계산과 계층 관리 비용이 발생합니다. 실제로 사용해보면 이 차이가 소수의 컴포넌트에서는 미미하지만, 수백 개의 액터가 각각 여러 컴포넌트를 가질 때는 성능에 영향을 줄 수 있습니다. 위치가 필요 없다면 UActorComponent를 사용하는 습관을 들이는 것이 좋습니다.

12-5. 프레임워크 클래스의 초기화 순서 인식

게임플레이 프레임워크 클래스들은 정해진 순서로 초기화됩니다. GameMode → GameState → PlayerController → PlayerState → Pawn 순서입니다. BeginPlay에서 다른 프레임워크 클래스에 접근할 때, 아직 초기화되지 않은 클래스를 참조하면 nullptr 크래시가 발생할 수 있으므로 항상 nullptr 체크를 수행해야 합니다.

 

 

게임플레이 프레임워크는 게임의 규칙(GameMode), 공개 상태(GameState), 플레이어 정보(PlayerState), 입력 처리(PlayerController), 물리적 표현(Pawn)을 각각 전담 클래스로 분리하는 언리얼 엔진의 핵심 아키텍처입니다. 이 구조를 이해하고 각 클래스의 역할에 맞게 로직을 배치하면, 싱글플레이어에서 멀티플레이어로의 전환이 자연스러운 확장 가능한 게임 구조를 설계할 수 있습니다. 또한 UActorComponent와 USceneComponent의 차이를 이해하여 필요에 맞는 가장 가벼운 컴포넌트를 선택하는 것이 성능과 설계 양면에서 중요합니다.

반응형