← 문제로

53. 탈것 탑승/재탑승의 검사-적용 비원자 (속도 버프 중복)

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

해설 — 탈것 탑승/재탑승의 검사-적용 비원자 (속도 버프 중복)

난이도: 하

요약

OnMountRequest가 "탑승 여부 확인 → 속도 배율 적용 → 플래그 설정"을 락 없이 수행한다. 같은 플레이어의 탑승 요청 두 개가 서로 다른 스레드 풀 워커에서 동시에 실행되면 둘 다 (A)의 if(IsMounted)를 통과해 MoveSpeed *= 1.2f를 두 번 적용한다. 결과적으로 이동 속도가 1.2배가 아니라 1.44배가 되고, 이후 하차해도 상태가 어긋난다.

문제점

  • 분류태그: check-then-act TOCTOU / 데이터 레이스(비원자 복합 연산)
  • 증상: 재탑승 요청이 겹치면 이동 속도가 기대(×1.2)보다 커진다(×1.44 이상). 속도핵처럼 보이는 정상 유저 신고가 발생.
  • 재현조건: 같은 플레이어의 탑승 패킷 2개가 짧은 간격으로 도착 → 두 워커가 동시에 OnMountRequest 진입 → 둘 다 IsMounted==false를 관측.
  • 근본원인: (A) 검사와 (B) 설정 사이가 원자적이지 않다. 두 스레드가 같은 초기 상태를 읽고 각자 배율을 곱한다. MoveSpeed *= k 자체도 read-modify-write라 비원자다.

수정안

플레이어(또는 컴포넌트) 단위 락으로 검사~적용을 하나의 임계 구역으로 묶는다.

private readonly object _gate = new object();

public void OnMountRequest()
{
    lock (_gate)
    {
        if (IsMounted) return;
        MoveSpeed = BaseSpeed * MountMultiplier;  // 절댓값 설정으로 누적 방지
        IsMounted = true;
    }
}

public void OnDismountRequest()
{
    lock (_gate)
    {
        if (!IsMounted) return;
        MoveSpeed = BaseSpeed;
        IsMounted = false;
    }
}

핵심은 두 가지다. (1) 검사~설정을 원자화하고, (2) 속도를 *= 누적이 아니라 상태로부터 절댓값으로 재계산해 중복 적용 자체를 무해화한다.

더 나은 설계

  • 한 플레이어의 모든 게임플레이 명령을 단일 액터/직렬 큐로 처리하면(세션당 single-threaded 실행) 이런 컴포넌트마다 락을 다는 대신 구조적으로 경합을 없앨 수 있다. 트레이드오프: 액터 큐잉 오버헤드와 처리 순서 보장 설계가 필요.
  • 속도는 "버프 목록으로부터 매 틱 파생"하는 방식(가산적 상태가 아니라 계산된 값)이 이상적이다. 상태를 곱셈으로 누적하지 않으므로 중복·유실에 강건. 트레이드오프: 매 틱 재계산 비용.

면접 포인트

  • "check-then-act"가 왜 락 없이는 안전하지 않은가, IsMounted 하나만 원자로 바꾸면 되는가?(아니다 — 검사와 속도 적용 전체가 원자여야 한다.)
  • 멱등성 관점: 재탑승 요청을 여러 번 받아도 결과가 같도록 상태를 절댓값으로 설정하는 설계.