I'm FanJae.

Unity 게임 개발 캠프 개인 프로젝트 21일차(Client). 서버 확정 위치 기반 클라이언트 이동 보정 본문

Projects/MyToyMapleServer

Unity 게임 개발 캠프 개인 프로젝트 21일차(Client). 서버 확정 위치 기반 클라이언트 이동 보정

FanJae 2026. 10. 5. 21:50

1. 시작에 앞서

- Server에서는 이번 작업을 통해 Player의 이동 요청을 그대로 적용하지 않고, Map 범위와 이동 속도를 검증한 뒤 최종 위치를 결정하도록 변경했다.

- 이에 따라 Client 역시 단순히 이동 요청을 전송한 뒤 자신의 위치를 바로 확정하는 방식으로는 동작할 수 없게 되었다.

- Client에서 화면을 자연스럽게 움직이는 것과 Server가 실제로 인정한 Player 위치를 구분할 필요가 생겼다.

- 따라서 이번 작업에서는 Client의 이동 예측 위치와 Server 확정 위치를 분리하고, MoveResponse 결과에 따라 위치를 보정하는 구조를 구현했다.


2. 기존 Client 이동 방식의 문제

- 기존에는 MoveRequest 전송이 정상적으로 끝나면 Client에서도 해당 좌표를 Player의 위치로 바로 반영했다.

이동 입력
   │
   ▼
MoveRequest
   │
   ▼
전송 성공
   │
   ▼
Client 위치 변경

- 하지만 Server에서 이동 가능 여부를 검사하기 시작하면서 다음과 같은 상황이 발생할 수 있다.

Client
(120, 45) 이동 요청
      │
      ▼
Server
SpeedExceeded
      │
      ▼
실제 위치는 기존 위치 유지

- Client가 요청한 위치를 그대로 확정한다면 Server와 Client의 위치가 서로 달라지게 된다.

- 따라서 이동을 요청한 위치와 Server가 확정한 위치를 별도로 관리하도록 변경했다.


3. LocalMovementState 추가

- Local Player의 이동 요청 상태를 관리하기 위해 LocalMovementState를 추가했다.

public uint MapId { get; private set; }

public int ServerX { get; private set; }
public int ServerY { get; private set; }

public ulong PendingSequence { get; private set; }

public bool HasPendingMove
    => PendingSequence != 0;

- 이 클래스에서는 현재 Server가 확정한 좌표와 함께 현재 처리 중인 MoveRequest의 Sequence를 관리한다.

LocalMovementState

Current Map ID
Server X / Y
Pending Sequence
Next Sequence

- 즉 화면상 Player가 어디를 향하고 있는지와 관계없이 실제 게임 상태의 기준 좌표는 Server Response를 통해서만 변경하도록 했다.


4. 이동 요청에 Sequence와 Map ID 적용

- 변경된 Server Protocol에 맞춰 Client의 MoveRequest에도 현재 Map ID와 Sequence를 포함하도록 했다.

public struct MoveRequestData
{
    public uint MapId;
    public ulong Sequence;

    public int X;
    public int Y;
}

- 이동 요청을 시작할 때 Sequence를 증가시키고 현재 처리 중인 요청으로 기록한다.

PendingSequence = ++_sequence;

request = new MoveRequestData
{
    MapId = MapId,
    Sequence = PendingSequence,
    X = x,
    Y = y
};

- 현재는 하나의 이동 요청에 대한 응답이 오기 전에 새로운 요청을 계속 보내지 않도록 했다.

if (MapId == 0 ||
    HasPendingMove ||
    _sequence == ulong.MaxValue)
{
    return false;
}

- 방향키 입력은 계속 받을 수 있지만 Network Request는 이전 응답을 확인한 뒤 다음 요청을 보내는 형태다.


5. 화면 이동과 Server 좌표 분리

- Local Player의 화면 표현 역시 변경했다.

- 기존 SetTargetPosition()은 Server 좌표와 화면 목표 위치를 동시에 변경했다.

- 이번에는 화면상의 이동만 먼저 수행할 수 있도록 PredictPosition()을 분리했다.

