서버 권한으로 데미지 판정하기: 클라이언트 조작을 막는 검증 설계
클라이언트가 보낸 공격 의도를 서버가 검증하고 피해를 확정하는 구조를 정리합니다. 사거리, 쿨다운, 탄약, 래그 보상처럼 실무에서 자주 필요한 검증 항목을 예제로 설명합니다.

왜 데미지를 클라이언트에 맡기면 안 될까
멀티플레이 게임에서 클라이언트는 신뢰할 수 없는 실행 환경이다. 메모리 값을 바꾸거나 패킷을 재전송할 수 있고 프로토콜을 분석해 임의의 메시지를 만들어 보낼 수도 있다. 따라서 적에게 100의 피해를 입혔다는 클라이언트의 결과 보고를 그대로 적용하면 공격력·사거리·연사 속도 조작을 막기 어렵다.
권한 서버 구조에서는 클라이언트가 공격 의도를 보내고 서버가 자신이 가진 게임 상태와 규칙을 기준으로 결과를 계산한다. 클라이언트는 즉시 이펙트를 보여 줄 수 있지만 HP 감소와 처치 판정은 서버가 확정한 결과를 따라야 한다.
flowchart LR
A[클라이언트 입력] --> B[공격 요청 전송]
B --> C[서버 검증]
C -->|유효| D[서버가 피해 적용]
C -->|무효| E[요청 거부]
D --> F[상태 동기화]
F --> G[모든 클라이언트에 결과 반영]
클라이언트는 결과가 아니라 의도를 보낸다
피해량이나 대상의 현재 HP를 클라이언트가 직접 전달하는 방식은 피해야 한다.
// 신뢰하면 안 되는 예시
SendToServer(new DamageRequest {
TargetId = enemyId,
Damage = 999999
});
이 요청만으로는 공격력이 실제로 999999인지 공격이 적중했는지 서버가 판단할 수 없다. 대신 무기 발사처럼 서버가 재현하고 검증할 수 있는 최소한의 입력을 보낸다.
public struct FireRequest
{
public int WeaponSlot;
public uint ShotSequence;
public Vector3 Origin;
public Vector3 Direction;
public double ClientShotTime;
}
여기서도 Origin, Direction, ClientShotTime은 사실이 아니라 검증 대상이다. 특히 ClientShotTime은 래그 보상에 참고할 수 있는 값일 뿐, 서버 시계를 대신하지 않는다. 서버는 플레이어의 권위 있는 위치와 조준 상태, 요청을 받은 시점, 과거 상태 기록을 근거로 허용 범위를 계산해야 한다. 가능하다면 발사 원점도 클라이언트가 보내게 하기보다 서버가 무기의 장착 위치에서 계산하는 편이 안전하다.

