← 문제로

35. 지연 보상(lag compensation) 되감기의 입력 검증·동시 접근 결함 (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] 복사가 비원자 — TimeMsPos 가 서로 다른 틱의 값으로 찢겨 불가능한 좌표가 나온다. _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 로 락을 줄이는 방법.