카테고리 없음

[Unreal 9기] 2026-07-30 UE멀티플레이 채팅

김인철_ 2026. 7. 30. 19:44

 

Unreal Engine 멀티플레이 채팅

Unreal Engine에서 채팅 입력창을 만드는 일은 어렵지 않다. EditableTextBox에 입력된 문자열을 받아 출력하면 로컬 기능은 완성된다. 하지만 같은 기능을 멀티플레이 환경에 올리면 전혀 다른 문제가 생긴다.

  • 위젯은 서버와 모든 클라이언트 중 어디에 생성해야 하는가?
  • PlayerController는 어느 머신에 존재하는가?
  • 다른 클라이언트 화면에도 문자열이 출력되었다면 정말 네트워크 통신에 성공한 것인가?
  • 에디터의 PIE(Play In Editor) 설정은 테스트 결과에 어떤 영향을 주는가?

1. 구현 전에 서버 구조부터 구분하기

멀티플레이 게임의 실행 구조는 크게 다음 세 가지로 나눌 수 있다.

구조 서버 역할 장점 주요 제약
P2P 각 피어가 클라이언트이자 서버 별도 서버 비용이 적음 연결 관리, 보안, 호스트 신뢰 문제
Listen Server 플레이어 한 명이 호스트이자 클라이언트 구현과 소규모 매치 구성이 간단함 호스트 이점, 호스트 종료 시 세션 유지 문제
Dedicated Server 플레이에 참여하지 않는 별도 프로세스가 서버 역할 공정성, 권한 집중, 운영과 확장에 유리함 별도 빌드와 서버 운영 비용 필요

 

Listen Server의 호스트는 서버 권한과 로컬 플레이어를 동시에 가진다. 반면 Dedicated Server는 화면에 게임을 플레이하는 로컬 사용자가 없고, 오직 월드의 권위 있는 상태를 관리한다.

채팅처럼 모든 참가자가 공유하는 기능을 설계할 때도 이 차이가 중요하다. 클라이언트가 자신의 화면에 문자열을 출력하는 것과, 서버가 메시지를 수신하고 다른 클라이언트에 전달하는 것은 서로 다른 단계이기 때문이다.

2. Dedicated Server에서 월드와 플레이어가 만들어지는 과정

Dedicated Server의 실행 흐름을 단순화하면 다음과 같다.

  1. 서버 프로세스가 특정 레벨을 연다.
  2. 외부 접속을 받을 수 있도록 소켓을 열고 대기한다.
  3. 레벨의 WorldSettings를 기준으로 GameModeGameState를 생성한다.
  4. 클라이언트가 서버의 IP와 포트로 접속한다.
  5. 클라이언트가 같은 레벨을 로드한다.
  6. 서버가 접속한 플레이어에 필요한 PlayerController, PlayerState, Pawn 또는 Character를 생성한다.
  7. 복제가 필요한 상태와 액터가 각 클라이언트에 전달된다.

이 과정에서 가장 중요한 기준은 서버 권한(Authority)소유권(Ownership) 이다.

클래스 서버 해당 플레이어의 클라이언트 다른 클라이언트
GameMode 존재 존재하지 않음 존재하지 않음
GameState 원본 존재 복제본 존재 복제본 존재
PlayerController 플레이어마다 존재 자신이 소유한 컨트롤러 존재 다른 플레이어의 컨트롤러는 일반적으로 존재하지 않음
PlayerState 플레이어마다 존재 복제본 존재 복제본 존재
Pawn/Character 권위 있는 액터 존재 관련성에 따라 복제 관련성에 따라 복제

 

GameMode가 서버에만 존재한다는 점은 서버 전용 규칙이나 판정을 배치하는 기준이 된다. 반대로 점수판처럼 모든 클라이언트가 읽어야 하는 상태는 GameState 또는 PlayerState가 더 적합하다.

또한 일반적인 서버-클라이언트 모델에서 클라이언트끼리 직접 통신하지 않는다. 클라이언트가 보낸 요청은 서버가 받고, 서버의 처리 결과가 다시 필요한 클라이언트로 전달된다. 이후 RPC와 프로퍼티 리플리케이션을 설계할 때도 이 경로가 기본 전제가 된다.

3. C++ 기반 클래스와 Blueprint 설정을 분리하기

실습 프로젝트는 렌더링할 3D 장면이 필요하지 않으므로 빈 Chatting 레벨을 사용한다. 이 레벨을 Editor Startup MapGame Default Map으로 지정하고, 다음과 같이 프레임워크 클래스를 구성한다.

