18. 레이드 페이즈 전환과 플레이어 입·퇴장 경합 (C#)
난이도 최상해설 — 레이드 페이즈 전환과 플레이어 입·퇴장 경합 (C#)
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
보스 전투 스레드(전환)와 입·퇴장 스레드(참가자 목록 변경)가 락 없이 같은
RaidInstance 를 동시에 건드린다. (A) Boss_Hp -= dmg 비원자 + 임계치 검사가
check-then-act 라, 동시에 막타급 데미지가 둘 들어오면 같은 페이즈로 두 번 전환해
StartPhase 가 중복 실행(몹 이중 소환/enrage 이중)되거나, 데미지 유실로 전환을 건너뛴다.
(B) StartPhase 가 Members 를 foreach 순회하는 도중 (C)/OnPlayerLeave 가
Add/Remove 하면 InvalidOperationException 으로 전환 스레드가 죽어 일부만 페이즈
효과를 받는다(영구 디싱크). (C) 지각 입장의 핵심 결함: Add 후 raid.Phase 를 읽어
동기화하는데, 전환과 입장이 인터리빙되면 (ⓐ 입장이 옛 Phase 를 읽어 보냈는데 직후 전환,
그 플레이어는 새 페이즈 효과를 못 받음) 또는 (ⓑ 전환의 foreach 가 막 추가된 플레이어를
포함해 효과를 적용하고 입장 측도 또 적용해 이중 적용)이 발생한다.
정답 한 줄: 인스턴스 단위 락(또는 단일 액터)으로 "HP 차감+전환 판정+효과 적용"과
"입·퇴장+현재 페이즈 동기화"를 각각 원자 구간으로 직렬화하고, 입장은 락 안에서 목록 추가와
현재 페이즈 동기화를 함께 수행한다.
문제점
(A) HP 차감·페이즈 전환 판정 비원자 — 이중 전환 / 전환 누락 (동시성) ★간판
- 분류: read-modify-write 경합 + check-then-act(TOCTOU).
- 증상: 두 데미지 처리가 동시에
Boss_Hp -= dmg후 둘 다Boss_Hp <= threshold[next-1]를 참으로 보면 둘 다raid.Phase = next; StartPhase(next)→ 같은 페이즈 효과 이중 적용(몹 2배 소환, enrage 2중첩). 반대로-=가 유실되면 임계치를 못 넘어 전환 누락. 여러 임계치를 한 번에 넘는 큰 데미지면 한 단계만 올라가는 버그도 있다(while 아님). - 재현조건: 페이즈 경계 근처 다중 동시 타격(레이드는 상시).
- 근본 원인: 차감·임계 통과·전환 실행이 하나의 원자 구간이 아니다.
(B) StartPhase 의 Members 순회 중 입·퇴장 변경 — 컬렉션 수정 예외 / 부분 적용 (동시성) ★간판
- 증상:
foreach (var m in raid.Members)도중 다른 스레드가Add/Remove→InvalidOperationException: Collection was modified. 전환 스레드가 던지면 남은 참가자는 페이즈 효과를 못 받아 영구 디싱크(서버는 P2 인데 일부 클라는 P1).List는 비스레드세이프라 동시Add로 내부 배열 손상도 가능. - 근본 원인: 가변 목록을 락/스냅샷 없이 순회.
(C) 지각 입장의 전환 경합 — 효과 누락 또는 이중 적용 (동시성·정합성) ★간판
- 증상(두 방향):
- 누락:
OnPlayerJoin이Members.Add(p)후raid.Phase(=1) 를 읽어 동기화하는 찰나에 전환이 일어나 Phase=2 가 되면, 이 플레이어는 P1 로 동기화돼 P2 의 소환/enrage 를 못 받는다 → 디싱크. - 이중: 반대로
Add직후 전환의foreach가 막 들어온p를 포함해ApplyPhaseEffects(2)를 적용하고,OnPlayerJoin도SendPhaseState/ApplyPhaseEffects상당을 적용하면 효과 이중 적용. - 어느 쪽이 일어날지는
Add와foreach/Phase 읽기의 인터리빙에 달림(비결정적).
- 누락:
- 근본 원인: "목록 추가" 와 "현재 페이즈 확정·동기화" 가 전환과 같은 락 아래 원자적으로 묶이지 않았다.
(보너스) 한 틱에 다중 임계치 통과 미처리 / 퇴장 중 효과 적용 (정확성)
- 큰 데미지로 두 임계치를 동시에 넘어도
if한 번이라 한 단계만 전환.while로 연쇄 전환 필요. 또 막 나간 플레이어에게 효과를 적용하면 자원 손상 가능.
수정안
핵심: ① 인스턴스 단위 락, ② HP 차감+전환을 원자 구간(연쇄 전환은 while), ③ 효과 적용은 락 안에서 스냅샷(또는 락 유지)으로 일관 적용, ④ 입장은 락 안에서 "추가 + 현재 페이즈 동기화"를 함께, 그 결과 전환은 추가된 플레이어를 포함하거나(이후 전환) 입장이 최신 페이즈로 동기화하거나 둘 중 하나로 일관.
public class RaidInstance
{
public readonly object Sync = new object();
public long Boss_Hp;
public int Phase = 1;
public readonly int[] PhaseThresholds;
public List<Player> Members = new List<Player>();
public RaidInstance(int[] t) { PhaseThresholds = t; }
}
public void OnBossDamaged(RaidInstance raid, long dmg)
{
lock (raid.Sync)
{
raid.Boss_Hp -= dmg;
// 연쇄 전환: 큰 데미지로 여러 임계치를 한 번에 넘을 수 있음
while (raid.Phase < raid.PhaseThresholds.Length &&
raid.Boss_Hp <= raid.PhaseThresholds[raid.Phase]) // 다음 임계치
{
raid.Phase++;
ApplyPhaseToAll(raid, raid.Phase); // 락 안에서 전원 일관 적용
}
}
}
private void ApplyPhaseToAll(RaidInstance raid, int phase)
{
// 락 안: 목록이 안정. 단, 효과가 느리면 스냅샷 떠서 락 밖 적용 고려.
foreach (var m in raid.Members)
{
m.ApplyPhaseEffects(phase);
m.SyncedPhase = phase;
}
}
public void OnPlayerJoin(RaidInstance raid, Player p)
{
lock (raid.Sync)
{
raid.Members.Add(p);
int cur = raid.Phase; // 추가와 현재 페이즈 읽기가 원자적
p.SendPhaseState(cur);
p.SyncedPhase = cur; // 정확히 현재 페이즈로 동기화
// 이후 전환은 이 p 를 Members 에 포함하므로 효과를 빠짐없이 받음
}
}
public void OnPlayerLeave(RaidInstance raid, Player p)
{
lock (raid.Sync) raid.Members.Remove(p); // 전환 순회와 직렬화
}
핵심 불변식: "Members 추가 시점의 Phase 로 동기화" + "전환은 그 시점 Members 전원에 적용" 을 같은 락 아래 두면, 어떤 인터리빙이든 각 플레이어는 페이즈 효과를 정확히 한 번 받는다(입장이 전환보다 먼저 락을 잡으면 옛 페이즈로 동기화 후 전환이 그를 포함; 전환이 먼저면 입장은 새 페이즈로 동기화).
ApplyPhaseEffects가 느린 I/O 면 락 안에서 대상 스냅샷만 만들고 적용은 락 밖에서 하되, "각 플레이어의 SyncedPhase 단조 증가"로 멱등화한다.
더 나은 설계
1) 인스턴스별 단일 액터(입력 큐)
- 한 레이드의 데미지/입장/퇴장/전환을 단일 스레드 메시지 큐로 직렬 처리 → 락·TOCTOU·순회 수정 예외가 구조적으로 소멸. 전환이 자연히 한 번. 트레이드오프: 인스턴스 간 병렬은 샤딩으로. 한 인스턴스 부하는 효과 적용을 비동기 작업으로 위임해 큐 블로킹 회피.
2) 페이즈 = 명시적 상태 머신 + 멱등 적용
SyncedPhase단조 증가로 효과 적용을 멱등화(이미 그 페이즈면 skip)하면 이중 적용을 방지. 전환은(from,to)전이 이벤트로 로깅해 재현/복구.
3) 입장 시 "현재 상태 스냅샷" 일괄 동기화
- 지각 입장은 단순 Phase 번호가 아니라 "현재 활성 몹/버프/타이머"의 스냅샷을 받아야 진짜 디싱크가 없다. 페이즈 효과를 선언적(데이터)으로 정의해 스냅샷 구성.
4) HP/전환은 권위 틱에서 한 번
- 데미지는 누적만 하고 전환 판정은 서버 틱 1회에서 수행하면 동시 타격의 이중 전환 자체가 사라진다. 트레이드오프: 전환이 틱 단위로 지연(보통 수십 ms, 수용 가능).
면접 포인트
- 핵심: 상태 머신 전이의 원자성과 전이 중 멤버십 변경을 어떻게 일관되게 — 전환과 입·퇴장을 같은 직렬화 단위로, 입장은 "추가+동기화"를 원자적으로.
- 예상 질문:
- "지각 입장이 페이즈 효과를 놓치는 인터리빙을 설명하라." → Add 후 옛 Phase 읽기 직후 전환. 추가와 현재 페이즈 동기화를 한 락에 묶으면 해결.
- "이중 전환은 왜 일어나나?" → 동시 타격이 둘 다 임계치 통과. 락 + while 연쇄 전환.
- "효과 적용이 느리면 락을 어떻게?" → 락 안 스냅샷 + 락 밖 적용 + SyncedPhase 멱등.
변별 메모: concurrency14(인스턴스 정원 초과 동시 입장)는 입장 카운트 상한, concurrency15 (던전 보상 지급 중 인스턴스 정리)는 보상 vs 생명주기 종료 가 축이다. 본 문제는 페이즈 상태 머신 전이의 원자성 × 전이 중 입·퇴장 멤버십 의 결합이 핵심으로 구분된다.
해설 — 레이드 페이즈 전환과 플레이어 입·퇴장 경합 (C++)
난이도: 최상
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
보스 전투 스레드(전환)와 입·퇴장 스레드(members 변경)가 동기화 없이 같은
RaidInstance 를 동시에 건드린다. (A) bossHp -= dmg 비원자 + 임계치 검사가
check-then-act 라, 동시 타격이 둘 다 임계치를 통과하면 같은 페이즈로 이중 전환(몹 이중
소환/enrage 이중)되거나 차감 유실로 전환을 건너뛴다. (B) StartPhase 가 members 를
range-for 순회하는 도중 push_back/erase 가 끼면 벡터 재할당으로 반복자/포인터 무효화
→ UB(크래시); 살아남아도 일부만 효과를 받아 영구 디싱크. (C) 지각 입장은 push_back
후 raid.phase 를 읽어 동기화하는데, 전환과 인터리빙되면 효과 누락(옛 phase 로 동기화
직후 전환) 또는 이중 적용(전환 순회가 막 추가된 포인터를 포함 + 입장도 적용)이 비결정적으로
발생한다. members 가 Player* 라 퇴장으로 delete 된 포인터를 전환이 역참조하면
use-after-free 위험도 있다.
정답 한 줄: 인스턴스 단위 뮤텍스(또는 단일 액터)로 "HP 차감+전환+효과 적용"과 "입·퇴장+
현재 페이즈 동기화"를 각각 원자 구간으로 직렬화하고, 입장은 락 안에서 추가와 현재 페이즈
동기화를 함께 한다.
문제점
(A) HP 차감·전환 판정 비원자 — 이중 전환 / 전환 누락 (동시성) ★간판
- 증상: 동시 타격이 둘 다
bossHp -= dmg후bossHp <= thresholds[next-1]통과 → 둘 다phase = next; StartPhase(next)→ 페이즈 효과 이중 적용.-=유실 시 전환 누락. 큰 데미지로 여러 임계치를 한 번에 넘어도if한 번이라 한 단계만 전환(while 필요). - 근본 원인: 차감·임계 통과·전환이 원자 구간이 아니다.
(B) members 순회 중 변경 — 반복자 무효화 UB / 부분 적용 (동시성·메모리) ★간판
- 증상:
for (Player* m : raid.members)도중push_back이 벡터 용량을 넘기면 재할당 으로 전체 반복자 무효화 → UB(세그폴트).erase도 순회 지점 이후를 흔들어 UB. 표준상 동일 벡터 동시 비-const 접근은 데이터 레이스. 살아남아도 남은 멤버는 효과 누락(디싱크). - 근본 원인: 가변 벡터를 락/스냅샷 없이 순회.
(C) 지각 입장의 전환 경합 — 누락 또는 이중 적용 (동시성·정합성) ★간판
- 증상:
push_back(p)후raid.phase읽기와 전환의 인터리빙에 따라, p 가 옛 페이즈로 동기화된 직후 전환이 일어나 P2 효과를 놓치거나(누락), 전환 순회가 막 추가된 p 를 포함해 적용하고 입장도 적용해 이중 적용. 비결정적. - 근본 원인: "목록 추가" 와 "현재 페이즈 확정·동기화" 가 전환과 같은 락 아래 원자적이지 않다.
(보너스) Player* dangling / 퇴장 중 효과 적용 (메모리)
members가 rawPlayer*. 퇴장 처리가 객체를 delete 하면 전환 순회가 dangling 포인터를 역참조(UAF). 소유권/수명(shared_ptr또는 풀+세대)을 정의해야.
수정안
핵심: ① 인스턴스별 std::mutex, ② 차감+전환 원자(연쇄는 while), ③ 효과 적용은 락 안 일관
적용, ④ 입장은 락 안에서 추가+현재 페이즈 동기화, ⑤ 수명 안전(shared_ptr).
#include <mutex>
#include <memory>
struct RaidInstance {
std::mutex mtx;
int64_t bossHp = 0;
int phase = 1;
std::vector<int> phaseThresholds;
std::vector<std::shared_ptr<Player>> members; // 수명 안전
};
void RaidService::OnBossDamaged(RaidInstance& raid, int64_t dmg) {
std::lock_guard<std::mutex> lk(raid.mtx);
raid.bossHp -= dmg;
// 연쇄 전환: 큰 데미지로 여러 임계치를 한 번에 넘을 수 있음
while (raid.phase < (int)raid.phaseThresholds.size() &&
raid.bossHp <= raid.phaseThresholds[raid.phase]) { // 다음 임계치
++raid.phase;
ApplyPhaseToAll(raid, raid.phase); // 락 안에서 전원 일관 적용
}
}
void RaidService::ApplyPhaseToAll(RaidInstance& raid, int phase) {
for (auto& m : raid.members) { // 락 안: 목록 안정
m->ApplyPhaseEffects(phase);
m->syncedPhase = phase;
}
}
void RaidService::OnPlayerJoin(RaidInstance& raid, std::shared_ptr<Player> p) {
std::lock_guard<std::mutex> lk(raid.mtx);
raid.members.push_back(p);
int cur = raid.phase; // 추가와 현재 페이즈가 원자
p->SendPhaseState(cur);
p->syncedPhase = cur; // 정확히 현재 페이즈로 동기화
// 이후 전환은 이 p 를 members 에 포함 → 효과를 빠짐없이 받음
}
void RaidService::OnPlayerLeave(RaidInstance& raid, const std::shared_ptr<Player>& p) {
std::lock_guard<std::mutex> lk(raid.mtx);
auto& v = raid.members;
v.erase(std::remove(v.begin(), v.end(), p), v.end()); // 전환 순회와 직렬화
}
불변식: "members 추가 시점의 phase 로 동기화" + "전환은 그 시점 members 전원에 적용" 을 같은 락에 두면, 어떤 인터리빙이든 각 플레이어가 효과를 정확히 한 번 받는다. 효과가 느린 I/O 면 락 안에서 대상 스냅샷(
shared_ptr복사)만 만들고 적용은 락 밖에서 하되,syncedPhase단조 증가로 멱등화한다(이미 그 페이즈면 skip).shared_ptr로 순회 중 퇴장에도 객체 수명이 보장돼 UAF 가 사라진다.
더 나은 설계
1) 인스턴스별 단일 액터(입력 큐)
- 데미지/입장/퇴장/전환을 단일 스레드 큐로 직렬 처리 → 락·UB·TOCTOU 소멸, 전환 한 번. 트레이드오프: 인스턴스 간 병렬은 샤딩, 한 인스턴스 부하는 효과 적용 비동기화.
2) 페이즈 = 명시적 상태 머신 + 멱등 적용
syncedPhase단조 증가로 효과 멱등화, 전이를(from,to)이벤트로 로깅해 복구/재현.
3) 입장 시 현재 상태 스냅샷 동기화
- 단순 phase 번호가 아니라 활성 몹/버프/타이머 스냅샷을 보내야 진짜 디싱크 제거. 효과를 선언적 데이터로 정의.
4) 전환 판정을 권위 틱 1회로
- 데미지는 누적만, 전환은 서버 틱에서 한 번 → 동시 타격 이중 전환 원천 차단. 트레이드오프: 전환이 틱 단위 지연(수용 가능).
면접 포인트
- 핵심: 상태 머신 전이 원자성 × 전이 중 멤버십 변경을 일관되게 — 같은 직렬화 단위, 입장은 추가+동기화 원자.
- 예상 질문:
- "지각 입장이 효과를 놓치는 인터리빙?" → push_back 후 옛 phase 읽기 직후 전환. 한 락에 묶어 해결.
- "벡터 순회 중 push_back 이 왜 크래시?" → 재할당 시 반복자 전체 무효화(UB). 락/스냅샷.
- "raw Player* 의 위험?" → 퇴장 delete 후 전환이 역참조(UAF). shared_ptr/풀+세대.
빌드/검증
g++ -std=c++17 -fsyntax-only problem.cpp
변별 메모: concurrency14(정원 초과 입장), concurrency15(보상 vs 인스턴스 정리)와 달리 본 문제는 페이즈 상태 머신 전이 원자성 × 전이 중 입·퇴장 멤버십의 결합이 핵심. C++ 판은 벡터 재할당 UB·Player* UAF 가 추가 함정.