2026. 6. 7. 11:11ㆍTIL
서버를 거치는 이유
데디케이티드 서버 구조에서 클라이언트끼리는 직접 통신할 수 없다.
모든 연결은 서버를 통해서만 가능하다.
채팅 메시지 하나를 보내는 것도 마찬가지다. 내가 입력한 메시지를 다른 플레이어 화면에도 띄우려면 반드시 이 순서를 따라야 한다.

1. 클라이언트 A가 채팅 입력
2. Server RPC로 서버에 메시지 전송
3. 서버가 모든 클라이언트에 Client RPC로 메시지 전달
4. 각 클라이언트 화면에 출력
전체 흐름
이 흐름을 구현하기 위해서 세 가지 클래스가 협력한다.
[UMyUserWidget]
엔터 입력 감지
↓
[AMyPlayerController::SetChatMassageString()]
IsLocalController() 체크 후 Server RPC 호출
↓
[AMyPlayerController::ServerRPCPrintChatMessageString_Implementation()]
서버에서 TActorIterator로 모든 PC 순회
각각 Client RPC 호출
↓
[AMyPlayerController::ClientRPCPrintChatMessageString_Implementation()]
오너 클라이언트에서 화면 출력
접속 알림은 별도 흐름으로 동작한다.
[AMyGameModeBase::OnPostLogin()]
플레이어 접속 감지 (서버에서만)
↓
[AMyGameStateBase::MulticastRPCBroadcastLoginMessage_Implementation()]
NetMulticast로 모든 클라이언트에 브로드캐스트
HasAuthority() == false인 클라이언트에서만 화면 출력
채팅 입력 처리
// MyUserWidget.h
UPROPERTY(meta = (BindWidget))
TObjectPtr<UEditableTextBox> EditableTextBox_ChatInput;
meta = (BindWidget) 덕분에 블루프린트에서 같은 이름으로 만든 위젯과 자동으로 바인딩된다. 이름이 다르면 연결되지 않으면 주의해야한다.
void UMyUserWidget::NativeConstruct()
{
Super::NativeConstruct();
if (EditableTextBox_ChatInput->OnTextCommitted.IsAlreadyBound(this, &ThisClass::OnChatInputTextCommitted) == false)
{
EditableTextBox_ChatInput->OnTextCommitted.AddDynamic(this, &ThisClass::OnChatInputTextCommitted);
}
}
void UMyUserWidget::NativeDestruct()
{
Super::NativeDestruct();
if (EditableTextBox_ChatInput->OnTextCommitted.IsAlreadyBound(this, &ThisClass::OnChatInputTextCommitted) == true)
{
EditableTextBox_ChatInput->OnTextCommitted.RemoveDynamic(this, &ThisClass::OnChatInputTextCommitted);
}
}
NativeConstruct()는 위젯이 생성될 때, NativeDestruct()는 소멸될 때 호출된다. 생성자와 소멸자 같은 개념이다.
IsAlreadyBound() 체크는 델리게이트가 중복 바인딩 되는 것을 막는다.
void UMyUserWidget::OnChatInputTextCommitted(const FText& Text, ETextCommit::Type CommitMethod)
{
if (CommitMethod == ETextCommit::OnEnter)
{
APlayerController* PC = GetOwningPlayer();
if (IsValid(PC) == true)
{
AMyPlayerController* MyPC = Cast<AMyPlayerController>(PC);
if (IsValid(MyPC) == true)
{
MyPC->SetChatMassageString(Text.ToString());
EditableTextBox_ChatInput->SetText(FText());
}
}
}
}
OnTextCommitted는 텍스트 입력이 확정될 때 호출된다. 확정 방식이 여러개 (OnEnter, OnUserMovedFocus, Oncleared 등)라서 CommitMethod == ETextCommit::OnEnter를 이용해서 엔터키만 걸러낸다. 처리 후 SetText(FText())로 입력창을 비운다.
RPC 핵심 처리
위젯 생성
void AMyPlayerController::BeginPlay()
{
Super::BeginPlay();
if (IsLocalController() == false)
{
return;
}
FInputModeUIOnly InputModeUIOnly;
SetInputMode(InputModeUIOnly);
if (IsValid(ChatInputWidgetClass) == true)
{
ChatInputWidgetInstance = CreateWidget<UMyUserWidget>(this, ChatInputWidgetClass);
if (IsValid(ChatInputWidgetInstance) == true)
{
ChatInputWidgetInstance->AddToViewport();
}
}
}
IsLocalController() 체크를 맨 앞에 두었다. PlayerController는 서버에도 존재하기 때문에 이 체크가 없으면 서버에서도 위젯을 생성하려고 시도한다.
서버에는 화면이 없으므로 로컬 컨트롤러가 아니면 바로 return한다.
FInputModeUIOnly는 입력을 UI에만 전달하는 모드이다. 채팅창이 열려 있는 동안 캐릭터가 움직이지 않게 하기 위한 설정이다.
ChatInputWidgetClass는 블루프린트에서 지정하고, CreateWidget->AddToViewport순서로 화면에 표시한다.
채팅 전송 - ServerRPC
void AMyPlayerController::SetChatMassageString(const FString& InChatMassageString)
{
ChatMassageString = InChatMassageString;
if (IsLocalController() == true)
{
ServerRPCPrintChatMessageString(InChatMassageString);
}
}
위젯에서 엔터가 눌리면 이 함수가 호출된다. 서버에서 ServerRPC를 호출하는 것은 의미가 없기 때문에 IsLocalController()를 체크해서 걸러낸다.
void AMyPlayerController::ServerRPCPrintChatMessageString_Implementation(const FString& InChatMassageString)
{
for (TActorIterator<AMyPlayerController> It(GetWorld()); It; ++It)
{
AMyPlayerController* MyPlayerController = *It;
if (IsValid(MyPlayerController) == true)
{
MyPlayerController->ClientRPCPrintChatMessageString(InChatMassageString);
}
}
}
TActorIterator<AMyPlayerController>로 서버 월드에 존재하는 모든 PlayerController를 순회한다. 서버에는 접속한 모든 클라이언트의 PlayerController가 있기 때문에 이걸 전부 돌면서 각각 Client RPC를 호출한다.
메시지 출력
void AMyPlayerController::ClientRPCPrintChatMessageString_Implementation(const FString& InChatMassageString)
{
PrintChatMassageString(InChatMassageString);
}
void AMyPlayerController::PrintChatMassageString(const FString& InChatMassageString)
{
FString NetModeString = ChatProjectFunctionLibrary::GetNetModeString(this);
FString CombinedMessage = FString::Printf(TEXT("%s : %s"), *NetModeString, *InChatMassageString);
ChatProjectFunctionLibrary::MyPrintString(this, InChatMassageString, 10.0f);
}
CombinedMessage를 만들어 두고 실제 출력에는 InChatMassageString만 넘기고 있다. 디버깅용으로 만들어둔 코드가 아직 활성화되지 않은 상태이다.
ChatProjectFunctionLibrary::MyPrintString()은 NetMode에 따라 출력 방식을 분기한다. 클라이언트와 리슨서버는 AddOnScreenDebugMessage, 데디케이티드 서버는 UE_LOG로 처리된다.

