← 문제로

34. 로그인 대기열 동접 상한의 check-then-act와 이탈 처리 (C#)

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

해설 — 로그인 대기열 동접 상한의 check-then-act와 이탈 처리 (C#)

난이도: 중상

요약

TryEnter는 (A)에서 _currentCount 검사와 증가를 원자적으로 묶지 않아, 여러 로그인 요청이 동시에 들어오면 상한 검사를 동시에 통과해 실제 접속 인원이 MaxConcurrent를 넘어설 수 있다. 또한 OnLeave는 (B)에서 그 connId가 "이미 입장해 접속 중이었는지" 또는 "아직 대기열에 있을 뿐 입장한 적이 없는지"를 구분하지 않고 무조건 _currentCount를 감소시킨다. 대기 중인 클라이언트에 대해 OnLeave가 잘못 호출되면(혹은 대기 취소가 OnCancelWaiting 대신 일반 종료 경로로 흘러들면) 카운터가 실제 접속 인원보다 작게 내려가 상한에 여유가 있는데도 이후 PromoteNext가 계속 잘못된 기준으로 판단하거나, 반대로 카운터가 실제보다 부풀려진 채 굳어져 자리가 비어도 대기열이 영원히 승격되지 않는 "유령 슬롯" 상태에 빠질 수 있다.

문제점

(A) 검사(_currentCount < _maxConcurrent)와 증가(_currentCount++)의 비원자성

  • 증상: _maxConcurrent가 100이고 _currentCount가 99일 때 두 로그인 요청이 동시에 TryEnter를 호출하면, 둘 다 99 < 100을 통과해 각자 _currentCount++를 실행 — 결과는 101로, 상한을 1 초과해 입장시킨다.
  • 재현 조건:
    1. _currentCount == _maxConcurrent - 1인 상태에서 서로 다른 네트워크 워커 스레드 T1, T2가 거의 동시에 TryEnter를 호출.
    2. T1, T2 모두 검사 시점엔 카운터가 상한 미만이라고 읽는다.
    3. 두 스레드 모두 "즉시 입장"으로 true를 반환 — 순간적으로 동시 접속자가 상한을 초과.
  • 근본 원인: 공유 변수 _currentCount에 대한 검사-증가가 락 없이 이루어져 TOCTOU(check-then-act) 경합이 발생한다.

(B) 대기 중 이탈과 접속 중 이탈을 구분하지 않는 카운터 감소

  • 증상: 대기열에만 있던(한 번도 입장 허가를 받지 못한) 클라이언트의 연결 종료가 OnLeave로 처리되면, 실제로는 _currentCount에 반영된 적이 없는 인원인데도 _currentCount--가 실행되어 카운터가 실제 접속자 수보다 낮게 내려간다. 이 상태가 누적되면 나중에는 _currentCount가 음수에 가까워지거나, PromoteNext가 과도하게 많은 인원을 승격시켜 결과적으로 실제 접속자가 상한을 초과한다. 반대로 접속 중이던 클라이언트의 종료가 어떤 경로로 카운터 감소 없이 누락되면(예: 강제 종료·크래시로 정상 OnLeave 호출 없이 소켓만 닫힘) 카운터가 실제보다 부풀려진 채 남아 자리가 비어도 대기열이 승격되지 않는 유령 슬롯이 쌓인다.
  • 재현 조건:
    1. 상한이 가득 차 클라이언트 C가 대기열에 등록됨(_waiting에 포함, 입장은 안 됨).
    2. C가 대기를 포기하고 연결을 끊었는데, 클라이언트 종료 처리 경로가 "대기 중"과 "접속 중"을 구분하지 못해 OnCancelWaiting 대신 OnLeave가 호출됨.
    3. OnLeave는 무조건 _currentCount--를 실행 — 실제로 접속 인원이 줄어든 게 아닌데도 카운터가 감소.
    4. 이후 PromoteNext가 실제 여유보다 하나 더 많은 인원을 승격시켜, 결과적으로 실제 동접자가 _maxConcurrent를 넘어선다.
  • 근본 원인: LoginGate가 "접속 중" 상태와 "대기 중" 상태를 명확한 상태 머신으로 관리하지 않고, 호출된 메서드 이름(OnLeave vs OnCancelWaiting)에만 의존해 어느 카운터를 건드릴지 결정한다. 호출 경로가 상태를 오인하면 즉시 정합성이 깨진다.

수정안 (정확한 코드)

락으로 검사-증가-감소를 원자화하고, 연결(connId)별 상태를 명시적으로 추적해 OnLeave가 실제 상태에 따라 올바른 처리를 하도록 분기한다.

using System.Collections.Generic;

public sealed class LoginGate
{
    private enum ConnState { Waiting, Admitted }

    private readonly int _maxConcurrent;
    private int _currentCount;
    private readonly object _lock = new object();

    private readonly Queue<long> _waitQueue = new Queue<long>();
    private readonly Dictionary<long, ConnState> _state = new Dictionary<long, ConnState>();

    public LoginGate(int maxConcurrent)
    {
        _maxConcurrent = maxConcurrent;
    }

    public bool TryEnter(long connId)
    {
        lock (_lock)
        {
            if (_currentCount < _maxConcurrent)
            {
                _currentCount++;
                _state[connId] = ConnState.Admitted;
                return true;
            }

            _waitQueue.Enqueue(connId);
            _state[connId] = ConnState.Waiting;
            return false;
        }
    }

    // 연결 종료(정상 로그아웃/끊김) — 접속 중이었는지 대기 중이었는지는
    // 상태 테이블로 판단하지, 호출된 메서드 이름에 의존하지 않는다.
    public void OnLeave(long connId)
    {
        lock (_lock)
        {
            if (!_state.TryGetValue(connId, out var state))
                return; // 이미 정리된 연결

            _state.Remove(connId);

            if (state == ConnState.Admitted)
            {
                _currentCount--;
            }
            // Waiting 상태였다면 _currentCount 에 반영된 적이 없으므로 건드리지 않는다.
            // 큐에는 남아있을 수 있으나 PromoteNext 가 _state 조회로 걸러낸다.

            PromoteNext();
        }
    }

    private void PromoteNext()
    {
        while (_currentCount < _maxConcurrent && _waitQueue.Count > 0)
        {
            var next = _waitQueue.Dequeue();

            // 이미 이탈해 _state 에서 제거된 대상이면 건너뛴다.
            if (!_state.TryGetValue(next, out var state) || state != ConnState.Waiting)
                continue;

            _state[next] = ConnState.Admitted;
            _currentCount++;
            // 실제로는 여기서 next 에게 입장 허가 통지를 보낸다.
        }
    }
}

핵심 변경:

  • _lock으로 TryEnter/OnLeave/PromoteNext를 하나의 임계 구역으로 묶어 (A)의 검사-증가 경합을 제거했다.
  • _waiting(HashSet)과 _currentCount 두 군데로 흩어져 있던 상태를 Dictionary<long, ConnState> 하나로 통합해, 연결이 "대기 중"인지 "접속 중"인지를 명시적으로 추적한다.
  • OnLeave는 이제 호출 경로가 아니라 _state에 저장된 실제 상태를 보고 _currentCount를 건드릴지 말지 결정한다 — 대기 중 이탈이 OnLeave로 잘못 들어와도 카운터가 오염되지 않는다.

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

  • 단일 스레드 큐/액터로 admission 로직 직렬화: 로그인 게이트 전체를 하나의 처리 루프(싱글 스레드 액터)로 두면 락 없이도 검사-증가-감소 순서를 보장할 수 있다. 대신 모든 접속/이탈 이벤트가 이 액터를 거쳐야 하므로 메시지 큐잉 지연이 추가된다.
  • 세마포어(SemaphoreSlim) 활용: _maxConcurrent를 세마포어의 초기 카운트로 두고 입장 시 획득, 이탈 시 해제하는 방식은 검사-증가를 표준 primitive에 위임할 수 있다. 다만 "대기 중 이탈"을 세마포어 해제 없이 정확히 걸러내는 로직은 여전히 별도 상태 추적이 필요하다.
  • 상태 머신을 연결 객체 자체에 내재화: connId -> ConnState를 딕셔너리로 별도 관리하는 대신, 세션 객체 자체에 상태 필드를 두고 상태 전이 메서드로만 접근하게 하면 "어떤 상태에서 어떤 전이가 가능한가"를 타입 수준에서 강제할 수 있다.
  • 트레이드오프: 전역 락은 구현이 단순하지만 로그인 트래픽이 매우 몰리는 순간(예: 이벤트 오픈 직후)에는 병목이 될 수 있다. 다만 admission control은 본질적으로 전역 자원(동접 상한)에 대한 조정이라 어떤 방식으로든 직렬화 지점이 필요하며, 락의 임계 구역을 최소화하는 것이 현실적 절충이다.

면접 포인트

  1. "검사 후 실행(check-then-act)"은 공유 카운터에 대한 가장 흔한 경합 패턴이다 — 검사와 실행을 하나의 원자적 연산(락, CAS, 세마포어)으로 묶어야 한다.
  2. 카운터 증감을 상태와 분리해서 관리하면(이 경우 _currentCount와 "이 connId가 실제로 입장했는가") 호출 경로의 실수 하나가 전역 카운터를 영구히 오염시킬 수 있다 — 카운터는 가능하면 명시적 상태에서 파생시키거나, 상태 전이 시점에만 갱신되도록 강제해야 한다.
  3. Admission control 같은 자원 상한 로직은 "정상 흐름"만이 아니라 "대기 중 이탈", "비정상 종료(크래시)" 같은 예외적 생명주기 전이를 반드시 설계 단계에서 명시적으로 다뤄야 한다 — 그렇지 않으면 유령 슬롯(카운터만 남고 실체가 없는 자원 점유)이 누적된다.