본문으로 건너뛰기

ChatClient

WebSocket 기반 채팅 클라이언트 코어 클래스입니다. 실제 서버 연결 및 메시지 처리를 담당합니다.

이벤트 코드

코드상수명설명
0EventChannels채널 목록
1EventSubscribe채널 구독
2EventUnSubscribe채널 구독 해제
3EventPublicMessage공개 메시지
4EventPrivateMessage비공개 메시지
5EventPlayerOnline플레이어 온라인 상태
97EventNotifyMessage알림 메시지
99EventError에러

주요 기능

메서드설명
ConnectWebSocket 서버에 연결
Disconnect서버 연결 해제
Subscribe채널 구독 (이전 메시지 개수 설정 가능)
Unsubscribe채널 구독 해제
GetChannels채널 목록 요청
GetPlayersOnline플레이어 온라인 상태 조회
SendPublicMessage공개 메시지 전송
SendPrivateMessage비공개 메시지 전송
SetUserName사용자 이름 설정
Service메인 스레드 콜백 처리

속성

속성타입설명
IsConnectedbool연결 상태
CurrentChannelFString현재 구독 중인 채널

Unreal C++ 구현

헤더 파일 (ChatClient.h)

// ChatClient.h
#pragma once

#include "CoreMinimal.h"
#include "IWebSocket.h"
#include "ChatModels.h"

class FChatClient
{
public:
// 이벤트 코드
static const uint8 EventChannels = 0;
static const uint8 EventSubscribe = 1;
static const uint8 EventUnSubscribe = 2;
static const uint8 EventPublicMessage = 3;
static const uint8 EventPrivateMessage = 4;
static const uint8 EventPlayerOnline = 5;
static const uint8 EventNotifyMessage = 97;
static const uint8 EventError = 99;

FChatClient(IChatListener* InListener, const FString& InUserName = TEXT(""));
~FChatClient();

// Public API
void Connect(const FChatServerItem& Server);
void Disconnect();
void Subscribe(const FString& Channel, int32 PrevMessageCount = 0);
void Unsubscribe(const FString& Channel);
void GetChannels();
void GetPlayersOnline(const TArray<FString>& UserIds);
void SendPublicMessage(const FString& Channel, const FString& Text);
void SendPrivateMessage(const FString& TargetUserId, const FString& Text);
void SetUserName(const FString& InUserName);

// Getters
bool GetIsConnected() const { return bIsConnected; }
FString GetCurrentChannel() const { return CurrentChannel; }

private:
void ConnectToServer(const FString& Url);
FString BuildServerUrl(const FString& BaseUrl);
void Send(uint8 Type, const FString& Channel, const FString& Message = TEXT(""),
const FString& PrivateToUserId = TEXT(""), int32 PrevMessageCount = 0);
void ProcessMessage(const FString& Json);
FString ComputeHash();
FString GetLocalIP();

// WebSocket 이벤트 핸들러
void OnConnected();
void OnConnectionError(const FString& Error);
void OnClosed(int32 StatusCode, const FString& Reason, bool bWasClean);
void OnMessage(const FString& Message);

IChatListener* Listener;
TSharedPtr<IWebSocket> WebSocket;
FString UserUniqueId;
FString UserName;
FString CurrentChannel;
bool bIsConnected;
};

구현 파일 (ChatClient.cpp)

OpenSSL 의존성

HMAC-SHA256 계산을 위해 OpenSSL을 사용합니다. 프로젝트의 Build.cs 파일에 "OpenSSL" 모듈을 추가해야 합니다.

// ChatClient.cpp
#include "ChatClient.h"
#include "WebSocketsModule.h"
#include "PlayNANOOAuth.h"
#include "Misc/Base64.h"
#include "SocketSubsystem.h"
#include "IPAddress.h"

#if PLATFORM_WINDOWS
#include "Windows/AllowWindowsPlatformTypes.h"
#endif
#include <openssl/hmac.h>
#include <openssl/sha.h>
#if PLATFORM_WINDOWS
#include "Windows/HideWindowsPlatformTypes.h"
#endif

