UE/멀티플레이

UE C++ 멀티플레이 게임 프레임워크

김인철_ 2026. 8. 4. 19:39

 

언리얼 멀티플레이 게임플레이 프레임워크와 네트워크 생명주기

언리얼 멀티플레이에서 같은 액터 클래스는 서버와 여러 클라이언트에 각각 생성될 수 있다. 생성자나 BeginPlay()에 로그 한 줄을 추가했는데 여러 번 출력되는 이유도 이 때문이다. 여기에 CDO 생성, PIE의 다중 월드, 별도 서버 프로세스까지 겹치면 함수가 어느 인스턴스에서 왜 호출됐는지 파악하기 어려워진다.

이 문제를 풀기 위해서는 두 가지 축을 함께 이해해야 한다.

  1. GameMode, GameState, PlayerState, PlayerController, Pawn이 각각 어느 컴퓨터에 존재하고 무엇을 소유하는가?
  2. 접속, 복제, 게임 시작, Possess 과정에서 어떤 이벤트가 어떤 순서로 호출되는가?

이번 글에서는 게임플레이 프레임워크의 책임, 데디케이티드 서버 PIE 환경, 네트워크 문맥이 포함된 로깅, 로그인부터 BeginPlay()와 Possess까지 이어지는 생명주기를 하나의 흐름으로 정리한다.

모듈 설정은 프레임워크 코드의 출발점

프로젝트 모듈의 .Build.cs 파일은 C++ 코드가 사용할 엔진 모듈과 헤더 탐색 경로를 정의한다. 현재 NumberBaseball 모듈은 기본 엔진 모듈 외에 Enhanced Input과 UMG 관련 모듈을 사용한다.

PublicDependencyModuleNames.AddRange(new string[]
{
    "Core",
    "CoreUObject",
    "Engine",
    "InputCore",
    "EnhancedInput",
    "UMG",
    "Slate",
    "SlateCore"
});

PublicIncludePaths.AddRange(new string[]
{
    "NumberBaseball"
});

EnhancedInput은 Input Action과 Input Mapping Context를 사용하는 캐릭터 입력 코드에 필요하다. UMG, Slate, SlateCore는 채팅과 공지 위젯을 구성할 때 사용한다.

모듈 루트를 include path에 추가하면 다음과 같이 모듈 내부 기준 경로로 헤더를 포함할 수 있다.

#include "Game/NBGameModeBase.h"
#include "Player/NBPlayerController.h"

의존 모듈이 빠지면 타입 선언을 찾지 못하거나 링크 단계에서 심볼을 해결하지 못한다. 반면 사용하지 않는 모듈을 무분별하게 추가하면 컴파일 의존성과 빌드 범위가 커진다. 프레임워크 클래스를 만들기 전에 실제 사용하는 시스템을 기준으로 모듈 의존성을 정리하는 편이 좋다.

게임플레이 프레임워크 클래스의 존재 범위

멀티플레이 설계에서 가장 먼저 확인할 것은 각 클래스가 어디에 존재하는지다.

클래스 서버 소유 클라이언트 다른 클라이언트 대표 책임
GameMode O X X 서버 규칙, 로그인 승인, 스폰과 승패 판정
GameState O O O 모든 참가자가 알아야 하는 경기 상태
PlayerState O O O 플레이어별 공개 상태
PlayerController O O X 연결의 대표, 입력과 소유자 RPC
replicated Pawn / Character O O O 월드에 표현되는 플레이어 아바타
AnimInstance O O O 각 Skeletal Mesh 인스턴스의 애니메이션 상태
로컬 UI X O X 화면 표시와 입력 수집

서버 전용 규칙을 GameMode에 두고, 모든 클라이언트가 읽어야 하는 값을 GameStatePlayerState로 복제하는 이유가 이 존재 범위에 있다.

GameMode

GameMode는 서버에만 존재한다. 접속 허용 여부, 사용할 Pawn과 Controller 클래스, 경기 시작과 종료 조건처럼 클라이언트가 결정해서는 안 되는 규칙을 맡는다.

