← 문제로

3. C# 중복 접속 / 동시 로그인 처리 (기존 세션 킥)

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

해설 — C# 중복 접속 / 동시 로그인 처리 (기존 세션 킥)

난이도: 상

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

요약

"기존 세션 조회 → (락 밖) 킥 → 새 세션 등록"이 **여러 락 구간으로 쪼개진 비원자 연산(check-then-act)**이다. 같은 계정의 로그인 두 개가 동시에 들어오면 둘 다 서로의 등록을 보지 못해 둘 다 살아남거나(중복 세션), 등록 순서가 엇갈려 방금 들어온 세션이 옛 세션에 의해 킥당하는(둘 다 죽거나 잘못된 세션이 죽는) 고전적 TOCTOU 버그가 발생한다. OnSessionClosed도 새 세션을 잘못 지운다.


문제점

(A)+(C) 비원자 check-then-act (TOCTOU) → 중복 세션 (분류: 동시성/정확성)

  • 증상: 락을 조회할 때 한 번, 등록할 때 또 한 번 따로 잡는다. 그 사이는 락이 풀려 있다. 같은 계정 로그인 T1, T2가 거의 동시에 들어오면:
    1. T1: 조회 → old=null
    2. T2: 조회 → old=null (아직 T1이 등록 안 함)
    3. T1: _online[acc]=S1
    4. T2: _online[acc]=S2 ← S1을 킥하지 않고 덮어씀 결과: S1은 맵에서 사라졌지만 Disconnect되지 않아 살아있는 유령, S2도 활성. 계정당 1세션 불변식 깨짐.
  • 재현조건: 멀티 디바이스 동시 로그인, 재접속 폭주, 따닥 클릭.
  • 근본원인: "조회+킥+등록"이 하나의 임계 구역이어야 하는데 분할됨. 게다가 (B)의 await 때문에 그 사이가 더 벌어진다.

(B) await를 사이에 둔 상태 변경 + 락 밖 킥 (분류: 동시성)

  • 증상: 인증(await AuthenticateAsync)이 수십 ms 걸리고, 킥 통지도 await. 이 await 구간 동안 다른 로그인이 끼어들어 맵 상태가 바뀐다. 또 "old를 읽은 시점"과 "old를 등록 덮어쓰는 시점"이 await 하나만큼 더 벌어져 race 창이 커진다.
  • 근본원인: 원자적이어야 할 트랜잭션 사이에 비동기 경계가 들어감.

(C) LoginAsync가 등록 전 새 세션을 누구도 모름 (분류: 정확성)

  • 증상: 등록 직전에 들어온 또 다른 로그인은 이 새 세션을 킥 대상으로 인지 못 함. "예약(claim)" 단계가 없어 등록 순간까지 충돌을 막을 방법이 없다.

(D) OnSessionClosed가 무조건 Remove → 새 세션 오삭제 (분류: 정확성/수명관리)

  • 증상: 옛 세션 S1이 킥당한 뒤 뒤늦게 자신의 종료 콜백 OnSessionClosed(S1)을 부르는데, 맵에는 이미 새 세션 S2가 들어있다. Remove(s.AccountId)키만 보고 지우므로 S2를 날려버린다. 결과적으로 멀쩡한 S2가 온라인 목록에서 사라져 "유령"이 되거나 후속 로그인이 오작동.
  • 근본원인: 제거 시 "지우려는 세션이 정말 현재 등록된 그 세션인지" 확인 안 함 (identity check 부재). 스테일 콜백(stale callback) 문제.

수정안

핵심: "예약→정착(claim & settle)" 원자 교체, await를 임계 구역 밖으로, 조건부 제거

using System.Collections.Concurrent;

public class Session
{
    public long AccountId;
    public string SessionKey = Guid.NewGuid().ToString();
    private int _alive = 1;
    public bool Alive => Volatile.Read(ref _alive) == 1;

    public Task SendKickNoticeAsync() => Task.CompletedTask;

    public void Disconnect()
    {
        if (Interlocked.Exchange(ref _alive, 0) == 0) return; // 멱등
        // 소켓 종료 등 ...
    }
}

public class LoginManager
{
    private readonly ConcurrentDictionary<long, Session> _online = new();

    public async Task<Session> LoginAsync(long accountId)
    {
        await AuthenticateAsync(accountId);          // 비동기는 임계 구역 밖에서
        var newSession = new Session { AccountId = accountId };

        // 원자 교체: 어떤 값이 있든 newSession 으로 한 방에 바꾼다.
        // AddOrUpdate 의 델리게이트는 해당 키에 대해 직렬화되어 실행된다.
        Session displaced = null;
        _online.AddOrUpdate(
            accountId,
            addValueFactory: _ => newSession,
            updateValueFactory: (_, existing) => { displaced = existing; return newSession; });

        // 교체로 밀려난 옛 세션만 킥(락/임계 구역 밖에서 await)
        if (displaced != null && !ReferenceEquals(displaced, newSession))
        {
            displaced.Disconnect();                  // 상태부터 죽이고
            await displaced.SendKickNoticeAsync();   // 통지는 best-effort
        }
        return newSession;
    }

