← 문제로

1. C# Heartbeat / 유휴 타임아웃 세션 정리

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

해설 — C# Heartbeat / 유휴 타임아웃 세션 정리

난이도: 하

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

요약

타이머 스레드와 수신 스레드가 공유 Dictionary와 세션 필드를 락 없이 동시에 만진다. (1) 컬렉션 동시성 위반, (2) 순회 중 제거로 인한 예외, (3) DateTime.Now로 인한 시계 역행 오인 끊김, (4) Close와 정리의 비원자성 이 핵심이다. 결과적으로 살아있는 유저가 오인 끊김되거나 서버가 InvalidOperationException으로 스윕을 멈춘다.


문제점

(D)+(B) 컬렉션 동시성 위반 + 순회 중 수정 (분류: 동시성/정확성)

  • 증상: 타이머 스레드가 SweepDeadSessions에서 foreach_sessions를 순회하는데, 같은 순간 수신 스레드가 OnPingReceivedTryGetValue로 읽거나 AddSession이 새 항목을 넣는다. Dictionary<TKey,TValue>는 스레드 안전하지 않다.
  • 재현조건: 동접이 늘어 스윕 중 AddSession/TryGetValue가 겹칠 때 간헐적으로 InvalidOperationException(구조 변경) 또는 손상된 해시버킷 읽기.
  • 근본원인: 공유 가변 컬렉션을 무동기화로 다중 스레드가 접근. 게다가 (D)에서 foreach 도중 _sessions.Remove를 호출 → 단일 스레드에서도 즉시 "Collection was modified" 예외. 한 번 터지면 스윕 루프가 죽어 이후 모든 좀비 세션이 영원히 정리되지 않는다.

(A) LastPing 필드의 비동기화 + tearing (분류: 동시성)

  • 증상: 수신 스레드가 s.LastPing = DateTime.Now로 쓰고 타이머 스레드가 now - s.LastPing으로 읽는다. DateTime은 8바이트 값 타입(내부 ulong)이라 32비트 환경/특정 JIT에서 워드 단위 tearing이 가능하고, 메모리 가시성 보장이 없어 타이머 스레드가 갱신을 한참 못 볼 수 있다.
  • 재현조건: 갱신 직후에도 타이머가 옛 값을 읽어 정상 유저를 만료로 오판.
  • 근본원인: 가시성/원자성 미보장. (실무에서 더 자주 무는 건 tearing보다 가시성.)

(A) DateTime.Now 사용 — 시계 역행 / 시간대 이슈 (분류: 정확성)

  • 증상: NTP 동기화, 서머타임, 수동 시계 변경으로 DateTime.Now가 거꾸로 가면 now - s.LastPing이 음수가 되어 멀쩡한 세션을 영원히 안 끊거나, 반대로 점프 시 대량 오인 끊김.
  • 근본원인: 경과 시간 측정에 벽시계(wall clock)를 사용. 경과 시간은 반드시 단조 시계(monotonic clock)로 재야 한다.

(C) Close와 정리의 비원자성 / 이중 정리 (분류: 수명관리)

  • 증상: 스윕이 CloseRemove하는 사이, 같은 세션에 대해 다른 경로(소켓 에러 콜백 등)에서도 Close가 불릴 수 있다. Connected 플래그 검사 없이 두 번 닫혀 중복 자원 해제·로그 노이즈.
  • 근본원인: 종료가 멱등(idempotent)하지 않고, 상태 전이가 원자적이지 않음.

수정안

핵심: 단조 시계 + 스레드 안전 컬렉션 + 스냅샷 순회 + 멱등 Close

using System.Collections.Concurrent;
using System.Diagnostics;

public class Session
{
    public int Id;
    private int _closed = 0;   // 0 = open, 1 = closed (멱등 보장)

    // 단조 시계 기반 타임스탬프(ms). long 단일 워드 → Interlocked로 원자적 갱신.
    private long _lastPingTicks = Stopwatch.GetTimestamp();

    public void Touch() =>
        Interlocked.Exchange(ref _lastPingTicks, Stopwatch.GetTimestamp());

    public TimeSpan IdleFor()
    {
        long last = Interlocked.Read(ref _lastPingTicks);
        long elapsed = Stopwatch.GetTimestamp() - last;
        return TimeSpan.FromSeconds((double)elapsed / Stopwatch.Frequency);
    }