ACXGameModeBase
└─ BP_GameModeBase
   └─ PlayerController Class: BP_PlayerController

ACXPlayerController
└─ BP_PlayerController
   └─ Chat Input Widget Class: WBP_ChatInput

UCXChatInput
└─ WBP_ChatInput
   └─ EditableTextBox_ChatInput

이 구조는 역할을 다음처럼 나눈다.

  • C++ 클래스는 위젯 생성, 이벤트 바인딩, 입력 처리 같은 핵심 로직을 담당한다.
  • Blueprint 자식 클래스는 실제 위젯 에셋과 클래스 참조를 연결한다.
  • 레벨의 WorldSettings는 사용할 GameMode를 선택한다.
  • GameMode는 해당 게임에서 사용할 PlayerController 클래스를 선택한다.

TSubclassOf<UCXChatInput>EditDefaultsOnly로 노출하면 C++ 코드가 특정 위젯 Blueprint의 경로를 직접 알 필요가 없다.

UCLASS()
class CHATX_API ACXPlayerController : public APlayerController
{
    GENERATED_BODY()

public:
    virtual void BeginPlay() override;

protected:
    UPROPERTY(EditDefaultsOnly, Category = "Chat")
    TSubclassOf<UCXChatInput> ChatInputWidgetClass;

    UPROPERTY()
    TObjectPtr<UCXChatInput> ChatInputWidgetInstance;
};

TSubclassOf는 지정 가능한 클래스를 UCXChatInput의 자식으로 제한한다. 생성된 인스턴스는 UPROPERTY가 붙은 TObjectPtr로 보관해 Unreal의 객체 추적과 가비지 컬렉션 대상에서 안전하게 참조한다.

4. UMG 위젯을 C++에 바인딩하기

UUserWidget 기반 클래스를 사용하려면 모듈 의존성에 UI 모듈을 추가해야 한다.

PublicDependencyModuleNames.AddRange(new string[]
{
    "Core",
    "CoreUObject",
    "Engine",
    "InputCore",
    "EnhancedInput",
    "UMG",
    "Slate",
    "SlateCore"
});
  • UMGUUserWidget, UEditableTextBox 같은 UMG API를 제공한다.
  • SlateSlateCore는 입력 커밋 타입과 저수준 UI 시스템을 사용할 때 필요하다.

위젯 클래스에서는 BindWidget으로 Blueprint 위젯 트리의 요소를 연결한다.

class UEditableTextBox;

UCLASS()
class CHATX_API UCXChatInput : public UUserWidget
{
    GENERATED_BODY()

public:
    virtual void NativeConstruct() override;
    virtual void NativeDestruct() override;

protected:
    UFUNCTION()
    void OnChatInputTextCommitted(
        const FText& Text,
        ETextCommit::Type CommitMethod
    );

    UPROPERTY(meta = (BindWidget))
    TObjectPtr<UEditableTextBox> EditableTextBox_ChatInput;
};

BindWidget은 단순한 이름 검색 편의 기능이 아니라 C++ 클래스와 Widget Blueprint 사이의 계약이다. WBP_ChatInput 안에 동일한 이름과 호환되는 타입의 위젯이 있어야 바인딩된다. Blueprint에서 위젯 이름을 변경하면 C++ 코드도 함께 수정해야 한다.

5. UI는 Owning Client에서만 생성해야 한다

PlayerController::BeginPlay()가 실행되었다고 해서 그 인스턴스가 항상 실제 사용자의 로컬 컨트롤러인 것은 아니다. 서버에는 접속한 플레이어마다 PlayerController가 존재하며, 클라이언트에는 자신이 소유한 로컬 컨트롤러가 있다.

따라서 화면에 표시할 위젯은 IsLocalController()로 로컬 소유 여부를 먼저 검사한 뒤 생성해야 한다.

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

    if (!IsLocalController())
    {
        return;
    }

    FInputModeUIOnly InputMode;
    SetInputMode(InputMode);

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

    ChatInputWidgetInstance =
        CreateWidget<UCXChatInput>(this, ChatInputWidgetClass);

    if (IsValid(ChatInputWidgetInstance))
    {
        ChatInputWidgetInstance->AddToViewport();
    }
}

여기서 this를 Owning Player로 전달하기 때문에 생성된 위젯은 해당 로컬 PlayerController와 연결된다. 서버의 원격 컨트롤러나 다른 플레이어의 컨트롤러에 같은 UI를 만들 필요는 없다.

