I'm FanJae.

Unity 게임 개발 캠프 개인 프로젝트 20일차. 테스트 클라이언트 책임 분리 및 Map-local 채팅 구현 본문

Projects/MyToyMapleServer

Unity 게임 개발 캠프 개인 프로젝트 20일차. 테스트 클라이언트 책임 분리 및 Map-local 채팅 구현

FanJae 2026. 10. 3. 22:21

1. 시작에 앞서

- 전날에는 LoginServer 접속부터 캐릭터 선택, GameServer 입장, 이동, Map 변경까지 하나의 테스트 클라이언트에서 확인할 수 있도록 구성했다.

- 처음에는 서버 기능을 빠르게 테스트하는 것이 목적이었기 때문에 대부분의 처리를 TestClientController 하나에 모아두었다.

- 하지만 기능이 추가되면서 TestClientController가 로그인과 캐릭터 선택, 게임 입장 이후의 이동 및 Map 관리까지 모두 처리하기 시작했다.

- 여기에 채팅 기능까지 추가하게 되면 하나의 Controller가 담당하는 역할이 계속 증가하게 된다.

- 따라서 이번 작업에서는 먼저 로그인 단계와 게임 플레이 단계의 UI 및 상태 관리를 분리했다.

- 이후 GameServer에서 구현되어 있는 Map-local 채팅 흐름에 맞춰 Client에서도 ChatRequest 전송 → PlayerChat 수신 → 현재 Map의 채팅 UI에 표시 과정을 연결했다.


2. 기존 TestClientController의 문제

- 기존 TestClientController는 테스트를 위해 다음 기능을 모두 담당하고 있었다.

TestClientController
 │
 ├─ LoginServer 연결
 ├─ 로그인 요청
 ├─ 캐릭터 목록 조회
 ├─ 캐릭터 선택
 ├─ GameServer 연결
 ├─ 게임 입장
 ├─ Player 이동
 ├─ Map 변경
 └─ 테스트 상태 표시

- 처음 테스트 환경을 구성할 때는 하나의 클래스에 기능을 모아두는 것이 구현하기 간단했다.

- 하지만 Login과 실제 게임 진입 이후의 기능은 동작하는 시점과 관리해야 하는 상태가 서로 다르다.

Login Phase
 ├─ Account
 ├─ Character List
 └─ Character Select

Game Phase
 ├─ Player Movement
 ├─ Map
 ├─ World State
 └─ Chat

- 게임 기능이 늘어날수록 이 두 영역을 하나의 Controller에서 계속 관리하는 것은 코드의 역할을 구분하기 어려워질 수 있다고 판단했다.


3. LoginScreenController 분리

- 로그인 및 캐릭터 선택 기능을 담당하는 LoginScreenController를 별도로 만들었다.

- 이 Controller에서는 다음 이벤트만 처리한다.

_networkManager.LoginCompleted
_networkManager.CharacterListReceived
_networkManager.CharacterSelected
_networkManager.EnterGameReceived
_networkManager.LoginDisconnected
_networkManager.GameDisconnected

- 역할 역시 로그인 과정에 필요한 내용으로 제한했다.

LoginScreenController

Login ID / Password 입력
          │
          ▼
LoginServer 연결
          │
          ▼
Login Request
          │
          ▼
Character List
          │
          ▼
Character Select
          │
          ▼
GameServer 연결
          │
          ▼
EnterGame

- 로그인 화면 역시 Game UI에서 분리했다.

- 아직 게임에 진입하지 않았다면 화면 중앙에 로그인 패널을 표시한다.

if (_inGame)
    return;

- EnterGame에 성공하면 _inGame을 갱신하여 로그인 UI를 더 이상 표시하지 않는다.

private void OnEnterGameReceived(
    EnterGameData data)
{
    _connecting = false;

    _inGame =
        data.Result ==
        EnterGameResult.Success;
}

- 반대로 GameServer 연결이 끊어지면 다시 로그인 가능한 상태로 돌아가도록 했다.


4. TestClientController는 게임 기능만 담당하도록 변경

- 기존 TestClientController에서는 로그인 관련 상태와 처리 코드를 제거했다.

Host
LoginServer Port
Login ID
Password
Character List
Login 연결 상태

- 대신 GameServer에 정상적으로 입장한 이후 필요한 기능을 담당하도록 역할을 좁혔다.

TestClientController

GameServer 입장 이후
       │
       ├─ Player 이동
       ├─ Map 변경
       ├─ World 상태 출력
       ├─ Player 정보 관리
       └─ Map Chat

- 실제 Game UI도 게임에 진입한 경우에만 표시한다.