FChatClient::FChatClient(IChatListener* InListener, const FString& InUserName)
: Listener(InListener)
, UserName(InUserName)
, bIsConnected(false)
{
// UUID는 DataManager에서 가져온다고 가정
UserUniqueId = UGameDataManager::Get()->GetAccessToken(); // 또는 별도 UUID 관리
}

FChatClient::~FChatClient()
{
Disconnect();
}

void FChatClient::Connect(const FChatServerItem& Server)
{
if (bIsConnected) return;

FString Protocol = Server.Secure == TEXT("Y") ? TEXT("wss") : TEXT("ws");
FString BaseUrl = FString::Printf(TEXT("%s://%s:%d"), *Protocol, *Server.Addr, Server.Port);
ConnectToServer(BuildServerUrl(BaseUrl));
}

void FChatClient::Disconnect()
{
if (WebSocket.IsValid())
{
bIsConnected = false;
WebSocket->Close();
WebSocket.Reset();
}
}

void FChatClient::Subscribe(const FString& Channel, int32 PrevMessageCount)
{
CurrentChannel = Channel;
Send(EventSubscribe, Channel, TEXT(""), TEXT(""), PrevMessageCount);
}

void FChatClient::Unsubscribe(const FString& Channel)
{
Send(EventUnSubscribe, Channel);
}

void FChatClient::GetChannels()
{
Send(EventChannels, TEXT(""));
}

void FChatClient::GetPlayersOnline(const TArray<FString>& UserIds)
{
if (!WebSocket.IsValid() || !bIsConnected)
{
if (Listener)
Listener->OnError(TEXT("NOT_CONNECTED"), TEXT("WebSocket is not connected"));
return;
}

FChatMessageModel Msg;
Msg.mid = FGuid::NewGuid().ToString();
Msg.type = EventPlayerOnline;
Msg.gameId = FPlayNANOOAuth::GameId;
Msg.serviceKey = FPlayNANOOAuth::ServiceKey;
Msg.userUniqueId = UserUniqueId;
Msg.userName = UserName;
Msg.onlinePlayers = UserIds;
Msg.ipAddr = GetLocalIP();
Msg.createdAt = FDateTime::Now().ToString(TEXT("%Y-%m-%dT%H:%M:%S"));

WebSocket->Send(Msg.ToJson());
}

void FChatClient::SendPublicMessage(const FString& Channel, const FString& Text)
{
Send(EventPublicMessage, Channel, Text);
}

void FChatClient::SendPrivateMessage(const FString& TargetUserId, const FString& Text)
{
Send(EventPrivateMessage, CurrentChannel, Text, TargetUserId);
}

void FChatClient::SetUserName(const FString& InUserName)
{
UserName = InUserName;
}

FString FChatClient::BuildServerUrl(const FString& BaseUrl)
{
FString Hash = ComputeHash();
// Unreal WebSocket은 경로가 없으면 '//'를 요청하는 버그가 있음
// 명시적으로 '/' 경로 추가
return FString::Printf(TEXT("%s/?gameId=%s&uuid=%s&hash=%s"),
*BaseUrl,
*FPlayNANOOAuth::GameId,
*UserUniqueId,
*FGenericPlatformHttp::UrlEncode(Hash));
}

void FChatClient::ConnectToServer(const FString& Url)
{
if (!FModuleManager::Get().IsModuleLoaded(TEXT("WebSockets")))
{
FModuleManager::Get().LoadModule(TEXT("WebSockets"));
}

WebSocket = FWebSocketsModule::Get().CreateWebSocket(Url);

WebSocket->OnConnected().AddLambda([this]()
{
OnConnected();
});

WebSocket->OnConnectionError().AddLambda([this](const FString& Error)
{
OnConnectionError(Error);
});

WebSocket->OnClosed().AddLambda([this](int32 StatusCode, const FString& Reason, bool bWasClean)
{
OnClosed(StatusCode, Reason, bWasClean);
});

WebSocket->OnMessage().AddLambda([this](const FString& Message)
{
OnMessage(Message);
});

WebSocket->Connect();
}

void FChatClient::OnConnected()
{
bIsConnected = true;
if (Listener)
Listener->OnConnected();
}