이 검사가 없으면 각 네트워크 인스턴스의 BeginPlay()가 위젯 생성을 시도할 수 있다. 전용 서버에는 표시할 화면 자체가 없으며, 다른 플레이어의 UI는 각자의 로컬 클라이언트가 책임져야 한다. IsLocalController()는 게임플레이 상태의 복제와 로컬 표현 계층을 분리하는 경계가 된다.

6. 위젯 생명주기에 맞춰 델리게이트 관리하기

채팅 입력은 UEditableTextBox::OnTextCommitted 델리게이트를 통해 처리할 수 있다. Enter 키로 입력을 확정했을 때만 메시지를 넘기도록 커밋 방식을 검사한다.

void UCXChatInput::NativeConstruct()
{
    Super::NativeConstruct();

    if (IsValid(EditableTextBox_ChatInput) &&
        !EditableTextBox_ChatInput->OnTextCommitted.IsAlreadyBound(
            this,
            &ThisClass::OnChatInputTextCommitted))
    {
        EditableTextBox_ChatInput->OnTextCommitted.AddDynamic(
            this,
            &ThisClass::OnChatInputTextCommitted);
    }
}

void UCXChatInput::NativeDestruct()
{
    if (IsValid(EditableTextBox_ChatInput) &&
        EditableTextBox_ChatInput->OnTextCommitted.IsAlreadyBound(
            this,
            &ThisClass::OnChatInputTextCommitted))
    {
        EditableTextBox_ChatInput->OnTextCommitted.RemoveDynamic(
            this,
            &ThisClass::OnChatInputTextCommitted);
    }

    Super::NativeDestruct();
}

NativeConstruct()는 위젯 인스턴스의 생애 동안 한 번만 호출된다고 가정하면 안 된다. 위젯이 다시 구성되었을 때 같은 함수를 중복 바인딩하면 입력 한 번에 콜백이 여러 번 실행될 수 있다. 바인딩 여부를 확인하고, NativeDestruct()에서 대칭적으로 해제하면 생명주기가 명확해진다.

입력 콜백에서는 위젯이 소유한 PlayerController를 얻어 로컬 메시지 처리 함수로 전달한다.

void UCXChatInput::OnChatInputTextCommitted(
    const FText& Text,
    ETextCommit::Type CommitMethod)
{
    if (CommitMethod != ETextCommit::OnEnter || Text.IsEmpty())
    {
        return;
    }

    ACXPlayerController* Controller =
        Cast<ACXPlayerController>(GetOwningPlayer());

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

    Controller->SetChatMessageString(Text.ToString());
    EditableTextBox_ChatInput->SetText(FText::GetEmpty());
}

현재 데이터 흐름은 다음과 같다.

EditableTextBox
    → OnTextCommitted
    → Owning PlayerController
    → SetChatMessageString()
    → 로컬 PrintString()

이 단계의 FString ChatMessageString은 복제 프로퍼티가 아니고, SetChatMessageString()도 RPC가 아니다. 따라서 여기까지는 로컬 채팅 입력과 출력만 구현한 상태다.

7. 다른 창에도 출력되었다고 네트워크 통신은 아니다

PIE에서 여러 플레이어 창을 실행했을 때 PrintString()이 모든 창에 보일 수 있다. 이 현상만 보고 메시지가 서버를 거쳐 복제되었다고 판단하면 안 된다.

Run Under One Process가 활성화되어 있으면 서버와 여러 클라이언트가 하나의 Unreal Editor 프로세스 안에서 실행된다. 이 환경에서는 에디터 디버그 출력과 일부 공유 상태 때문에 각 인스턴스가 완전히 분리된 것처럼 보이지 않을 수 있다. PrintString()은 채팅 메시지의 네트워크 전송을 증명하는 도구가 아니다.

멀티플레이 기능을 검증할 때는 최소한 다음을 구분해야 한다.

  • 함수가 어느 프로세스에서 호출되었는가?
  • 해당 액터의 HasAuthority() 결과는 무엇인가?
  • GetLocalRole()GetRemoteRole()은 무엇인가?
  • 실행 중인 컨트롤러가 IsLocalController()를 만족하는가?
  • 출력이 RPC 또는 복제 결과인가, 단순 에디터 디버그 출력인가?

로그에 네트워크 모드, 로컬 역할, 액터 이름을 함께 남기면 실행 위치를 훨씬 정확하게 추적할 수 있다.

