13. 시즌 랭킹 마감과 종료 직전 점수 갱신 경합 · C#
난이도 중해설 — 시즌 랭킹 마감과 종료 직전 점수 갱신 경합 · C#
난이도: 중상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
시즌 경계에서 확정(FinalizeSeason)과 점수 가산(AddScore)이 동기화 없이 경쟁하는 것이
핵심이다. (A) 종료 판정이 평범한(volatile 도 아닌) bool _ended 라 검사-후-가산 사이에
Finalize 가 끼어들어 점수가 유실되거나 비워진/다음 시즌 보드에 오귀속된다. (B) 발생 시각
eventTimeMs 를 무시해 종료 이전 발생·지연 도착분은 버려지고 종료 이후 발생분이 섞인다.
(C) Dictionary 동시 접근으로 데이터 손상, (D) FinalizeSeason 이 라이브 딕셔너리를
OrderByDescending 으로 순회하는 도중 워커가 삽입하면 "Collection was modified"
예외(또는 손상)가 난다. 정답의 한 줄: 시각 기준 귀속 + 락으로 스왑 후 스냅샷을 확정.
문제점
(A) 종료 판정이 비원자 플래그 — TOCTOU/가시성 (동시성) ★간판
- 증상: 종료 직전 점수가 사라지거나 비워진 보드/다음 시즌에 들어간다.
- 재현 조건: 워커 T1 이
if(_ended)통과 → 컨텍스트 스위치. 타이머 T2 가 Finalize 로_ended=true, 정렬/지급,_scores.Clear(),_ended=false. 깨어난 T1 이 비워진 보드에 가산. - 근본 원인: 검사와 가산이 한 임계 구역이 아니고,
_ended가volatile/lock없이 스레드 간 가시성도 보장되지 않는다(JIT/CPU 재배열·캐시).
(B) 발생 시각 미사용 — 경계 귀속 오류 (정확성/공정성)
- 종료 이전 발생했지만 지연 도착한 점수가 유실되고, 종료 이후 발생분이 이번 시즌에 섞인다.
귀속 기준이 "도착 순간"이 아니라
eventTimeMs여야 한다.
(C) Dictionary 동시 접근 — 데이터 레이스 (동시성)
- 여러 워커의
_scores[pid]=...동시 실행은 lost update + 내부 버킷 손상(드물게IndexOutOfRange/무한루프). 타이머 순회와도 경쟁.
(D) 라이브 컬렉션 순회 중 변경 — 예외/손상 (동시성)
_ended=true가 워커를 즉시 막지 못한다(가시성). 플래그를 막 통과한 워커가_scores를 수정하는 동안OrderByDescending(...).ToList()가 열거하면InvalidOperationException (Collection was modified).Clear()직후 삽입은 유령 데이터.
(보조) Finalize 재진입 — 견고성
- 두 번 호출되면 보상 이중 지급. 멱등 가드 필요.
수정안
private readonly object _gate = new object();
private Dictionary<long, long> _cur = new();
private Dictionary<long, long> _next = new();
private bool _finalized = false;
public void AddScore(long pid, long delta, long eventTimeMs)
{
lock (_gate)
{
// (B) 발생 시각으로 귀속
var board = (eventTimeMs <= _seasonEndMs) ? _cur : _next;
board.TryGetValue(pid, out long cur);
board[pid] = cur + delta;
}
}
public void FinalizeSeason()
{
Dictionary<long, long> finalized;
lock (_gate)
{
if (_finalized) return; // 멱등
_finalized = true;
finalized = _cur; // 라이브 맵을 떼어내고
_cur = _next; // 다음 시즌 승격
_next = new Dictionary<long, long>();
}
// 락 밖: 스냅샷에 대해 정렬/지급
int rank = 1;
foreach (var kv in finalized.OrderByDescending(k => k.Value))
{
if (rank > TopN) break;
_sink.Grant(kv.Key, rank++);
}
}
- 정렬/지급 같은 무거운 작업은 락 밖에서 스냅샷에 대해 수행 → 워커 블로킹 최소화.
- 더 단순하게는
ConcurrentDictionary로 바꾸되, 경계 일관성(스왑/멱등)은 여전히 락/ Interlocked 로 보장해야 한다.
더 나은 설계
1) 단일 액터/채널
- 보드 갱신·확정을 한 채널(예:
System.Threading.Channels)로 직렬화 → 락 제거, 경계 단순. 처리량은 플레이어 해시별 샤딩으로 확장.
2) 시각 워터마크
- "이 시각 이전 발생분 모두 도착" 워터마크가
_seasonEndMs를 넘을 때 확정. 지연 이벤트 안전 흡수(스트림 처리 표준).
3) 멱등·내구성
- 확정 순위/지급 내역을 영속화 후 멱등 키로 지급. 타이머 중복/재기동 이중 지급 방지.
4) 동점 타이브레이커
- 점수만으로 정렬하면 동점 순위 비결정적. 보조 키(도달 시각 등)로 안정 정렬.
면접 포인트
- 핵심: 경계 동시성을 플래그 하나로 표현하면 TOCTOU·가시성 문제. 스왑+스냅샷, 발생 시각 귀속, 멱등.
- 예상 질문:
- "
_ended를volatile로만 바꾸면 충분한가?" → 아니다. 가시성은 개선돼도 검사-가산의 원자성(TOCTOU)은 그대로. 임계 구역이 필요. - "지연 도착한 정당 점수를 어떻게 살리나?" → 발생 시각 귀속 + 이중 보드/워터마크.
- "정렬 중 'Collection was modified' 가 왜 나나?" → 라이브 컬렉션을 열거하는 동안 다른 스레드가 수정. 스냅샷을 떼어 락 밖에서 처리.
- "
해설 — 시즌 랭킹 마감과 종료 직전 점수 갱신 경합 · C++
난이도: 중상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
핵심은 시즌 경계에서 확정(Finalize)과 점수 가산(AddScore)이 동기화 없이 경쟁한다는
것이다. (A) 종료 판정이 평범한 bool ended_ 라 검사-후-가산 사이에 Finalize 가 끼어들어
점수가 유실되거나 이미 비워진/다음 시즌 보드에 오귀속된다. (B) 발생 시각 eventTimeMs
를 전혀 보지 않아 종료 시각 이전에 발생했지만 늦게 도착한 점수는 버려지고, 종료 이후
발생분이 이번 시즌에 섞인다(공정성 위반). (C) unordered_map 동시 읽기/쓰기로 데이터
레이스(UB), (D) Finalize 가 ended_=true 직후 살아있는 맵을 순회/clear 하므로 플래그를
막 통과한 워커의 삽입과 경쟁해 iterator/구조 손상이 난다. 정답의 한 줄: 경계는 시각 기준
귀속 + 락(또는 단일 액터)으로 스왑 후 스냅샷을 확정하라.
문제점
(A) 종료 판정이 비원자 플래그 — TOCTOU/가시성 (동시성) ★간판
- 증상: 종료 직전 점수가 사라지거나, 비워진 보드/다음 시즌에 들어간다.
- 재현 조건: 워커 T1 이
if(ended_)를 통과(거짓) → 스케줄 양보. 타이머 T2 가FinalizeSeason()으로ended_=true, 정렬/지급,scores_.clear(),ended_=false. 깨어난 T1 이scores_[pid]+=delta를 비워진(또는 다음 시즌) 보드에 적용. - 근본 원인: 검사와 가산이 한 임계 구역이 아니고,
ended_에 메모리 동기화가 없어 가시성도 보장되지 않는다.
(B) 발생 시각 미사용 — 경계 귀속 오류 (정확성/공정성)
- 증상: 종료 직전 발생했지만 지연 도착한 점수가 유실되고, 종료 이후 발생분이 이번 시즌에 섞인다.
- 재현 조건: 네트워크/처리 지연으로
eventTimeMs <= seasonEndMs인 패킷이 Finalize 이후 도착 → (A)로 버려짐. 반대로eventTimeMs > seasonEndMs인데 Finalize 전 도착 → 이번 시즌에 가산. - 근본 원인: 귀속 기준이 "도착 순간"이 아니라 "발생 시각"이어야 하는데
eventTimeMs를 무시했다.
(C) 맵 동시 접근 — 데이터 레이스 (동시성/UB)
- 여러 워커의
scores_[pid]+=delta동시 실행은 lost update +unordered_map동시 변경 UB. 타이머의 순회와도 경쟁.
(D) Finalize 가 라이브 맵을 순회/clear — 구조 손상 (동시성/UB)
ended_=true가 모든 워커를 즉시 막아주지 못한다(가시성/타이밍). 플래그를 막 통과한 워커가 삽입하는 동안vector(scores_.begin(), scores_.end())범위 생성·sort·clear가 동시에 일어나 iterator 무효화/UAF. clear 후 들어온 삽입은 유령 데이터.
(보조) 재진입/단일 호출 가정 — 견고성
FinalizeSeason이 두 번 호출되면(타이머 중복/재시작) 보상 이중 지급. 멱등 가드 필요.
수정안
핵심: ① 점수에 발생 시각으로 귀속, ② 경계는 락으로 보드를 스왑한 뒤 스냅샷을 확정, ③ 종료 이후 발생분은 다음 시즌 보드로, ④ Finalize 멱등.
class Leaderboard {
public:
void AddScore(int64_t pid, int64_t delta, int64_t eventTimeMs) {
std::lock_guard<std::mutex> lk(mtx_);
// (B) 발생 시각으로 귀속: 종료 시각 이전이면 현재 시즌, 이후면 다음 시즌
auto& board = (eventTimeMs <= seasonEndMs_) ? cur_ : next_;
board[pid] += delta;
}
void FinalizeSeason() {
std::unordered_map<int64_t,int64_t> finalized;
{
std::lock_guard<std::mutex> lk(mtx_);
if (finalized_) return; // 멱등
finalized_ = true;
finalized.swap(cur_); // 라이브 맵을 스왑 → 락 밖에서 안전 처리
cur_.swap(next_); // 다음 시즌을 현재로 승격
next_.clear();
}
// 락 밖: 스냅샷 위에서 정렬/지급(워커와 무관)
std::vector<std::pair<int64_t,int64_t>> r(finalized.begin(), finalized.end());
std::sort(r.begin(), r.end(), [](auto&a,auto&b){ return a.second>b.second; });
for (int i = 0; i < (int)r.size() && i < kTopN; ++i)
sink_.Grant(r[i].first, i + 1);
}
private:
std::mutex mtx_;
std::unordered_map<int64_t,int64_t> cur_, next_;
bool finalized_ = false;
// ... seasonEndMs_, sink_, kTopN
};
- 스왑 패턴: 라이브 맵을 잠깐의 락 안에서 통째로 떼어내고(
swap), 무거운 정렬·지급은 락 밖에서 스냅샷에 대해 수행 → 워커 지연 최소화 + 일관성. cur_/next_이중 보드로 경계 발생분을 시각 기준으로 정확히 분리.
더 나은 설계
1) 단일 액터(시리얼) 모델
- 보드 갱신/확정을 한 스레드의 큐로 직렬화하면 락이 사라지고 경계 처리도 단순. AddScore/Finalize 가 같은 큐 메시지로 순서대로 처리됨. 트레이드오프: 단일 스레드 처리량 한계 → 샤딩(플레이어 해시별 액터)으로 확장.
2) 시각 기준 워터마크
- "이 시각 이전 발생분은 모두 도착했다"는 워터마크를 두고, 워터마크가
seasonEndMs를 넘을 때 확정. 지연 도착(late event)을 안전하게 흡수. 스트림 처리의 표준 패턴.
3) 확정/지급의 멱등·내구성
- Finalize 결과(순위·지급 내역)를 먼저 영속화하고, 지급은 멱등 키로. 타이머 중복/장애 재기동 시 이중 지급 방지(영속화 problem 들과 연결).
4) 동점 처리
- 정렬이 점수만 본다. 동점자 타이브레이커(먼저 도달 시각 등)를 명시하지 않으면 순위가 비결정적. 보조 키를 둬 안정 정렬.
면접 포인트
- 핵심: 경계(boundary) 동시성 — 플래그 하나로 "끝났다"를 표현하면 TOCTOU·가시성으로 유실/오귀속이 난다. 스왑+스냅샷, 발생 시각 귀속, 멱등.
- 예상 질문:
- "종료 직전 점수가 사라지는 정확한 인터리빙을 설명하라." → (A) 재현 시퀀스.
- "지연 도착한 정당 점수를 어떻게 살리나?" → 발생 시각 귀속 + 워터마크/이중 보드.
- "정렬·지급을 락 안에서 다 하면 왜 나쁜가?" → 워커 블로킹·락 보유 시간 폭증. 스왑 후 락 밖 처리.
구문 검증:
g++ -std=c++17 -fsyntax-only problem.cpp통과.