6. C# 죽은 연결 감지: TCP KeepAlive 설정 vs 앱 레벨 Ping
난이도 하내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — C# 죽은 연결 감지: TCP KeepAlive 설정 vs 앱 레벨 Ping
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
"OS의 TCP KeepAlive 만 켜면 죽은 연결을 알아서 정리해 준다"는 잘못된 가정이 핵심이다.
- KeepAlive 의 기본 주기는 분~시간 단위 — 게임에 필요한 수십 초 감지를 못 한다.
KeepAliveValuesIOControl 의 단위·플랫폼 의존성 — 코드의 의도(10s/5s)와 실제 설정값/동작이 어긋난다.- KeepAlive 는 half-open 의 일부만 잡는다 — NAT 가 프로브를 통과시키거나, 단방향 경로 고장은 못 잡는다. 또 게임은 RTT 측정·상태 점검 등 앱 레벨 정보가 필요하다.
- 정리 트리거가 "다음 수신/송신 시도"에 묶여 있다 — 조용한 연결은 능동 트래픽이 없어 에러를 영영 안 볼 수 있다(좀비 영속).
결론: 앱 레벨 ping/pong(하트비트) 이 1차 방어선이어야 하고, TCP KeepAlive 는 보조다.
문제점
(A)(B) KeepAlive 기본 주기가 너무 길다 (분류: 정확성/운영)
- 증상:
SO_KEEPALIVE만 켜면 OS 기본값(예: Windows 2시간 idle 후 시작, Linuxtcp_keepalive_time7200초)이 적용된다. 운영팀이 원한 "수십 초 내 정리"와 동떨어진다. - 재현조건: 단말이 사라진 뒤 1~2시간 동안 연결이 그대로 살아있음.
- 근본원인: KeepAlive 는 연결 유지/극단적 사망 감지용이지, 게임용 빠른 장애 감지용이 아니다. 그래서 (C)에서 파라미터를 직접 만진 것인데, 거기에 또 함정이 있다.
(C) KeepAliveValues 단위·플랫폼 의존 (분류: 이식성/정확성)
- 증상: Windows 의
tcp_keepalive구조체(IOControlKeepAliveValues)는keepalivetime/keepaliveinterval이 "밀리초" 단위다(여기서는 우연히 맞다). 하지만 재시도 횟수는 이 구조체로 제어할 수 없고 OS 전역값을 따른다 → 실제 "끊김까지 걸리는 총 시간"은time + interval × (전역 probe count)로 결정된다. 의도한 "10초+5초면 곧 끊긴다"가 성립하지 않는다. - 이식성: 이 코드는 Windows 전용이다. Linux/.NET 에서는 IOControl
KeepAliveValues가 동작하지 않거나 의미가 다르다. .NET Core 3.0+ 라면TcpKeepAliveTime/Interval/Retrycount소켓 옵션(초 단위)을 써야 이식성이 있다. - 근본원인: OS/런타임 버전마다 keepalive 설정 API·단위·세부 동작이 제각각.
(B)/(A) half-open 을 KeepAlive 만으로는 못 잡는다 (분류: 정확성)
- 증상: 단말이 갑자기 사라지면 한쪽(서버→클라)으로는 데이터가 안 빠지지만, KeepAlive 프로브가 NAT/방화벽에서 통과(또는 응답 위조) 되거나, 클라 OS 가 살아있어 ACK 만 돌려주면 연결이 "살아있다"고 오판될 수 있다. 또 앱이 멈췄지만 TCP 스택은 살아있는 경우(앱 데드락)도 KeepAlive 로는 못 잡는다.
- 근본원인: TCP KeepAlive 는 TCP 스택 수준 신호다. "앱이 실제로 반응하는가"는 앱 레벨 ping/pong 으로만 알 수 있다.
(D) 정리 트리거가 능동 I/O 에 의존 (분류: 수명관리)
- 증상: 죽음 판정이 "다음 recv/send 에서 에러가 나면" 으로 묶여 있다. 조용한 연결은 보낼 것도 받을 것도 없어 그 시도 자체가 안 일어난다 → 에러를 영영 못 봄. KeepAlive 가 RST 를 유발해도, 읽기를 걸어두지 않으면(pending recv 없음) 에러를 관측할 경로가 없을 수 있다.
- 근본원인: 장애 감지를 수동적(passive)으로 설계. 능동적(active) 하트비트 부재.
- 부수:
OnRecvError → Cleanup이 멱등하지 않다. KeepAlive RST + 앱 타임아웃이 동시에 정리를 부르면 이중 정리/이중 Close 가능.
수정안
핵심: 앱 레벨 하트비트를 1차로, KeepAlive 는 보조로, 정리는 멱등으로
public sealed class LobbyConnection
{
private readonly Socket _socket;
private long _lastInboundTicks = Environment.TickCount64; // 단조 시계
private int _cleaned = 0;
// 앱 레벨 파라미터: 5초마다 ping, 15초 무응답이면 죽음 판정
private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(5);
private static readonly TimeSpan DeadAfter = TimeSpan.FromSeconds(15);
public LobbyConnection(Socket socket)
{
_socket = socket;
EnableKeepAliveBackup(); // 보조 안전망
}
private void EnableKeepAliveBackup()
{
_socket.SetSocketOption(SocketOptionLevel.Socket,
SocketOptionName.KeepAlive, true);
// .NET Core 3.0+ : 이식성 있는 초 단위 API
_socket.SetSocketOption(SocketOptionLevel.Tcp,
SocketOptionName.TcpKeepAliveTime, 30); // 30초 idle 후 시작
_socket.SetSocketOption(SocketOptionLevel.Tcp,
SocketOptionName.TcpKeepAliveInterval, 5); // 5초 간격
_socket.SetSocketOption(SocketOptionLevel.Tcp,
SocketOptionName.TcpKeepAliveRetryCount, 4); // 4회 실패면 끊음
}
// 어떤 인바운드(앱 데이터 또는 pong)든 받으면 갱신
public void OnAnyInbound()
=> Interlocked.Exchange(ref _lastInboundTicks, Environment.TickCount64);
// 별도 타이머 스레드가 모든 연결을 주기적으로 점검 (active heartbeat)
public void HeartbeatTick(long nowTicks)
{
long last = Interlocked.Read(ref _lastInboundTicks);
var idle = TimeSpan.FromMilliseconds(nowTicks - last);
if (idle > DeadAfter) { Cleanup("heartbeat timeout"); return; }
if (idle > PingInterval) SendAppPing(); // 능동 ping (pong 받으면 OnAnyInbound)
}
private void SendAppPing() { /* 앱 레벨 PING 패킷 비동기 송신 */ }
public void OnRecvError(SocketException ex) => Cleanup($"recv error {ex.SocketErrorCode}");
public void Cleanup(string reason)
{
if (Interlocked.Exchange(ref _cleaned, 1) == 1) return; // 멱등
Console.WriteLine($"cleanup: {reason}");
try { _socket.Shutdown(SocketShutdown.Both); } catch { }
_socket.Close();
}
}
포인트
- 앱 레벨 ping/pong 이 주 감지 수단 → 수 초 단위로 죽음을 능동 감지하고, RTT 도 덤으로 얻는다(앱 데드락도 잡힘). KeepAlive 는 "극단적 케이스의 안전망"으로 둔다.
- 정리 트리거가 더 이상 능동 I/O 에 묶이지 않는다 → 조용한 연결도 타이머가 본다.
TcpKeepAlive*소켓 옵션은 초 단위·이식성 있고 retry count 까지 제어 가능.Cleanup멱등화로 KeepAlive RST 와 하트비트 타임아웃의 이중 정리 차단.
더 나은 설계
1) 하트비트 정책을 클라 환경별로
- 모바일은 배터리 때문에 ping 주기를 늘리고 싶지만, 그러면 감지 지연. 절충으로 "adaptive heartbeat" — RTT/네트워크 품질에 따라 ping 주기를 동적 조정.
- 트레이드오프: 구현 복잡도 ↑. 단순함이 더 중요하면 고정 주기 + 넉넉한 timeout.
2) 단일 타이머가 N 세션을 훑는 O(N) 대신 타임휠
- problem1 과 동일 논리. 다음 ping/만료 예정 시각을 타임휠에 넣어 "곧 만료될 것"만 처리. 수십만 동접에서 매초 전수 스캔 회피.
3) KeepAlive 는 "유지" 목적으로만
- KeepAlive 의 진짜 효용은 NAT 매핑 유지(주기적 패킷으로 NAT 타임아웃 회피)와 최후의 좀비 정리. 빠른 장애 감지를 KeepAlive 에 기대하지 말 것.
4) 양방향 검증
- ping 은 서버→클라뿐 아니라 클라→서버도. 단방향 경로 고장(한쪽만 죽은 라우트)을 잡으려면 양쪽이 서로의 생존을 독립적으로 판정해야 한다.
면접 포인트
- "TCP KeepAlive 만으로 게임의 죽은 연결을 정리하면 안 되는 이유는?" → 기본 주기가 분~시간 단위라 너무 느리고, half-open/NAT 통과/앱 데드락을 못 잡으며, 정리 트리거가 능동 I/O 에 묶이면 조용한 연결을 놓친다. 앱 레벨 ping/pong 이 1차 방어선이고 KeepAlive 는 보조.
- "앱 레벨 ping 이 TCP KeepAlive 대비 추가로 주는 것은?" → 앱 프로세스가 실제로 반응하는지(스택만 살아있는 데드락 감지), RTT/지터 측정, 양방향 독립 판정, OS·플랫폼 비의존 일관된 동작.
- "half-open 연결이란? 무엇이 못 잡히나?" → 한쪽은 닫혔는데 다른 쪽은 모르는 상태. 송신이 없으면 RST/FIN 을 받을 일이 없어 오래 좀비로 남는다. 능동 송신(ping)을 해봐야 비로소 끊김을 관측한다.
해설 · C++
해설 — C++ 죽은 연결 감지: TCP KeepAlive 설정 vs 앱 레벨 Ping
난이도: 하
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
"OS의 TCP KeepAlive 만 켜면 죽은 연결을 알아서 정리해 준다"는 잘못된 가정이 핵심이다.
- KeepAlive 의 기본 주기는 분~시간 단위 — 게임에 필요한 수십 초 감지를 못 한다.
TCP_KEEPCNT미설정 + setsockopt 반환값 미확인 — 코드의 의도(10s/5s + 곧 끊김)와 실제 동작이 어긋난다.- KeepAlive 는 half-open 의 일부만 잡는다 — NAT 가 프로브를 통과시키거나, 단방향 경로 고장은 못 잡는다. 또 게임은 RTT 측정·상태 점검 등 앱 레벨 정보가 필요하다.
- 정리 트리거가 "다음 수신/송신 시도"에 묶여 있다 — 조용한 연결은 능동 트래픽이
없어 에러를 영영 안 볼 수 있고,
Cleanup도 멱등하지 않다.
결론: 앱 레벨 ping/pong(하트비트) 이 1차 방어선이어야 하고, TCP KeepAlive 는 보조다.
문제점
(A)(B) KeepAlive 기본 주기가 너무 길다 (분류: 정확성/운영)
- 증상:
SO_KEEPALIVE만 켜면 OS 기본값(Linuxtcp_keepalive_time7200초 = 2시간 idle 후 시작)이 적용된다. 운영팀이 원한 "수십 초 내 정리"와 동떨어진다. - 재현조건: 단말이 사라진 뒤 1~2시간 동안 연결이 그대로 살아있음.
- 근본원인: KeepAlive 는 연결 유지/극단적 사망 감지용이지, 게임용 빠른 장애 감지용이 아니다. 그래서 (C)에서 파라미터를 직접 만진 것인데, 거기에 또 함정이 있다.
(C) TCP_KEEPCNT 미설정 + setsockopt 반환값 미확인 (분류: 정확성/이식성)
- 증상:
TCP_KEEPIDLE/TCP_KEEPINTVL만 설정하고 프로브 횟수(TCP_KEEPCNT)는 설정 안 함 → OS 전역값(Linux 기본 9회)을 따른다. 실제 "끊김까지 총 시간"은idle + intvl × keepcnt = 10 + 5×9 = 55초처럼 전역값에 좌우되어 의도와 어긋난다. 또 모든setsockopt반환값을 확인하지 않아 설정이 조용히 실패해도 모른다. - 이식성:
TCP_KEEPIDLE/TCP_KEEPINTVL/TCP_KEEPCNT는 Linux 전용이다. macOS 는TCP_KEEPALIVE(단일, idle 초만), Windows 는WSAIoctl(SIO_KEEPALIVE_VALS)로 다르다. 헤더가 있어도 플랫폼마다 단위·의미가 다르다. - 근본원인: OS 마다 keepalive 설정 API·단위·세부 동작이 제각각이고, 횟수까지 명시하지 않으면 총 감지 시간이 비결정적.
(B)/(A) half-open 을 KeepAlive 만으로는 못 잡는다 (분류: 정확성)
- 증상: 단말이 갑자기 사라지면 서버→클라 방향으로 데이터가 안 빠지지만, KeepAlive 프로브가 NAT/방화벽에서 통과(또는 응답 위조) 되거나 클라 OS 가 살아있어 ACK 만 돌려주면 "살아있다"고 오판될 수 있다. 또 앱이 멈췄지만 TCP 스택은 살아있는 경우 (앱 데드락)도 KeepAlive 로는 못 잡는다.
- 근본원인: TCP KeepAlive 는 TCP 스택 수준 신호다. "앱이 실제로 반응하는가"는 앱 레벨 ping/pong 으로만 알 수 있다.
(D) 정리 트리거가 능동 I/O 에 의존 + Cleanup 비멱등 (분류: 수명관리)
- 증상: 죽음 판정이 "다음 recv/send 에서 에러가 나면" 으로 묶여 있다. 조용한
연결은 보낼 것도 받을 것도 없어 그 시도 자체가 안 일어난다 → 에러를 영영 못 봄.
KeepAlive 가 RST 를 유발해도 읽기를 걸어두지 않으면 에러를 관측할 경로가 없다.
또
Cleanup이 멱등하지 않아 KeepAlive RST 와 앱 타임아웃이 동시에 정리를 부르면close(fd_)가 두 번 → 이미 닫힌(혹은 재사용된) fd 를 닫는 double-close 위험. - 근본원인: 장애 감지를 수동적(passive)으로 설계. 능동적(active) 하트비트 부재 + 종료의 멱등성 부재.
수정안
핵심: 앱 레벨 하트비트를 1차로, KeepAlive 는 보조로(횟수까지 명시), 정리는 멱등으로
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <unistd.h>
#include <atomic>
#include <chrono>
class LobbyConnection {
int fd_;
std::atomic<long long> lastInboundMs_; // 단조 시계 기준 마지막 인바운드
std::atomic<bool> cleaned_{false};
// 앱 레벨 파라미터: 5초마다 ping, 15초 무응답이면 죽음 판정
static constexpr long long kPingIntervalMs = 5000;
static constexpr long long kDeadAfterMs = 15000;
public:
explicit LobbyConnection(int fd) : fd_(fd), lastInboundMs_(NowMs()) {
EnableKeepAliveBackup(); // 보조 안전망
}
void EnableKeepAliveBackup() {
int on = 1;
if (setsockopt(fd_, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on)) < 0) return;
#if defined(TCP_KEEPIDLE)
int idle = 30, intvl = 5, cnt = 4; // idle 30s, 간격 5s, 4회 실패면 끊음
setsockopt(fd_, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
setsockopt(fd_, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl));
setsockopt(fd_, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt)); // 횟수 명시!
#endif
// (선택) TCP_USER_TIMEOUT 으로 ACK 미수신 시 강제 끊김 시간 명시
#if defined(TCP_USER_TIMEOUT)
unsigned int uto = 20000; // 20s
setsockopt(fd_, IPPROTO_TCP, TCP_USER_TIMEOUT, &uto, sizeof(uto));
#endif
}
// 어떤 인바운드(앱 데이터 또는 pong)든 받으면 갱신
void OnAnyInbound() { lastInboundMs_.store(NowMs()); }
// 별도 타이머 스레드가 모든 연결을 주기적으로 점검 (active heartbeat)
void HeartbeatTick() {
long long now = NowMs();
long long idle = now - lastInboundMs_.load();
if (idle > kDeadAfterMs) { Cleanup(); return; }
if (idle > kPingIntervalMs) SendAppPing(); // 능동 ping (pong → OnAnyInbound)
}
void OnRecvError(int /*err*/) { Cleanup(); }
void Cleanup() {
bool expected = false;
if (!cleaned_.compare_exchange_strong(expected, true)) return; // 멱등
// 세션 정리, 매칭 큐 제거 ...
::shutdown(fd_, SHUT_RDWR);
::close(fd_);
fd_ = -1;
}
static long long NowMs() {
using namespace std::chrono;
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}
void SendAppPing() { /* 앱 레벨 PING 패킷 비동기 송신 */ }
};
포인트
- 앱 레벨 ping/pong 이 주 감지 수단 → 수 초 단위로 죽음을 능동 감지하고, RTT 도 덤으로 얻는다(앱 데드락도 잡힘). KeepAlive 는 "극단적 케이스의 안전망"으로 둔다.
- 정리 트리거가 더 이상 능동 I/O 에 묶이지 않는다 → 조용한 연결도 타이머가 본다.
- KeepAlive 는
TCP_KEEPCNT까지 명시해 총 감지 시간을 결정적으로.TCP_USER_TIMEOUT으로 송신 버퍼가 막힌 half-open 까지 보조 감지. 모든 setsockopt 반환값 확인. Cleanup을compare_exchange로 멱등화해 KeepAlive RST 와 하트비트 타임아웃의 double-close 를 차단.
더 나은 설계
1) 하트비트 정책을 클라 환경별로
- 모바일은 배터리 때문에 ping 주기를 늘리고 싶지만 그러면 감지 지연. 절충으로 "adaptive heartbeat" — RTT/네트워크 품질에 따라 ping 주기를 동적 조정.
- 트레이드오프: 구현 복잡도 ↑. 단순함이 더 중요하면 고정 주기 + 넉넉한 timeout.
2) 단일 타이머가 N 세션을 훑는 O(N) 대신 타임휠
- problem1 과 동일 논리. 다음 ping/만료 예정 시각을 타임휠에 넣어 "곧 만료될 것"만 처리. 수십만 동접에서 매초 전수 스캔 회피.
3) KeepAlive 는 "유지" 목적으로만
- KeepAlive 의 진짜 효용은 NAT 매핑 유지(주기 패킷으로 NAT 타임아웃 회피)와 최후의 좀비 정리. 빠른 장애 감지를 KeepAlive 에 기대하지 말 것.
4) 양방향 검증
- ping 은 서버→클라뿐 아니라 클라→서버도. 단방향 경로 고장(한쪽만 죽은 라우트)을 잡으려면 양쪽이 서로의 생존을 독립적으로 판정해야 한다(problem9 참고).
면접 포인트
- "TCP KeepAlive 만으로 게임의 죽은 연결을 정리하면 안 되는 이유는?" → 기본 주기가 분~시간 단위라 너무 느리고, half-open/NAT 통과/앱 데드락을 못 잡으며, 정리 트리거가 능동 I/O 에 묶이면 조용한 연결을 놓친다. 앱 레벨 ping/pong 이 1차 방어선이고 KeepAlive 는 보조.
- "
TCP_KEEPIDLE/INTVL만 설정하면 총 감지 시간이 왜 비결정적인가?" → 프로브 횟수TCP_KEEPCNT를 설정 안 하면 OS 전역값을 따라 총 시간이 달라진다. 횟수까지 명시해야idle + intvl×cnt로 결정적. 또 이 옵션들은 Linux 전용이라 이식성 주의(macOS/Windows 다름). - "half-open 연결이란? 무엇이 못 잡히나?"
→ 한쪽은 닫혔는데 다른 쪽은 모르는 상태. 송신이 없으면 RST/FIN 을 받을 일이 없어
오래 좀비로 남는다. 능동 송신(ping)을 해봐야 비로소 끊김을 관측한다.
TCP_USER_TIMEOUT이 보조가 된다.