UE_LOG(
    LogTemp,
    Log,
    TEXT("Controller=%s Local=%d Authority=%d NetMode=%d"),
    *GetNameSafe(this),
    IsLocalController(),
    HasAuthority(),
    static_cast<int32>(GetNetMode())
);

8. PIE를 실제 서버-클라이언트 구조에 가깝게 설정하기

Dedicated Server 동작을 확인할 때는 PIE 옵션도 테스트 조건의 일부로 관리해야 한다.

옵션 권장 설정 의미
Launch Separate Server 활성화 플레이어 창과 별개의 서버 인스턴스를 실행
Run Under One Process 비활성화 서버와 클라이언트를 서로 다른 프로세스로 분리
Net Mode Play as Client 에디터 플레이어 창을 클라이언트로 실행
Number of Players 2 이상 복수 클라이언트 동작 확인
Allow Late Joining 필요 시 활성화 실행 중 클라이언트 추가 접속 확인

 

Run Under One Process는 실행 속도와 자원 사용 면에서는 유리하므로 빠른 반복 테스트에 쓸 수 있다. 다만 네트워크 경계, 프로세스별 전역 상태, 로그와 디버그 출력을 확인하는 단계에서는 비활성화한 테스트가 필요하다.

즉, 두 모드는 용도가 다르다.

  • 빠른 UI 반복 작업: 단일 프로세스 PIE
  • 네트워크 동작 검증: 프로세스를 분리한 PIE

9. 실제 멀티플레이 채팅에 필요한 네트워크 경계

현재 구현을 실제 채팅으로 확장할 때의 기본 경로는 다음과 같다.

Owning Client
    → 로컬 UI 입력
    → Server RPC
Server
    → 메시지 검증 및 발신자 판별
    → 필요한 클라이언트에 전달
Clients
    → 수신한 메시지를 각자의 로컬 UI에 표시

여기서 중요한 설계 원칙은 다음과 같다.

  1. UI 객체는 복제하지 않는다. 각 클라이언트가 자신의 로컬 위젯을 생성하고 관리한다.
  2. 클라이언트 입력은 서버가 신뢰하지 않는다. 길이 제한, 빈 문자열, 전송 빈도, 금칙어 같은 검증은 서버에서 수행한다.
  3. 발신자 정보는 서버 기준으로 결정한다. 클라이언트가 임의의 닉네임이나 플레이어 ID를 메시지와 함께 보내도록 두지 않는다.
  4. 전송과 표시는 분리한다. PlayerController는 소유 클라이언트와의 RPC 경계로 사용할 수 있고, 채팅 로그를 어느 클래스에 저장할지는 수명과 공개 범위에 따라 별도로 결정한다.
  5. 전역 채팅과 개인 메시지는 전달 범위가 다르다. 전역 채팅은 참가자 전체, 귓속말은 특정 소유 클라이언트만 대상으로 삼아야 한다.

채팅 한 줄처럼 유실되면 사용자 경험에 직접 영향을 주는 낮은 빈도의 이벤트에는 Reliable RPC가 자연스러운 선택이 될 수 있다. 다만 신뢰성 있는 RPC도 무제한 호출을 허용한다는 뜻은 아니므로 서버 측 rate limit은 별도로 필요하다.

10. 핵심 정리

멀티플레이 채팅의 첫 단계에서 확인해야 할 것은 문자열 처리보다 실행 위치다.

  • GameMode는 서버에만 존재하고 게임 규칙의 권위를 가진다.
  • 클라이언트끼리는 직접 통신하지 않으며 서버가 중계 지점이 된다.
  • PlayerController는 서버와 해당 Owning Client 사이의 중요한 소유권 경계다.
  • 화면에 표시할 UMG 위젯은 IsLocalController()를 확인한 뒤 로컬에만 생성한다.
  • BindWidget은 C++ 위젯 클래스와 Widget Blueprint의 이름 및 타입 계약이다.
  • 동적 델리게이트는 위젯 생명주기에 맞춰 바인딩하고 해제한다.
  • 단일 프로세스 PIE의 PrintString() 결과는 네트워크 복제의 증거가 아니다.
  • 실제 채팅은 로컬 입력, Server RPC, 서버 검증, 클라이언트 전달, 로컬 UI 표시의 단계를 가져야 한다.

이 경계를 분명히 하면 이후 RPC와 프로퍼티 리플리케이션을 붙일 때도 “어떤 객체에서, 어느 방향으로, 누구에게 보낼 것인가”를 일관된 기준으로 판단할 수 있다.