← 문제로

11. 계정 정지/강제 종료(킥) vs 진행 중인 정상 처리

난이도 중
내 리뷰 · C#
해설 · C#

해설 — 계정 정지/강제 종료(킥) vs 진행 중인 정상 처리

난이도: 중

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

요약

어드민 스레드의 "정지+킥" 과 워커 스레드의 "정상 처리" 가 같은 세션/플레이어 상태를 락 없이 동시에 만진다. 결함이 세 갈래다. (1) TOCTOU: 워커는 진입 시 Banned 를 한 번만 보고, 그 뒤 보상 지급(D)→저장(E)을 진행한다. 어드민이 그 사이에 정지시켜도 워커는 멈추지 않아 정지된 플레이어가 행동을 완료한다. (2) 가시성: Banned/Closed 가 비동기화 bool 이라 워커 스레드가 어드민의 쓰기를 영영 못 볼 수 있다. (3) 소켓 이중/동시 정리 & UAF: 어드민이 Socket.Close()(F) 한 뒤 워커가 같은 소켓에 쓰면 ObjectDisposedException, 또한 Dictionary 를 동시 Read/Remove 하면 손상. 핵심: 세션 종료는 상태 머신으로 직렬화하고, 정상 작업은 커밋 직전 정지를 재확인해야 한다.


문제점

(C)+(D)+(E) 정지 검사-적용 비원자 — TOCTOU (정확성/보안) ★간판

  • 증상: 정지된 플레이어가 정지 직후의 보상 지급/거래 체결을 그대로 완료한다.
  • 재현 조건: 워커가 (C)에서 Banned==false 를 보고 긴 작업을 시작. 그 사이 어드민이 (G)에서 Banned=true. 워커는 재확인 없이 (D)(E)를 진행 → 부정 이득.
  • 근본 원인: "정지 여부" 검사와 "효과 적용/커밋" 사이가 원자적이지 않다. 정지는 커밋 직전(또는 커밋과 같은 트랜잭션)에서 재확인해야 한다.

(A)/(B) bool 의 가시성 — 메모리 모델 (동시성)

  • 증상: 워커가 어드민의 Banned=true/Closed=true관측하지 못해 계속 진행.
  • 근본 원인: 일반 bool 필드는 스레드 간 가시성/순서 보장이 없다. volatile/ Interlocked/락 또는 상태를 단일 소유 스레드로 모아야 한다.

(F)+(D) 소켓 Close 와 워커의 사용 경합 — UAF/예외 (동시성/메모리)

  • 증상: 어드민이 (F)에서 Socket.Close() 한 직후 워커가 그 소켓으로 송신/수신하면 ObjectDisposedException, in-flight 비동기 콜백과 겹치면 더 지저분한 경합.
  • 근본 원인: 소켓 수명과 진행 중 작업이 조율되지 않는다. 종료는 "진행 중 작업 드레인 → 소켓 정리" 순서로 가야 한다(session_network/problem4 의 종료 순서 문제와 연결).

(H)+(I) 종료/제거 비원자 + 이중 정리 (동시성/정확성)

  • 증상: 어드민 킥과 정상 로그아웃(또는 두 번의 어드민 킥)이 겹치면 Close() 가 두 번 호출돼 이중 Dispose, _sessions 를 동시 Read(Find)/Remove 해 자료구조 손상.
  • 근본 원인: Closed 플래그를 검사·설정하는 곳이 비원자(이중 정리 방어 부재)이고, 레지스트리 접근에 락이 없다. 종료는 정확히 한 번만 일어나야 한다(멱등).

(보조) 절반 적용 위험 — 정확성

  • (D) 보상 지급과 (E) 저장이 분리돼 있고, 그 사이 킥/크래시가 나면 "메모리엔 지급됐는데 저장 안 됨" 또는 그 반대의 절반 상태가 남는다. 한 트랜잭션이어야 한다.

수정안

핵심: ① 세션에 종료 상태(0=Active,1=Closing,2=Closed)를 Interlocked 로 1회 전이, ② 정상 작업은 세션 락 안에서 정지/종료를 재확인하고 지급+저장을 한 트랜잭션으로, ③ 레지스트리 접근/제거에 락, ④ 소켓 정리는 진행 중 작업을 막은 뒤 1회만.