NumberBaseball 프로젝트의 ANBGameModeBase도 다음 서버 로직을 소유한다.

  • OnPostLogin()에서 접속한 플레이어 등록
  • Logout()에서 퇴장한 컨트롤러 제거
  • 비밀 숫자 생성과 판정
  • 승리·무승부 검사와 라운드 리셋

GameState

GameState는 서버에서 생성되어 모든 클라이언트로 복제된다. 서버의 경기 상태를 클라이언트에 공개하는 통로다.

게임 시작 과정에서도 중요한 역할을 한다. 서버의 GameMode는 클라이언트에 존재하지 않으므로 클라이언트 액터에게 직접 게임 시작을 알릴 수 없다. 대신 GameState의 replicated state가 게임 시작 신호를 전달한다.

PlayerState

PlayerState는 플레이어별 공개 상태다. 다른 클라이언트에도 복제되므로 이름, 점수, 팀, 공개된 시도 횟수처럼 점수판이나 플레이어 목록에 필요한 데이터를 두기 적합하다.

NumberBaseball 프로젝트에서는 다음 값을 ANBPlayerState가 소유한다.

UPROPERTY(Replicated)
FString PlayerNameString;

UPROPERTY(Replicated)
int32 CurrentGuessCount;

UPROPERTY(Replicated)
int32 MaxGuessCount;

PlayerController

PlayerController는 네트워크 연결과 플레이어 입력을 대표한다. 서버에는 모든 플레이어의 컨트롤러가 있지만, 클라이언트에는 자신의 컨트롤러만 존재한다.

이 특성 때문에 Server RPC의 진입점이나 소유 클라이언트 대상 Client RPC에 적합하다. NumberBaseball의 채팅 입력과 개인 공지도 ANBPlayerController를 경유한다.

Pawn과 Character

Pawn은 Controller가 Possess할 수 있는 월드 액터다. Character는 이동 컴포넌트, 캡슐, 메시 등 캐릭터 게임에 자주 필요한 기능을 갖춘 Pawn의 파생 클래스다.

로컬 입력 설정은 모든 복제본에서 수행할 필요가 없다. 입력을 소유한 인스턴스인지 확인한 뒤 로컬 플레이어 서브시스템에 Mapping Context를 추가해야 한다.

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

    if (!IsLocallyControlled())
    {
        return;
    }

    APlayerController* PlayerController =
        Cast<APlayerController>(GetController());

    UEnhancedInputLocalPlayerSubsystem* InputSubsystem =
        ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(
            PlayerController->GetLocalPlayer());

    InputSubsystem->AddMappingContext(InputMappingContext, 0);
}

NumberBaseball의 ANBPlayerController::BeginPlay()도 같은 원칙으로 IsLocalController()를 검사한 뒤 로컬 채팅과 공지 위젯만 생성한다.

PIE 멀티플레이 환경을 어떻게 구성할 것인가

PIE의 실행 방식은 로그와 재현성에 직접 영향을 준다.

Launch Separate Server

별도 서버를 실행하면 플레이 화면이 없는 서버 인스턴스와 클라이언트 인스턴스를 구분할 수 있다. Play As Client와 함께 사용하면 데디케이티드 서버 구조를 테스트하기 좋다.

별도 서버를 사용하지 않고 Play As Listen Server로 실행하면 한 인스턴스가 서버와 로컬 플레이어 역할을 동시에 수행한다. 리슨 서버에서만 우연히 동작하는 로컬 UI나 입력 코드가 데디케이티드 서버에서는 실패할 수 있으므로 두 모드를 구분해야 한다.

Run Under One Process

설정 장점 주의점
활성화 서버와 클라이언트 로그를 한 Output Log에서 확인하기 쉽고 실행이 빠름 같은 프로세스와 에디터 자원을 공유하므로 완전한 격리 환경은 아님
비활성화 서버와 클라이언트가 별도 프로세스로 실행되어 실제 배포 환경과 유사 로그 창과 프로세스가 분리되어 추적이 번거롭고 실행 비용이 큼

