34. 로그인 대기열 동접 상한의 check-then-act와 이탈 처리 (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 초과해 입장시킨다. - 재현 조건:
_currentCount == _maxConcurrent - 1인 상태에서 서로 다른 네트워크 워커 스레드 T1, T2가 거의 동시에TryEnter를 호출.- T1, T2 모두 검사 시점엔 카운터가 상한 미만이라고 읽는다.
- 두 스레드 모두 "즉시 입장"으로
true를 반환 — 순간적으로 동시 접속자가 상한을 초과.
- 근본 원인: 공유 변수
_currentCount에 대한 검사-증가가 락 없이 이루어져 TOCTOU(check-then-act) 경합이 발생한다.
(B) 대기 중 이탈과 접속 중 이탈을 구분하지 않는 카운터 감소
- 증상: 대기열에만 있던(한 번도 입장 허가를 받지 못한) 클라이언트의 연결 종료가
OnLeave로 처리되면, 실제로는_currentCount에 반영된 적이 없는 인원인데도_currentCount--가 실행되어 카운터가 실제 접속자 수보다 낮게 내려간다. 이 상태가 누적되면 나중에는_currentCount가 음수에 가까워지거나,PromoteNext가 과도하게 많은 인원을 승격시켜 결과적으로 실제 접속자가 상한을 초과한다. 반대로 접속 중이던 클라이언트의 종료가 어떤 경로로 카운터 감소 없이 누락되면(예: 강제 종료·크래시로 정상OnLeave호출 없이 소켓만 닫힘) 카운터가 실제보다 부풀려진 채 남아 자리가 비어도 대기열이 승격되지 않는 유령 슬롯이 쌓인다. - 재현 조건:
- 상한이 가득 차 클라이언트 C가 대기열에 등록됨(
_waiting에 포함, 입장은 안 됨). - C가 대기를 포기하고 연결을 끊었는데, 클라이언트 종료 처리 경로가 "대기 중"과 "접속 중"을 구분하지 못해
OnCancelWaiting대신OnLeave가 호출됨. OnLeave는 무조건_currentCount--를 실행 — 실제로 접속 인원이 줄어든 게 아닌데도 카운터가 감소.- 이후
PromoteNext가 실제 여유보다 하나 더 많은 인원을 승격시켜, 결과적으로 실제 동접자가_maxConcurrent를 넘어선다.
- 상한이 가득 차 클라이언트 C가 대기열에 등록됨(
- 근본 원인:
LoginGate가 "접속 중" 상태와 "대기 중" 상태를 명확한 상태 머신으로 관리하지 않고, 호출된 메서드 이름(OnLeavevsOnCancelWaiting)에만 의존해 어느 카운터를 건드릴지 결정한다. 호출 경로가 상태를 오인하면 즉시 정합성이 깨진다.
수정안 (정확한 코드)
락으로 검사-증가-감소를 원자화하고, 연결(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은 본질적으로 전역 자원(동접 상한)에 대한 조정이라 어떤 방식으로든 직렬화 지점이 필요하며, 락의 임계 구역을 최소화하는 것이 현실적 절충이다.
면접 포인트
- "검사 후 실행(check-then-act)"은 공유 카운터에 대한 가장 흔한 경합 패턴이다 — 검사와 실행을 하나의 원자적 연산(락, CAS, 세마포어)으로 묶어야 한다.
- 카운터 증감을 상태와 분리해서 관리하면(이 경우
_currentCount와 "이 connId가 실제로 입장했는가") 호출 경로의 실수 하나가 전역 카운터를 영구히 오염시킬 수 있다 — 카운터는 가능하면 명시적 상태에서 파생시키거나, 상태 전이 시점에만 갱신되도록 강제해야 한다. - Admission control 같은 자원 상한 로직은 "정상 흐름"만이 아니라 "대기 중 이탈", "비정상 종료(크래시)" 같은 예외적 생명주기 전이를 반드시 설계 단계에서 명시적으로 다뤄야 한다 — 그렇지 않으면 유령 슬롯(카운터만 남고 실체가 없는 자원 점유)이 누적된다.
해설 — 로그인 대기열의 비원자적 상한 검사와 대기열 잔류 포인터 (C++)
난이도: 최상
요약
tryEnter는 (A)에서 currentCount_.load()로 읽은 값과 fetch_add(1)로 증가시키는 시점 사이에 다른 스레드가 끼어들 수 있는 load-then-add 패턴이라, atomic을 쓰고 있음에도 상한 검사 자체는 원자적이지 않다. 더 심각한 문제는 (B) onCancelWaiting이 대기열(waitQueue_)에서 해당 세션 포인터를 실제로 제거하지 않고 아무 일도 하지 않는다는 점이다 — 대기 중이던 세션이 이탈하면 상위 레이어가 그 Session 객체를 곧 delete하는데, 포인터는 waitQueue_에 그대로 남아있다. 이후 promoteNext가 큐에서 이 포인터를 꺼내 next->admitted = true로 역참조하면 이는 해제된 메모리에 대한 use-after-free이며, 결과적으로 실제 자리가 비었음에도(유령 슬롯이 승격 시도되며 소모됨) 상한 미만인데 정상 대기자가 계속 승격되지 못하는 상황도 함께 발생한다.
문제점
(A) load() 후 fetch_add()의 비원자적 검사-증가
- 증상:
currentCount_가maxConcurrent_ - 1일 때 두 스레드가 동시에tryEnter를 호출하면 둘 다load()에서 상한 미만임을 확인하고 각자fetch_add(1)을 실행 — 결과적으로maxConcurrent_ + 1명이 입장 처리된다. - 재현 조건:
currentCount_ == maxConcurrent_ - 1.- 스레드 T1, T2가 거의 동시에
tryEnter의currentCount_.load() < maxConcurrent_를 평가 — 둘 다 참. - T1, T2 모두
fetch_add(1)실행,session->admitted = true,true반환. - 실제 접속자는 상한을 1명 초과.
- 근본 원인:
std::atomic<int>는 개별 연산(load,fetch_add)의 원자성만 보장할 뿐, "읽은 값을 기준으로 조건부 증가"라는 복합 연산의 원자성은 보장하지 않는다. 이런 조건부 갱신에는compare_exchange루프나 락이 필요하다.
(B) 대기열에서 이탈한 포인터를 제거하지 않아 발생하는 use-after-free
- 증상: 대기 중이던 세션이 연결을 끊어
onCancelWaiting이 호출돼도waitQueue_에서 해당 포인터가 제거되지 않는다. 상위 레이어가 곧이어 해당Session을delete하면, 그 포인터는waitQueue_안에서 댕글링(dangling) 상태가 된다. 나중에 슬롯이 비어promoteNext가 이 포인터를pop_front로 꺼내next->admitted = true로 역참조하는 순간 이미 해제된 메모리에 쓰기가 일어난다 — UB이며 힙 손상·크래시로 이어질 수 있다. - 재현 조건:
- 세션 S가 상한 초과로 대기열에 들어감(
waitQueue_에S포인터 push). - S가 대기를 포기하고 연결을 끊어
onCancelWaiting(S)호출 — 그러나 큐에서 제거되지 않음. - 곧이어 상위 레이어가
delete S수행. - 다른 접속자가 로그아웃해
onLeave→promoteNext호출,currentCount_에 여유가 생겨 큐에서S(이미 해제됨)를 꺼내 역참조.
- 세션 S가 상한 초과로 대기열에 들어감(
- 근본 원인: "대기 취소"라는 생명주기 이벤트가 대기열이라는 자료구조에 실제로 반영되지 않는다. 포인터의 소유권과 유효성 보장 없이 원시 포인터를 컨테이너에 보관하면서, 그 포인터가 가리키는 객체의 해제 시점을 컨테이너가 전혀 알지 못한다.
수정안 (정확한 코드)
검사-증가를 락으로 원자화하고, 세션을 원시 포인터 대신 shared_ptr로 관리해 대기열에 남아있는 한 객체가 해제되지 않도록 하며, 취소 시 큐에서 실제로 제거(또는 무효화 플래그)한다.
#include <deque>
#include <mutex>
#include <memory>
#include <algorithm>
struct Session {
long long connId;
bool admitted = false;
bool cancelled = false; // 대기 취소 표시
};
class LoginGate {
int maxConcurrent_;
int currentCount_ = 0;
std::mutex mx_; // currentCount_ 와 waitQueue_ 를 함께 보호
std::deque<std::shared_ptr<Session>> waitQueue_;
public:
explicit LoginGate(int maxConcurrent) : maxConcurrent_(maxConcurrent) {}
bool tryEnter(const std::shared_ptr<Session>& session)
{
std::lock_guard<std::mutex> lk(mx_); // 검사와 증가를 하나의 임계 구역으로
if (currentCount_ < maxConcurrent_)
{
currentCount_++;
session->admitted = true;
return true;
}
waitQueue_.push_back(session);
return false;
}
// shared_ptr 이 살아있는 한 Session 은 해제되지 않으므로, 취소는
// "큐에서 제거 + 취소 플래그"만으로 안전하게 처리할 수 있다.
void onCancelWaiting(const std::shared_ptr<Session>& session)
{
std::lock_guard<std::mutex> lk(mx_);
session->cancelled = true;
waitQueue_.erase(
std::remove(waitQueue_.begin(), waitQueue_.end(), session),
waitQueue_.end());
}
void onLeave(const std::shared_ptr<Session>& /*session*/)
{
std::lock_guard<std::mutex> lk(mx_);
currentCount_--;
promoteNextLocked();
}
private:
// 호출자가 이미 mx_ 를 잡고 있다고 가정.
void promoteNextLocked()
{
while (currentCount_ < maxConcurrent_ && !waitQueue_.empty())
{
auto next = waitQueue_.front();
waitQueue_.pop_front();
if (next->cancelled)
continue; // 이미 취소된 대상 — shared_ptr 덕분에 역참조는 안전, 값만 건너뜀
next->admitted = true;
currentCount_++;
// 실제로는 여기서 next 에게 입장 허가 통지를 보낸다.
}
}
};
핵심 변경:
currentCount_를 일반int로 바꾸고mx_락 하나로 검사·증가·감소·큐 조작을 모두 같은 임계 구역에 묶어 (A)의 경합을 제거했다.Session을 원시 포인터 대신shared_ptr<Session>로 다뤄, 대기열에 참조가 남아있는 한 상위 레이어가delete해도(참조 카운트가 0이 되지 않는 한) 객체가 해제되지 않는다 — (B)의 use-after-free 가능성 자체를 구조적으로 제거했다.onCancelWaiting이 실제로waitQueue_에서 해당 항목을 제거하고cancelled플래그를 세워, 혹시 제거 타이밍이 어긋나도promoteNextLocked가 안전하게(역참조는 유효하되 값만 무시) 건너뛴다.
더 나은 설계 (+ 트레이드오프)
std::weak_ptr로 대기열 보관: 세션의 실제 소유권은 다른 곳(연결 관리자)에 두고 대기열은weak_ptr만 보관하면, 세션이 다른 이유로 이미 해제됐어도lock()실패로 안전하게 감지할 수 있다. 대신 매 순회마다lock()오버헤드가 붙는다.- 락 대신 단일 스레드 admission 액터: 모든
tryEnter/onLeave/onCancelWaiting호출을 하나의 처리 큐로 직렬화하면 락 경합 없이 순서를 보장할 수 있다. 대신 네트워크 워커에서 액터로의 메시지 전달 지연이 추가된다. - 세마포어 기반:
maxConcurrent_를 카운팅 세마포어로 표현하고 입장 시try_acquire, 이탈 시release를 쓰면 검사-증가를 표준 primitive에 위임할 수 있다. 다만 "대기 중 취소"를 세마포어 해제 없이 정확히 큐에서 제거하는 로직은 여전히 필요하다. - 트레이드오프:
shared_ptr은 참조 카운팅 오버헤드(원자적 증감)가 있고, 순환 참조를 만들지 않도록 소유권 그래프를 신경 써야 한다. 하지만 원시 포인터를 컨테이너에 보관하며 수명 관리를 수동으로 하는 것보다, 이런 admission-control처럼 "포인터가 언제 어디서 해제될지" 여러 스레드가 관여하는 코드에서는 훨씬 안전하다.
면접 포인트
std::atomic은 개별 연산의 원자성만 보장한다 — "읽고 조건부로 갱신"하는 복합 연산은compare_exchange루프나 별도 락이 필요하다.- 컨테이너에 원시 포인터를 보관할 때는 그 포인터가 가리키는 객체의 수명이 컨테이너의 수명(또는 항목이 컨테이너에 머무는 기간)을 포괄하는지 반드시 확인해야 한다 — 그렇지 않으면 "제거를 깜빡한" 코드 경로 하나가 use-after-free로 직결된다.
- Admission control 같은 자원 상한 로직은 정상 흐름 외에 "대기 중 취소", "비정상 종료" 같은 생명주기 이벤트를 자료구조 수준에서 명시적으로 반영해야 한다 — 그렇지 않으면 유령 슬롯(카운터 불일치)과 메모리 안전성 문제가 함께 나타난다.