정상적으로 출력되는 것을 확인할 수 있다.
NetMulticast를 쓰지 않는 이유
채팅은 모두에게 보내야 하기 때문에 ClientRPC 대신에 NetMulticast를 보내면 더 간단하지 않을까 라는 생각을 했다.
결론부터 말하면 안된다.
이유는 PlayerController의 존재 위치 때문이다.
PlayerController 존재 위치:
서버 → O
본인 클라이언트 → O
다른 클라이언트 → X 없음
NetMulticast는 서버 + 모든 클라이언트에서 실행된다.
그런데 다른 클라이언트 PC에는 내 PlayerContoller가 없다. 결국 나한테만 채팅이 보이게 되고, 다른 사람한테는 보이지 않는다.
이것이 서버에서 TActorIterator로 모든 PlayerController를 찾아 개별 ClientRPC를 날려야 하는 이유이다.
접속 알림
새 플레이어가 접속하면 모든 클라이언트 화면에 알림을 띄운다. 이 부분에선 NetMulticast를 사용한다.
PlayerController에서는 사용할 수 없지만 GameState는 서버 + 클라이언트에 존재하기 때문에 NetMulticast를 쓰기 적합하다.
// AMyGameModeBase
void AMyGameModeBase::OnPostLogin(AController* NewPlayer)
{
Super::OnPostLogin(NewPlayer);
AMyGameStateBase* MyGameStateBase = GetGameState<AMyGameStateBase>();
if (IsValid(MyGameStateBase))
{
MyGameStateBase->MulticastRPCBroadcastLoginMessage(TEXT("???"));
}
}
OnPostLogin()은 플레이어 접속 완료 시 GameMode에서 자동 호출된다. GameMode는 서버에만 존재하므로 항상 서버에서만 실행된다.
// AMyGameStateBase
void AMyGameStateBase::MulticastRPCBroadcastLoginMessage_Implementation(const FString& InNameString)
{
if (HasAuthority() == false)
{
APlayerController* PC = UGameplayStatics::GetPlayerController(GetWorld(), 0);
if (IsValid(PC))
{
AMyPlayerController* MyPC = Cast<AMyPlayerController>(PC);
if (IsValid(MyPC))
{
FString NotificationString = InNameString + TEXT(" has joined the game!");
MyPC->PrintChatMassageString(NotificationString);
}
}
}
}
HasAuthority() == false
서버에는 화면이 없으니 클라이언트에서만 실행하도록 걸러낸다.
GetPlayerController(GetWorld(), 0) 에서 인덱스 0은 이 PC의 로컬 플레이어이다. 각 클라이언트 PC에서 실행될 때 자기 자신의 PlayerController를 찾는다.

핵심 포인트
- 클라이언트끼리 직접 통신은 불가능하다. 채팅 메시지는 반드시 서버를 거쳐야 한다.
- IsLocalController() 체크로 서버에서 ServerRPC가 호출되는 것을 막는다.
- TActorIterator로 서버 월드의 모든 PlayerController를 순회해서 개별 Client RPC를 날린다.
- PlayerController는 다른 클라이언트 PC에 없으므로 채팅 배포에 NetMulticast를 쓰면 안 된다.
- NetMulticast는 서버 + 모든 클라이언트에 존재하는 액터에서만 의미가 있다.
- NetMulticast 구현부에서 HasAuthority() == false로 클라이언트에서만 실행하도록 필터링한다.
- BindWidget은 블루프린트 위젯과 C++ 변수 이름이 정확히 일치해야 바인딩된다.
- OnTextCommitted 델리게이트는 확정 방식이 여러개이다.
'TIL' 카테고리의 다른 글
| <Unreal> 멀티플레이 기초 - RPC(Remote Procedure Call) (0) | 2026.06.04 |
|---|---|
| 게임 멀티플레이 기초 - 서버와 클라이언트 <NetMode, NetRole> (0) | 2026.06.03 |
| < 언리얼 C++ > GameMode (0) | 2026.04.12 |
| < 언리얼 C++ > 회전 발판과 움직이는 장애물 만들기 (0) | 2026.04.11 |
| [TIL 46일차] <언리얼 C++> 리플렉션 시스템 (2) | 2026.04.09 |