I'm FanJae.

Unity 게임 개발 캠프 개인 프로젝트 19일차. Unity 네트워크 테스트 클라이언트 보완 본문

Projects/MyToyMapleServer

Unity 게임 개발 캠프 개인 프로젝트 19일차. Unity 네트워크 테스트 클라이언트 보완

FanJae 2026. 10. 2. 23:01

1. 시작에 앞서

- 전날에는 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. 테스트 영상

 

Comments