public void PredictPosition(int x, int y)
{
    _targetPosition =
        WorldManager.ToUnityPosition(x, y);
}

- 따라서 이동 요청 과정은 다음과 같다.

Direction Input
      │
      ▼
MoveRequest 생성
      │
      ├──────────────► Server
      │
      ▼
화면상 이동 예측

- 이 시점에서 Player Sprite는 요청 위치 방향으로 이동하지만 ServerX, ServerY는 아직 바뀌지 않는다.

화면 위치
     ≠
Server 확정 위치

- Server에서 응답을 받은 이후에만 실제 Server 좌표를 갱신한다.


6. MoveResponse를 이용한 위치 확정

- Server에서 새롭게 전달하는 MoveResponse도 Client Protocol에 추가했다.

public struct MoveResponseData
{
    public ulong Sequence;
    public uint MapId;

    public MoveResult Result;

    public int X;
    public int Y;
}

- 응답을 받으면 현재 기다리고 있던 요청과 동일한지 먼저 확인한다.

if (!HasPendingMove || data.Sequence != PendingSequence || data.MapId != MapId)
{
    return false;
}

- 현재 요청과 관계없는 Response라면 Client 상태에 반영하지 않는다.

MoveResponse
     │
     ▼
Map ID 확인
     │
     ▼
Sequence 확인
     │
     ├─ 현재 요청 → 적용
     │
     └─ 이전 요청 → 무시

- 정상적인 Response라면 Server가 보내준 좌표를 현재 확정 좌표로 저장한다.

ServerX = data.X;
ServerY = data.Y;

7. 이동 성공과 실패에 따른 처리

- 이동에 성공했다면 Server가 반환한 위치를 Player의 최종 목표 위치로 사용한다.

_worldManager.SetLocalTargetPosition(data.X,data.Y);

- 반면 이동 요청이 거절된 경우에는 입력 목표 역시 Server 좌표로 되돌린다.

if (data.Result != MoveResult.Success)
{
    _moveTarget = new Vector2(data.X, data.Y);
}

- 결과적으로 다음과 같은 흐름이 만들어졌다.

Client Prediction
       │
       ▼
MoveRequest
       │
       ▼
Server Validation
       │
   ┌───┴────┐
   ▼        ▼
Success   Reject
   │        │
   └───┬────┘
       ▼
MoveResponse
Server Position
       │
       ▼
Client Correction

- Client의 화면 예측이 틀렸더라도 최종적으로는 Server가 반환한 위치로 수렴하게 된다.


8. 이전 응답이 현재 이동에 영향을 주지 않도록 처리

- 비동기 Network 환경에서는 이전 요청의 응답이 늦게 도착할 가능성도 고려해야 한다.

- 특히 Map을 이동한 뒤 이전 Map의 MoveResponse가 도착한다면 현재 Player 위치를 잘못 변경할 수 있다.

- 이를 방지하기 위해 MapId와 Sequence가 모두 현재 상태와 일치하는 경우만 적용하도록 했다.

Map A
Sequence 10 이동
      │
      │ Response 지연
      ▼

Map B 이동
Sequence 11 요청
      │
      ▼

Map A의 Sequence 10 응답 도착
      │
      ▼
현재 Map / Sequence와 다름
      │
      ▼
무시

- Map을 변경할 때는 현재 대기 중인 이동 요청도 취소한다.

_movementState.CancelMove(_movementState.PendingSequence);

9. 이동 응답 Timeout 처리

- MoveRequest를 전송했지만 Server 응답이 오지 않는 경우도 고려했다.

- 요청 이후 일정 시간 동안 MoveResponse가 도착하지 않으면 Pending 상태를 해제한다.

_moveResponseDeadline = Time.unscaledTime + 5f;

- 시간이 초과되면 마지막 Server 확정 위치로 화면과 이동 목표를 되돌린다.

_moveTarget = new Vector2(_movementState.ServerX,_movementState.ServerY);
_worldManager.SetLocalTargetPosition(_movementState.ServerX,_movementState.ServerY);

- 따라서 Packet 유실이나 연결 문제 때문에 Client가 영구적으로 이동 요청 대기 상태에 남지 않도록 했다.


10. MapInfo 기반 이동 속도 적용