private void OnGUI()
{
    if (!_inGame)
        return;

    ...
}

- 결과적으로 테스트 클라이언트의 구조가 다음과 같이 변경됐다.

NetworkManager
      │
      ├───────────────┐
      ▼               ▼
LoginScreen      TestClient
Controller       Controller
      │               │
      │               ├─ Move
      │               ├─ Map
      │               └─ Chat
      │
      ├─ Login
      ├─ Character List
      └─ Character Select

- 테스트용 코드이기는 하지만 기능이 늘어날 것을 고려하여 최소한의 책임 분리를 적용했다.


5. Runtime 테스트 객체 구성도 변경

- 현재 테스트 클라이언트는 Scene에 별도 Prefab이나 Manager를 배치하지 않아도 Runtime에서 자동 생성하도록 되어 있다.

- 기존에는 다음 세 Component를 생성했다.

NetworkManager
WorldManager
TestClientController

- 로그인 기능을 분리하면서 LoginScreenController도 함께 생성하도록 변경했다.

GameObject gameObject = new("Test Client");

gameObject.AddComponent<NetworkManager>();
gameObject.AddComponent<WorldManager>();
gameObject.AddComponent<LoginScreenController>();
gameObject.AddComponent<TestClientController>();

- 따라서 Sample Scene 자체는 단순하게 유지하면서도 실행하면 필요한 테스트 환경이 자동으로 구성된다.


6. Map-local 채팅 기능 추가

- 구조를 정리한 이후 GameServer의 채팅 기능을 Client에 연결했다.

- 현재 채팅은 전체 서버 Broadcast가 아니라 같은 Map에 존재하는 Player를 대상으로 하는 Map-local 채팅이다.

- Client에서의 흐름은 다음과 같다.

Player A
   │
   │ ChatRequest
   ▼
GameServer
   │
   │ 현재 Map의 Player들에게
   │ PlayerChat 전송
   ▼
Player A / Player B / Player C

- Client에서는 채팅을 보내기 위한 ChatRequest와 서버가 전달하는 PlayerChat을 각각 처리해야 한다.


7. ChatRequest 패킷 생성

- 채팅 메시지는 GameProtocol에서 패킷 Payload로 직렬화하도록 했다.

public static byte[] CreateChatRequest(string message)
{
    using PacketWriter writer = new();
    writer.WriteFixedString(message,MaxChatMessageLength);
    
    return writer.ToArray();
}

- 기존 패킷과 동일하게 고정 길이 문자열 방식을 사용한다.

public const int MaxChatMessageLength = 128;

- 즉 Client가 Server로 보내는 Payload는 최대 128Byte 크기의 고정 문자열 필드를 사용한다.

ChatRequest

┌────────────────────────────┐
│ Message : Fixed 128 Bytes  │
└────────────────────────────┘

8. PlayerChat 패킷 처리

- Server에서 전달되는 채팅에는 어떤 Character가 보낸 메시지인지 확인할 수 있도록 CharacterId가 포함된다.

public struct PlayerChatData
{
    public uint CharacterId;
    public string Message;
}

- 패킷 구조는 다음과 같은 형태다.

PlayerChat

┌───────────────┬─────────────────────┐
│ Character ID  │ Message             │
│    4 Byte     │ Fixed 128 Bytes     │
└───────────────┴─────────────────────┘

- 역직렬화 시 이전에 추가했던 Payload 길이 검증 방식도 그대로 사용한다.

PacketReader reader = new(payload, 4 + MaxChatMessageLength);

- 이후 Character ID와 Message를 읽는다.

return new PlayerChatData
{
    CharacterId = reader.ReadUInt32(),
    Message = reader.ReadFixedString(MaxChatMessageLength)
};

9. NetworkManager에 채팅 흐름 연결

- Protocol에서 패킷을 만들고 읽을 수 있게 된 뒤 실제 NetworkManager에 채팅 송수신 기능을 연결했다.

- 메시지를 보낼 때는 ChatRequest Opcode를 사용한다.

public Task SendChatAsync(string message)
{
    byte[] payload = GameProtocol.CreateChatRequest(message);
    return _gameSession.SendAsync((ushort)GamePacketOpcode.ChatRequest,payload);
}

- Server에서 PlayerChat을 받으면 기존 Game Packet 처리 흐름에서 역직렬화한다.

case GamePacketOpcode.PlayerChat:
    PlayerChatReceived?.Invoke(GameProtocol.ReadPlayerChat(payload));
    break;

- 게임 UI에서는 NetworkManager 내부의 패킷 구조를 직접 알 필요 없이 이벤트만 구독한다.