    public void OnSessionClosed(Session s)
    {
        // identity check: 현재 등록된 게 정확히 이 세션일 때만 제거.
        // ConcurrentDictionary 의 (key,value) 조건부 제거 오버로드 사용.
        ((ICollection<KeyValuePair<long, Session>>)_online)
            .Remove(new KeyValuePair<long, Session>(s.AccountId, s));
        // → 맵에 들어있는 값이 s 와 동일 참조일 때만 삭제. S2 를 잘못 지우지 않음.
    }

    private Task AuthenticateAsync(long accountId) => Task.Delay(20);
}

설명

  • AddOrUpdate의 update 델리게이트는 같은 키에 대해 원자적으로 실행되므로 "기존 값 확인 + 교체"가 하나의 연산이 된다. 동시에 두 로그인이 와도 한 쪽이 먼저 newSession을 넣고, 다른 쪽은 그것을 existing으로 받아 다시 교체 → 항상 마지막 하나만 남고 밀려난 세션은 정확히 displaced로 회수되어 킥된다. (계정당 정확히 1세션 불변식 유지, "둘 다 살아남음" 차단.)
  • 킥의 await는 임계 구역 밖으로 빼서 등록 원자성과 분리.
  • **조건부 제거(identity check)**로 스테일 콜백이 새 세션을 지우는 (D) 문제 차단.
  • Disconnect멱등이라 두 경로에서 불려도 안전.

주의: AddOrUpdate의 델리게이트는 락이 걸린 채 실행될 수 있으니 그 안에서 await/네트워크 호출을 하면 안 된다(여기선 참조 대입만 하므로 안전).


더 나은 설계

1) 계정 단위 직렬화(per-account lock / login gate)

  • 계정 id를 해시해 N개의 락(또는 SemaphoreSlim) 슬롯으로 나누고, 같은 계정의 로그인은 같은 슬롯에서 직렬 처리. "예약→인증→정착" 전체를 한 트랜잭션으로 묶음.
  • 트레이드오프: 전역 락보다 경합은 낮지만, 같은 계정 따닥 로그인은 어차피 직렬. 분산 서버라면 단일 노드 락으로는 부족(아래 2번).

2) 분산 환경: 중앙 권위(authoritative) 토큰

  • 여러 게임 노드가 있으면 로그인 권한을 Redis 등 중앙 저장소의 원자 연산 (SET key val NX / Lua 스크립트 CAS)으로 발급. 새 로그인이 토큰 세대를 올리고, 옛 노드는 자신의 토큰이 더 이상 최신이 아님을 보고 자진 킥(fencing token).
  • 트레이드오프: 네트워크 왕복 비용, Redis 가용성 의존. 하지만 멀티 노드에서 "둘 다 살아남음"을 막는 사실상 표준.

3) 세션 상태머신 + 세대 카운터

  • Authenticating → Active → Kicked/Closed. 각 세션에 단조 증가 generation을 부여하고, 맵에는 "최신 generation"만 유효. 늦게 도착한 콜백/패킷은 generation 비교로 무시(스테일 처리 일반화).

면접 포인트

  1. "check-then-act가 왜 위험하고, 동시 로그인에서 어떤 형태로 나타나나?" → 조회와 등록 사이 락이 풀려 다른 스레드가 끼어듦(TOCTOU). 동시 로그인이면 둘 다 old=null을 보고 서로를 킥하지 못해 중복 세션이 남는다. 해법은 조회+교체를 단일 원자 연산(AddOrUpdate/CAS/per-key lock)으로 묶는 것.
  2. "await 중에 잡고 있던 상태가 바뀌는 문제를 어떻게 다루나?" → await는 임계 구역 밖으로 빼고, await 전후로 상태를 재검증하거나 세대 토큰으로 stale 여부를 판정. lock 안에서 await는 (그리고 lock은 await를 못 넘김) 금지.
  3. "단일 서버에선 막아도 멀티 노드면 같은 계정이 두 노드에 붙을 수 있다. 어떻게?" → 중앙 저장소의 원자 CAS로 권한 토큰(fencing token)을 발급하고, 토큰 세대가 뒤처진 세션은 자진 종료. 로컬 락만으로는 분산 중복 접속을 못 막는다.