- 이전 Client에서는 방향키 이동 속도를 다음과 같이 고정값으로 사용하고 있었다.

private const float ArrowMoveSpeed = 80f;

- 하지만 이번 Server 구조에서는 이동 속도가 Map 데이터에 포함되어 있다.

- 따라서 Client에서도 Server에서 받은 MapInfo 값을 사용하도록 변경했다.

_moveTarget += direction * _mapInfo.MoveSpeed * Time.deltaTime;

- Map에 입장하면 Server가 다음 정보를 전달한다.

MapInfo

Map ID

Min X / Max X
Min Y / Max Y

Move Speed
Move Burst

- Client 테스트 화면에서도 현재 Server 설정을 확인할 수 있도록 표시했다.

Bounds X: -400..400
Bounds Y: -200..200

Move Speed: 80
Burst: 12

- 이동 규칙의 기준값을 Client 코드에 중복해서 가지고 있지 않고 Server가 전달한 값을 사용하도록 한 것이다.


11. Map 변경 시 위치 상태 보정

- Map 변경 응답에서도 Server가 현재 Map ID와 좌표를 반환하기 때문에 Client는 해당 값을 기준으로 상태를 다시 설정한다.

_movementState.EnterMap(data.MapId,data.X,data.Y);

- 성공 여부와 관계없이 Response에 포함된 Server 기준 상태를 이용해 Local Player의 위치를 맞춘다.

_currentMapId = data.MapId;

_moveTarget =
    new Vector2(data.X, data.Y);

_worldManager.SetLocalTargetPosition(
    data.X,
    data.Y);

- 특히 존재하지 않는 Map으로 이동을 요청했을 경우에도 기존 Map과 기존 Server 좌표가 그대로 유지된다.


12. Map-local 통합 테스트 추가

- 이번 변경에서는 Unity 화면 테스트뿐 아니라 실제 Network와 Protocol 코드를 이용하는 별도 통합 테스트도 추가했다.

- 두 Client를 구성하여 다음과 같은 흐름을 자동으로 확인하도록 했다.

Login
 ↓
Character Select
 ↓
GameServer Enter
 ↓
MapInfo
 ↓
Player / Monster 동기화
 ↓
MoveRequest
 ↓
MoveResponse
 ↓
PlayerMove

- 특히 이동 관련해서는 다음 항목을 검증했다.

정상 이동

Map 범위 밖 이동

속도 초과

극단적인 좌표

잘못된 Map ID

중복 Sequence

이동 거절 이후 정상 이동 복구

Map 변경 후 이전 Response 무시

고빈도 이동 요청

- 기존에 구현했던 Map-local Player, Monster, Chat 기능도 함께 테스트하여 이동 구조 변경 이후 기존 기능이 깨지지 않는지 확인했다.


13. 정리

- 이번 작업에서는 Server가 Player 이동 결과를 직접 검증하도록 변경됨에 따라 Client의 이동 처리 방식도 함께 변경했다.

- LocalMovementState를 추가하여 화면에서 예측하고 있는 위치와 Server가 실제로 확정한 좌표를 분리했다.

- 이동 요청에는 현재 Map ID와 Sequence를 포함하고, 해당 요청과 일치하는 MoveResponse만 Client 상태에 반영하도록 했다.

- Player는 입력 즉시 화면상 목표 위치로 이동할 수 있지만 실제 Server 좌표는 Response를 받은 이후에만 변경된다.

- 이동이 Server에서 거절된 경우에는 화면 위치와 다음 입력 목표를 Server가 반환한 좌표로 다시 보정하도록 했다.

- 또한 일정 시간 동안 이동 응답이 없거나 Map이 변경되는 경우 Pending Move 상태를 정리해 이전 요청이 이후 상태에 영향을 주지 않도록 했다.

- 방향키 이동 속도 역시 Client의 고정값이 아니라 MapInfo를 통해 Server가 전달한 설정을 사용하도록 변경했다.

- 이번 작업을 통해 Client의 이동 표현은 빠르게 반응시키면서도 실제 Player 위치의 기준은 Server가 확정하도록 이동 처리 흐름을 정리했다.

Comments