_networkManager.PlayerChatReceived
    += OnPlayerChatReceived;

- 기존에 구성한 Network 계층 구조를 그대로 활용한 것이다.

TcpSession
    │
    ▼
NetworkManager
    │
    ▼
GameProtocol
    │
    ▼
PlayerChatReceived
    │
    ▼
TestClientController

10. 채팅 발신자 이름 관리

- PlayerChat 패킷에는 Character ID와 Message만 포함되어 있다.

- 하지만 UI에서 Player 이름을 표시하는 편이 확인하기 쉽다.

- 따라서 TestClientController에서 현재 Map에 존재하는 Player의 Character ID와 이름을 관리하도록 했다.

private readonly Dictionary<uint, string> _playerNames = new();

- 자신의 Character 정보는 EnterGame 시 등록한다.

_playerNames[data.CharacterId] = data.Name;

- 다른 Player가 Map에 들어오면 PlayerEnter 패킷에 포함된 이름을 이용해 등록한다.

private void OnPlayerEntered(PlayerEnterData data)
{
    _playerNames[data.CharacterId] = data.Name;
}

- Player가 Map에서 나가면 해당 정보를 제거한다.

private void OnPlayerLeft(uint characterId)
{
    _playerNames.Remove(characterId);
}

- 따라서 채팅 패킷을 받았을 때 Character ID를 이용해 이름을 찾는다.

string name = _playerNames.TryGetValue(data.CharacterId, out string playerName) ? playerName : data.CharacterId.ToString();

11. Map Chat UI 구성

- 게임 테스트 화면 하단에 별도의 Map Chat 영역을 추가했다.

┌───────────────────────────────┐
│ Map Chat                      │
│                               │
│ Player1: Hello                │
│ Player2: Hi                   │
│ Player1: Test                 │
│                               │
├───────────────────────────────┤
│ Message...              Send  │
└───────────────────────────────┘

- 수신한 메시지는 List에 저장한다.

private readonly List<string> _chatMessages = new();

- 메시지를 수신하면 이름과 메시지를 조합해서 추가한다.

_chatMessages.Add($"{name}: {message}");

- 테스트 클라이언트에서 메시지가 무한히 쌓이는 것을 막기 위해 최근 40개까지만 유지한다.

if (_chatMessages.Count > 40) _chatMessages.RemoveAt(0);

- 새로운 메시지가 들어오면 Scroll 위치를 아래쪽으로 이동시켜 최신 메시지를 바로 볼 수 있도록 했다.


12. Enter 키를 이용한 채팅 전송

- Send 버튼뿐 아니라 일반 게임 채팅처럼 Enter 키를 이용해서도 메시지를 보낼 수 있도록 했다.

- 채팅 입력창에 Focus가 있는 상태에서 Enter 또는 Keypad Enter가 입력되면 SendChat()을 호출한다.

if (Event.current.type == EventType.KeyDown && (Event.current.keyCode == KeyCode.Return ||
     Event.current.keyCode == KeyCode.KeypadEnter) && GUI.GetNameOfFocusedControl() == "MapChatInput")
{
    SendChat();

    Event.current.Use();
}

- 채팅 입력 중 방향키 등이 Player 이동으로 처리되지 않도록 기존 이동 로직에서 GUI 입력 Focus 여부도 확인하고 있다.

if (GUIUtility.keyboardControl != 0)
    return;

- 결과적으로 채팅 입력과 Player 이동 입력이 서로 겹치지 않도록 했다.


13. 채팅 메시지 검증

- 채팅 메시지를 그대로 보내지 않고 몇 가지 Client 측 검증을 추가했다.

- 먼저 앞뒤 공백을 제거하고 줄바꿈 문자는 공백으로 변경한다.

string message = _chatInput.Trim().Replace('\r', ' ').Replace('\n', ' ');

- 아무 내용도 없다면 패킷을 보내지 않는다.

if (message.Length == 0) return;

- 여기서 string.Length가 아닌 UTF-8 Byte Length를 기준으로 검사했다.

if (Encoding.UTF8.GetByteCount(message) >= GameProtocol.MaxChatMessageLength)
{
    ...
}

- 패킷의 제한은 문자의 개수가 아니라 Byte 크기로 정의되어 있기 때문이다.

- 특히 한글은 UTF-8에서 일반적으로 영문 한 글자보다 더 많은 Byte를 사용하기 때문에 다음 두 값은 동일하지 않다.

문자 개수 ≠ UTF-8 Byte 개수

- 따라서 서버 패킷 규격과 동일한 Byte 기준으로 입력을 제한하도록 했다.


14. 전송 실패 시 입력 복구

- 채팅 전송을 시작하면 입력창을 비운다.

