← 문제로

18. 레이드 페이즈 전환과 플레이어 입·퇴장 경합 (C#)

난이도 최상
내 리뷰 · C#
해설 · C#

해설 — 레이드 페이즈 전환과 플레이어 입·퇴장 경합 (C#)

난이도: 최상

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

보스 전투 스레드(전환)와 입·퇴장 스레드(참가자 목록 변경)가 락 없이 같은 RaidInstance 를 동시에 건드린다. (A) Boss_Hp -= dmg 비원자 + 임계치 검사가 check-then-act 라, 동시에 막타급 데미지가 둘 들어오면 같은 페이즈로 두 번 전환StartPhase 가 중복 실행(몹 이중 소환/enrage 이중)되거나, 데미지 유실로 전환을 건너뛴다. (B) StartPhaseMembersforeach 순회하는 도중 (C)/OnPlayerLeaveAdd/Remove 하면 InvalidOperationException 으로 전환 스레드가 죽어 일부만 페이즈 효과를 받는다(영구 디싱크). (C) 지각 입장의 핵심 결함: Addraid.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/RemoveInvalidOperationException: Collection was modified. 전환 스레드가 던지면 남은 참가자는 페이즈 효과를 못 받아 영구 디싱크(서버는 P2 인데 일부 클라는 P1). List 는 비스레드세이프라 동시 Add 로 내부 배열 손상도 가능.
  • 근본 원인: 가변 목록을 락/스냅샷 없이 순회.

(C) 지각 입장의 전환 경합 — 효과 누락 또는 이중 적용 (동시성·정합성) ★간판

  • 증상(두 방향):
    • 누락: OnPlayerJoinMembers.Add(p)raid.Phase(=1) 를 읽어 동기화하는 찰나에 전환이 일어나 Phase=2 가 되면, 이 플레이어는 P1 로 동기화돼 P2 의 소환/enrage 를 못 받는다 → 디싱크.
    • 이중: 반대로 Add 직후 전환의 foreach 가 막 들어온 p 를 포함해 ApplyPhaseEffects(2) 를 적용하고, OnPlayerJoinSendPhaseState/ApplyPhaseEffects 상당을 적용하면 효과 이중 적용.
    • 어느 쪽이 일어날지는 Addforeach/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, 수용 가능).

면접 포인트

  • 핵심: 상태 머신 전이의 원자성전이 중 멤버십 변경을 어떻게 일관되게 — 전환과 입·퇴장을 같은 직렬화 단위로, 입장은 "추가+동기화"를 원자적으로.
  • 예상 질문:
    1. "지각 입장이 페이즈 효과를 놓치는 인터리빙을 설명하라." → Add 후 옛 Phase 읽기 직후 전환. 추가와 현재 페이즈 동기화를 한 락에 묶으면 해결.
    2. "이중 전환은 왜 일어나나?" → 동시 타격이 둘 다 임계치 통과. 락 + while 연쇄 전환.
    3. "효과 적용이 느리면 락을 어떻게?" → 락 안 스냅샷 + 락 밖 적용 + SyncedPhase 멱등.

변별 메모: concurrency14(인스턴스 정원 초과 동시 입장)는 입장 카운트 상한, concurrency15 (던전 보상 지급 중 인스턴스 정리)는 보상 vs 생명주기 종료 가 축이다. 본 문제는 페이즈 상태 머신 전이의 원자성 × 전이 중 입·퇴장 멤버십 의 결합이 핵심으로 구분된다.