← 문제로

12. 플레이어 이동 좌표 검증 (속도핵/벽통과 방지)

난이도 중
내 리뷰 · C#
해설 · C#

해설 — 플레이어 이동 좌표 검증 (속도핵/벽통과 방지)

난이도: 중

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

핵심 결함은 이동 가능성 판정에 필요한 값(경과시간·속도)을 클라이언트가 보낸 값으로 계산하고, 통행 가능(벽/장애물) 검증을 아예 하지 않으며, 클라가 보낸 시각을 서버의 권위 시각으로 저장한다는 것이다. 그 결과 (A) clientSpeed/clientTimeMs 를 키우면 속도 검증이 무력화되어 텔레포트성 순간이동·속도핵이 통과하고, (B) 목적지 통행 검사가 없어 벽 통과·낭떠러지 건너뛰기가 가능하며, (C) 클라 시각을 그대로 저장해 다음 검증의 기준선까지 오염된다. 부수적으로 (D) GrantTeleport 로 받은 순간이동 허가가 실제 검증과 연결되지 않고, (E) 같은 플레이어 패킷 동시 처리 시 위치 갱신이 비원자적이다. 정답의 한 줄: 경과시간은 서버 단조시계, 허용거리는 서버가 아는 MoveSpeed 로만 계산하고, 목적지는 CollisionMap 으로 검증하며, 플레이어 단위로 직렬화한다.


문제점

(A) 속도 검증에 클라 시간·속도 사용 — 클라 신뢰 (보안/정확성) ★간판

  • 증상: 속도핵/순간이동이 검증을 통과한다.
  • 재현 조건: 치터가 clientSpeed = 99999 또는 clientTimeMs 를 크게 부풀려 보낸다. maxDist = clientSpeed * dt 가 임의로 커져 어떤 거리도 통과한다. clientTimeMs 가 과거면 dtMs 가 음수가 되어 maxDist < 0 → 정상 이동도 거부되는 역효과도 가능.
  • 근본 원인: 경과시간과 속도는 서버가 결정해야 하는 권위 값이다. 서버 MoveSpeedNowMs() - p.LastMoveMs 로만 계산해야 한다.

(B) 통행 가능 검증 부재 — 검증 누락 (보안/정확성)

  • 증상: 벽 통과, 낭떠러지/물 위 이동, 막힌 방 진입.
  • 재현 조건: 속도 한도만 만족하면(짧은 거리씩 쪼개 보내면) 어떤 좌표로도 이동 가능. _map.IsWalkable(dst) 를 호출하지 않는다.
  • 근본 원인: 목적지/경로의 통행 가능성은 서버 맵 데이터로 검증해야 하는데 생략됐다. 경로 상의 벽도 봐야 하므로 목적지 한 점뿐 아니라 선분 샘플링(또는 서버 이동 시뮬레이션)이 필요하다.

(C) 클라 시각을 권위 시각으로 저장 — 클라 신뢰 (정확성)

  • 증상: 다음 검증의 기준선(LastMoveMs)이 오염되어 누적 악용된다.
  • 재현 조건: clientTimeMs 를 미래로 보내면 다음 패킷의 dtMs 가 커져 더 먼 이동이 허용된다. 시각을 과거로 보내면 정상 이동이 거부된다.
  • 근본 원인: LastMoveMs 는 서버 NowMs() 로 갱신해야 한다.

(D) 텔레포트 허가가 검증과 단절 — 설계 결함 (정확성)

  • GrantTeleportTeleportGranted 를 켜지만 OnMove 는 이 플래그를 보지 않는다. 포탈/블링크로 인한 정당한 장거리 이동은 속도 검증에 걸려 거부되고, 반대로 플래그가 소비(소진)되지 않아 의미가 없다. 허가가 있을 때만 1회 장거리 이동을 허용하고 즉시 플래그를 내려야 한다.

(E) 동시 이동 처리 비원자 — 경합 (동시성)

  • 같은 playerIdOnMove 가 여러 IO 스레드에서 동시에 실행되면 p.Pos/p.LastMoveMs 읽기-검사-쓰기 사이에 끼어들어 검증을 우회하거나 위치가 뒤섞인다. 또한 _players[playerId] 가 없으면 KeyNotFoundException. 플레이어 단위 직렬화와 존재 검증이 필요.