    // 여러 스레드가 동시에 불러도 실제 종료는 한 번만 수행
    public void Close(string reason)
    {
        if (Interlocked.Exchange(ref _closed, 1) == 1) return;  // 멱등
        // 소켓 닫기 등 ...
        Console.WriteLine($"[Session {Id}] closed: {reason}");
    }
}

public class HeartbeatManager
{
    private readonly ConcurrentDictionary<int, Session> _sessions = new();
    private readonly TimeSpan _idleTimeout = TimeSpan.FromSeconds(30);
    private Timer _timer;

    public void Start() => _timer = new Timer(_ => SweepDeadSessions(), null, 1000, 1000);

    public void AddSession(Session s) => _sessions[s.Id] = s;

    public void OnPingReceived(int sessionId)
    {
        if (_sessions.TryGetValue(sessionId, out var s))
            s.Touch();
    }

    private void SweepDeadSessions()
    {
        // ConcurrentDictionary 순회는 안전하지만, 명확성을 위해 만료 대상만 모은다.
        foreach (var kv in _sessions)   // 스냅샷성 열거(스윕 중 추가/삭제 안전)
        {
            if (kv.Value.IdleFor() > _idleTimeout)
            {
                if (_sessions.TryRemove(kv.Key, out var s))   // 원자적 제거
                    s.Close("idle timeout");
            }
        }
    }
}

포인트

  • Stopwatch.GetTimestamp()단조 시계 → 시계 역행/서머타임 영향 없음.
  • ConcurrentDictionary 열거자는 순회 중 수정에도 예외를 던지지 않는다(약한 일관성).
  • TryRemove가 성공한 스레드만 Close를 부르고, Close도 멱등이라 이중 종료 차단.
  • Interlocked.Exchange/Readlong 갱신·읽기의 원자성/가시성 확보.

더 나은 설계

1) "한 번에 전체 스윕" 대신 타임휠(Timing Wheel) / 우선순위 큐

  • O(N) 전수 스캔은 동접 수십만이면 매초 부담. 만료 예정 시각을 타임휠이나 최소 힙에 넣고 "곧 만료될 것"만 본다. 갱신 시 휠 슬롯을 재배치.
  • 트레이드오프: 자료구조 복잡도 증가, Touch마다 재삽입 비용 → 갱신이 잦으면 "재삽입 대신 만료 시점에 LastPing 재확인 후 재투입(lazy)" 기법으로 완화.

2) 세션 상태머신으로 모델링

  • Connecting → Active → Closing → Closed. 스윕은 Active만 검사하고 Closing/Closed로의 전이는 Interlocked.CompareExchange로 단 한 스레드만 성공.
  • 오인 끊김 방지: 끊기 전에 "마지막 경고 Ping" 1회를 보내고 grace window를 두는 2단계 판정(soft → hard timeout)으로 일시적 네트워크 흔들림을 흡수.

3) 잡 큐(단일 스레드) 모델

  • 세션 상태 변경을 전부 한 스레드의 잡 큐로 직렬화하면 락 자체가 사라진다.
  • 트레이드오프: 그 스레드가 병목이 될 수 있어 샤딩(세션 id 해시로 N개 큐) 필요.

면접 포인트

  1. "경과 시간 측정에 DateTime.Now를 쓰면 안 되는 이유는?" → 벽시계는 NTP/서머타임으로 역행·점프 가능. 단조 시계(Stopwatch, Environment.TickCount64, C++ steady_clock)를 써야 한다.
  2. "foreachRemove가 왜 위험한가? ConcurrentDictionary면 괜찮은가?" → 일반 Dictionary는 즉시 예외. ConcurrentDictionary는 약한 일관성 열거라 안전하지만, "본 항목이 최신이라는 보장"은 없다(보고 나서 갱신될 수 있음) → TryRemove 후 한 번 더 확인하거나 만료 판정을 제거 직전에 재검사.
  3. "정상 유저를 오인 끊김 시키지 않으려면?" → soft/hard 2단계 타임아웃 + grace window, 그리고 끊기 직전 마지막 keepalive 왕복으로 확인. timeout 값은 클라 ping 주기의 최소 2~3배로 여유.