_chatInput = "";

- 하지만 실제 네트워크 송신 과정에서 예외가 발생할 수 있다.

- 이 경우 사용자가 입력했던 메시지까지 사라져버리면 다시 작성해야 하기 때문에 실패 시 기존 메시지를 복원하도록 했다.

catch (Exception exception)
{
    _chatInput = message;

    _status =
        $"Chat failed: {exception.Message}";
}

- 테스트 UI 수준이기는 하지만 네트워크 요청 실패에 따른 UI 상태 역시 함께 처리했다.


15. Map-local 데이터로서의 채팅 처리

- 이번 채팅은 Map-local 기능이기 때문에 Map이 변경되었을 때 이전 Map의 채팅 기록을 그대로 유지할 필요가 없다.

- 따라서 Map 이동에 성공하면 현재 Player 정보와 채팅 기록을 초기화한다.

_playerNames.Clear();

_chatMessages.Clear();

- 다만 Local Player의 이름 정보는 유지해야 하기 때문에 초기화 전에 저장했다가 다시 등록한다.

Map A

Player1
Player2
Player3

Chat History
    │
    │ ChangeMap
    ▼

Map B

Player1

Chat History = Empty

- 새로운 Map의 Remote Player는 이후 Server에서 전달되는 PlayerEnterMap 패킷을 통해 다시 등록된다.

- GameServer 연결 자체가 종료된 경우에도 동일하게 Player 이름과 채팅 상태를 모두 정리한다.


16. 이번 작업 이후 구조

- 이번 작업을 포함하면 테스트 클라이언트의 역할은 대략 다음과 같이 나뉘게 됐다.

                    TcpSession
                        │
                        ▼
                 NetworkManager
                        │
           ┌────────────┴────────────┐
           ▼                         ▼
  LoginScreenController      TestClientController
           │                         │
           │                         ├─ Move
           ├─ Login                  ├─ Map Change
           ├─ Character List         ├─ Player State
           ├─ Character Select       └─ Map Chat
           └─ GameServer Connect
                                      │
                                      ▼
                                WorldManager
                                      │
                          ┌───────────┴───────────┐
                          ▼                       ▼
                     PlayerView             MonsterView

- 전날에는 모든 테스트 기능을 하나의 Controller에서 처리하고 있었지만, 이번 작업부터 로그인 단계와 실제 Game 단계가 구분되기 시작했다.


17. 아직 고려할 부분

- 현재 채팅 UI는 서버 기능 검증을 목적으로 OnGUI()를 이용해 구성한 테스트 UI다.

- 실제 Client UI로 사용할 구조는 아니며, 향후 실제 게임 UI를 구성한다면 별도의 UI System으로 대체할 필요가 있다.

- 현재 발신자 이름 역시 Map 입장 시 받은 Player 정보에 의존한다.

- 따라서 Player 정보 관리가 더 복잡해진다면 Character ID와 표시 정보를 별도의 Player State 또는 Entity 정보에서 관리하도록 구조를 바꿀 수 있다.

- 또한 현재 채팅 기록은 Client 메모리에만 존재하며 Map 변경 시 모두 제거된다.

- 향후 Party, Whisper, Guild 등의 채팅 종류가 추가된다면 Map-local 채팅과 별도로 Channel을 구분하는 구조도 필요할 수 있다.


18. 정리

- 이번 작업에서는 먼저 기능이 많아지고 있던 테스트 클라이언트의 책임을 정리했다.

- LoginServer 연결, 로그인, 캐릭터 목록 조회 및 캐릭터 선택은 LoginScreenController로 분리하고, TestClientController는 GameServer 입장 이후의 이동, Map 관리 및 게임 테스트 기능을 담당하도록 변경했다.

- 이후 기존 GameProtocol과 NetworkManager 구조를 이용해 Map-local 채팅 기능을 Client에 연결했다.

- ChatRequest를 통해 고정 길이 UTF-8 문자열을 Server에 전달하고, Server에서 전달하는 PlayerChat을 Character ID와 Message 형태로 역직렬화하도록 했다.

- 현재 Map에 존재하는 Player의 Character ID와 이름을 관리하여 채팅 화면에서는 실제 Character 이름을 표시하도록 했다.

- 또한 빈 메시지와 UTF-8 Byte 길이를 검증하고, Send 버튼뿐 아니라 Enter 입력을 이용해서도 채팅을 전송할 수 있도록 했다.

- Map 변경 또는 GameServer 연결 종료 시에는 이전 Map에 종속되어 있던 Player 정보와 채팅 기록을 함께 정리하도록 했다.


19. 실행 화면

Comments