| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- c#
- BOJ
- design pattern
- System Programming
- multi-thread
- Network Programming
- team project
- Online Judge
- PS
- C++
- 독서
- Toy Project
- Unity
- git
- docker
- Photon
- Data Structure
- Today
- Total
I'm FanJae.
Unity 게임 개발 캠프 개인 프로젝트 19일차. Unity 네트워크 테스트 클라이언트 보완 본문
Unity 게임 개발 캠프 개인 프로젝트 19일차. Unity 네트워크 테스트 클라이언트 보완
FanJae 2026. 10. 2. 23:011. 시작에 앞서
- 전날에는 Unity Client에서 LoginServer와 GameServer에 접속하고, 서버에서 전달되는 Player와 Monster 정보를 실제 Unity 객체에 반영할 수 있도록 기본적인 네트워크 구조를 구성했다.
- 하지만 구조를 연결한 것만으로는 실제 서버 동작이 정상적인지 충분히 확인하기 어려웠다.
- 특히 여러 클라이언트를 동시에 실행했을 때 세션이 정상적으로 분리되는지, Player 이동 정보가 다른 클라이언트에 제대로 전달되는지, Map 이동 이후 기존 객체가 정상적으로 정리되는지 등을 직접 확인할 수 있는 환경이 필요했다.
- 따라서 이번 작업에서는 먼저 네트워크 세션과 패킷 검증을 보완한 뒤, 서버에서 전달되는 이동 정보를 화면에 자연스럽게 표현하고, 로그인부터 이동 및 Map 변경까지 직접 조작할 수 있는 테스트 클라이언트를 구성했다.
2. 동시 패킷 송신 문제에 대한 보완
- 기존 TcpSession에서는 여러 곳에서 SendAsync()가 동시에 호출될 가능성에 대한 처리가 없었다.
- 예를 들어 Player 이동 요청과 다른 요청이 거의 동시에 발생한다면 동일한 NetworkStream에 여러 비동기 Write가 겹칠 수 있다.
Task A
MoveRequest
│
├──────────────┐
│ │
Task B ▼
ChangeMapRequest → NetworkStream
- TCP 자체는 Byte Stream을 제공하지만, 클라이언트 코드에서 여러 Write가 동시에 수행될 경우 애플리케이션에서 의도한 패킷 단위 송신을 보장하기 어렵다.
- 따라서 SemaphoreSlim을 이용하여 하나의 세션에서 패킷 송신을 직렬화하도록 변경했다.
private readonly SemaphoreSlim _sendLock = new(1, 1);
await _sendLock.WaitAsync();
try
{
if (_stream == null)
throw new InvalidOperationException(
"Session is not connected.");
await _stream.WriteAsync(packet, 0, packet.Length);
}
finally
{
_sendLock.Release();
}
- 결과적으로 여러 송신 요청이 동시에 발생하더라도 실제 NetworkStream에 대한 Write는 순차적으로 수행된다.
MoveRequest
│
▼
Send Lock
│
▼
NetworkStream
│
▼
ChangeMapRequest
- 서버에서 패킷을 구분하기 위해 Header 기반 프로토콜을 사용하고 있는 만큼, 클라이언트에서도 하나의 패킷 바이트 배열이 하나의 Write 흐름으로 처리되도록 보완했다.
3. 재연결 과정에서 이전 세션 패킷이 섞이는 문제
- 네트워크 테스트 과정에서는 연결이 끊어지거나 다시 접속하는 상황 역시 고려해야 했다.
- 기존 구조에서는 새로운 TcpSession을 생성하더라도 이전 세션에서 이미 비동기로 처리되고 있던 패킷 이벤트가 늦게 도착할 가능성이 있었다.
Old Session
│
└─ Packet Received
│
│ 처리 지연
▼
New Session 생성
│
▼
Old Session Packet가 뒤늦게 처리
- 이 경우 현재 연결과 관계없는 이전 세션의 데이터가 Client 상태에 반영될 수 있다.
- 이를 막기 위해 이벤트를 등록할 때 해당 패킷을 발생시킨 TcpSession 자체를 함께 전달하도록 했다.
session.PacketReceived += (opcode, payload) =>
OnGamePacketReceived(session,opcode,payload);
- 그리고 Main Thread에서 실제 패킷을 처리하기 전에 현재 사용 중인 Session과 동일한 객체인지 확인한다.
if (!ReferenceEquals(_gameSession, session))
return;
- 이를 통해 이전 연결에서 늦게 전달된 패킷은 현재 세션 상태에 반영되지 않도록 했다.
Packet
│
▼
Session 확인
│
├─ Current Session → 처리
│
└─ Old Session → 무시
4. 연결 종료 상태 처리
- 기존에는 TCP 수신 중 예외가 발생했을 때 세션 내부에서 연결이 종료됐다는 사실만 확인할 수 있었다.
- 그러나 상위 게임 로직에서도 LoginServer 또는 GameServer 연결 종료 여부를 알아야 UI 상태나 World 객체를 정리할 수 있다.
- 따라서 NetworkManager에 두 종류의 연결 종료 이벤트를 추가했다.
public event Action LoginDisconnected;
public event Action GameDisconnected;
- 세션에서 Disconnect 이벤트가 발생하면 Main Thread Queue를 통해 상위 계층으로 전달하도록 했다.
TcpSession
│
│ Disconnected
▼
NetworkManager
│
▼
Main Thread Queue
│
▼
LoginDisconnected
or
GameDisconnected
- 특히 GameServer 연결이 종료되면 현재 Map에 존재하던 객체를 유지할 이유가 없기 때문에 WorldManager에서도 이를 받아 Player와 Monster를 정리하도록 했다.
5. 잘못된 패킷 데이터 검증
- 네트워크 패킷을 역직렬화할 때 서버가 항상 정확한 크기의 데이터를 보내준다고 가정하는 것은 안전하지 않다.
- 기존 PacketReader에서는 전달받은 Payload를 그대로 읽고 있었는데, 패킷 길이가 예상보다 짧거나 길더라도 실제 값을 읽는 시점까지 문제를 알기 어려웠다.
- 이를 보완하기 위해 생성 시점에 예상 Payload 크기를 전달하고 실제 데이터 길이와 비교하도록 변경했다.
public PacketReader(
byte[] data,
int expectedLength)
{
if (data == null ||
data.Length != expectedLength)
{
throw new InvalidDataException(
$"Invalid payload size");
}
}
- 각 Protocol에서도 패킷마다 정확한 크기를 지정하도록 했다.
PacketReader reader =
new(payload, 4 + 4 + 4);
- Character List의 경우 단순한 Byte Length뿐 아니라 캐릭터 개수 값도 검증했다.
if (count > MaxCharacterCount) throw new InvalidDataException($"Invalid character count: {count}");
- 즉 패킷을 단순히 읽을 수 있는가만 확인하는 것이 아니라, 서버와 클라이언트가 약속한 Protocol 규격과 일치하는가를 먼저 검사하도록 했다.
6. Main Thread 작업 중 예외 처리 보완
- 네트워크 패킷은 ConcurrentQueue<Action>에 넣고 Unity의 Update()에서 처리하도록 구성되어 있다.
- 그런데 Queue에 들어간 Action 하나에서 예외가 발생하면 이후 처리 흐름에도 영향을 줄 수 있다.
- 따라서 각 작업을 개별적으로 try-catch하여 하나의 패킷 처리 실패가 전체 Main Thread Queue 처리를 중단하지 않도록 했다.
while (_mainThreadQueue.TryDequeue(out Action action))
{
try
{
action();
}
catch (Exception exception)
{
Debug.LogException(exception);
}
}
- 네트워크 패킷은 외부 데이터에 해당하기 때문에 하나의 비정상 패킷이 들어왔다고 해서 이후 정상 패킷까지 처리하지 못하는 상황은 피하도록 했다.
7. Player 이동을 즉시 이동 방식에서 보간 방식으로 변경
- 전날에는 서버에서 Player 이동 좌표를 받으면 즉시 transform.position을 변경했다.
Packet A
(10, 10)
↓
Packet B
(20, 10)
↓
Packet C
(30, 10)
- 화면에서는 다음과 같이 위치가 순간적으로 변경될 수 있다.
● → ● → ●
- 네트워크 패킷은 매 Frame마다 오는 것이 아니기 때문에 이러한 방식은 실제 플레이 화면에서 이동이 끊겨 보일 수 있다.
- 따라서 Player가 현재 서버 좌표와 화면에서 이동할 목표 위치를 분리해서 관리하도록 변경했다.
public int ServerX { get; private set; }
public int ServerY { get; private set; }
private Vector3 _targetPosition;
- 서버에서 PlayerMove가 도착하면 즉시 Transform을 변경하지 않고 목표 위치만 갱신한다.
public void SetTargetPosition(int x, int y)
{
ServerX = x;
ServerY = y;
_targetPosition =
WorldManager.ToUnityPosition(x, y);
}
- 이후 Update()에서 현재 위치와 목표 위치 사이를 보간한다.
float blend = 1f - Mathf.Exp(-InterpolationSpeed * Time.deltaTime);
transform.position = Vector3.Lerp(transform.position,_targetPosition,blend);
- 결과적으로 구조는 다음과 같이 바뀌었다.
Server Position
│
▼
Target Position
│
│ Interpolation
▼
Unity Transform
- 네트워크상의 실제 좌표와 화면에서의 표현을 분리한 것이다.
8. Server 좌표와 Unity 좌표 분리
- 테스트 중에는 서버 좌표를 그대로 Unity 좌표에 적용할 경우 객체 사이의 거리가 화면에서 지나치게 크게 표현됐다.
- 서버에서는 정수 좌표를 게임 로직 단위로 사용하지만, Unity 화면에서는 이를 그대로 1 Unit으로 사용할 필요는 없다.
- 따라서 서버 좌표를 Unity 좌표로 변환하는 과정에서 Scale을 적용했다.
public static Vector3 ToUnityPosition(int x,int y)
{
return new Vector3(x * 0.05f,y * 0.05f,0f);
}
- 즉 서버의 좌표 체계와 Client의 Rendering 좌표 체계를 분리했다.
Server
(100, 200)
│
│ × 0.05
▼
Unity
(5, 10)
- 서버 좌표 자체는 그대로 유지하면서 화면 표현에 필요한 Scale만 Client에서 적용하는 방식이다.
9. 테스트를 위한 Player와 Monster 시각화
- 아직 실제 Player 또는 Monster Prefab이 준비된 단계는 아니었기 때문에 네트워크 동작을 확인하기 위해 별도의 임시 시각화가 필요했다.
- 이에 Prefab이 연결되어 있지 않더라도 Runtime에서 간단한 Sprite 객체를 생성하도록 했다.
GameObject gameObject = new(name);
SpriteRenderer renderer = gameObject.AddComponent<SpriteRenderer>();
- Player는 Character ID를 기반으로 색상을 결정하도록 했다.
uint hash =
unchecked(
characterId * 2654435761u);
- 동일한 Character ID라면 어떤 Client에서 생성하더라도 같은 색상을 갖는다.
Character 1 → Pink
Character 2 → Blue
Character 3 → Green
- 이를 통해 클라이언트를 두 개 실행했을 때 어느 Player가 동일한 캐릭터인지 시각적으로 구분하기 쉽게 했다.
- Monster는 테스트 단계에서 단순히 빨간색 Sprite로 표시했다.
10. Local Player를 따라가는 테스트 카메라
- Player 위치가 변경될수록 화면 밖으로 벗어날 수 있기 때문에 테스트를 위한 Camera 추적 기능도 추가했다.
- LateUpdate()에서 Local Player의 현재 위치를 기준으로 Camera 위치를 조정한다.
Vector3 position = _localPlayer.transform.position;
Camera.main.transform.position = new Vector3(position.x,position.y + CameraHeightOffset,-10f);
- 이를 통해 이동 테스트 중 Local Player가 화면 중심 부근에 유지되도록 했다.
11. 실제 조작 가능한 통합 테스트 클라이언트 구성
- 네트워크와 화면 표시가 어느 정도 준비된 뒤에는 전체 흐름을 직접 테스트할 수 있도록 TestClientController를 추가했다.
- 테스트 클라이언트에서는 다음 과정을 직접 수행할 수 있다.
LoginServer 연결
│
▼
Login Request
│
▼
Character List
│
▼
Character Select
│
▼
GameServer 연결
│
▼
Enter Game
│
├─ Move
└─ Change Map
- 별도의 Scene 설정이나 Prefab 연결 없이도 테스트할 수 있도록 Runtime에 필요한 객체를 자동 생성하도록 했다.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void CreateTestClient()
{
GameObject gameObject =
new("Test Client");
gameObject.AddComponent<NetworkManager>();
gameObject.AddComponent<WorldManager>();
gameObject.AddComponent<TestClientController>();
}
- 전날에는 SampleScene만 만들어 둔 상태였지만 이번 작업을 통해 실제 네트워크 테스트를 수행할 수 있는 진입점을 만든 셈이다.
12. 테스트 UI 구성
- 테스트 UI에서는 Host와 LoginServer Port를 입력하고 로그인을 요청할 수 있도록 했다.
- 로그인 이후에는 서버에서 받은 Character List를 버튼으로 표시하고 원하는 캐릭터를 선택할 수 있다.
- GameServer 입장 이후에는 다음 상태를 화면에서 확인할 수 있도록 했다.
Current Map ID
Local Character ID
Local Player Position
Remote Player Count
Remote Player Position
Monster Count
Last PlayerMove Packet
- 서버 로그만 확인하는 것이 아니라 실제 Client가 어떤 상태를 가지고 있는지 동시에 확인할 수 있도록 했다.
13. 방향키를 이용한 연속 이동 테스트
- 이전에는 이동 요청을 보내기 위해 X, Y 좌표를 직접 입력해야 했다.
- 서버 동작 확인에는 사용할 수 있지만, Player가 실제로 연속 이동할 때 패킷이 어떻게 발생하는지 확인하기에는 불편했다.
- 따라서 방향키 입력을 이용해 이동 목표 좌표를 계속 변경하도록 했다.
if (keyboard.leftArrowKey.isPressed) direction.x -= 1f;
if (keyboard.rightArrowKey.isPressed) direction.x += 1f;
if (keyboard.downArrowKey.isPressed) direction.y -= 1f;
if (keyboard.upArrowKey.isPressed) direction.y += 1f;
- 대각선 입력 시 X와 Y가 동시에 증가하면 이동 속도가 약 √2배 증가하기 때문에 방향 벡터를 정규화하도록 했다.
if (direction.sqrMagnitude > 1f) direction.Normalize();
- 이후 Frame마다 이동 패킷을 보내는 것이 아니라 일정 주기를 두었다.
private const float MoveSendInterval = 0.05f;
- 따라서 최대 약 20Hz 주기로 MoveRequest를 전송한다.
Frame
││││││││││││││││││││
MoveRequest
│ │ │ │ │
- Client Rendering Frame과 Network Packet 전송 주기를 분리해서 테스트하도록 했다.
14. Local Player 이동 표현
- Local Player는 방향키 입력과 동시에 화면에서 움직일 수 있도록 서버 좌표 요청값을 목표 위치로 사용했다.
- 하지만 MoveRequest 전송 자체가 실패할 가능성이 있기 때문에 무조건 Client 위치를 변경하는 구조로 만들지는 않았다.
- 요청 전송에 성공한 이후 Local Player의 목표 위치를 갱신한다.
await _networkManager
.SendMoveAsync(x, y);
_worldManager
.SetLocalTargetPosition(x, y);
- 반대로 요청 과정에서 예외가 발생하면 현재 서버 좌표를 기준으로 이동 목표값을 다시 맞춘다.
- 현재 단계에서는 Client Prediction이라고 보기보다는 테스트 화면에서 이동 입력을 확인하기 위한 단순한 Local 표현에 가깝다.
15. Map 변경 테스트
- 테스트 UI에서 Map ID를 직접 입력하고 ChangeMapRequest를 보낼 수 있도록 했다.
- 성공하면 현재 Map ID와 Local Player 위치를 갱신한다.
ChangeMapRequest
│
▼
GameServer
│
▼
ChangeMapResponse
│
├─ Success
│ ├─ Current Map 변경
│ ├─ Player 위치 변경
│ └─ 이전 Map 객체 정리
│
└─ Failure
└─ 기존 상태 유지
- 이를 통해 Map-local 객체가 실제로 분리되어 관리되는지도 확인할 수 있게 되었다.
16. 두 클라이언트를 동시에 실행하기 위한 환경 구성
- MMORPG 서버 기능을 테스트하려면 단일 Client만 실행해서는 확인하기 어려운 기능이 많다.
- 예를 들어 Player 입장, PlayerMove Broadcast, Player 퇴장 등은 최소 두 개의 Client가 있어야 실제 동작을 확인할 수 있다.
- 따라서 Standalone 실행 기본 해상도를 960 × 540으로 낮추고 Windowed Mode를 사용하도록 했다.
- 창 크기 변경도 가능하도록 설정했다.
- 또한 다른 Client 창을 조작하는 동안에도 현재 Client가 네트워크 패킷을 계속 처리해야 하기 때문에 Background 실행을 활성화했다.
Application.runInBackground = true;
- 결과적으로 다음과 같은 형태의 테스트를 염두에 둔 구성이다.
┌────────────────────┐
│ Client A │
│ Character 1 │
└────────────────────┘
┌────────────────────┐
│ Client B │
│ Character 2 │
└────────────────────┘
│
▼
GameServer
- 한 Client에서 이동했을 때 다른 Client에서 Player가 움직이는지 직접 확인할 수 있도록 했다.
17. 이번 작업 이후의 테스트 흐름
- 이번 작업까지 진행하면서 서버와 Client의 전체 테스트 흐름은 다음과 같이 연결됐다.
Client A
LoginServer
Login ────────────────►
│
Character Select ◄───┘
│
│ AuthKey
▼
GameServer
EnterGame ───────────►
│
│
MoveRequest ─────────►
│
│ Broadcast
▼
Client B ◄──────── PlayerMove
- 여기에 Map 변경 및 Player 입·퇴장 처리까지 포함되면서 서버에서 구현한 Map-local 구조를 Client 화면에서 확인할 수 있는 기반이 마련됐다.
18. 주의할 부분
- 현재 이동 보간은 단순히 가장 최근에 받은 서버 위치를 목표로 Lerp하는 구조다.
- 따라서 RTT, Packet Jitter, Packet Loss 등을 고려한 정교한 Network Interpolation 구조는 아니다.
- 테스트 클라이언트 역시 실제 게임 UI가 아니라 서버 기능 검증을 위한 임시 도구다.
- 특히 Runtime에 Player와 Monster Sprite를 직접 생성하는 부분이나 OnGUI() 기반 UI는 실제 게임 Client 구조로 그대로 사용할 목적이 아니다.
- 현재 단계에서는 서버 기능을 빠르게 검증하기 위한 테스트 도구처럼 처리해둔 상태다.
19. 정리
- 이번 작업에서는 전날 구현한 Client 네트워크 구조를 실제 테스트에 사용할 수 있도록 보완했다.
- 동시 TCP 송신이 겹치지 않도록 SemaphoreSlim을 이용해 Write를 직렬화하고, 재연결 이후 이전 Session의 패킷이 현재 상태에 반영되지 않도록 Session 객체를 검증했다.
- 또한 각 패킷의 Payload 크기와 Character Count를 검증하여 Protocol 규격과 다른 데이터가 들어오는 경우 즉시 처리할 수 있도록 했다.
- 서버에서 전달받은 PlayerMove 좌표는 즉시 Transform에 적용하지 않고 목표 위치로 사용하여 화면상 이동을 보간하도록 변경했다.
- 아직 실제 Asset이 없는 상황에서도 테스트할 수 있도록 Runtime에서 Player와 Monster Sprite를 생성하고, Character ID마다 색상을 다르게 표시했다.
- 이후 로그인, 캐릭터 선택, GameServer 진입, Player 이동, Map 이동을 하나의 화면에서 확인할 수 있는 TestClientController를 구현했다.
- 방향키 입력을 통해 연속 이동 패킷을 전송하고, Local 및 Remote Player의 서버 좌표와 현재 Map 상태를 확인할 수 있도록 했다.
- 또한 두 개 이상의 Client를 동시에 실행하면서 테스트하기 쉽도록 Windowed Mode, Background 실행 환경도 구성했다.
20. 테스트 영상
'Projects > MyToyMapleServer' 카테고리의 다른 글
| Unity 게임 개발 캠프 개인 프로젝트 21일차(Server). 서버 기준 Player 이동 검증 구현 (0) | 2026.10.04 |
|---|---|
| Unity 게임 개발 캠프 개인 프로젝트 20일차. 테스트 클라이언트 책임 분리 및 Map-local 채팅 구현 (0) | 2026.10.03 |
| Unity 게임 개발 캠프 개인 프로젝트 18일차. 클라이언트 네트워크 구조 구성 및 월드 객체 동기화 (0) | 2026.10.01 |
| Unity 게임 개발 캠프 개인 프로젝트 17일차. Map 기반 몬스터 가시성 관리와 이동 책임 정리 (0) | 2026.09.30 |
| Unity 게임 개발 캠프 개인 프로젝트 16일차. 맵 단위 플레이어 동기화 및 통신 범위 구현 (0) | 2026.09.29 |