public class GameSession
{
    public long Id;
    public Player Player;
    public Socket Socket;
    private int _state;                 // 0 Active / 1 Closing / 2 Closed
    private readonly object _lock = new object();

    public volatile bool Banned;        // 가시성 보장(또는 _lock 안에서만 접근)

    public void HandleRewardPacket(int amount)
    {
        lock (_lock)
        {
            // 커밋 직전 재확인: 정지/종료되었으면 아무것도 적용하지 않음
            if (Banned || Volatile.Read(ref _state) != 0) return;

            // 지급 + 저장을 한 트랜잭션으로(절반 적용 금지)
            using var tx = Db.BeginTransaction();
            Player.ApplyReward(amount, tx);
            Player.SaveProgress(tx);
            tx.Commit();
        }
    }

    // 정확히 한 번만 종료(멱등)
    public bool BeginClose()
    {
        // Active(0) -> Closing(1) 전이에 성공한 스레드만 정리 책임을 진다
        return Interlocked.CompareExchange(ref _state, 1, 0) == 0;
    }

    public void FinishClose()
    {
        lock (_lock) { /* 진행 중 작업과 직렬화 */ }
        try { Socket.Close(); } catch { /* 이미 닫힘 무시 */ }
        Volatile.Write(ref _state, 2);
    }
}

public class SessionManager
{
    private readonly Dictionary<long, GameSession> _sessions = new();
    private readonly object _regLock = new object();

    public void BanAndKick(long sessionId)
    {
        GameSession s;
        lock (_regLock) { _sessions.TryGetValue(sessionId, out s); }
        if (s == null) return;

        s.Banned = true;                 // 가시성 보장 필드
        if (s.BeginClose())              // 종료 책임을 한 스레드만 갖는다
        {
            s.FinishClose();
            lock (_regLock) { _sessions.Remove(sessionId); }
        }
    }
}

Banned 를 세운 뒤 BeginClose 로 단 한 번만 정리한다. 워커는 커밋 직전 Banned/ _state 를 재확인하므로 정지 이후 작업은 커밋되지 않는다. 지급+저장은 한 트랜잭션이라 절반 적용도 없다.


더 나은 설계

1) 협조적 취소(cooperative cancellation) + 드레이닝

  • 즉시 소켓을 끊는 대신 세션에 CancellationToken 을 두고, 정지 시 토큰을 취소. 워커는 단계마다 토큰을 확인해 안전 지점에서 롤백/중단하고, 진행 중 작업이 빠진 뒤 소켓을 정리한다. graceful shutdown(problem10) 과 같은 드레이닝 모델.

2) 단일 액터로 세션 소유

  • 한 세션의 모든 이벤트(게임 패킷, 어드민 명령, 종료)를 단일 큐로 직렬 처리하면 TOCTOU/가시성/이중정리가 구조적으로 사라진다. 어드민 킥도 그 큐에 메시지로 넣는다. 트레이드오프: 세션당 큐 비용 vs 동시성 버그 제거. MMO 에서 널리 쓰는 패턴.

3) 권위 있는 정지 상태는 DB/중앙에

  • ban 은 게임서버 메모리 플래그만으로 부족하다(멀티 인스턴스/재접속). 계정 상태를 중앙(DB/인증서버)에 두고, 경제적 행위는 "정지 아님" 을 같은 트랜잭션에서 확인한다.

4) 종료를 명시적 상태 머신으로

  • Active→Closing→Closed 단방향 전이 + CAS 로 "정리 책임 1회" 를 보장. in-flight 비동기 완료를 카운트(또는 Drain)한 뒤 소켓 Dispose(problem4 의 종료 순서 원칙).

면접 포인트

  • 핵심: "정지/킥은 언제 효력을 가져야 하는가?" — 진입 시점이 아니라 커밋 시점. 그리고 종료는 멱등(정확히 1회), 소켓 수명은 진행 중 작업과 조율.
  • 예상 질문:
    1. "정지됐는데도 보상이 들어가는 경로를 설명하라." → 진입 검사 통과 후 정지 → 재확인 없이 커밋(TOCTOU). 커밋 직전 재확인 필요.
    2. "어드민 킥이 두 번 와도 안전하려면?" → CAS 로 Active→Closing 전이에 성공한 스레드만 정리(멱등 종료).
    3. "소켓을 바로 Close 하면 왜 위험한가?" → in-flight 송수신과 겹쳐 ObjectDisposedException/경합. 드레인 후 정리.