35. 지연 보상(lag compensation) 되감기의 입력 검증·동시 접근 결함 (C#)
난이도 상내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 지연 보상(lag compensation) 되감기의 입력 검증·동시 접근 결함 (C#)
난이도: 상
요약
사수가 보낸 clientRenderTimeMs 를 (1) 유효 되감기 창으로 클램프/검증하지 않고 인덱스 산술에 쓰고, (2) 무부호 wrap + 모듈로로 강제로 범위에 접어 엉뚱한 슬롯을 읽으며, (3) 사격 스레드의 조회와 틱 스레드의 기록이 동기화 없이 같은 배열·필드에 동시 접근한다.
문제점
- [입력 검증 / 무부호 언더플로] (A)
ulong age = newest - clientRenderTimeMs;- 증상: 미래 시각(
clientRenderTimeMs > newest)이면ulong뺄셈이 언더플로해age가 거대해지고back도 폭발. 300ms 를 넘는 과거 요청도 그대로 통과. - 재현: 조작 클라가
clientRenderTimeMs = nowMs + 1000(미래) 또는nowMs - 5000(보관 범위 밖) 전송. - 근본원인: 신뢰 불가 입력을 유효 창
[newest - MaxRewindMs, newest]과 실제 보관 범위로 클램프하지 않음.
- 증상: 미래 시각(
- [잘못된 인덱스 / 무효 wrap] (B)
int slot = (int)((_head - 1 - (ulong)back) % N);- 증상:
back이 보관 개수(_count)보다 크면_head - 1 - back이 무부호 언더플로하고% N이 다시[0,N)으로 접어 미래/미초기화 슬롯을 가리킨다. C# 은IndexOutOfRangeException은 안 나지만(모듈로가 막음) "조용히 틀린 좌표"를 반환. - 근본원인: 선택 슬롯의
TimeMs가 요청 시각과 실제로 맞는지(또는back < _count인지) 사후 검증이 없음.
- 증상:
- [데이터 레이스 / 찢긴 읽기] (A)(B) 공통
SampleAt(사격 스레드) 이_head/_buf[slot]을 읽는 동안Record(틱 스레드) 가_head++/슬롯을 덮어쓴다.- 증상:
Snapshot은 24바이트 구조체라_buf[slot]복사가 비원자 —TimeMs와Pos가 서로 다른 틱의 값으로 찢겨 불가능한 좌표가 나온다._head도 비동기 읽기/쓰기.
- 증상:
수정안
유효 창 클램프 → 보관 범위 검증 → 일관 조회. 동시 접근은 lock 으로 보호(읽기 우세면 불변 스냅샷 publish).
public Vec2? SampleAt(ulong nowMs, ulong clientRenderTimeMs)
{
lock (_gate)
{
if (_count == 0) return null;
ulong newest = _buf[(int)((_head - 1) % N)].TimeMs;
ulong oldest = _buf[(int)((_head - _count) % N)].TimeMs;
// (A) 신뢰 불가 시각을 유효 창으로 클램프
ulong lo = newest > MaxRewindMs ? newest - MaxRewindMs : 0;
if (lo < oldest) lo = oldest;
ulong t = clientRenderTimeMs < lo ? lo : (clientRenderTimeMs > newest ? newest : clientRenderTimeMs);
// (B) 인접 두 스냅샷을 TimeMs로 검증 후 선형 보간
for (ulong i = 0; i + 1 < _count; i++)
{
ref readonly Snapshot a = ref _buf[(int)((_head - 1 - i) % N)];
ref readonly Snapshot b = ref _buf[(int)((_head - 2 - i) % N)];
if (t <= a.TimeMs && t >= b.TimeMs && a.TimeMs > b.TimeMs)
{
float f = (float)(t - b.TimeMs) / (a.TimeMs - b.TimeMs);
return new Vec2 { X = b.Pos.X + (a.Pos.X - b.Pos.X) * f,
Y = b.Pos.Y + (a.Pos.Y - b.Pos.Y) * f };
}
}
return _buf[(int)((_head - _count) % N)].Pos;
}
}
// 필드: private readonly object _gate = new(); (Record 도 같은 lock 사용)
더 나은 설계
- 불변 스냅샷 publish(읽기 우세): 틱마다
Snapshot[](또는 작은 배열)을 새로 만들어Volatile.Write/Interlocked.Exchange로 교체하면 사격 스레드는 락 없이 참조 하나만 읽어 일관 조회. 트레이드오프: 틱마다 할당(풀로 완화) —concurrency_memory/problem28(안전 공개)과 연결. - 클라 시각은 힌트:
nowMs - rtt/2 - interpDelay로 서버가 재구성한 시각을 상한으로 교차검증. 절대시각 맹신 금지. MaxRewindMs정책: p99 핑에 맞춰 200~300ms. 크면 "엄폐물 뒤 대상이 맞는" 불만, 작으면 고핑 사수 손해.- OOB를 모듈로로 가리지 말 것: 범위 밖은 "유효 창 밖" 신호 — 클램프 후에도 어긋나면 보상을 적용하지 않는 게 안전.
면접 포인트
- 되감기의 공정성/악용 트레이드오프와 한계값 산정.
ulong언더플로 → "조용히 틀린 인덱스", 그리고 모듈로가 어떻게 그 버그를 은폐하는가.- 구조체 배열 요소 복사가 비원자라 찢긴 읽기가 가능한 이유와, 불변 스냅샷 publish 로 락을 줄이는 방법.
해설 · C++
해설 — 지연 보상(lag compensation) 되감기의 입력 검증·동시 접근 결함 (C++)
난이도: 상
요약
사수 클라이언트가 보낸 clientRenderTimeMs 로 대상을 과거로 되감아 히트 판정을 한다. 그런데 (1) 신뢰 불가 시각을 유효 되감기 창으로 클램프/검증하지 않고 그대로 인덱스 산술에 쓰고, (2) 산술 결과를 무부호 wrap + 모듈로로 강제로 범위에 욱여넣어 엉뚱한 슬롯을 읽는다. 게다가 (3) sampleAt()(사격 스레드)이 record()(틱 스레드)와 동기화 없이 같은 버퍼를 읽어 데이터 레이스가 난다.
문제점
- [입력 검증 / 무부호 언더플로] (A)
age = newest - clientRenderTimeMs- 증상: 사수가 미래 시각(
clientRenderTimeMs > newest)을 보내면uint64_t뺄셈이 언더플로해age가 천문학적 값이 되고,back도 거대해진다. 과거로 너무 멀리(>300ms) 보낸 값도 그대로 통과한다. - 재현: 조작된 클라이언트가
clientRenderTimeMs = nowMs + 1000또는nowMs - 5000을 전송. - 근본원인: 신뢰 불가 입력을 유효 창
[newest - MAX_REWIND_MS, newest]으로 클램프하지 않음. 되감기 한계(MAX_REWIND_MS)와 실제 보관 범위(가장 오래된 스냅샷) 어느 쪽으로도 검증이 없다.
- 증상: 사수가 미래 시각(
- [잘못된 인덱스 / 무효 wrap] (B)
slot = (head_ - 1 - back) % N- 증상:
back >= count_(요청 시각이 보관된 가장 오래된 스냅샷보다 과거)일 때head_ - 1 - back이 무부호 언더플로하고% N이 그것을 다시[0,N)으로 접어, 미래 슬롯이나 아직 채워지지 않은(또는 막 덮어써진) 슬롯을 가리킨다. 모듈로가 OOB는 막지만 그 대가로 "조용히 틀린 좌표"를 반환한다. - 근본원인: 선택한 슬롯의
timeMs가 요청 시각과 실제로 일치하는지(또는back < count_인지) 사후 검증이 없다. 모듈로로 정상 인덱스처럼 보이게 만든 게 오히려 버그를 가린다.
- 증상:
- [데이터 레이스] (A)(B) 공통
sampleAt()의head_/buf_[...]읽기 vsrecord()의head_++/슬롯 쓰기가 동기화 없이 동시 실행.- 증상:
Snapshot{timeMs,pos}를 비원자로 읽는 도중 틱 스레드가 그 슬롯을 덮어써timeMs와pos가 서로 다른 틱의 값으로 찢긴(torn) 좌표가 되어 불가능한 위치에서 명중/빗맞음이 발생. C++ 표준상 데이터 레이스는 정의되지 않은 동작(UB) 이다.
- 증상:
수정안
유효 창으로 클램프 → 보관 범위 검증 → 일관 스냅샷 조회. 동시 접근은 락(또는 seqlock)으로 보호한다.
#include <array>
#include <cstdint>
#include <mutex>
#include <optional>
#include <algorithm>
struct Vec2 { float x = 0, y = 0; };
struct Snapshot { uint64_t timeMs = 0; Vec2 pos; };
class PositionHistory {
public:
static constexpr int N = 32;
static constexpr uint64_t TICK_MS = 50;
static constexpr uint64_t MAX_REWIND_MS = 300;
void record(uint64_t timeMs, Vec2 pos) {
std::lock_guard<std::mutex> lk(m_);
buf_[(int)(head_ % N)] = Snapshot{timeMs, pos};
++head_;
if (count_ < N) ++count_;
}
// 유효 창 밖이면 판정 불가(nullopt) — 호출부가 "보상 미적용"으로 처리
std::optional<Vec2> sampleAt(uint64_t nowMs, uint64_t clientRenderTimeMs) const {
std::lock_guard<std::mutex> lk(m_);
if (count_ == 0) return std::nullopt;
const uint64_t newest = buf_[(int)((head_ - 1) % N)].timeMs;
const uint64_t oldest = buf_[(int)((head_ - count_) % N)].timeMs;
// (A) 신뢰 불가 시각을 유효 창으로 클램프/검증
uint64_t lo = (newest > MAX_REWIND_MS) ? newest - MAX_REWIND_MS : 0;
lo = std::max(lo, oldest);
uint64_t t = std::clamp(clientRenderTimeMs, lo, newest);
// (B) 가장 가까운 두 스냅샷을 찾아 보간(선형). 슬롯의 timeMs로 사후 검증.
for (uint64_t i = 0; i + 1 < count_; ++i) {
const Snapshot& a = buf_[(int)((head_ - 1 - i) % N)];
const Snapshot& b = buf_[(int)((head_ - 1 - i - 1) % N)];
if (t <= a.timeMs && t >= b.timeMs && a.timeMs > b.timeMs) {
float f = float(t - b.timeMs) / float(a.timeMs - b.timeMs);
return Vec2{ b.pos.x + (a.pos.x - b.pos.x) * f,
b.pos.y + (a.pos.y - b.pos.y) * f };
}
}
return buf_[(int)((head_ - count_) % N)].pos; // 경계: 가장 오래된 값
}
private:
mutable std::mutex m_;
std::array<Snapshot, N> buf_{};
uint64_t head_ = 0;
uint64_t count_ = 0;
};
더 나은 설계
- 읽기 잦음 → seqlock: 틱 쓰기 1, 사격 읽기 다수라면 뮤텍스 대신 seqlock(짝/홀 시퀀스 카운터 + acquire/release)으로 읽기를 락-프리에 가깝게. 찢김 감지 시 재시도. 트레이드오프: 구현 복잡, 쓰기 빈도가 높으면 재시도 비용.
- 보상 한계는 정책으로:
MAX_REWIND_MS는 핑 분포(p99)에 맞춰 정한다. 너무 크면 "이미 엄폐물 뒤로 숨은 대상이 맞는" 피격자 불만, 너무 작으면 고핑 사수 손해. 흔히 200~300ms. - 틱 시각 기준 보간: 클라가 보낸 절대시각을 그대로 믿지 말고
nowMs - estimatedRtt/2 - interpDelay로 서버가 재구성한 시각과 교차검증(상한). 클라 시각은 '힌트'로만. - OOB를 모듈로로 가리지 말 것: 인덱스가 범위를 벗어나는 건 정상 흐름이 아니라 "유효 창 밖"이라는 신호다. 클램프 후에도 어긋나면 보상을 적용하지 않는 편이 안전하다.
면접 포인트
- "favor the shooter" 되감기의 공정성/악용 트레이드오프와
MAX_REWIND_MS산정 근거. - 무부호 정수 산술의 언더플로가 어떻게 "조용히 틀린 인덱스"로 이어지는가, 그리고 모듈로가 그 버그를 어떻게 은폐하는가.
- 틱 스레드 1 : 판정 스레드 N 구조에서 일관 스냅샷을 얻는 방법(락 vs seqlock vs 불변 스냅샷 publish)과 각 비용.