43. 경험치 누적·레벨업의 비원자 처리 (C#)
난이도 중내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — 경험치 누적·레벨업의 비원자 처리 (C#)
난이도: 중
요약
같은 플레이어에게 여러 로직 스레드가 동시에 GrantXp를 호출하는데 Xp, Level, StatPoints 갱신이 아무 동기화 없이 검사-처리(read-modify-write)로 이뤄진다. (A)의 Xp = Xp + amount가 lost update 로 경험치를 유실하고, (B)~(C)의 레벨업 루프가 두 스레드에서 겹치면 같은 경험치로 레벨/스탯을 이중 적용하거나 레벨을 건너뛰며, Xp가 음수가 되기도 한다.
문제점
1) [비원자 RMW] 경험치 누적 유실 (증상: 총 경험치 < 부여량)
- 증상: (A)
Xp = Xp + amount는 읽기→더하기→쓰기 3단계다. 두 스레드가 같은Xp를 읽고 각자 더해 쓰면 한쪽 증가분이 사라진다(lost update). 파티 처치 보상과 퀘스트 보상이 동시에 들어오면 총 경험치가 부여한 합보다 적어진다. - 재현조건: 동일 플레이어에 대한 동시
GrantXp.
2) [레벨업 루프 경합] 레벨/스탯 이중 적용·건너뜀 (증상: 스탯 과다/누락, 레벨 스킵)
- 증상: (B)의
while (Xp >= XpToNext(Level))와 (C)의Level++; StatPoints += 5가 락 없이 두 스레드에서 인터리브되면:- 둘 다
Xp >= 요구치를 통과한 뒤 각자Level++·StatPoints += 5→ 같은 경험치로 스탯을 두 번 준다. Xp -= XpToNext(Level)와 다른 스레드의Level++가 겹치면 차감 기준 레벨이 어긋나Xp가 음수가 되거나 요구치가 뒤바뀌어 레벨을 건너뛴다.
- 둘 다
- 근본원인:
Xp/Level/StatPoints가 하나의 일관된 상태인데 각 필드가 개별·비원자로 갱신된다.
3) [가시성] 필드 동기화 부재 (증상: stale 읽기)
Snapshot을 읽는 스레드가 배리어 없이Level/Xp를 읽으면 서로 다른 시점의 필드가 섞여 보일 수 있다.
수정안
플레이어 진행 상태를 한 단위로 락(또는 단일 스레드 직렬화)으로 원자화한다.
public sealed class PlayerProgress
{
private readonly object _gate = new();
private long _xp;
private int _level = 1;
private int _statPoints;
private static long XpToNext(int level) => 100L * level;
public void GrantXp(long amount)
{
if (amount <= 0) return;
lock (_gate)
{
_xp += amount;
while (_xp >= XpToNext(_level)) // 남는 경험치는 다음 레벨로 이월
{
_xp -= XpToNext(_level);
_level++;
_statPoints += 5;
}
}
}
public (int level, long xp, int sp) Snapshot()
{
lock (_gate) return (_level, _xp, _statPoints);
}
}
- 누적과 레벨업 루프 전체가 한 임계구역이라 lost update·이중 적용·음수·건너뜀이 모두 사라진다.
- 락 경합이 걱정되면 **플레이어별 단일 스레드(액터)**로 그 플레이어의 모든 진행 갱신을 직렬화한다(플레이어 간 병렬성은 유지).
더 나은 설계 (트레이드오프)
- 경험치 부여를 이벤트 큐로 직렬화: 각 플레이어의 진행 갱신을 한 큐/한 스레드에서 처리하면 락이 사라지고 순서가 보장된다. 트레이드오프: 큐 지연·역압 관리.
- 레벨 테이블 누적합(prefix sum):
while루프 대신 누적 경험치→레벨을 이분 탐색으로 O(log n)에 계산하면 대량 경험치에서 루프 폭을 줄인다. 트레이드오프: 테이블 유지. - 영속화와의 일관성: 레벨업 보상 지급·DB 반영을 한 트랜잭션으로 묶어 부분 반영(스탯만 주고 레벨 롤백 등)을 막는다.
면접 포인트
- 비원자 RMW(누적)와 검사-처리 루프(레벨업)가 각각 어떤 이상(유실 vs 이중/건너뜀)을 내는지 인터리빙으로 설명.
- "여러 필드가 하나의 불변식(Xp·Level·StatPoints 정합)을 이룰 때 임계구역을 어디에 둬야 하는가".
- 락 vs 플레이어별 액터 직렬화의 트레이드오프, 대량 경험치의 레벨 계산 최적화.
해설 · C++
해설 — 경험치·레벨업의 비원자 처리와 벡터 동시 접근 (C++)
난이도: 중상
요약
여러 로직 스레드가 같은 플레이어의 xp_, level_, statPoints_, unlockedSkills_를 락 없이 동시에 갱신한다. C# 판의 lost update·레벨 이중/건너뜀·음수에 더해, C++ 에서는 (D)의 unlockedSkills_.push_back과 다른 스레드의 GetUnlocked 순회가 겹치면 std::vector 재할당으로 반복자·참조·반환 참조가 무효화되어 use-after-free/OOB 가 발생한다.
문제점
1) [비원자 RMW] 경험치 누적 유실 (증상: 총 경험치 < 부여량)
- (A)
xp_ = xp_ + amount가 비원자라 동시 부여 시 lost update. C++ 에서는 비원자 공유 변수 동시 접근 자체가 데이터 레이스 = UB.
2) [레벨업 루프 경합] 레벨/스탯 이중 적용·건너뜀·음수 (증상: 보상 오지급)
- (B)~(C)의 검사-처리 루프가 두 스레드에서 인터리브되면 같은 경험치로
level_++/statPoints_ += 5가 이중 적용되거나,xp_ -= XpToNext(level_)의 차감 기준 레벨이 어긋나xp_가 음수·레벨 스킵.
3) [벡터 재할당·반복자 무효화] 언락 목록 동시 접근 (증상: UAF·OOB)
- 증상: (D)에서 레벨업 시
unlockedSkills_.push_back이 벡터 용량을 넘기면 재할당이 일어나 기존 원소가 새 버퍼로 옮겨지고 옛 버퍼는 해제된다. 그 순간 다른 스레드가GetUnlocked()로 얻은 참조로 순회 중이면 해제된 메모리를 읽는다(UAF). 재할당이 없어도push_back중 크기 변경과 동시 순회는 데이터 레이스. - 근본원인: 컨테이너 쓰기와 읽기 순회에 동기화가 없고, 내부 버퍼를 가리키는 참조를 API 로 노출.
수정안
진행 상태 전체를 뮤텍스로 원자화하고, 언락 목록은 값 스냅샷으로 반환.
#include <mutex>
class PlayerProgress {
mutable std::mutex m_;
int64_t xp_ = 0;
int level_ = 1, statPoints_ = 0;
std::vector<std::string> unlockedSkills_;
static int64_t XpToNext(int level) { return 100LL * level; }
public:
void GrantXp(int64_t amount) {
if (amount <= 0) return;
std::lock_guard<std::mutex> lk(m_);
xp_ += amount;
while (xp_ >= XpToNext(level_)) {
xp_ -= XpToNext(level_);
++level_;
statPoints_ += 5;
if (level_ % 10 == 0)
unlockedSkills_.push_back("skill_L" + std::to_string(level_));
}
}
// 내부 벡터 참조를 내주지 않는다: 복사본(스냅샷)을 반환해 수명·레이스 차단
std::vector<std::string> GetUnlocked() const {
std::lock_guard<std::mutex> lk(m_);
return unlockedSkills_;
}
int Level() const { std::lock_guard<std::mutex> lk(m_); return level_; }
};
- 스니펫 검증:
g++ -std=c++17 -pthread -fsyntax-only통과. - 핵심: 컨테이너 내부를 가리키는 참조를 노출하지 않는다. 복사가 비싸면
shared_ptr<const vector>로 불변 스냅샷을 공유(갱신 시 새로 만들어 교체).
더 나은 설계 (트레이드오프)
- 플레이어별 액터 직렬화: 진행 갱신을 단일 스레드/큐로 몰면 락·레이스·UAF 모두 제거. 트레이드오프: 메시지 지연.
- 불변 스냅샷 교체(copy-on-write): 언락 목록을
shared_ptr<const vector>로 두고 변경 시 새 벡터로 교체하면 리더는 락 없이 안전하게 순회. 트레이드오프: 갱신 시 복사 비용. - 레벨 계산 O(log n): 대량 경험치는 누적합 이분 탐색으로 루프 폭 축소.
면접 포인트
std::vector재할당이 반복자·참조·포인터를 무효화하는 규칙과, 그것이 동시 순회 시 UAF 로 이어지는 경로.- "컨테이너 내부 참조를 API 밖으로 내보내지 말라"는 수명 안전 원칙과 스냅샷/COW 대안.
- 비원자 RMW·검사-처리 루프의 이상 현상과 임계구역 설계.