← 문제로

11. 스킬 사용 검증과 쿨다운 (서버 권위)

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

해설 — 스킬 사용 검증과 쿨다운 (서버 권위)

난이도: 하

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

요약

이 코드의 핵심 결함은 서버가 권위적으로 결정해야 하는 값(쿨다운 종료 시각, 마나 비용)을 클라이언트가 보낸 값으로 신뢰한다는 것이다. 그 결과 (1) 클라가 clientCooldownEndMs 를 과거로 보내면 쿨다운을 완전히 무시하고 무한 연타가 가능하고, (2) manaCost 를 0 이나 음수로 보내면 마나 소모 없이(또는 마나를 늘리며) 시전할 수 있다. 부수적으로 (3) 같은 플레이어 패킷을 여러 IO 스레드가 동시에 처리할 때 검사-차감-쿨다운적용이 원자적이 지 않아 연타로 쿨다운/마나 검사를 뚫는 TOCTOU 가 있다. 정답의 한 줄: 쿨다운과 비용은 서버 SkillDef 와 서버 시계로만 계산하고, 플레이어 단위 락으로 직렬화한다.


문제점

(A) 쿨다운 판정에 클라 값 사용 — 클라 신뢰 (보안/정확성) ★간판

  • 증상: 쿨다운이 전혀 걸리지 않은 것처럼 스킬을 무한 연타할 수 있다.
  • 재현 조건: 치터가 clientCooldownEndMs = 0(또는 과거 시각)으로 패킷을 보낸다. now < clientCooldownEndMs 가 항상 거짓 → 쿨다운 검사 통과. 서버가 저장해 둔 p.CooldownEndMs[skillId](end) 는 읽기만 하고 비교에 쓰지 않는다.
  • 근본 원인: 쿨다운 종료 시각은 서버가 "직전 시전 시각 + def.CooldownMs" 로 계산해야 하는 서버 권위 값인데, 클라가 보낸 값을 판단 기준으로 삼았다. 서버가 가진 end 를 무시하는 것이 결정적 버그.

(B) 마나 비용에 클라 값 사용 — 클라 신뢰 (보안/정확성)

  • 증상: 마나 0으로 스킬을 난사하거나, 음수 비용으로 마나가 오히려 증가한다.
  • 재현 조건: manaCost = 0p.Mana < 0 거짓 → 통과, 차감 0. manaCost = -1000p.Mana -= (-1000) 으로 마나 증가.
  • 근본 원인: 비용은 def.ManaCost(서버 값)로만 계산해야 한다. 클라 값은 연출 참고용일 뿐 권위가 없다.

(D) 쿨다운 적용도 클라 값 저장 — 클라 신뢰 (보안)

  • 설령 (A)를 고쳐 서버 end 로 비교하더라도, 저장하는 값 자체가 clientCooldownEndMs 라 클라가 짧은(또는 과거) 종료 시각을 보내면 다음 시전부터 쿨다운이 무력화된다. 저장 값도 now + def.CooldownMs 여야 한다.

(A)+(B)+(D) 검사-차감-적용 비원자 — TOCTOU (동시성)

  • 증상: 더블클릭/매크로로 같은 스킬 패킷이 거의 동시에 두 번 오면, 두 스레드가 모두 쿨다운 통과·마나 통과를 본 뒤 둘 다 시전(마나 이중 차감 또는 쿨다운 중복 시전).
  • 재현 조건: 같은 playerIdUseSkill 동시 호출. (A)검사~(D)적용 사이에 락이 없다. 또한 Dictionary 동시 쓰기로 자료구조 손상 가능.
  • 근본 원인: 플레이어 상태(마나/쿨다운 맵)는 공유 가변 상태인데 임계 구역이 없다.

(보조) 사전 검증 부재 — 견고성

  • _players[playerId], _defs[skillId] 가 없으면 KeyNotFoundException. 미인증/잘못된 skillId 패킷으로 서버가 죽을 수 있다. 인증·존재 검증 후 처리해야 한다.

수정안

