← 문제로

31. 여러 이벤트 경로에서 트리거되는 연결 정리의 중복 실행 (이중 정리)

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

해설 — 여러 이벤트 경로에서 트리거되는 연결 정리의 중복 실행 (이중 정리)

난이도: 중상

요약

Cleanup 은 (A) 에서 s.IsClosed 를 확인하고 정리 작업 후 (B) 에서 IsClosed = true 를 설정하지만, 검사와 표시 사이가 원자적이지 않다. HandleError, HandleTimeout, HandleLogout 이 서로 다른 스레드(수신/타이머/워커)에서 같은 세션에 대해 거의 동시에 호출되면, 둘 이상이 (A) 를 동시에 통과해 SaveToDb, NotifyOtherSystems, ReleaseResources중복 실행된다. 게다가 이 세 경로에 대한 조회(_sessions.TryGetValue)와 Cleanup 내부의 _sessions.Remove 도 동기화 없이 같은 딕셔너리에 동시 접근해 그 자체로 안전하지 않다.

문제점

(A)→(B) 검사-표시 비원자성 — 여러 이벤트 경로의 동시 중복 정리 (TOCTOU)

  • 증상: DB 저장이 두 번 일어나 마지막에 실행된 쪽의 (아마도 더 오래된) 상태로 덮어써질 수 있고, NotifyOtherSystems 가 두 번 호출되어 길드/파티에 퇴장 통지가 중복 발송되며, ReleaseResources 가 두 번 호출되면 이미 반납된 리소스를 다시 반납하려다 오류가 나거나 다른 세션에 재할당된 자원을 잘못 건드릴 수 있다.
  • 재현 조건: 같은 세션에 대해 에러 콜백과 타임아웃 콜백(또는 로그아웃)이 거의 동시에 도착.
    1. T1(에러 콜백), T2(타임아웃 콜백) 모두 (A) 에서 IsClosed == false 를 읽고 통과.
    2. T1 이 SaveToDb~ReleaseResources 실행 후 (B) 에서 IsClosed = true.
    3. T2 도 이미 (A) 를 통과했으므로 자신의 SaveToDb~ReleaseResources 를 그대로 실행 — 중복.
  • 근본 원인: "정리 완료 여부 확인"과 "정리 완료 표시"가 하나의 락으로 보호되지 않은 check-then-act.

_sessions 딕셔너리 동시 접근 — 조회/삭제 경합

  • 증상: HandleError/HandleTimeout/HandleLogoutTryGetValueCleanup 내부의 Remove 가 서로 다른 스레드에서 동기화 없이 겹치면 Dictionary 내부 상태가 손상되거나 예외가 발생할 수 있다.
  • 재현 조건: 정리 진행 중(Remove 호출 시점)에 다른 경로가 같은 세션을 조회.
  • 근본 원인: Dictionary<int, PlayerSession> 는 스레드 안전하지 않은데 외부 동기화가 없다.

수정안 (정확한 코드)

세션별 락으로 "검사+정리+표시"를 하나의 임계 구역으로 묶고, _sessions 컬렉션 접근도 별도로 보호한다. 원자적 CAS 스타일의 Interlocked.CompareExchange 로 "정리 시작 권한"을 정확히 한 스레드에만 부여하는 방식이 락보다 더 간단하고 안전하다.

using System;
using System.Collections.Generic;
using System.Threading;

class PlayerSession
{
    public int SessionId;
    // 0 = 열림, 1 = 정리 중/완료. CompareExchange 로 "정리 시작 권한"을 단 한 번만 부여.
    private int _closedFlag;
    public bool TryMarkClosing() => Interlocked.CompareExchange(ref _closedFlag, 1, 0) == 0;
}

class SessionManager
{
    private readonly object _mapLock = new();
    private readonly Dictionary<int, PlayerSession> _sessions = new();

    private void SaveToDb(PlayerSession s) { /* 최종 상태 DB 저장 */ }
    private void NotifyOtherSystems(PlayerSession s) { /* 길드/파티 등에 퇴장 통지 */ }
    private void ReleaseResources(PlayerSession s) { /* 버퍼/소켓 핸들 반납 */ }

    private PlayerSession? FindSession(int sessionId)
    {
        lock (_mapLock)
        {
            return _sessions.TryGetValue(sessionId, out var s) ? s : null;
        }
    }

    public void HandleError(int sessionId) => TryCleanup(sessionId);
    public void HandleTimeout(int sessionId) => TryCleanup(sessionId);
    public void HandleLogout(int sessionId) => TryCleanup(sessionId);

    private void TryCleanup(int sessionId)
    {
        var s = FindSession(sessionId);
        if (s == null) return;

        // 원자적 CAS: 오직 이 호출만 "정리 시작"에 성공한다.
        // 다른 경로가 동시에 들어와도 CompareExchange 는 단 한 스레드에게만 true 를 반환한다.
        if (!s.TryMarkClosing())
            return;   // 이미 다른 경로가 정리를 시작/완료함 — 여기서 즉시 반환

        SaveToDb(s);
        NotifyOtherSystems(s);
        ReleaseResources(s);

        lock (_mapLock)
        {
            _sessions.Remove(sessionId);
        }
    }
}

더 나은 설계 (+ 트레이드오프)

  • 세션 소유 큐/액터로 직렬화: 세 이벤트 경로가 세션에 대한 작업을 직접 실행하지 않고 세션 전용 큐에 "정리 요청"만 넣으면, 큐 소비자가 단일 스레드로 처리하므로 애초에 경합이 없다. 대신 이벤트→큐→처리의 지연이 생기고 큐 인프라가 필요하다.
  • 상태 머신 + 명시적 상태 전이 로그: Open → Closing → Closed 상태를 두고 어떤 경로가 종료를 트리거했는지 로그로 남기면 디버깅이 쉬워진다(예: "타임아웃이 먼저 트리거됐는데 로그아웃 패킷이 뒤늦게 도착" 같은 레이스를 사후 분석 가능).
  • 참조 카운트/약한 참조로 세션 수명 관리: 정리 이후에도 진행 중이던 다른 콜백이 세션 객체에 안전하게 접근(정리됐음을 확인만 하고 조용히 무시)하도록 하면, 정리 타이밍과 콜백 도착 타이밍이 어긋나도 안전하다.
  • 트레이드오프: Interlocked.CompareExchange 방식은 락보다 가볍고 데드락 위험이 없지만, "정리 권한을 놓친 스레드"가 정리 작업의 완료를 기다려야 하는 경우(예: 정리 완료 후 응답을 보내야 함)라면 완료 신호(이벤트/Task)를 별도로 제공해야 한다.

면접 포인트

  1. 하나의 자원(세션)을 여러 독립 이벤트 경로가 종료시킬 수 있는 구조에서는 "정리 완료 여부 확인+표시"를 원자적 연산(CAS) 또는 락으로 묶어야 한다 — 그렇지 않으면 검사-표시 사이 TOCTOU 로 중복 실행된다.
  2. Interlocked.CompareExchange 를 이용한 "단 한 번만 성공하는 플래그"패턴은 락보다 가볍게 정확히 한 번(exactly-once) 진입을 보장하는 관용구다.
  3. 정리 작업(DB 저장, 알림, 리소스 반납) 각각이 멱등하지 않다면 중복 실행이 데이터 오염·중복 통지로 직결된다 — 가능하면 멱등하게 만들되, 근본적으로는 진입 자체를 한 번으로 제한하는 것이 우선이다.