수정안

public MoveResult OnMove(long playerId, float x, float y, float z)  // 클라 시간/속도 인자 제거
{
    if (!_players.TryGetValue(playerId, out var p))
        return MoveResult.Reject;

    lock (p)   // 플레이어 단위 직렬화(또는 단일 액터)
    {
        long now = NowMs();
        var dst = new Vec3(x, y, z);
        float dist = Distance(p.Pos, dst);

        // 텔레포트 허가가 있으면 1회 장거리 이동 허용(단, 목적지는 통행 가능해야 함)
        if (p.TeleportGranted)
        {
            if (!_map.IsWalkable(dst)) return MoveResult.Reject;
            p.TeleportGranted = false;        // 1회 소비
            p.Pos = dst; p.LastMoveMs = now;
            return MoveResult.Accept;
        }

        // 일반 이동: 서버 시계·서버 속도로 허용 거리 계산
        long dtMs = Math.Clamp(now - p.LastMoveMs, 0, 1000);   // 비정상 간격 방어
        float maxDist = p.MoveSpeed * (dtMs / 1000f) * SpeedTolerance;  // 약간의 여유
        if (dist > maxDist) return MoveResult.Correct;          // 거부 + 보정

        // 경로 통행 가능 검사(목적지 + 선분 샘플링)
        if (!IsPathWalkable(p.Pos, dst)) return MoveResult.Correct;

        p.Pos = dst;
        p.LastMoveMs = now;
        return MoveResult.Accept;
    }
}
  • 거부 시 S_Correction(serverPos) 를 내려보내 클라 위치를 서버 값으로 되돌린다.
  • SpeedTolerance(예: 1.1~1.2)와 dt 상한은 서버 상수다. 네트워크 지터로 패킷이 몰려 올 때의 경계 오류를 흡수하되 클라가 정하지 못하게 한다.

더 나은 설계

1) 서버 권위 이동 시뮬레이션

  • 가장 강한 방식은 클라가 "입력(방향/시각)"만 보내고 서버가 물리/충돌을 직접 시뮬레이션해 위치를 산출하는 것. 클라는 예측(prediction)으로 부드럽게 그리되 판정은 서버. 트레이드오프: 서버 CPU 비용↑, 그러나 치팅 방어·정합성이 압도적으로 우수.

2) 누적·통계 기반 이상탐지

  • 매 패킷을 엄격히 막기 어렵다면(경계 케이스 다수), 짧은 창에서의 평균 속도·순간 점프 횟수를 누적해 임계 초과 시 보정/플래그. 한 패킷의 미세 초과는 관용하되 누적 악용을 잡는다.

3) 단조시계 + dt 클램프

  • LastMoveMs 는 단조시계(Stopwatch) 기반으로 관리하면 시스템 시계 점프에 강하다. dt 는 상한·하한 클램프로 "오래 멈췄다가 한 번에 점프" 류 악용을 막는다.

4) 경로 검증의 비용/정확도 균형

  • 목적지 한 점만 보면 벽을 관통해 반대편 통행 지점에 착지하는 악용이 가능. 출발-목적지 선분을 셀 단위로 샘플링하거나, 내비메시 레이캐스트로 막힌 구간을 검출한다. 비용이 크면 타일 그리드 가시성 캐시로 완화.

면접 포인트

  • 핵심: 무엇이 서버 권위 값인가 — 위치·경과시간·속도·통행 가능 여부는 전부 서버가 계산/검증해야 하고 클라 값은 연출/예측 참고용. 그다음 경로(벽) 검증동시성.
  • 예상 질문:
    1. "클라가 좌표만 보내는데 속도핵을 어떻게 막나?" → 서버 시계·서버 속도로 허용 거리 계산, 목적지·경로 통행 검증, 누적 이상탐지.
    2. "정당한 텔레포트(포탈/블링크)와 순간이동핵을 어떻게 구분하나?" → 서버가 발급한 1회성 허가 토큰을 검증·소비. 허가 없는 장거리 점프는 거부·보정.
    3. "엄격히 막으면 지연 큰 유저가 자꾸 보정당한다. 어떻게 완화하나?" → 관용 계수·dt 클램프· 누적 기반 판정, 클라 예측 + 서버 보정 모델.