1. C# Heartbeat / 유휴 타임아웃 세션 정리
난이도 하내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — C# Heartbeat / 유휴 타임아웃 세션 정리
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
타이머 스레드와 수신 스레드가 공유 Dictionary와 세션 필드를 락 없이 동시에 만진다.
(1) 컬렉션 동시성 위반, (2) 순회 중 제거로 인한 예외, (3) DateTime.Now로 인한
시계 역행 오인 끊김, (4) Close와 정리의 비원자성 이 핵심이다. 결과적으로 살아있는
유저가 오인 끊김되거나 서버가 InvalidOperationException으로 스윕을 멈춘다.
문제점
(D)+(B) 컬렉션 동시성 위반 + 순회 중 수정 (분류: 동시성/정확성)
- 증상: 타이머 스레드가
SweepDeadSessions에서foreach로_sessions를 순회하는데, 같은 순간 수신 스레드가OnPingReceived의TryGetValue로 읽거나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와 정리의 비원자성 / 이중 정리 (분류: 수명관리)
- 증상: 스윕이
Close후Remove하는 사이, 같은 세션에 대해 다른 경로(소켓 에러 콜백 등)에서도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/Read로long갱신·읽기의 원자성/가시성 확보.
더 나은 설계
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개 큐) 필요.
면접 포인트
- "경과 시간 측정에
DateTime.Now를 쓰면 안 되는 이유는?" → 벽시계는 NTP/서머타임으로 역행·점프 가능. 단조 시계(Stopwatch,Environment.TickCount64, C++steady_clock)를 써야 한다. - "
foreach중Remove가 왜 위험한가?ConcurrentDictionary면 괜찮은가?" → 일반Dictionary는 즉시 예외.ConcurrentDictionary는 약한 일관성 열거라 안전하지만, "본 항목이 최신이라는 보장"은 없다(보고 나서 갱신될 수 있음) →TryRemove후 한 번 더 확인하거나 만료 판정을 제거 직전에 재검사. - "정상 유저를 오인 끊김 시키지 않으려면?" → soft/hard 2단계 타임아웃 + grace window, 그리고 끊기 직전 마지막 keepalive 왕복으로 확인. timeout 값은 클라 ping 주기의 최소 2~3배로 여유.
해설 · C++
해설 — C++ Heartbeat / 유휴 타임아웃 세션 정리
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
타이머 스레드와 수신 스레드가 공유 unordered_map과 세션 필드를 동기화 없이 동시에
만진다. **(1) 컨테이너 동시성 위반(data race), (2) 순회 중 erase로 인한 반복자 무효화,
(3) system_clock(벽시계)로 인한 시계 역행 오인 끊김, (4) delete와 정리의 비원자성
- dangling 포인터** 가 핵심이다. 결과적으로 살아있는 유저가 오인 끊김되거나 서버가 반복자 무효화/UAF로 크래시한다.
문제점
(D)+(B) 컨테이너 동시성 위반 + 순회 중 erase (분류: 동시성/정확성)
- 증상: 타이머 스레드가
SweepDeadSessions에서unordered_map을 순회하는데, 같은 순간 수신 스레드가OnPingReceived의find로 읽거나AddSession이 새 항목을 넣는다.std::unordered_map은 스레드 안전하지 않다(동시 읽기/쓰기 = data race = 미정의 동작). - 재현조건: 동접이 늘어 스윕 중
AddSession/find가 겹칠 때 간헐적으로 해시버킷 손상·세그폴트.AddSession의 rehash가 순회 중 일어나면 모든 반복자/포인터 무효화. - 근본원인: 공유 가변 컨테이너를 무동기화 다중 스레드 접근. 게다가 (D)에서
it = sessions_.erase(it)로 받지 않고 그냥erase(it)후++it→ 무효화된 반복자를 증가시켜 미정의 동작. 한 번 터지면 스윕 스레드가 죽어 이후 모든 좀비 세션이 영원히 정리되지 않는다.
(A) lastPing 필드의 비동기화 data race (분류: 동시성)
- 증상: 수신 스레드가
lastPing = now로 쓰고 타이머 스레드가now - s->lastPing으로 읽는다.time_point(내부 정수)에 대한 비원자 동시 read/write는 C++ 메모리 모델상 data race → 미정의 동작. 메모리 가시성 보장도 없어 타이머가 갱신을 한참 못 볼 수 있다. - 재현조건: 갱신 직후에도 타이머가 옛 값을 읽어 정상 유저를 만료로 오판.
- 근본원인: 가시성/원자성 미보장. (실무에서 더 자주 무는 건 tearing보다 가시성.)
(A) system_clock 사용 — 시계 역행 / 시간대 이슈 (분류: 정확성)
- 증상:
system_clock은 벽시계(wall clock)다. NTP 동기화, 서머타임, 수동 시계 변경으로 시각이 거꾸로 가면now - s->lastPing이 음수가 되어 멀쩡한 세션을 영원히 안 끊거나, 반대로 점프 시 대량 오인 끊김. - 근본원인: 경과 시간 측정에 벽시계를 사용. 경과 시간은 반드시 단조 시계
(
std::chrono::steady_clock)로 재야 한다.
(C)+(E) Close/delete의 비원자성 / 이중 정리·dangling (분류: 수명관리)
- 증상: 스윕이
Close후delete하는 사이, 같은 세션에 대해 다른 경로(소켓 에러 콜백 등)에서도Close/접근이 일어날 수 있다.connected플래그 검사 없이 두 번 닫히거나, 이미delete된 세션을 수신 스레드가find로 받아lastPing을 쓰면 use-after-free. 맵에서 빼기 전에delete하면 dangling 포인터가 맵에 남는다. - 근본원인: 종료가 멱등(idempotent)하지 않고, 객체 수명이 "마지막 참조자" 규칙
없이 raw 포인터 +
delete this성 정리로 처리됨.
수정안
핵심: 단조 시계 + 락(또는 동시성 자료구조) + 안전한 erase + shared_ptr 수명 + 멱등 Close
#include <unordered_map>
#include <chrono>
#include <mutex>
#include <memory>
#include <thread>
#include <atomic>
#include <vector>
class Session {
public:
int id;
std::atomic<bool> closed{false}; // 멱등 종료용
// 단조 시계 기반 타임스탬프. atomic 으로 가시성/원자성 확보.
std::atomic<long long> lastPingNs{NowNs()};
void Touch() { lastPingNs.store(NowNs(), std::memory_order_relaxed); }
std::chrono::nanoseconds IdleFor() const {
return std::chrono::nanoseconds(NowNs() - lastPingNs.load(std::memory_order_relaxed));
}
// 여러 경로에서 불려도 실제 종료는 한 번만
void Close(const char* reason) {
bool expected = false;
if (!closed.compare_exchange_strong(expected, true)) return; // 멱등
// 소켓 닫기 등 ...
}
static long long NowNs() {
using namespace std::chrono;
return duration_cast<nanoseconds>(steady_clock::now().time_since_epoch()).count();
}
};
class HeartbeatManager {
std::unordered_map<int, std::shared_ptr<Session>> sessions_;
mutable std::mutex mtx_; // 맵 보호
std::chrono::seconds idleTimeout_{30};
std::thread timer_;
std::atomic<bool> running_{true};
public:
void Start() {
timer_ = std::thread([this] {
while (running_.load()) {
std::this_thread::sleep_for(std::chrono::seconds(1));
SweepDeadSessions();
}
});
}
void AddSession(const std::shared_ptr<Session>& s) {
std::lock_guard<std::mutex> lk(mtx_);
sessions_[s->id] = s;
}
void OnPingReceived(int sessionId) {
std::shared_ptr<Session> s;
{
std::lock_guard<std::mutex> lk(mtx_);
auto it = sessions_.find(sessionId);
if (it == sessions_.end()) return;
s = it->second; // 수명 고정(맵에서 빠져도 안전)
}
s->Touch();
}
void SweepDeadSessions() {
std::vector<std::shared_ptr<Session>> expired;
{
std::lock_guard<std::mutex> lk(mtx_);
for (auto it = sessions_.begin(); it != sessions_.end(); ) {
if (it->second->IdleFor() > idleTimeout_) {
expired.push_back(it->second);
it = sessions_.erase(it); // erase 가 다음 유효 반복자 반환
} else {
++it;
}
}
}
// 락 밖에서 Close(미지의 코드/소켓 호출을 락 밖으로)
for (auto& s : expired) s->Close("idle timeout");
// expired 가 스코프를 벗어나면 마지막 ref 해제 → 자동 소멸
}
};
포인트
steady_clock단조 시계 → 시계 역행/서머타임 영향 없음.mutex로 맵 보호 + 반복자를it = erase(it)로 받아 순회 중 안전 삭제. (동접이 크면mutex대신 락 샤딩이나 lock-free 구조로 확장.)shared_ptr수명 관리:find로 꺼낼 때 ref를 잡으므로, 스윕이 맵에서 빼고 소멸시켜도 수신 스레드는 dangling을 안 본다.delete this/raw delete 제거.Close를compare_exchange로 멱등화 → 이중 종료 차단.lastPing을atomic으로 → data race 제거, 가시성 확보.
더 나은 설계
1) "한 번에 전체 스윕" 대신 타임휠(Timing Wheel) / 우선순위 큐
- O(N) 전수 스캔은 동접 수십만이면 매초 부담. 만료 예정 시각을 타임휠이나 최소 힙에 넣고 "곧 만료될 것"만 본다. Touch 시 휠 슬롯을 재배치.
- 트레이드오프: 자료구조 복잡도 증가, Touch마다 재삽입 비용 → 갱신이 잦으면 "재삽입 대신 만료 시점에 lastPing 재확인 후 재투입(lazy)" 기법으로 완화.
2) 세션 상태머신으로 모델링
Connecting → Active → Closing → Closed. 스윕은Active만 검사하고Closing/Closed로의 전이는compare_exchange로 단 한 스레드만 성공.- 오인 끊김 방지: 끊기 전에 "마지막 경고 Ping" 1회를 보내고 grace window를 두는 2단계 판정(soft → hard timeout)으로 일시적 네트워크 흔들림을 흡수.
3) 잡 큐(단일 스레드) 모델 / per-connection 스트랜드
- 세션 상태 변경을 한 스레드의 잡 큐로 직렬화하면 락 자체가 사라진다.
- 트레이드오프: 그 스레드가 병목이 될 수 있어 샤딩(세션 id 해시로 N개 큐) 필요.
면접 포인트
- "경과 시간 측정에
system_clock을 쓰면 안 되는 이유는?" → 벽시계는 NTP/서머타임으로 역행·점프 가능. 단조 시계(steady_clock, C#Stopwatch/Environment.TickCount64)를 써야 한다. - "순회 중
erase가 왜 위험한가? 어떻게 안전하게 지우나?" →erase(it)는 그 반복자를 무효화하므로 이후++it는 미정의 동작.it = container.erase(it)로 다음 유효 반복자를 받아야 한다. 그리고 다중 스레드면 컨테이너 자체를 락/동시성 구조로 보호해야 data race가 없다. - "raw 포인터 + delete 대신 무엇을 쓰나? 왜?"
→
shared_ptr로 "마지막 참조자가 해제"하게 하면 수신 스레드가 들고 있는 동안 스윕이 맵에서 빼도 UAF가 안 난다. 종료는compare_exchange로 멱등화해 double-free /double-close를 막는다.