21. 일일/주간 초기화 시점과 진행도 갱신이 겹치는 상황 (C#)
난이도 상해설 — 일일/주간 초기화 시점과 진행도 갱신이 겹치는 상황 (C#)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
_players(Dictionary)와 각 DailyMission을 락 없이 게임 워커 다수(AddProgress)와
스케줄러(ResetAll)가 동시에 만진다. ① AddProgress가 새 키를 삽입하는 도중
ResetAll이 foreach로 순회하면 InvalidOperationException(컬렉션 변경) 또는 손상(A)(D).
② 날짜 비교 후 리셋–누적이 비원자라 ResetAll과 겹치면 진행도 유실/오귀속(B)(C).
③ 리셋 책임이 "스케줄러 전역 리셋" 과 "AddProgress 자가 리셋" 두 곳에 중복돼 경계에서
증가분이 초기화로 덮이거나 어제 날짜에 귀속된다. 정답 한 줄: Dictionary/엔트리 접근을
동기화하고, 리셋 책임을 lazy 자가 리셋 한 곳으로 단일화하며, 리셋–누적을 원자화한다.
문제점
(A)(D) 순회 중 삽입 → 컬렉션 변경 예외/손상 — 동시성 ★간판
- 분류 태그: data race / collection modified during enumeration.
- 증상:
ResetAll의foreach (var kv in _players)도중 다른 스레드AddProgress가_players[playerId] = dm로 새 키 삽입 →InvalidOperationException("Collection was modified"). 동시 읽기/쓰기로 내부 버킷 손상·무한 루프도 가능. - 재현조건: 자정 리셋 순회와 신규 플레이어 활동이 겹침.
- 근본 원인: 공유 Dictionary 동기화 부재.
(B)(C) 리셋–누적 비원자 → 유실/오귀속 (동시성·정합) ★간판
- 분류 태그: lost update / TOCTOU.
- 증상 1(유실): T1
dm.Progress += amount직전 T2ResetAll이dm.Progress=0→ 상호 덮어쓰기로 증가분 또는 리셋이 사라진다. - 증상 2(오귀속/이중 리셋): 자정 직후
ResetAll이 아직 안 돈 사이AddProgress가dm.Day != currentDay로 자가 리셋 후 누적 → 직후ResetAll이 또 0 으로 → 방금 쌓은 진행도 증발. - 근본 원인: 리셋 책임 중복 + 비원자 갱신.
(정합) Progress/ClaimedToday 일관성 — 정합
- 증상: 부분 리셋이 관측되면 "Progress=0 인데 ClaimedToday=true" 모순(보상 누락/중복).
수정안
핵심: 엔트리 단위 lock + lazy 리셋으로 책임 단일화. ConcurrentDictionary로 map 안전화.
public class DailyMission
{
public int Day = 0, Progress = 0, Target = 10;
public bool ClaimedToday = false;
public readonly object Gate = new();
}
public class DailyMissionManager
{
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, DailyMission>
_players = new();
public void AddProgress(long playerId, int amount, int currentDay)
{
if (amount <= 0) return;
var dm = _players.GetOrAdd(playerId, _ => new DailyMission());
lock (dm.Gate)
{
if (dm.Day != currentDay) // lazy 리셋: 접근 시 1회
{ dm.Day = currentDay; dm.Progress = 0; dm.ClaimedToday = false; }
dm.Progress += amount;
}
}
public bool IsComplete(long playerId, int currentDay)
{
if (!_players.TryGetValue(playerId, out var dm)) return false;
lock (dm.Gate)
{
if (dm.Day != currentDay) return false; // 날짜 지났으면 미완료
return dm.Progress >= dm.Target;
}
}
// 전역 ResetAll 제거: 리셋은 lazy 로 단일화.
// 자정 정산(미수령 보상 등)이 필요하면 멱등한 별도 배치로 분리.
}
포인트
- lazy 리셋: 전역 순회 대신 엔트리 접근 시
Day비교로 1회 리셋 → 순회-삽입 충돌과 리셋 경합이 동시에 사라진다. ConcurrentDictionary.GetOrAdd로 삽입 안전 + 엔트리 락으로 리셋–누적 원자화.- 조회도
currentDay를 받아 "지난 날 진행도"를 0으로 취급(일관 읽기).
더 나은 설계 (+트레이드오프)
- 세대(epoch) 기반 lazy 리셋: 전역
currentDay만 올리고 엔트리는 지연 리셋 → 자정에 대량 행을 건드리지 않아 스파이크 없음. 조회 시 day 비교 필요. - 샤딩 락: 엔트리별 락 객체가 부담이면 N개 샤드 락으로 절충.
- Interlocked 패킹:
long에 (day<<32 | progress) 패킹 후 CAS 로 lock-free 갱신. 구현 난도↑. - 영속화 멱등: 자정 배치를 멱등 키로 이중 실행 방지, 재기동 시 day 비교로 복구.
면접 포인트 (예상 질문)
foreach순회 중 다른 스레드가 Dictionary 에 키를 넣으면 왜 예외가 나는가?- 전역 일괄 리셋 vs lazy 리셋의 동시성·부하 트레이드오프는?
- 리셋 직후 들어온 진행도가 "어제"에 귀속되는 인터리빙과, lazy 리셋이 이를 막는 원리는?
해설 — 일일/주간 초기화 시점과 진행도 갱신이 겹치는 상황 (C++)
난이도: 상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
players_(unordered_map)와 각 DailyMission을 락 없이 게임 워커 다수(AddProgress)와
스케줄러(ResetAll)가 동시에 만진다. 세 종류의 버그가 겹친다: ① AddProgress의
players_[playerId] 가 없으면 삽입(쓰기) 이라, ResetAll이 같은 map 을 순회 중
삽입이 일어나면 rehash → 반복자/참조 무효화(UB)(A)(D). ② 날짜 비교 후 리셋–누적이
원자적이지 않아 ResetAll과 겹치면 진행도 유실/이중 리셋(B)(C). ③ ResetAll의 전역
리셋과 AddProgress의 "날짜 바뀜 자가 리셋"이 둘 다 리셋 책임을 가져, 경계에서 증가분이
초기화로 덮이거나 어제 날짜에 귀속된다. 정답 한 줄: map 과 엔트리 접근을 동기화하고,
리셋 책임을 한 곳으로 단일화하며(자가 리셋 lazy 방식 권장), 리셋–누적을 엔트리 단위
원자 연산으로 만든다.
문제점
(A)(D) 순회 중 삽입 → 반복자/참조 무효화 — 동시성/메모리 (UB) ★간판
- 분류 태그: data race / iterator invalidation.
- 증상:
ResetAll이for (auto& kv : players_)로 순회하는 동안, 다른 스레드의AddProgress가players_[newPlayer]로 새 키를 삽입하면unordered_map이 rehash → 진행 중인 반복자와ResetAll이 잡은DailyMission&가 무효화 → 크래시/메모리 오염. 동시 읽기/쓰기 자체가 C++ 메모리모델상 데이터 경쟁(UB). - 재현조건: 자정 리셋 순회와 신규 플레이어 활동이 겹침.
- 근본 원인: 공유 컨테이너에 동기화 전무,
operator[]가 조회처럼 보이지만 삽입.
(B)(C) 리셋–누적 비원자 → 진행도 유실/오귀속 (동시성·정합) ★간판
- 분류 태그: lost update / TOCTOU.
- 증상 1(유실): T1
AddProgress가dm.progress += amount직전, T2ResetAll이dm.progress = 0수행 → T1 의 증가가 0 위에 더해지거나, T1 이 읽은 옛 값 기준으로 써서 리셋이 사라진다(상호 덮어쓰기). 증가분 또는 리셋이 유실. - 증상 2(오귀속/이중 리셋): 자정 직후
ResetAll이 아직 안 돈 사이AddProgress가dm.day != currentDay를 보고 스스로 리셋한 뒤 누적 → 그 직후ResetAll이 다시progress=0→ 방금 쌓은 진행도 증발. 반대로ResetAll이 먼저 newDay 로 바꾸면AddProgress의currentDay와 어긋나 또 자가 리셋. - 근본 원인: 리셋 책임이 두 경로(스케줄러 + 자가 리셋)에 중복, 그리고 비원자.
(정합) claimedToday 와 진행도의 일관성 — 정합
- 증상: 리셋이 부분 적용되면 "progress 는 0 인데 claimedToday=true" 같은 모순 상태가 관측될 수 있다(보상 못 받음/중복 받음).
수정안
핵심: 엔트리 단위 락 + 리셋 책임 단일화(lazy 자가 리셋), 전역 ResetAll 은 제거하거나 동일 규칙으로.
struct DailyMission {
int day = 0;
int progress = 0;
int target = 10;
bool claimedToday = false;
std::mutex mtx; // 엔트리 단위 락(또는 샤드 락)
};
class DailyMissionManager {
public:
void AddProgress(std::int64_t playerId, int amount, int currentDay) {
if (amount <= 0) return;
std::shared_ptr<DailyMission> dm = GetOrCreate(playerId); // map 접근은 mapMtx_ 보호
std::lock_guard<std::mutex> g(dm->mtx);
if (dm->day != currentDay) { // lazy 리셋: 접근 시점에 1회
dm->day = currentDay; dm->progress = 0; dm->claimedToday = false;
}
dm->progress += amount;
}
bool IsComplete(std::int64_t playerId, int currentDay) {
std::shared_ptr<DailyMission> dm;
{ std::lock_guard<std::mutex> g(mapMtx_);
auto it = players_.find(playerId);
if (it == players_.end()) return false; dm = it->second; }
std::lock_guard<std::mutex> g(dm->mtx);
if (dm->day != currentDay) return false; // 날짜 지났으면 미완료로 간주
return dm->progress >= dm->target;
}
private:
std::shared_ptr<DailyMission> GetOrCreate(std::int64_t id) {
std::lock_guard<std::mutex> g(mapMtx_);
auto& slot = players_[id];
if (!slot) slot = std::make_shared<DailyMission>();
return slot;
}
std::mutex mapMtx_;
std::unordered_map<std::int64_t, std::shared_ptr<DailyMission>> players_;
};
포인트
- lazy 리셋: "전역 ResetAll" 대신 각 엔트리가 접근될 때
day비교로 스스로 1회 리셋. 순회-삽입 충돌·전역 리셋 경합이 사라진다. 자정에 일괄 처리가 필요하면(예: 미수령 보상 정산) 별도 배치로 분리하되, 진행도 리셋 자체는 lazy 로 단일화. shared_ptr<DailyMission>로 엔트리 수명을 분리해 map rehash 와 무관하게 안전.- 리셋–누적이 엔트리 락 안에서 원자 → 유실/오귀속 제거.
더 나은 설계 (+트레이드오프)
- 버전/세대(epoch) 기반 lazy 리셋: 전역
currentDay만 원자적으로 올리고, 각 엔트리는 자신의day와 비교해 지연 리셋. 자정에 수백만 행을 건드리지 않아 스파이크가 없다. 트레이드오프: "오늘 진행도" 조회 시 항상 day 비교 필요. - 샤딩 락(lock striping): 엔트리별 mutex 가 부담이면 N개 샤드 락으로 절충. 메모리↓, 경합은 샤드 단위.
- 원자 정수 + 세대 태그:
progress를atomic<uint64_t>에 (day<<32 | count) 로 패킹해 CAS 로 "다른 날이면 count=amount, 같은 날이면 +amount". lock-free 지만 구현 까다. - 영속화 일관성: 메모리 리셋과 DB 반영 사이 크래시 시 재기동 후 day 비교로 복구 가능하게(멱등). 자정 배치는 멱등 키로 이중 실행 방지.
면접 포인트 (예상 질문)
operator[]가 "읽기처럼 보이지만 쓰기" 인 점이ResetAll순회와 왜 충돌하는가?- 전역 일괄 리셋 대신 lazy 리셋이 동시성·부하 측면에서 유리한 이유와 단점은?
- 자정에 수백만 플레이어를 리셋해야 한다면 스파이크 없이 어떻게 설계하겠는가?