11. 스킬 사용 검증과 쿨다운 (서버 권위)
난이도 하해설 — 스킬 사용 검증과 쿨다운 (서버 권위)
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
이 코드의 핵심 결함은 서버가 권위적으로 결정해야 하는 값(쿨다운 종료 시각, 마나 비용)을
클라이언트가 보낸 값으로 신뢰한다는 것이다. 그 결과 (1) 클라가 clientCooldownEndMs
를 과거로 보내면 쿨다운을 완전히 무시하고 무한 연타가 가능하고, (2) manaCost 를 0
이나 음수로 보내면 마나 소모 없이(또는 마나를 늘리며) 시전할 수 있다. 부수적으로 (3)
같은 플레이어 패킷을 여러 IO 스레드가 동시에 처리할 때 검사-차감-쿨다운적용이 원자적이
지 않아 연타로 쿨다운/마나 검사를 뚫는 TOCTOU 가 있다. 정답의 한 줄: 쿨다운과 비용은
서버 SkillDef 와 서버 시계로만 계산하고, 플레이어 단위 락으로 직렬화한다.
문제점
(A) 쿨다운 판정에 클라 값 사용 — 클라 신뢰 (보안/정확성) ★간판
- 증상: 쿨다운이 전혀 걸리지 않은 것처럼 스킬을 무한 연타할 수 있다.
- 재현 조건: 치터가
clientCooldownEndMs = 0(또는 과거 시각)으로 패킷을 보낸다.now < clientCooldownEndMs가 항상 거짓 → 쿨다운 검사 통과. 서버가 저장해 둔p.CooldownEndMs[skillId](end) 는 읽기만 하고 비교에 쓰지 않는다. - 근본 원인: 쿨다운 종료 시각은 서버가 "직전 시전 시각 + def.CooldownMs" 로 계산해야
하는 서버 권위 값인데, 클라가 보낸 값을 판단 기준으로 삼았다. 서버가 가진
end를 무시하는 것이 결정적 버그.
(B) 마나 비용에 클라 값 사용 — 클라 신뢰 (보안/정확성)
- 증상: 마나 0으로 스킬을 난사하거나, 음수 비용으로 마나가 오히려 증가한다.
- 재현 조건:
manaCost = 0→p.Mana < 0거짓 → 통과, 차감 0.manaCost = -1000→p.Mana -= (-1000)으로 마나 증가. - 근본 원인: 비용은
def.ManaCost(서버 값)로만 계산해야 한다. 클라 값은 연출 참고용일 뿐 권위가 없다.
(D) 쿨다운 적용도 클라 값 저장 — 클라 신뢰 (보안)
- 설령 (A)를 고쳐 서버
end로 비교하더라도, 저장하는 값 자체가clientCooldownEndMs라 클라가 짧은(또는 과거) 종료 시각을 보내면 다음 시전부터 쿨다운이 무력화된다. 저장 값도now + def.CooldownMs여야 한다.
(A)+(B)+(D) 검사-차감-적용 비원자 — TOCTOU (동시성)
- 증상: 더블클릭/매크로로 같은 스킬 패킷이 거의 동시에 두 번 오면, 두 스레드가 모두 쿨다운 통과·마나 통과를 본 뒤 둘 다 시전(마나 이중 차감 또는 쿨다운 중복 시전).
- 재현 조건: 같은
playerId의UseSkill동시 호출. (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는 서버 구성 데이터에서 로드하고, 클라에는 동일 테이블의 해시/버전만 내려 "표 자체"의 변조도 막는다.
면접 포인트
- 면접관이 듣고 싶은 핵심: "무엇이 서버 권위 값인가" 를 즉시 식별하는 능력. 쿨다운 종료 시각·마나 비용·스킬 보유 여부는 전부 서버가 결정/검증해야 하며, 클라 값은 연출 참고용이라는 원칙. 그다음 동시 연타에 대한 원자성(락/액터).
- 예상 질문:
- "클라가 보낸 값 중 어떤 것을 신뢰해도 되고 어떤 것은 안 되나?" → 의도(어떤 스킬을 쓰겠다)는 입력으로 받되, 결과/비용/쿨다운은 서버가 계산·검증.
- "쿨다운을 절대시각 대신 어떻게 관리하면 시계 문제에 강한가?" → 단조시계 기반 잔여시간, 존 이전 시 잔여 쿨다운을 함께 이관.
- "더블클릭 연타로 마나가 두 번 빠지거나 쿨이 뚫리는 이유는?" → 검사-차감-적용이 비원자(TOCTOU). 플레이어 단위 락/액터로 직렬화.
해설 — 스킬 사용 검증과 쿨다운 (서버 권위, C++)
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
C# 트윈과 동일한 본질: 서버 권위 값(쿨다운 종료 시각, 마나 비용)을 클라가 보낸 값으로
신뢰한다. (1) clientCooldownEndMs 를 과거로 보내면 쿨다운 무력화, (2) manaCost 를
0/음수로 보내면 마나 없이 시전 또는 마나 증가. C++ 특유로 (3) unordered_map 을
여러 스레드가 락 없이 동시 변경하면 rehash 중 자료구조 손상/UB, (4) operator[]
로 없는 키를 조회하면 조용히 기본값 원소를 삽입(존재 검증 부재). 정답: 쿨다운/비용은
서버 SkillDef+서버 시계로만 계산하고, 플레이어 단위 mutex 로 직렬화한다.
문제점
(A) 쿨다운 판정에 클라 값 사용 — 클라 신뢰 (보안/정확성) ★간판
- 증상: 무한 연타.
- 재현 조건:
clientCooldownEndMs = 0→now < 0항상 거짓 → 통과. 서버가 저장한cooldownEndMs[skillId]는 존재만 확인하고 비교에 안 쓴다. - 근본 원인: 종료 시각은 "직전 시전 + def.cooldownMs" 로 서버가 계산할 값.
(B) 마나 비용에 클라 값 사용 — 클라 신뢰 (보안/정확성)
manaCost = 0→ 무한 시전,manaCost < 0→p.mana -= 음수로 마나 증가.- 비용은
def.manaCost로만.
(D) 쿨다운 적용도 클라 값 저장 — 클라 신뢰 (보안)
- 저장 값이
clientCooldownEndMs라, (A)를 고쳐도 다음 시전부터 클라가 정한 종료시각이 적용돼 무력화. 저장도now + def.cooldownMs.
동시성 — unordered_map 무보호 + 비원자 검사/차감 (동시성/메모리) ★C++ 특화
- 증상: 같은 플레이어 패킷 동시 처리 시 마나 이중 차감/쿨 뚫림. 더 위험하게,
p.cooldownEndMs또는*players_자체에 동시 삽입/rehash 가 일어나면 반복자 무효화 /힙 손상으로 크래시(UB). - 재현 조건: 두 IO 스레드가 같은
playerId로UseSkill동시 호출. 임계 구역 없음. - 근본 원인: 공유 가변 컨테이너에 동기화 부재. C#의 Dictionary 손상보다 C++ 에서는 정의되지 않은 동작(UB)이라 더 치명적.
operator[] 의 부작용 — 견고성 (메모리/정확성)
(*players_)[playerId],(*defs_)[skillId]는 키가 없으면 새 원소를 삽입한다. 잘못된/미인증 skillId 로 맵이 무한히 커지고(메모리 증식), 기본값 def(cooldown 0, mana 0)로 통과해버린다.find로 존재를 검증해야 한다.
수정안
bool UseSkill(int64_t playerId, int skillId) // 클라 값 인자 제거
{
auto pit = players_->find(playerId);
if (pit == players_->end()) return false;
auto dit = defs_->find(skillId);
if (dit == defs_->end()) return false;
Player& p = pit->second;
SkillDef& def = dit->second;
std::lock_guard<std::mutex> g(p.mtx); // 플레이어 단위 직렬화
int64_t now = NowMs();
auto cit = p.cooldownEndMs.find(skillId);
if (cit != p.cooldownEndMs.end() && now < cit->second)
return false; // 서버 저장값으로만 판단
if (p.mana < def.manaCost) return false;
p.mana -= def.manaCost; // 서버 def 기준
CastEffect(p, skillId);
p.cooldownEndMs[skillId] = now + def.cooldownMs; // 서버 계산
return true;
}
Player에std::mutex mtx;를 추가. 플레이어 컨테이너 자체의 동시 삽입이 가능하면 그 컨테이너도 별도 락으로 보호하거나, 접속 시 한 번만 생성하고 이후 포인터를 고정한다.find로 바꿔operator[]의 묵시적 삽입 부작용을 제거한다.
더 나은 설계
1) 서버 권위 + 거부 응답으로 클라 보정
- 판정은 100% 서버. 거부 시
S_SkillRejected{reason, serverCooldownEndMs}로 정확한 종료 시각을 내려 클라 게이지를 보정. 트레이드오프: 패킷 1개 추가 vs 일관성/치팅 방어.
2) 단조시계(steady_clock) 기반 잔여 쿨다운
system_clock은 NTP 보정/시계 점프에 흔들린다. 쿨다운은steady_clock기반 잔여 시간으로 관리하면 시계 점프에 강하다. 존 이전(서버-서버) 시 잔여 쿨다운을 함께 이관.
3) 단일 액터 모델
- 한 플레이어 입력을 단일 스레드 큐로 직렬 처리하면 mutex 자체가 불필요해지고 TOCTOU 가 구조적으로 불가능. C++ 게임서버에서 흔한 패턴.
4) 구성 데이터 검증
- def 테이블은 서버에서 로드하고 클라엔 해시/버전만. 표 자체 변조를 차단.
면접 포인트
- 핵심: 서버 권위 값 식별 + 공유 컨테이너 동기화(C++ 에선 UB 위험) +
operator[]의 묵시적 삽입 부작용. - 예상 질문:
- "C# 의 Dictionary 손상과 C++ unordered_map 동시 변경의 차이는?" → 후자는 정의되지 않은 동작(UB)이라 즉시 크래시/힙 손상 가능. 더 위험.
- "operator[] 와 find 의 차이가 왜 보안 이슈가 되나?" → 없는 키에 기본 원소를 삽입 → 메모리 증식 + 0비용/0쿨 기본 def 로 통과.
- "system_clock 대신 steady_clock 을 쓰는 이유?" → 시계 점프/NTP 보정에도 쿨다운 잔여 계산이 단조 증가로 안전.