핵심: ① 쿨다운/마나는 서버 SkillDef + 서버 시계로만 계산, ② 클라가 보낸 clientCooldownEndMs/manaCost무시(연출 참고용), ③ 플레이어 단위 락으로 검사~차감~쿨다운적용을 하나의 임계 구역으로, ④ 입력 존재 검증.

public bool UseSkill(long playerId, int skillId)   // 클라 값 인자 제거
{
    if (!_players.TryGetValue(playerId, out var p)) return false;
    if (!_defs.TryGetValue(skillId, out var def))   return false;

    lock (p)   // 플레이어 단위 직렬화 (또는 per-player lock 객체)
    {
        long now = NowMs();

        // 쿨다운: 서버가 저장한 종료 시각으로만 판단
        if (p.CooldownEndMs.TryGetValue(skillId, out long end) && now < end)
            return false;

        // 마나: 서버 def 기준
        if (p.Mana < def.ManaCost) return false;

        // 검사 통과 → 차감 + 시전 + 쿨다운(서버 계산)을 한 임계 구역에서
        p.Mana -= def.ManaCost;
        CastEffect(p, skillId);
        p.CooldownEndMs[skillId] = now + def.CooldownMs;
        return true;
    }
}

lock (p) 는 예시. 실제로는 락 대상 객체를 명시적으로 두거나(예: p.SyncRoot), 플레이어를 단일 스레드(액터)가 소유해 락 자체를 없애는 편이 깔끔하다.


더 나은 설계

1) 클라이언트 예측 vs 서버 권위의 분리

  • 클라는 자기 화면에서 쿨다운 게이지를 "예측" 으로 돌리되, 판정은 100% 서버. 서버는 거부 시 S_SkillRejected(reason, serverCooldownEndMs) 로 정확한 종료 시각을 내려줘 클라가 게이지를 보정하게 한다. 트레이드오프: 패킷 1개 추가지만 일관성/치팅 방어가 압도적으로 중요.

2) 시계/지연 보정

  • 네트워크 지연 때문에 "막 끝났는데 거부" 되는 경계 케이스가 있다. 서버에서 작은 허용 오차(예: 50~100ms grace)를 두되, 이는 서버가 정한 상수여야 하고 클라가 못 정한다. 서버-서버(존 이전) 시에는 시계 동기화(NTP) 가정이 필요하므로 절대시각보다 단조시계 기반 잔여 쿨다운으로 관리하면 시계 점프에 강하다.

3) 단일 액터 모델

  • 한 플레이어의 모든 입력을 단일 스레드/액터 큐로 직렬 처리하면 락이 사라지고 TOCTOU 가 구조적으로 불가능. MMO 필드 서버에서 흔한 패턴.

4) 비용 테이블 검증

  • def.ManaCost/CooldownMs 는 서버 구성 데이터에서 로드하고, 클라에는 동일 테이블의 해시/버전만 내려 "표 자체"의 변조도 막는다.

면접 포인트

  • 면접관이 듣고 싶은 핵심: "무엇이 서버 권위 값인가" 를 즉시 식별하는 능력. 쿨다운 종료 시각·마나 비용·스킬 보유 여부는 전부 서버가 결정/검증해야 하며, 클라 값은 연출 참고용이라는 원칙. 그다음 동시 연타에 대한 원자성(락/액터).
  • 예상 질문:
    1. "클라가 보낸 값 중 어떤 것을 신뢰해도 되고 어떤 것은 안 되나?" → 의도(어떤 스킬을 쓰겠다)는 입력으로 받되, 결과/비용/쿨다운은 서버가 계산·검증.
    2. "쿨다운을 절대시각 대신 어떻게 관리하면 시계 문제에 강한가?" → 단조시계 기반 잔여시간, 존 이전 시 잔여 쿨다운을 함께 이관.
    3. "더블클릭 연타로 마나가 두 번 빠지거나 쿨이 뚫리는 이유는?" → 검사-차감-적용이 비원자(TOCTOU). 플레이어 단위 락/액터로 직렬화.