53. 탈것 탑승/재탑승의 검사-적용 비원자 (속도 버프 중복)
난이도 하내 리뷰 · C#
내 리뷰 · 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하나만 원자로 바꾸면 되는가?(아니다 — 검사와 속도 적용 전체가 원자여야 한다.) - 멱등성 관점: 재탑승 요청을 여러 번 받아도 결과가 같도록 상태를 절댓값으로 설정하는 설계.
해설 · C++
해설 — 탈것 탑승/재탑승의 검사-적용 비원자 (속도 버프 중복, C++)
난이도: 하
요약
OnMountRequest가 락 없이 "탑승 여부 확인(A) → 속도 배율 적용 → 플래그 설정(B)"을 수행한다. 두 IO 워커가 같은 플레이어의 탑승 요청을 동시에 처리하면 둘 다 is_mounted_==false를 보고 move_speed_ *= 1.2f를 두 번 실행해 속도가 ×1.44가 된다. bool·float에 대한 비동기화 동시 접근은 C++에서 **데이터 레이스 = 정의되지 않은 동작(UB)**이기도 하다.
문제점
- 분류태그: check-then-act TOCTOU / 데이터 레이스(UB)
- 증상: 재탑승 겹침 시 이동 속도가 기대보다 커짐. 최적화 빌드에서는 찢긴 읽기/재배열로 더 이상한 값도 가능.
- 재현조건: 동일 플레이어 탑승 요청 2개가 서로 다른 스레드에서 동시 실행.
- 근본원인:
(A)검사와(B)설정 사이가 원자적이지 않다. 게다가move_speed_ *= k는 read-modify-write이고, 공유 변수에 대한 동기화 없는 동시 접근은 표준상 UB다.
수정안
컴포넌트/플레이어 단위 뮤텍스로 임계 구역을 만들고, 속도는 상태로부터 절댓값으로 설정한다.
#include <mutex>
class MountComponent {
std::mutex gate_;
public:
static constexpr float kBaseSpeed = 5.0f;
static constexpr float kMountMultiplier = 1.2f;
bool is_mounted_ = false;
float move_speed_ = kBaseSpeed;
void OnMountRequest() {
std::lock_guard<std::mutex> lk(gate_);
if (is_mounted_) return;
move_speed_ = kBaseSpeed * kMountMultiplier; // 누적이 아니라 절댓값
is_mounted_ = true;
}
void OnDismountRequest() {
std::lock_guard<std::mutex> lk(gate_);
if (!is_mounted_) return;
move_speed_ = kBaseSpeed;
is_mounted_ = false;
}
};
더 나은 설계
- 세션/플레이어 단위 직렬 실행(스트랜드,
boost::asio::strand같은)으로 한 플레이어의 명령을 순차 처리하면 컴포넌트마다 뮤텍스를 다는 비용과 락 순서 고민을 없앨 수 있다. 트레이드오프: 디스패치 큐 오버헤드. - 속도를 "버프 집합에서 파생되는 값"으로 두면 곱셈 누적 자체가 사라진다. 트레이드오프: 매 틱 재계산.
면접 포인트
- C++에서 데이터 레이스가 왜 "값이 조금 틀리는" 수준이 아니라 UB인지(컴파일러 재배열·찢긴 접근).
std::atomic<bool>로 플래그만 바꾸면 충분한가? — 아니다. 검사~속도 적용 전체가 하나의 임계 구역이어야 한다(원자 하나로는 복합 연산을 못 지킨다).