void FChatClient::OnConnectionError(const FString& Error)
{
if (Listener)
Listener->OnError(TEXT("CONNECTION_FAILED"), Error);
}

void FChatClient::OnClosed(int32 StatusCode, const FString& Reason, bool bWasClean)
{
bIsConnected = false;
if (Listener)
Listener->OnDisconnected();
}

void FChatClient::OnMessage(const FString& Message)
{
ProcessMessage(Message);
}

void FChatClient::Send(uint8 Type, const FString& Channel, const FString& Message,
const FString& PrivateToUserId, int32 PrevMessageCount)
{
if (!WebSocket.IsValid() || !bIsConnected)
{
if (Listener)
Listener->OnError(TEXT("NOT_CONNECTED"), TEXT("WebSocket is not connected"));
return;
}

FChatMessageModel Msg;
Msg.mid = FGuid::NewGuid().ToString();
Msg.type = Type;
Msg.gameId = FPlayNANOOAuth::GameId;
Msg.serviceKey = FPlayNANOOAuth::ServiceKey;
Msg.channelId = Channel;
Msg.userUniqueId = UserUniqueId;
Msg.userName = UserName;
Msg.message = Message;
Msg.privateToUserUniqueId = PrivateToUserId;
Msg.prevMessageCount = PrevMessageCount;
Msg.ipAddr = GetLocalIP();
Msg.createdAt = FDateTime::Now().ToString(TEXT("%Y-%m-%dT%H:%M:%S"));

WebSocket->Send(Msg.ToJson());
}

void FChatClient::ProcessMessage(const FString& Json)
{
UE_LOG(LogTemp, Log, TEXT("[Chat] Received: %s"), *Json);

FChatMessageModel Msg;
if (!Msg.FromJson(Json))
{
UE_LOG(LogTemp, Error, TEXT("[Chat] ProcessMessage error: Failed to parse JSON"));
return;
}

FChatUserInfo User;
User.visitorId = Msg.userUniqueId;
User.visitorName = Msg.userName;

if (!Listener) return;

switch (Msg.type)
{
case EventChannels:
Listener->OnChannels(Msg.channels);
break;
case EventSubscribe:
Listener->OnSubscribed(User);
break;
case EventUnSubscribe:
Listener->OnUnSubscribed(User);
break;
case EventPublicMessage:
Listener->OnPublicMessage(User, Msg.message);
break;
case EventPrivateMessage:
Listener->OnPrivateMessage(User, Msg.message);
break;
case EventPlayerOnline:
Listener->OnPlayerOnline(Msg.players);
break;
case EventNotifyMessage:
if (Msg.userUniqueId != UserUniqueId)
Listener->OnNotifyMessage(User, Msg.message);
break;
case EventError:
Listener->OnError(FString::FromInt(Msg.error), Msg.message);
break;
}
}

FString FChatClient::ComputeHash()
{
// HMAC-SHA256 해시 생성
FString Message = FPlayNANOOAuth::GameId + FPlayNANOOAuth::ServiceKey + UserUniqueId;

// UTF-8로 변환
FTCHARToUTF8 KeyUtf8(*FPlayNANOOAuth::SecretKey);
FTCHARToUTF8 MessageUtf8(*Message);

// HMAC-SHA256 계산 (OpenSSL)
unsigned char Hash[SHA256_DIGEST_LENGTH];
unsigned int HashLen = 0;

HMAC(EVP_sha256(),
KeyUtf8.Get(), KeyUtf8.Length(),
(const unsigned char*)MessageUtf8.Get(), MessageUtf8.Length(),
Hash, &HashLen);

// Base64 인코딩
TArray<uint8> HashArray;
HashArray.Append(Hash, HashLen);
return FBase64::Encode(HashArray);
}

FString FChatClient::GetLocalIP()
{
bool bCanBindAll;
TSharedPtr<FInternetAddr> LocalAddr = ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->GetLocalHostAddr(*GLog, bCanBindAll);
if (LocalAddr.IsValid())
{
return LocalAddr->ToString(false);
}
return TEXT("0.0.0.0");
}