12. 플레이어 이동 좌표 검증 (속도핵/벽통과 방지)
난이도 중해설 — 플레이어 이동 좌표 검증 (속도핵/벽통과 방지)
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
핵심 결함은 이동 가능성 판정에 필요한 값(경과시간·속도)을 클라이언트가 보낸 값으로
계산하고, 통행 가능(벽/장애물) 검증을 아예 하지 않으며, 클라가 보낸 시각을 서버의
권위 시각으로 저장한다는 것이다. 그 결과 (A) clientSpeed/clientTimeMs 를 키우면 속도
검증이 무력화되어 텔레포트성 순간이동·속도핵이 통과하고, (B) 목적지 통행 검사가 없어 벽
통과·낭떠러지 건너뛰기가 가능하며, (C) 클라 시각을 그대로 저장해 다음 검증의 기준선까지
오염된다. 부수적으로 (D) GrantTeleport 로 받은 순간이동 허가가 실제 검증과 연결되지 않고,
(E) 같은 플레이어 패킷 동시 처리 시 위치 갱신이 비원자적이다. 정답의 한 줄:
경과시간은 서버 단조시계, 허용거리는 서버가 아는 MoveSpeed 로만 계산하고, 목적지는
CollisionMap 으로 검증하며, 플레이어 단위로 직렬화한다.
문제점
(A) 속도 검증에 클라 시간·속도 사용 — 클라 신뢰 (보안/정확성) ★간판
- 증상: 속도핵/순간이동이 검증을 통과한다.
- 재현 조건: 치터가
clientSpeed = 99999또는clientTimeMs를 크게 부풀려 보낸다.maxDist = clientSpeed * dt가 임의로 커져 어떤 거리도 통과한다.clientTimeMs가 과거면dtMs가 음수가 되어maxDist < 0→ 정상 이동도 거부되는 역효과도 가능. - 근본 원인: 경과시간과 속도는 서버가 결정해야 하는 권위 값이다. 서버
MoveSpeed와NowMs() - p.LastMoveMs로만 계산해야 한다.
(B) 통행 가능 검증 부재 — 검증 누락 (보안/정확성)
- 증상: 벽 통과, 낭떠러지/물 위 이동, 막힌 방 진입.
- 재현 조건: 속도 한도만 만족하면(짧은 거리씩 쪼개 보내면) 어떤 좌표로도 이동 가능.
_map.IsWalkable(dst)를 호출하지 않는다. - 근본 원인: 목적지/경로의 통행 가능성은 서버 맵 데이터로 검증해야 하는데 생략됐다. 경로 상의 벽도 봐야 하므로 목적지 한 점뿐 아니라 선분 샘플링(또는 서버 이동 시뮬레이션)이 필요하다.
(C) 클라 시각을 권위 시각으로 저장 — 클라 신뢰 (정확성)
- 증상: 다음 검증의 기준선(
LastMoveMs)이 오염되어 누적 악용된다. - 재현 조건:
clientTimeMs를 미래로 보내면 다음 패킷의dtMs가 커져 더 먼 이동이 허용된다. 시각을 과거로 보내면 정상 이동이 거부된다. - 근본 원인:
LastMoveMs는 서버NowMs()로 갱신해야 한다.
(D) 텔레포트 허가가 검증과 단절 — 설계 결함 (정확성)
GrantTeleport가TeleportGranted를 켜지만OnMove는 이 플래그를 보지 않는다. 포탈/블링크로 인한 정당한 장거리 이동은 속도 검증에 걸려 거부되고, 반대로 플래그가 소비(소진)되지 않아 의미가 없다. 허가가 있을 때만 1회 장거리 이동을 허용하고 즉시 플래그를 내려야 한다.
(E) 동시 이동 처리 비원자 — 경합 (동시성)
- 같은
playerId의OnMove가 여러 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회성 허가 토큰을 검증·소비. 허가 없는 장거리 점프는 거부·보정.
- "엄격히 막으면 지연 큰 유저가 자꾸 보정당한다. 어떻게 완화하나?" → 관용 계수·dt 클램프· 누적 기반 판정, 클라 예측 + 서버 보정 모델.
해설 — 플레이어 이동 좌표 검증 (속도핵/벽통과 방지) · C++
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
C# 판과 동일한 설계 결함에 더해 C++ 고유 위험이 겹친다. 핵심은 (A) 속도 검증을
클라가 보낸 시간·속도로 하고, (B) 통행 가능 검증을 생략하며, (C) 클라 시각을 권위
시각으로 저장하고, (D) teleportGranted 가 검증과 단절된 점이다. C++ 고유로는 (E)
players_[playerId] 가 operator[] 라 없는 키면 기본 Player{} 를 묵시적으로 삽입해
좌표 (0,0,0)·속도 0짜리 유령 플레이어가 생기고, (F) 반환한 Player& 참조가 동시
삽입/리해시로 무효화될 수 있으며, (G) 락이 없어 같은 플레이어 동시 접근이 **데이터
레이스(UB)**다. 정답의 한 줄: 서버 단조시계·서버 속도로 계산, 맵으로 통행 검증, find
로 존재 확인, 플레이어 단위 직렬화.
문제점
(A) 속도 검증에 클라 시간·속도 사용 — 클라 신뢰 (보안) ★간판
maxDist = clientSpeed * (dtMs/1000.0f).clientSpeed를 키우면 어떤 거리도 통과.clientTimeMs가 과거면dtMs음수 →maxDist음수 → 정상 이동 거부. 서버moveSpeed와now - lastMoveMs로 계산해야 한다.
(B) 통행 가능 검증 부재 — 검증 누락 (보안)
map_.IsWalkable(dst)미호출. 짧은 거리로 쪼개면 벽 너머·낭떠러지로 이동 가능. 목적지 점 + 출발-목적지 선분 샘플링이 필요.
(C) 클라 시각을 권위 시각으로 저장 — 클라 신뢰 (정확성)
p.lastMoveMs = clientTimeMs. 다음 패킷의 dt 기준선이 오염되어 누적 악용. 서버 시계로 갱신해야 한다.
(D) 텔레포트 허가 단절 — 설계 결함
teleportGranted를OnMove가 참조·소비하지 않는다. 정당한 블링크는 속도 검증에 걸리고, 플래그는 영구히 켜진 채 의미가 없다.
(E) operator[] 묵시적 삽입 — C++ 고유 (정확성/보안)
players_[playerId]는 키가 없으면 기본 생성된Player를 삽입한다. 인증 안 된/잘못된 id 패킷으로 좌표 0, 속도 0 짜리 엔트리가 무한 생성될 수 있고(메모리/맵 오염), 그 직후 검증도 0 기준으로 통과/거부가 뒤틀린다.find로 존재를 먼저 확인해야 한다.
(F) 참조 무효화 — C++ 고유 (메모리/UB)
Player& p = players_[playerId]의 참조를 보유한 사이, 다른 스레드가players_에 삽입/리해시(또는 erase)하면 참조가 무효화되어 UAF/오염.unordered_map은 리해시 시 노드 포인터는 유지되지만(노드 기반) erase·동시쓰기는 여전히 위험하며, 표준적으로 동시 변경은 UB.
(G) 데이터 레이스 — C++ 고유 (동시성/UB)
- 같은 플레이어의
OnMove동시 실행 시pos/lastMoveMs의 읽기-쓰기가 동기화 없이 경쟁 → C++ 메모리 모델상 정의되지 않은 동작. 플레이어 단위 락/단일 액터 필요.
수정안
enum class MoveResult { Accept, Reject, Correct };
MoveResult OnMove(int64_t playerId, float x, float y, float z) { // 클라 시간/속도 인자 제거
auto it = players_.find(playerId); // (E) 묵시적 삽입 방지
if (it == players_.end()) return MoveResult::Reject;
Player& p = it->second;
std::lock_guard<std::mutex> lk(p.mtx); // (G) 플레이어 단위 직렬화
const int64_t now = NowMonotonicMs();
const Vec3 dst{ x, y, z };
const float dist = Distance(p.pos, dst);
if (p.teleportGranted) { // (D) 허가 기반 1회 순간이동
if (!map_.IsWalkable(dst)) return MoveResult::Reject;
p.teleportGranted = false;
p.pos = dst; p.lastMoveMs = now;
return MoveResult::Accept;
}
int64_t dt = now - p.lastMoveMs; // (A)(C) 서버 시계
if (dt < 0) dt = 0; else if (dt > 1000) dt = 1000; // dt 클램프
const float maxDist = p.moveSpeed * (dt / 1000.0f) * kSpeedTolerance;
if (dist > maxDist) return MoveResult::Correct;
if (!IsPathWalkable(p.pos, dst)) return MoveResult::Correct; // (B) 경로 검증
p.pos = dst; p.lastMoveMs = now;
return MoveResult::Accept;
}
Player에std::mutex mtx;를 두거나, 플레이어를 단일 워커가 소유해 락을 제거.- 거부/보정 시
S_Correction(serverPos)송신.
더 나은 설계
1) 서버 권위 시뮬레이션
- 클라는 입력만, 서버가 충돌/물리로 위치 산출. 클라 예측 + 서버 보정. CPU 비용↑ 대신 치팅 방어·정합성↑.
2) 단조시계 + dt 클램프
std::chrono::steady_clock로 dt 계산(시스템 시계 점프 무관). dt 상하한 클램프로 "멈췄다 한 번에 점프" 악용 차단.
3) 경로 검증 비용/정확도
- 목적지 점만 보면 벽 관통 후 반대편 착지 악용. 선분 셀 샘플링 또는 내비메시 레이캐스트. 비용은 타일 가시성 캐시로 완화.
4) 자료구조/소유권
- 플레이어를
shared_ptr/안정 슬롯으로 관리해 참조 무효화를 피하고, 맵 접근은 단일 스레드(또는 read-write 락)로 보호.
면접 포인트
- 핵심: 서버 권위 값 식별(위치·시간·속도·통행) + 경로 검증 + C++ 고유 함정
(
operator[]삽입, 참조 무효화, 데이터 레이스 UB). - 예상 질문:
- "
map[key]와map.find(key)의 차이가 이 버그에서 왜 중요한가?" → 전자는 묵시적 기본 삽입으로 유령 엔트리·검증 왜곡. - "정당한 텔레포트와 핵을 어떻게 구분하나?" → 서버 발급 1회 허가 토큰 검증·소비.
- "여러 IO 스레드가 같은 플레이어를 만질 때의 UB를 어떻게 없애나?" → 플레이어 단위 락 또는 단일 액터 소유.
- "
구문 검증:
g++ -std=c++17 -fsyntax-only problem.cpp통과.