서버가 확인해야 할 항목
1. 공격 가능한 상태인가
서버는 플레이어가 살아 있는지 기절·재장전 상태가 아닌지, 해당 무기를 실제로 장착했는지 확인한다. 무기 슬롯 번호 역시 범위를 벗어나지 않는지 검증해야 한다. 쿨다운과 탄약은 반드시 서버 상태를 기준으로 판단한다.
bool CanFire(Player player, Weapon weapon, double serverTime)
{
if (!player.IsAlive || player.IsStunned)
return false;
if (player.EquippedWeaponId != weapon.Id)
return false;
if (weapon.Ammo <= 0)
return false;
return serverTime >= weapon.NextFireTime;
}
검증을 통과한 뒤에만 서버가 탄약을 줄이고 다음 발사 가능 시각을 갱신한다. 요청이 거부됐다고 해서 탄약이나 쿨다운을 변경하면 안 된다. 여러 요청이 동시에 처리될 수 있다면 이 검사와 상태 변경은 하나의 원자적인 작업으로 묶어야 한다.
2. 발사 위치와 방향이 가능한 범위인가
클라이언트가 보낸 발사 원점을 사용할 경우에는 서버가 알고 있는 캐릭터 위치와 무기의 장착 위치 근처인지 확인해야 한다. 약간의 오차는 애니메이션, 카메라 위치, 네트워크 보간 때문에 생길 수 있으므로 게임에 맞는 허용 오차를 둔다. 다만 고정된 1.5f가 모든 게임에 맞는 것은 아니다. 큰 차이를 허용하면 벽 너머나 먼 거리에서 총알을 시작시키는 조작을 허용하게 된다.
bool IsValidOrigin(Player player, Vector3 claimedOrigin)
{
const float maxOriginOffset = 0.25f; // 게임의 무기·네트워크 조건에 맞춰 조정
return Vector3.Distance(player.ServerMuzzlePosition, claimedOrigin)
<= maxOriginOffset;
}
방향도 서버가 가진 시선 또는 조준 방향과 비교한다. 방향 벡터가 0이거나 NaN·무한대인지 먼저 확인한 뒤 각도를 비교해야 한다. 허용 각도는 게임의 조작 방식에 맞춰 정해야 하며 조준 보정이 강한 게임일수록 그 보정 규칙까지 서버와 일관되게 설계해야 한다. 단순히 현재 조준 방향만 비교하면 래그 보상 판정과 충돌할 수 있으므로 과거 시점 판정에서는 해당 시점의 서버 상태를 기준으로 비교한다.
3. 실제로 맞은 공격인가
히트스캔 무기라면 서버가 레이캐스트 또는 충돌 판정을 수행한다. 투사체라면 서버가 투사체를 생성하고 이후 위치와 충돌을 시뮬레이션한다. 피해량은 요청에 포함하지 않고 무기 데이터와 버프, 방어력, 헤드샷 배율을 서버에서 계산한다.
void HandleFire(Player player, FireRequest request, double serverTime)
{
// 중복 요청과 슬롯 범위 검사는 앞단에서 처리한다고 가정한다.
Weapon weapon = player.GetWeapon(request.WeaponSlot);
if (weapon == null || !CanFire(player, weapon, serverTime))
return;
if (!IsValidOrigin(player, request.Origin))
return;
if (!IsValidDirection(player, request.Direction))
return;
weapon.Ammo--;
weapon.NextFireTime = serverTime + weapon.FireInterval;
// 검증이 끝난 뒤에는 클라이언트가 보낸 원점 대신 서버가 계산한 값을 사용한다.
Ray ray = new Ray(player.ServerMuzzlePosition, request.Direction.normalized);
if (Physics.Raycast(ray, out RaycastHit hit, weapon.Range, weapon.HitMask))
{
ApplyServerDamage(player, weapon, hit.collider, hit.point);
}
}
이 코드는 흐름을 보여 주기 위한 단순화된 예시다. 실제 구현에서는 서버가 계산한 무기 원점을 사용하고 자신의 콜라이더를 무시하며 레이캐스트 결과를 서버가 관리하는 엔티티와 히트박스로 변환해야 한다. ApplyServerDamage는 대상의 생존 여부, 팀 규칙, 무적 상태, 피격 부위와 방어 효과까지 다시 확인한 뒤 HP를 변경하는 서버 전용 함수여야 한다.
지연 시간과 시점 보정
서버가 현재 위치만으로 판정하면 지연 시간이 큰 플레이어는 화면에서 맞힌 적을 서버에서는 놓칠 수 있다. 이를 줄이기 위해 서버는 플레이어 히트박스의 최근 권위 상태를 짧은 시간 동안 보관하고 요청에 담긴 발사 시점 근처의 과거 상태에서 히트스캔 판정을 수행한다. 이를 보통 래그 보상 또는 서버 리와인드라고 한다. 모든 월드 상태를 통째로 되감는다는 뜻은 아니다.
중요한 점은 클라이언트 시간이 곧바로 신뢰되는 값이 아니라는 것이다. 서버는 각 연결의 시계 오프셋을 추정하고 요청 수신 시점과 발사 간격을 함께 고려해 허용 가능한 과거 범위를 제한해야 한다. 예를 들어 최근 200ms까지만 되감기를 허용하고 그보다 오래된 요청은 현재 시점으로 처리하거나 거부할 수 있다. 되감기 시점만 과거로 옮길 뿐, 탄약·쿨다운·피해 적용 같은 상태 변경은 현재의 서버 권한 규칙에 따라 한 번만 처리해야 한다.
// 개념 예시: 서버가 허용한 시점으로만 되감는다.
double rewindTime = Clamp(
ConvertClientTimeToServerTime(request.clientShotTime),
serverTime - 0.20,
serverTime
);
CollisionHistory historicalHitboxes = history.SampleHitboxes(rewindTime);
Vector3 origin = historicalHitboxes.GetMuzzlePosition(player.Id);
HitResult hit = historicalHitboxes.Raycast(origin, request.direction);
위 코드에서 request.direction은 현재 조준 방향이 아니라 발사 시점에 대한 검증을 통과한 방향이라고 가정한다. 리와인드 중에는 플레이어의 과거 히트박스처럼 판정에 필요한 데이터만 조회하고 판정이 끝나면 즉시 현재 상태로 돌아와야 한다. 실제 월드 오브젝트를 잠시 과거 상태로 되돌리는 방식은 다른 요청과 충돌하기 쉬우므로 주의해야 한다. 이동 가능한 문, 파괴물, 방패처럼 충돌 상태가 변하는 오브젝트도 판정 대상이라면 과거 상태를 함께 기록할지, 현재 상태만 사용할지 정책을 정해야 한다.
클라이언트 예측은 유지하되 확정은 서버에 둔다
서버 확인만 기다린 뒤 총구 화염과 반동을 재생하면 조작감이 나빠진다. 그래서 클라이언트는 발사 입력 직후 사운드, 반동, 탄흔 같은 시각 효과를 예측으로 보여 줄 수 있다. 다만 예측 효과와 게임 상태 변경을 분리해야 한다.
- 클라이언트: 발사 연출을 즉시 재생하고 요청을 전송한다.
- 서버: 검증 후 탄약, HP, 처치, 점수를 확정한다.
- 클라이언트: 서버 결과가 다르면 탄약과 명중 표식, 피해 UI를 수정한다.
특히 명중 표식이나 처치 알림은 서버 승인 뒤에 표시하는 편이 안전하다. 예측 명중 표식을 사용한다면 서버 거부 시 사라지거나 수정될 수 있다는 점을 UI 설계에 반영한다.
구현할 때 자주 놓치는 부분
요청 순서와 재전송을 처리한다
신뢰할 수 없는 전송 방식이나 재시도 로직에서는 같은 요청이 중복 도착하거나 순서가 바뀔 수 있다. 연결별로 증가하는 ShotSequence를 두고 이미 처리한 번호를 다시 적용하지 않도록 한다. 다만 순서 번호만으로 재전송 문제를 모두 해결할 수는 없으므로 요청의 유효 범위와 만료 시간도 확인하고 필요하다면 requestId를 이용한 멱등성 처리를 둔다.
비정상 요청을 관찰한다
검증 실패는 항상 치트를 뜻하지 않는다. 패킷 지연, 동기화 오차, 클라이언트 버그도 원인이 될 수 있다. 즉시 차단하기보다 실패 사유와 빈도를 기록하고 짧은 시간에 반복되는 불가능한 요청을 탐지하는 방식이 안전하다.
서버 부담을 측정한다
모든 총알을 정밀하게 되감아 판정하면 비용이 커질 수 있다. 무기 종류별로 필요한 정확도를 나누고 히스토리 길이와 샘플 주기를 조절한다. 충돌 후보를 공간 분할로 줄이고 서버 틱 예산 안에서 부하 테스트를 수행해야 한다.
점검 목록
서버 권한 피해 판정은 단순히 서버에서 레이캐스트를 한 번 실행하는 것으로 끝나지 않는다. 공격 요청이 가능한 상태인지 확인하고 입력의 범위를 제한하며 서버 데이터로 명중과 피해를 계산한 뒤 확정 결과만 동기화해야 한다.
출시 전에는 다음 질문에 모두 답할 수 있는지 확인해 보자.
- 클라이언트가 피해량이나 탄약 수를 바꿔도 서버 결과가 달라지지 않는가?
- 동일한 발사 요청이 여러 번 도착해도 피해가 한 번만 적용되는가?
- 벽 너머, 사거리 밖, 비정상적인 각도에서 보낸 요청은 거부되는가?
- 지연 시간이 있는 플레이어도 일관된 명중 판정을 받는가?
- 검증 실패와 서버 판정 시간을 관찰할 수 있는가?
이 기준을 만족해도 치트를 완전히 없앨 수는 없다. 다만 클라이언트 조작만으로 피해를 만들어 내는 경로를 줄이고 중요한 판정을 서버가 통제하는 경로로 옮길 수 있다.



