| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- System Programming
- PS
- Online Judge
- BOJ
- team project
- Network Programming
- 독서
- Unity
- C++
- git
- Toy Project
- Photon
- multi-thread
- c#
- Data Structure
- design pattern
- docker
- Today
- Total
I'm FanJae.
Unity 게임 개발 캠프 개인 프로젝트 20일차. 테스트 클라이언트 책임 분리 및 Map-local 채팅 구현 본문
Unity 게임 개발 캠프 개인 프로젝트 20일차. 테스트 클라이언트 책임 분리 및 Map-local 채팅 구현
FanJae 2026. 10. 3. 22:211. 시작에 앞서
- 전날에는 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. 실행 화면

'Projects > MyToyMapleServer' 카테고리의 다른 글
| Unity 게임 개발 캠프 개인 프로젝트 21일차(Client). 서버 확정 위치 기반 클라이언트 이동 보정 (0) | 2026.10.05 |
|---|---|
| Unity 게임 개발 캠프 개인 프로젝트 21일차(Server). 서버 기준 Player 이동 검증 구현 (0) | 2026.10.04 |
| Unity 게임 개발 캠프 개인 프로젝트 19일차. Unity 네트워크 테스트 클라이언트 보완 (0) | 2026.10.02 |
| Unity 게임 개발 캠프 개인 프로젝트 18일차. 클라이언트 네트워크 구조 구성 및 월드 객체 동기화 (0) | 2026.10.01 |
| Unity 게임 개발 캠프 개인 프로젝트 17일차. Map 기반 몬스터 가시성 관리와 이동 책임 정리 (0) | 2026.09.30 |