25. 업적 달성 판정과 동시 다발 이벤트 집계
난이도 중해설 — 업적 달성 판정과 동시 다발 이벤트 집계
난이도: 중상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
OnProgress 가 공유 Achievement(Progress, Unlocked)와 Dictionary 들을 락 없이
동시 갱신한다. (A) ach.Progress += amount 는 읽기-수정-쓰기라 동시 실행 시 증가 유실
(몹을 잡아도 카운트가 누락). (B) "Unlocked 검사 → Progress 증가 → >= Threshold 검사 →
달성/보상" 이 원자 단위가 아니라, 두 이벤트가 임계 근처에서 동시에 들어오면 둘 다 미달성을
통과한 뒤 둘 다 임계 도달을 보고 둘 다 보상한다(이중 달성·보상 중복). (C) Dictionary 는
스레드세이프하지 않아 동시 읽기/쓰기/등록이 손상·예외를 낸다. 정답 한 줄: (플레이어,업적)
단위로 진행도 증가~달성 확정을 원자화하고, "달성 확정" 을 정확히 한 번만 일어나게(락 또는 CAS)
하여 보상을 단 한 번만 지급한다.
변별: problem14(퀘스트 보상 중복 수령 — 단일 요청의 멱등성/더블클릭)와 달리, 본 문제는 여러 스레드의 누적 카운터 집계 정확성 + 임계 도달 시 단 1회 달성 확정 이 핵심이다.
문제점
(A) 진행도 비원자 누적 — 증가 유실 (동시성) ★간판
- 분류 태그: lost update.
- 증상:
ach.Progress += amount는read → add → write. 두 스레드가 각각 +1 하면 둘 다 옛 값을 읽고 +1 해 써, 실제로는 +1 만 반영(1 유실). "몹 100마리" 가 영원히 99 에서 멈출 수 있다. - 재현조건: 같은 (플레이어,업적)에 이벤트가 동시 도착.
- 근본 원인: 복합 연산에 동기화 없음.
(B) 달성 판정 TOCTOU — 이중 달성/보상 중복 (동시성·정합) ★간판
- 분류 태그: check-then-act.
- 증상: Threshold=100, Progress=99. 두 스레드가 각각 +1:
- T1:
Unlocked=false 통과 →Progress = 100 - T2:
Unlocked=false 통과 →Progress = 101(또는 인터리빙에 따라 100) - T1:
Progress>=100? true →Unlocked=true, 보상 지급 - T2:
Progress>=100? true → 보상 다시 지급Unlocked검사와 설정 사이가 열려 있어 둘 다 보상 분기에 들어간다.
- T1:
- 근본 원인: 진행 증가와 "달성 확정"이 같은 임계구역이 아니고,
Unlocked전이가 한 번만 일어나도록 보장되지 않는다(check-then-set).
(C) 비스레드세이프 Dictionary — 데이터 경쟁/예외 (동시성)
- 증상:
_byPlayer/map에 대한 동시TryGetValue/Register인덱싱은 내부 구조를 손상시키고InvalidOperationException(컬렉션 변경) 또는 무한 루프를 낸다.Register가 로그인 스레드에서,OnProgress가 워커에서 동시에 일어나면 위험. - 근본 원인: 공유 컬렉션에 동기화/동시성 컬렉션 미사용.
수정안
핵심: (플레이어,업적) 단위 락으로 증가~달성 판정을 원자화, 달성은 한 번만 확정.
public class Achievement
{
public int AchievementId;
public long Threshold;
public long Progress;
public bool Unlocked;
public readonly object Gate = new();
}
public class AchievementService
{
// 동시 등록/조회 대비 — 동시성 컬렉션
private readonly System.Collections.Concurrent.ConcurrentDictionary<long,
System.Collections.Concurrent.ConcurrentDictionary<int, Achievement>> _byPlayer = new();
private readonly Action<long, int> _grantReward;
public void OnProgress(long playerId, int achievementId, long amount)
{
if (amount <= 0) return;
if (!_byPlayer.TryGetValue(playerId, out var map)) return;
if (!map.TryGetValue(achievementId, out var ach)) return;
bool justUnlocked = false;
lock (ach.Gate)
{
if (ach.Unlocked) return; // 이미 달성 → 누적/보상 없음
ach.Progress += amount; // 락 안이라 유실 없음
if (ach.Progress >= ach.Threshold)
{
ach.Unlocked = true; // 달성 확정: 정확히 한 번
justUnlocked = true;
}
}
if (justUnlocked)
_grantReward(playerId, achievementId); // 부작용은 락 밖에서(단 한 번)
}
public void Register(long playerId, Achievement ach)
{
var map = _byPlayer.GetOrAdd(playerId,
_ => new System.Collections.Concurrent.ConcurrentDictionary<int, Achievement>());
map[ach.AchievementId] = ach;
}
}
포인트
Unlocked검사 +Progress증가 +Unlocked확정이 같은 락 → 하나의 스레드만justUnlocked.- 보상 콜백은 락 밖에서(재진입/데드락 회피).
- 컬렉션은
ConcurrentDictionary로 동시 등록/조회 안전.
lock-free 대안
Progress 를 Interlocked.Add(ref Progress, amount) 로 누적한다(반환값 = 누적 후 값).
누적 후 값이 처음으로 >= Threshold 가 된 그 스레드만
Interlocked.CompareExchange(ref _unlockedFlag, 1, 0) == 0 으로 달성을 확정해 보상한다.
("이전 값 < Threshold && 이후 값 >= Threshold" 판정으로 경계 한 번만 통과.)
트레이드오프: 음수 amount/리셋이 있으면 경계 판정이 복잡해진다.
더 나은 설계 (+트레이드오프)
- 이벤트 소싱 + 단일 집계기: 진행 이벤트를 (플레이어,업적) 파티션 큐로 보내 한 스레드가 순차 집계하면 락 없이 원자성·정확성. 트레이드오프: 파티셔닝/지연.
- 멱등 보상(지급 측에서 한 번 더 방어):
_grantReward자체가 (플레이어,업적) 키로 멱등(중복 무시)하게 → 판정 버그가 나도 이중 지급 방지(problem17 멱등성 연계). - 원자 카운터 + 단조 unlock 플래그: 위 lock-free 안. 카운터·플래그를 DB/Redis 의 원자 연산으로 올리면 다중 게임서버에서도 정확. 트레이드오프: 왕복 비용.
- 배치 집계: 고빈도 이벤트는 스레드로컬로 모았다가 주기적으로 원자 반영(경합 감소). 트레이드오프: 달성 판정 지연(준실시간).
면접 포인트 (예상 질문)
- Threshold 근처에서 두 이벤트가 동시에 들어올 때 "둘 다 달성 분기"에 들어가는 인터리빙을
단계별로 설명하라.
Unlocked플래그만으로 왜 부족한가? Interlocked.Add의 반환값을 이용해 "임계를 처음 넘긴 스레드" 만 보상하는 코드를 써보라.- 다중 게임서버에서 같은 플레이어의 업적 이벤트가 분산돼 들어올 때 정확한 집계와 1회 달성을 어떻게 보장하겠는가?
해설 — 업적 달성 판정과 동시 다발 이벤트 집계 (C++)
난이도: 중상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
onProgress 가 공유 Achievement(progress, unlocked)와 중첩 unordered_map 을 동기화
없이 동시 갱신한다. (A) ach.progress += amount 는 비원자 RMW → 증가 유실. (B) "unlocked
검사 → 증가 → >= threshold 검사 → 달성/보상" 이 원자 단위가 아니라 임계 근처 동시 이벤트가
둘 다 보상한다(이중 달성). (C) registerAch 의 byPlayer_[...] 삽입은 동시 onProgress
의 조회/참조와 경합한다 — rehash 가 일어나면 버킷 재배치로 진행 중인 순회/참조가 깨지고,
Achievement& 참조 수명도 위태롭다(데이터 레이스 UB). 정답 한 줄: (플레이어,업적) 단위로
증가~달성 확정을 원자화하고, 달성 확정을 단 한 번만(락 또는 CAS) 일어나게 하며, 맵 동시
접근을 락 또는 안정적 자료구조로 보호한다.
변별: problem14(퀘스트 보상 중복 수령/멱등성)와 달리, 본 문제는 다중 스레드 누적 카운터 정확성 + 임계 도달 시 1회 달성 확정 + 맵 동시성(rehash/참조 수명) 이 핵심.
문제점
(A) 진행도 비원자 누적 — 증가 유실 (동시성) ★간판
- 분류 태그: lost update / data race.
- 증상:
ach.progress += amount는read-add-write. 동시 실행 시 한쪽 증가가 사라진다. 표준상 동기화 없는 동시 R/W 는 정의되지 않은 동작(UB). - 근본 원인: 복합 연산에 원자/락 없음.
(B) 달성 판정 TOCTOU — 이중 달성/보상 중복 (동시성·정합) ★간판
- 분류 태그: check-then-act.
- 증상: threshold=100, progress=99. 두 이벤트(+1) 동시:
- T1:
unlocked=false 통과, progress 증가 - T2:
unlocked=false 통과, progress 증가 - 둘 다
progress>=100을 보고unlocked=true+grantReward2회.
- T1:
- 근본 원인: 증가와 "달성 확정"이 같은 임계구역이 아니고,
unlocked전이가 1회 보장 안 됨.
(C) 중첩 map 동시 삽입/조회 — rehash·참조 무효화 (메모리/동시성) ★C++ 핵심
- 증상:
registerAch의byPlayer_[playerId]와 내부[...]=ach는 삽입이라 로드팩터 초과 시 rehash 를 유발한다.unordered_map은 rehash 시 모든 iterator 가 무효화되고(노드 자체는 살지만 버킷 배치가 바뀜), 동시에onProgress가 다른 키로find/순회 중이면 자료구조 손상/크래시. 참조Achievement& ach도 외부(부모) 맵이 rehash 되면 부모 노드는 안정적이나 동시 수정과의 데이터 레이스로 UB. - 근본 원인: 공유 컨테이너 동시 접근에 보호가 전혀 없다.
수정안
핵심: (플레이어,업적) 단위 락으로 증가~판정 원자화 + 맵 보호. (unordered_map 은 노드
기반이라 erase 되지 않는 한 원소 주소가 안정적 → 외부는 한 번 구성 후 읽기 위주로.)
#include <mutex>
struct Achievement {
int achievementId;
int64_t threshold;
int64_t progress = 0;
bool unlocked = false;
std::mutex gate; // 업적 단위 락 (이동/복사 불가 주의)
};
class AchievementService {
public:
void onProgress(int64_t playerId, int achievementId, int64_t amount) {
if (amount <= 0) return;
Achievement* ach = nullptr;
{
std::lock_guard<std::mutex> g(mapMu_); // 맵 조회 보호
auto pit = byPlayer_.find(playerId);
if (pit == byPlayer_.end()) return;
auto ait = pit->second.find(achievementId);
if (ait == pit->second.end()) return;
ach = &ait->second; // 노드 기반: erase 안 하면 주소 안정
}
bool justUnlocked = false;
{
std::lock_guard<std::mutex> g(ach->gate); // 업적 단위 임계구역
if (ach->unlocked) return;
ach->progress += amount;
if (ach->progress >= ach->threshold) {
ach->unlocked = true;
justUnlocked = true;
}
}
if (justUnlocked) grantReward_(playerId, achievementId); // 락 밖, 단 한 번
}
// Achievement 가 std::mutex 를 가져 복사/이동 불가 → in-place 로만 생성.
void registerAch(int64_t playerId, int achievementId, int64_t threshold) {
std::lock_guard<std::mutex> g(mapMu_);
auto& m = byPlayer_[playerId]; // 없으면 inner map 기본 생성
auto [it, inserted] = m.try_emplace(achievementId); // 기본 생성 in place(이동 없음)
it->second.achievementId = achievementId; // 비-mutex 필드만 대입
it->second.threshold = threshold;
}
private:
std::unordered_map<int64_t, std::unordered_map<int, Achievement>> byPlayer_;
std::mutex mapMu_;
std::function<void(int64_t, int)> grantReward_;
};
포인트
mapMu_로 조회/삽입을 보호 → rehash 와 조회의 경합 차단. 원소 포인터는 노드 기반이라 락을 풀고도 안전(단 같은 키를 erase 하지 않는 한).unlocked검사 + 증가 + 확정이 같은gate→ 하나의 스레드만justUnlocked.Achievement가std::mutex를 멤버로 가지므로 복사/이동 불가 — 컨테이너에는emplace/std::move로 넣는다(값 복사 API 금지).
lock-free 대안
progress 를 std::atomic<int64_t> 로, fetch_add(amount) 의 반환값(=이전 값)을 이용해
"이전 < threshold && 이전+amount >= threshold" 인 그 스레드만 unlocked(std::atomic_flag
또는 atomic<bool> CAS) 를 0→1 로 바꿔 보상. 트레이드오프: 맵 동시성은 여전히 별도 보호 필요,
음수/리셋이 있으면 경계 판정 복잡.
더 나은 설계 (+트레이드오프)
- (플레이어,업적) 파티션 단일 집계기(액터): 이벤트를 키별 큐로 보내 한 스레드가 순차 처리 → 락 없이 정확. 트레이드오프: 파티셔닝/지연.
- 멱등 보상:
grantReward_가 (플레이어,업적) 키로 멱등 → 판정 버그에도 이중 지급 방지. - 분산 원자 카운터(Redis INCR + 단조 unlock 플래그): 다중 게임서버 정확. 왕복 비용 ↑.
- 고빈도 배치 집계: 스레드로컬 누적 후 주기적 원자 반영(경합 감소, 준실시간).
면접 포인트 (예상 질문)
unordered_map에 동시 삽입이 일어날 때 rehash 가 왜 위험한가? "노드 기반 주소 안정성" 은 어디까지 보장되며 그 예외는?- threshold 근처 동시 이벤트가 둘 다 달성 분기에 들어가는 인터리빙을 설명하라.
std::atomic+ CAS 로 1회만 확정하는 코드를 써보라. std::mutex멤버를 가진 구조체를unordered_map에 넣을 때 무엇을 주의해야 하나 (복사/이동 불가, emplace)?