이벤트 순서를 빠르게 관찰할 때는 One Process가 편리하다. 프로세스 격리, 정적 상태 공유 문제, 실제 접속 동작을 검증할 때는 Separate Process가 더 신뢰할 수 있다.

Number of Players와 Late Join

다중 클라이언트의 역할과 소유권을 보려면 두 명 이상으로 실행해야 한다. Late Join을 허용하면 실행 중인 서버에 클라이언트를 추가해 다음 항목도 확인할 수 있다.

  • ClientConnection 생성
  • PlayerControllerPlayerState 등록
  • 이미 진행 중인 replicated state의 초기 동기화
  • PostLogin과 접속 알림 처리

네트워크 문맥을 포함하는 로그

함수명과 메시지만 기록하면 어느 월드에서 나온 로그인지 알 수 없다. 멀티플레이 로그에는 최소한 다음 정보가 필요하다.

  • NetMode: Server, Client, Standalone
  • PIE 인스턴스 번호
  • 함수명
  • 필요할 때 Local Role과 Remote Role

프로젝트 전용 로그 카테고리를 선언하면 네트워크 로그만 필터링할 수 있다.

// NumberBaseball.h

DECLARE_LOG_CATEGORY_EXTERN(LogNBNet, Log, All);
// NumberBaseball.cpp

DEFINE_LOG_CATEGORY(LogNBNet);

액터 멤버 함수 안에서 사용하는 로그 헬퍼는 다음 형태로 구성할 수 있다.

#define NB_FUNCTION_NAME ANSI_TO_TCHAR(__FUNCTION__)

#define NB_LOCAL_ROLE \
    *UEnum::GetValueAsString( \
        TEXT("Engine.ENetRole"), GetLocalRole())

#define NB_REMOTE_ROLE \
    *UEnum::GetValueAsString( \
        TEXT("Engine.ENetRole"), GetRemoteRole())

#define NB_LOG_ROLE(Verbosity, Format, ...) \
    UE_LOG( \
        LogNBNet, \
        Verbosity, \
        TEXT("[%s][%s/%s] %s %s"), \
        *NumberBaseballFunctionLibrary::GetNetModeString(this), \
        NB_LOCAL_ROLE, \
        NB_REMOTE_ROLE, \
        NB_FUNCTION_NAME, \
        *FString::Printf(Format, ##__VA_ARGS__))

출력은 다음처럼 읽을 수 있다.

[Server][ROLE_Authority/ROLE_AutonomousProxy]
ANBPlayerController::OnPostLogin Begin

[Client01][ROLE_AutonomousProxy/ROLE_Authority]
ANBPlayerController::PostNetInit End

이 포맷을 사용하면 동일한 함수가 서버 원본과 클라이언트 프록시에서 호출됐는지, Possess 전후 Role이 어떻게 달라졌는지 한눈에 비교할 수 있다.

생성자 로그와 CDO

UObject 기반 클래스의 생성자는 플레이 인스턴스만을 위해 호출되는 것이 아니다. 에디터가 클래스를 로드할 때 CDO를 생성하거나 Blueprint 파생 클래스를 준비하면서 생성자 로그가 먼저 출력될 수 있다.

따라서 생성자 호출 횟수만 보고 런타임 액터 수를 판단하면 안 된다. 생성자를 분석할 때는 다음 정보를 함께 출력하는 편이 안전하다.

UE_LOG(
    LogNBNet,
    Log,
    TEXT("Name=%s CDO=%s"),
    *GetName(),
    HasAnyFlags(RF_ClassDefaultObject)
        ? TEXT("true")
        : TEXT("false"));

플레이 직전에 Output Log를 비우면 에디터 초기화 로그와 PIE 런타임 로그를 구분하기 쉽다.

접속 생명주기: PreLogin에서 PostLogin까지

클라이언트 접속은 하나의 함수에서 끝나지 않는다. 서버의 로그인 파이프라인과 클라이언트의 네트워크 초기화 이벤트가 이어진다.

sequenceDiagram
    participant Client as "접속 클라이언트"
    participant Server as "서버 NetDriver"
    participant GM as "서버 GameMode"
    participant PC as "PlayerController"

    Client->>Server: 접속 요청
    Server->>GM: PreLogin
    alt ErrorMessage가 비어 있지 않음
        GM-->>Client: 접속 거부
    else 접속 허용
        GM->>GM: Login
        GM->>PC: PlayerController 생성/연결
        GM->>GM: PostLogin / OnPostLogin
        PC-->>Client: 초기 복제
        Client->>PC: PostNetInit
    end

PreLogin: 접속 승인 이전

PreLogin()은 실제 플레이어 등록 전에 접속을 거부할 수 있는 단계다.

void AMyGameMode::PreLogin(
    const FString& Options,
    const FString& Address,
    const FUniqueNetIdRepl& UniqueId,
    FString& ErrorMessage)
{
    Super::PreLogin(
        Options,
        Address,
        UniqueId,
        ErrorMessage);

    if (GetNumPlayers() >= MaxPlayerCount)
    {
        ErrorMessage =
            TEXT("The server is currently full.");
    }
}

ErrorMessage를 설정하면 로그인 파이프라인이 중단된다. 서버 정원, 밴 목록, 버전 호환성처럼 Controller를 생성하기 전에 확인해야 하는 조건에 적합하다.

테스트를 위해 항상 에러를 넣었다면 확인 후 반드시 제거해야 한다. 접속이 거부된 창이 Standalone처럼 보일 수 있으므로 NetMode 로그와 서버의 거부 메시지를 함께 확인하는 것이 좋다.

Login: PlayerController 생성 단계

Login()은 접속할 플레이어의 PlayerController를 준비하는 단계다. 일반적인 프로젝트는 Super::Login()이 수행하는 내부 초기화를 유지하고 반환된 컨트롤러를 검사한다.

APlayerController* LoginPlayerController =
    Super::Login(
        NewPlayer,
        InRemoteRole,
        Portal,
        Options,
        UniqueId,
        ErrorMessage);

return LoginPlayerController;

이 단계를 직접 재구현하기보다 필요한 진단 로그를 앞뒤에 추가하는 편이 안전하다.

PostLogin과 OnPostLogin: 접속 완료 이후

로그인 완료 이후에는 서버의 PlayerController와 그에 대응하는 ClientConnection이 준비되어 있다. 플레이어 등록, 초기 서버 상태 설정, 소유 클라이언트 Client RPC에 적합한 단계다.

NumberBaseball 프로젝트의 OnPostLogin()은 다음 작업을 수행한다.

void ANBGameModeBase::OnPostLogin(AController* NewPlayer)
{
    Super::OnPostLogin(NewPlayer);

    ANBPlayerController* PlayerController =
        Cast<ANBPlayerController>(NewPlayer);

    if (!IsValid(PlayerController))
    {
        return;
    }

    AllPlayerControllers.Add(PlayerController);
    ++JoinedPlayerCount;

    PlayerController->ClientRPCSetNotificationText(
        FText::FromString(
            TEXT("Connected to the game server.")));
}

접속 순번을 현재 배열 길이와 분리한 이유도 생명주기와 관련 있다. 플레이어가 나가면 배열 길이는 줄지만 누적 접속 순번은 되돌아가지 않는다. 별도 카운터를 사용하면 퇴장 후 새 접속자가 기존 이름을 다시 받는 문제를 피할 수 있다.

Logout: 서버 레지스트리 정리

접속한 Controller를 별도 배열에 보관했다면 퇴장 시 반드시 제거해야 한다.

void ANBGameModeBase::Logout(AController* Exiting)
{
    ANBPlayerController* PlayerController =
        Cast<ANBPlayerController>(Exiting);

    if (IsValid(PlayerController))
    {
        AllPlayerControllers.Remove(PlayerController);
    }

    Super::Logout(Exiting);
}

정리하지 않으면 이후 방송이나 게임 리셋이 이미 퇴장한 컨트롤러를 대상으로 시도될 수 있다. UPROPERTY()가 붙은 TObjectPtr 배열은 GC 참조도 유지하므로, 접속과 퇴장 양쪽에서 컬렉션의 불변 조건을 관리해야 한다.

서버와 클라이언트의 NetConnection 확인

UNetDriver가 관리하는 연결 구조는 서버와 클라이언트에서 다르다.

Server NetDriver
└─ ClientConnections[N]
   ├─ ClientConnection 1
   ├─ ClientConnection 2
   └─ ...

Client NetDriver
└─ ServerConnection

서버의 로그인 완료 단계에서는 ClientConnections를 관찰할 수 있다.

UNetDriver* ServerNetDriver = GetNetDriver();

if (IsValid(ServerNetDriver))
{
    for (UNetConnection* ClientConnection
         : ServerNetDriver->ClientConnections)
    {
        if (IsValid(ClientConnection))
        {
            UE_LOG(
                LogNBNet,
                Log,
                TEXT("ClientConnection=%s"),
                *ClientConnection->GetName());
        }
    }
}

클라이언트에서는 replicated PlayerControllerPostNetInit() 시점에 자신의 ServerConnection을 확인할 수 있다.

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

    if (!IsLocalController())
    {
        return;
    }

    UNetDriver* ClientNetDriver = GetNetDriver();
    UNetConnection* ServerConnection =
        IsValid(ClientNetDriver)
            ? ClientNetDriver->ServerConnection
            : nullptr;
}

PostNetInit()은 액터의 초기 네트워크 속성이 클라이언트에 적용된 뒤 호출되는 진단 지점이다. 클라이언트 복제본에서 Owner, Role, Connection 같은 초기 네트워크 문맥을 확인할 때 생성자보다 적합하다.

Connection, Channel, Bunch, Packet

언리얼 네트워크 로그와 엔진 소스를 읽으려면 데이터 계층을 구분해야 한다.

단위 역할
UNetConnection 서버와 클라이언트 사이의 통신 경로
Channel 하나의 Connection 안에서 목적별 데이터를 나누는 논리 채널
Actor Channel 액터 생성, 프로퍼티 복제, RPC 등 액터 관련 데이터 전송
Control Channel 연결 제어와 로그인 같은 제어 메시지
Voice Channel 음성 데이터
Bunch 언리얼이 Channel을 통해 전송하는 데이터 묶음
Packet 네트워크 계층에서 실제로 전달되는 전송 단위

관계는 다음과 같이 볼 수 있다.

NetConnection
├─ ControlChannel
├─ ActorChannel: PlayerController
├─ ActorChannel: PlayerState
├─ ActorChannel: Pawn
└─ ...
    └─ Bunch
       └─ Packet에 패킹되어 전송

OnActorChannelOpen()은 액터 채널이 열리는 낮은 수준의 이벤트를 관찰할 수 있지만, 일반 게임 로직을 넣기보다는 복제 문제를 진단하는 용도로 제한하는 편이 좋다.

게임 시작 신호와 BeginPlay 전파

서버에는 GameMode가 있지만 클라이언트에는 없다. 그런데도 클라이언트 액터의 BeginPlay()가 시작되는 이유는 GameState가 게임 시작 상태를 복제하기 때문이다.

전체 흐름은 다음과 같다.

flowchart TD
    A["Server GameMode::StartPlay"] --> B["GameState::HandleBeginPlay"]
    B --> C["bReplicatedHasBegunPlay = true"]
    B --> D["서버 WorldSettings::NotifyBeginPlay"]
    D --> E["서버 액터 DispatchBeginPlay"]
    E --> F["서버 액터 BeginPlay"]
    C --> G["Property Replication"]
    G --> H["클라이언트 GameState::OnRep_ReplicatedHasBegunPlay"]
    H --> I["클라이언트 WorldSettings::NotifyBeginPlay"]
    I --> J["클라이언트 액터 DispatchBeginPlay"]
    J --> K["클라이언트 액터 BeginPlay"]

AGameStateBase는 게임 시작 여부를 replicated property로 갖는다.

UPROPERTY(
    Transient,
    ReplicatedUsing = OnRep_ReplicatedHasBegunPlay)
bool bReplicatedHasBegunPlay;

서버의 HandleBeginPlay()는 값을 true로 바꾸고 서버 월드의 액터들에게 시작을 알린다. 클라이언트가 이 값을 받으면 OnRep_ReplicatedHasBegunPlay()가 클라이언트 월드의 액터들에게 시작을 알린다.

Super::StartPlay()를 생략하면 생기는 문제

GameMode::StartPlay()를 오버라이드하면서 Super::StartPlay()를 호출하지 않으면 기본 게임 시작 파이프라인이 끊어진다.

void AMyGameMode::StartPlay()
{
    Super::StartPlay();

    // 프로젝트 고유 시작 로직
}

서버의 시작 처리가 진행되지 않으면 GameState::HandleBeginPlay()와 월드의 NotifyBeginPlay()로 이어지지 않아 다른 액터의 BeginPlay()도 실행되지 않을 수 있다. 캐릭터 입력이 설정되지 않거나 게임이 멈춘 것처럼 보일 때 개별 Pawn만 조사하기보다 StartPlay()의 Super 호출부터 확인해야 하는 이유다.

액터 초기화 이벤트의 역할

서로 비슷해 보이는 이벤트도 보장하는 준비 상태가 다르다.

이벤트 주요 의미 적합한 진단
생성자 기본 서브오브젝트와 클래스 기본값 구성 CDO 여부, 컴포넌트 생성
PostInitializeComponents() 액터 컴포넌트 초기화 완료 컴포넌트 참조와 설정
PostNetInit() 클라이언트 복제본의 초기 네트워크 데이터 적용 Role, Owner, NetConnection
StartPlay() 서버가 경기 시작을 지시 게임 시작 파이프라인
BeginPlay() 해당 월드에서 액터 플레이 시작 입력, 타이머, 런타임 초기화

모든 초기화를 생성자나 BeginPlay() 하나에 몰아넣으면 네트워크 속성이 아직 준비되지 않은 시점에 접근하거나, 서버 복제본에서도 로컬 UI를 만들 수 있다. 필요한 보장에 맞는 이벤트를 선택해야 한다.

Possess와 Owner 복제

Possess는 단순히 Controller 포인터를 Pawn에 대입하는 작업이 아니다. 네트워크 소유 관계와 RPC 경로를 결정한다.

서버에서 Pawn이 Possess될 때 APawn::PossessedBy()는 Owner를 새 Controller로 설정한다.

void APawn::PossessedBy(AController* NewController)
{
    SetOwner(NewController);

    // Controller, PlayerState 등 Possess 상태 설정
}

이 Owner 체인을 통해 Pawn은 자신을 소유한 PlayerControllerUNetConnection을 찾는다. 클라이언트가 Pawn에서 Server RPC를 호출하거나 서버가 소유 클라이언트 대상으로 Client RPC를 보낼 수 있는 기반이다.

Possess 해제 시에는 Owner와 Controller, PlayerState 연결이 정리된다.

void APawn::UnPossessed()
{
    ForceNetUpdate();
    SetPlayerState(nullptr);
    SetOwner(nullptr);
    Controller = nullptr;
}

서버의 PossessedBy와 클라이언트의 OnRep_Owner

Possess의 권한 있는 처리는 서버에서 실행된다. 클라이언트가 서버의 PossessedBy()를 똑같이 호출해 소유권을 재현하는 것이 아니다.

AActor::Owner는 복제되는 프로퍼티이므로 클라이언트가 새 Owner를 받으면 OnRep_Owner()가 호출된다.

sequenceDiagram
    participant ServerPC as "서버 PlayerController"
    participant ServerPawn as "서버 Pawn"
    participant ClientPawn as "클라이언트 Pawn 복제본"

    ServerPC->>ServerPawn: Possess
    ServerPawn->>ServerPawn: PossessedBy
    ServerPawn->>ServerPawn: SetOwner(PlayerController)
    ServerPawn-->>ClientPawn: Owner 프로퍼티 복제
    ClientPawn->>ClientPawn: OnRep_Owner

서버 로직은 PossessedBy()OnPossess()에서 관찰하고, 클라이언트가 소유 관계를 받은 시점은 OnRep_Owner()에서 관찰한다.

Possess 전후 Role 변화

서버에서 생성된 Pawn은 항상 서버 쪽 Local Role이 Authority지만, 어느 클라이언트가 조종하게 되면 Remote Role이 Autonomous Proxy로 바뀔 수 있다. 소유 클라이언트의 Pawn 복제본은 Local Role이 Autonomous Proxy가 된다. 다른 클라이언트에서는 Simulated Proxy다.

관찰 위치 Local Role Remote Role
서버의 소유된 Pawn Authority AutonomousProxy
Pawn 소유 클라이언트 AutonomousProxy Authority
다른 클라이언트 SimulatedProxy Authority

Possess 직전과 직후에 Local/Remote Role을 함께 기록하면 입력과 Server RPC가 어느 Pawn에서 유효한지 확인할 수 있다.

문제별로 선택할 이벤트 함수

멀티플레이 문제는 증상과 가장 가까운 이벤트를 로깅해야 호출 순서를 좁힐 수 있다.

확인할 문제 우선 로깅할 위치
서버가 접속을 거부하는 이유 GameMode::PreLogin()ErrorMessage
로그인 중 Controller 생성 실패 GameMode::Login() 반환값
서버의 접속자 등록과 ClientConnection PostLogin() / OnPostLogin()
퇴장 후 남은 참조 GameMode::Logout()
클라이언트의 ServerConnection PlayerController::PostNetInit()
Actor Channel 생성 OnActorChannelOpen()
모든 액터의 BeginPlay() 미호출 GameMode::StartPlay(), GameState::HandleBeginPlay()
클라이언트만 BeginPlay()가 늦거나 없음 OnRep_ReplicatedHasBegunPlay()
컴포넌트가 아직 준비되지 않음 PostInitializeComponents()
Pawn 소유권과 RPC 실패 PossessedBy(), OnRep_Owner(), Role 로그
로컬 입력이 여러 복제본에서 실행 IsLocallyControlled() / IsLocalController()

이벤트 이름을 외우는 것보다 그 이벤트가 호출될 때 어떤 상태까지 준비되어 있는가를 기준으로 선택하는 것이 중요하다.

NumberBaseball 프로젝트에서 확인되는 프레임워크 패턴

현재 프로젝트에도 같은 게임플레이 프레임워크 원칙이 적용되어 있다.

ANBGameModeBase (Server Only)
├─ OnPostLogin / Logout
├─ 접속자 배열
├─ 비밀 숫자와 게임 규칙
└─ 승패 판정

ANBGameStateBase (Server + All Clients)
└─ 접속 알림 NetMulticast RPC

ANBPlayerState (Server Authority -> All Clients)
├─ PlayerNameString
├─ CurrentGuessCount
└─ MaxGuessCount

ANBPlayerController (Server + Owning Client)
├─ 채팅 Server / Client RPC
├─ 소유자 공지 Client RPC
└─ IsLocalController 이후 UI 생성

이 구조를 디버깅할 때 함수명만 출력하는 것보다 NetMode, PIE ID, Local/Remote Role을 함께 기록하면 실행 위치와 소유 관계를 훨씬 빠르게 확인할 수 있다. 접속 문제는 로그인 생명주기에서, 게임 시작 문제는 StartPlay()GameState의 시작 복제에서, 입력과 RPC 문제는 Possess와 Owner 복제에서 추적하는 식으로 조사 범위를 나눌 수 있다.