7. C# accept 루프와 연결 폭주(connection storm) 방어
난이도 중내 리뷰 · C#
내 리뷰 · C++
해설 · C#
해설 — C# accept 루프와 연결 폭주(connection storm) 방어
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
acceptor 가 루프마다 한 번만 accept 하고, 핸들 고갈/폭주/IP 제한을 전혀 처리하지 않는다. 핵심 결함:
- 직렬 1-by-1 accept = 처리량 병목 — 한 번에 하나만 받고 그 사이 분배까지 기다려 폭주 시 accept 큐가 쌓이고 클라가 타임아웃·연결 거부.
- 핸들 고갈(EMFILE/너무 많은 핸들) 미처리 — 핸들이 동나면
AcceptAsync가 계속 실패하는데, 그 실패를 일시/치명 구분 없이 즉시return→ busy accept 실패 루프. - per-IP/계정 동시 연결 제한 부재 — 봇/공격이 핸들·메모리를 싹쓸이.
- 에러 분류 부재 + fire-and-forget — 일시 오류와 치명 오류를 같게 처리하고, 분배 태스크의 예외를 아무도 관측하지 않는다.
문제점
(B) 루프마다 한 번만 accept → 처리량 병목 / 누적 (분류: 정확성/성능)
- 증상:
RunAsync가await AcceptOnceAsync()를 직렬로 돈다. 한 연결을 받고 세션 생성·분배 호출까지 마친 뒤에야 다음AcceptAsync로 돌아간다. 폭주로 accept 큐(backlog)에 수천 연결이 쌓여 있어도 한 번에 하나씩, 직렬로만 빼내므로 처리 속도가 못 따라가 backlog 가 가득 차면 커널이 이후 SYN 을 거부(연결 실패)한다. - 재현조건: 점검 종료/이벤트 오픈 시 수만 동시 SYN. 큐에 쌓인 연결이 지연·타임아웃.
- 근본원인: accept 와 후속 처리(세션 생성·분배)가 한 직렬 경로에 묶여 있고, accept 자체를 병렬/배치로 빼내는 구조가 없다.
(B)/(C) 핸들 고갈 시 busy 실패 루프 (분류: 견고성)
- 증상: 프로세스 핸들 한도에 도달하면
AcceptAsync가SocketException(예:TooManyOpenSockets/EMFILE 류)으로 실패한다. 그러나 커널 accept 큐에는 완료된 연결이 그대로 남아 곧바로 다시 accept 가능 상태. 코드는 실패를 찍고 즉시return→RunAsync가 바로 다시 호출 → CPU 를 태우는 실패 루프(backoff 없음). - 근본원인: 핸들 고갈은 "정상적으로 발생하는" 백프레셔 신호인데, 이를 감지·완충 (backoff/슬롯 확보)하는 로직이 없다.
(C) accept 에러 분류·로깅 폭주 (분류: 견고성/운영)
- 증상: 모든
SocketException을 한 줄로 찍고return. 일시 오류 (ConnectionAborted: 클라가 SYN 후 바로 끊음 — 폭주 시 흔함)도, 핸들 고갈도, 리슨 소켓이 닫힌 치명 오류(OperationAborted)도 모두 같게 처리 → 로그 폭주 및 종료해야 할 상황에서 무한 재시도. - 근본원인:
SocketErrorCode분기 부재. 일시/정상/치명 오류를 구분하지 않음.
(D) per-IP/계정 동시 연결 제한 부재 (분류: 보안/공정성)
- 증상: 한 IP/계정이 수백 연결을 열어도 막지 않는다. 핸들·메모리·세션 슬롯을 독점해 정상 유저가 밀려난다(자원 고갈형 DoS). 봇 재시도 폭주도 그대로 통과.
- 근본원인: 연결 수락 단계의 admission control(입장 제어)이 없음.
(E) fire-and-forget 분배 — 예외 유실 + 백프레셔 없음 (분류: 견고성)
- 증상:
_ = _pool.AssignAsync(session)로 던져두면 그 태스크의 예외는 아무도 관측하지 않는다(unobserved task exception). 분배가 실패하면 소켓이 닫히지도, 카운트가 복구되지도 않아 누수. 또 풀이 포화돼도 acceptor 가 그것을 모르고 계속 받아들인다. - 근본원인: 비동기 작업의 결과/예외를 관측하지 않고 throttle 도 없음.
수정안
핵심: 병렬 accept(또는 빠른 비차단 분배) + 핸들 고갈 backoff + 에러 분류 + per-IP 제한
using System;
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
public sealed class Acceptor
{
private readonly Socket _listenSocket;
private readonly IoPool _pool;
private readonly ConcurrentDictionary<IPAddress, int> _perIp = new();
private const int MaxPerIp = 16;
private readonly SemaphoreSlim _inFlightAdmit = new(1024); // 분배 백프레셔
public Acceptor(Socket listenSocket, IoPool pool)
{ _listenSocket = listenSocket; _pool = pool; }
public async Task RunAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
Socket conn;
try
{
conn = await _listenSocket.AcceptAsync(ct);
}
catch (SocketException ex)
{
if (IsFatal(ex.SocketErrorCode)) throw; // 리슨 소켓 종료 등
if (IsResourceExhaustion(ex.SocketErrorCode)) // 핸들 고갈
{
await Task.Delay(50, ct); // backoff → busy-loop 방지
continue;
}
// ConnectionAborted 등 일시 오류는 조용히 다음 연결로
continue;
}
catch (OperationCanceledException) { break; }
// 후속 처리는 accept 루프를 막지 않도록 별도 태스크로(에러는 관측).
_ = HandleAcceptedAsync(conn);
}
}
private async Task HandleAcceptedAsync(Socket conn)
{
IPAddress ip = ((IPEndPoint)conn.RemoteEndPoint!).Address;
// per-IP 동시 연결 제한 (입장 제어)
int now = _perIp.AddOrUpdate(ip, 1, (_, c) => c + 1);
if (now > MaxPerIp)
{
_perIp.AddOrUpdate(ip, 0, (_, c) => c - 1);
try { conn.Close(); } catch { } // 정중한 거절
return;
}
try
{
var session = new Session(conn, ip);
session.OnClosed = () => _perIp.AddOrUpdate(ip, 0, (_, c) => c - 1); // 닫힐 때 복구
await _pool.AssignAsync(session); // 예외 관측 가능
}
catch (Exception)
{
_perIp.AddOrUpdate(ip, 0, (_, c) => c - 1);
try { conn.Close(); } catch { }
}
}
private static bool IsFatal(SocketError e) =>
e is SocketError.OperationAborted or SocketError.InvalidArgument;
private static bool IsResourceExhaustion(SocketError e) =>
e is SocketError.TooManyOpenSockets or SocketError.NoBufferSpaceAvailable;
}
포인트
- accept 루프와 후속 처리 분리:
HandleAcceptedAsync를 분리해 accept 가 분배를 기다리지 않게 한다(누락·지연 완화). 처리량이 더 필요하면 여러 acceptor 태스크를 같은 리슨 소켓에 돌려 병렬 accept. - 핸들 고갈 backoff:
TooManyOpenSockets시 짧게 delay 후 재시도 → busy 실패 루프 방지 + 알람·핸들 한도 상향 병행. - per-IP 입장 제어: 동시 연결 상한 + (선택) 토큰버킷 rate limit 으로 폭주/봇 완화. 세션 종료 시 카운트 복구.
- 에러 분류: 치명(
throw)/고갈(backoff)/일시(무시) 구분으로 로그 폭주·오판 제거. - 예외 관측: 분배 실패 시 소켓 닫고 카운트 복구(누수 차단).
더 나은 설계
1) 다중 acceptor + SocketAsyncEventArgs 풀
- 단일 직렬 accept 가 폭주 시 병목. 여러 acceptor 태스크가 동시에
AcceptAsync를 돌리고,SocketAsyncEventArgs를 재사용해 할당을 줄인다(GC 압력↓). - 트레이드오프: 동시성 관리 복잡도↑. OS backlog/
SOMAXCONN튜닝과 병행.
2) accept 큐·SYN 방어 튜닝
Socket.Listen(backlog)를 충분히, OSSOMAXCONN상향, SYN cookie 활성으로 SYN flood 완충. 폭주를 "큐"로 흡수.
3) 입장 제어를 계층화
- L4(방화벽/LB)에서 IP rate limit → L7(서버)에서 계정/디바이스 동시성 제한 → 앱 레벨 큐잉(대기열, "잠시 후 재시도" 응답). 한 곳에 의존하지 않음.
4) 연결 폭주 셰이핑
- 점검 종료 등 예측되는 폭주는 클라에 jitter 재접속(랜덤 지연)을 강제해 thundering herd 를 평탄화. 서버측 admission 과 함께 작동.
면접 포인트
- "
AcceptAsync를 직렬 1-by-1 로 돌리면 폭주 시 무슨 일이 생기나?" → 후속 처리까지 기다리느라 accept 속도가 못 따라가 backlog 가 차고 커널이 SYN 을 거부. accept 와 처리를 분리하고 여러 acceptor 로 병렬화해야 한다. - "accept 가 핸들 고갈로 실패하면? 즉시 return 하면 왜 위험한가?" → 큐에 연결이 남아 있어 바로 다시 실패 → CPU 를 태우는 busy 실패 루프. 짧은 backoff + 알람 + 핸들 한도 상향. 에러 코드로 일시/고갈/치명을 구분해야 한다.
- "연결 폭주/봇에서 정상 유저를 어떻게 지키나?" → per-IP/계정 동시 연결 상한 + rate limit, L4/L7 계층 방어, SYN cookie, 클라 재접속 jitter. accept 단계에서 정중히 거절하고, fire-and-forget 대신 예외를 관측해 누수 차단.
해설 · C++
해설 — C++ accept 루프와 연결 폭주(connection storm) 방어
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
엣지 트리거 epoll 에서 acceptor 가 이벤트당 한 번만 accept 하고, fd 고갈/폭주/IP 제한을 전혀 처리하지 않는다. 핵심 결함:
- ET + 단일 accept = 연결 누락 — 한 번의 readable 이벤트 동안 도착한 다수 연결 중 하나만 받고 나머지는 다음 이벤트가 올 때까지(또는 영영) 대기열에 묶인다.
- fd 고갈(EMFILE/ENFILE) 미처리 — fd 가 동나면
accept가 계속 실패하는데, ET 에서 리슨 소켓을 비우지 못해 busy-loop 또는 영구 정지에 빠질 수 있다. - per-IP/계정 동시 연결 제한 부재 — 봇/공격이 fd·메모리를 싹쓸이.
- accept 에러 분류 부재 — 일시 오류(EAGAIN)와 치명 오류(EMFILE)를 같게 처리.
문제점
(E)+(B) 엣지 트리거인데 accept 를 한 번만 한다 → 연결 누락 (분류: 정확성/성능)
- 증상:
EPOLLET로 등록한 리슨 소켓은 "새로 readable 이 된 순간"에만 이벤트를 준다. 폭주로 accept 큐에 연결 10개가 쌓여 있어도, 콜백이accept를 1번만 하면 나머지 9개는 처리되지 않는다. 다음 새 연결이 도착해 다시 엣지가 트리거될 때까지 대기 — 트래픽이 잠잠해지면 남은 연결이 영영 안 받아질 수도 있다. - 재현조건: 점검 종료/이벤트 오픈 시 수만 동시 SYN. 큐에 쌓인 연결이 지연·타임아웃.
- 근본원인: ET 모델의 핵심 규칙(EAGAIN 이 날 때까지 비워라)을 위반.
(B)/(E) fd 고갈(EMFILE) 시 ET busy-loop / 정지 (분류: 견고성)
- 증상: 프로세스 fd 한도(
RLIMIT_NOFILE)에 도달하면accept가EMFILE/ENFILE로 실패한다. 하지만 커널의 accept 큐에는 완료된 연결이 그대로 남아 readable 상태. ET 에서 이걸 소비하지 못하면, 레벨 트리거였다면 무한 busy-loop, ET 에서는 더 이상 엣지가 안 와 새 연결을 영영 못 받는 교착에 가까운 상태가 된다. - 근본원인: fd 고갈은 "정상적으로 발생하는" 백프레셔 신호인데, 이를 감지·완충하는 로직이 없다.
(C) accept 에러 분류·로깅 폭주 (분류: 견고성/운영)
- 증상: 모든 실패를
perror후return. EAGAIN/EWOULDBLOCK(더 받을 것 없음, ET 에선 정상 종료 신호)도 에러로 찍고, EMFILE 도 똑같이 찍어 로그 폭주. 또 ECONNABORTED(클라가 SYN 후 바로 끊음 — 폭주 시 흔함)도 치명처럼 다룬다. - 근본원인: errno 분기 부재. 일시/정상/치명 오류를 구분하지 않음.
(D) per-IP/계정 동시 연결 제한 부재 (분류: 보안/공정성)
- 증상: 한 IP/계정이 수백 연결을 열어도 막지 않는다. fd·메모리·세션 슬롯을 독점해 정상 유저가 밀려난다(자원 고갈형 DoS). 봇 재시도 폭주도 그대로 통과.
- 근본원인: 연결 수락 단계의 admission control(입장 제어)이 없음.
수정안
핵심: ET 에서 EAGAIN 까지 루프 accept + fd 고갈 완충 + per-IP 제한
void Acceptor::OnListenReadable() {
while (true) { // (1) ET: EAGAIN 날 때까지 비운다
sockaddr_in addr{};
socklen_t len = sizeof(addr);
int fd = accept4(listenFd, (sockaddr*)&addr, &len,
SOCK_NONBLOCK | SOCK_CLOEXEC); // 논블록+CLOEXEC 원자적
if (fd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK)
return; // 더 받을 것 없음 — 정상 종료
if (errno == EINTR)
continue; // 시그널 — 재시도
if (errno == ECONNABORTED)
continue; // 폭주 시 흔함 — 다음 연결로
if (errno == EMFILE || errno == ENFILE) {
// (2) fd 고갈: 예약해 둔 더미 fd 를 닫아 슬롯 확보 → accept → 즉시 close
// 로 큐를 비우고, 잠시 backoff. 큐가 막혀 영구정지되는 것을 방지.
HandleFdExhaustion(); // 아래 설명
return;
}
return; // 기타 — 일단 종료(다음 이벤트 대기)
}
// (3) per-IP 동시 연결 제한 (입장 제어)
if (!admission_.TryAdmit(addr)) { // 원자적 카운터/락 보호 맵
close(fd); // 정중한 거절(또는 짧은 거절 패킷 후 close)
continue;
}
Session* s = new Session(fd, addr);
s->onClose = [this, addr]{ admission_.Release(addr); }; // 닫힐 때 카운트 복구
pool->Assign(s);
}
}
// fd 고갈 대처: 시작 시 더미 fd 하나를 열어 두고(open("/dev/null")),
// EMFILE 시 닫아서 슬롯 1개 확보 → accept 후 즉시 close 해 큐를 비우고
// 다시 더미를 연다. (Marc Lehmann 의 고전 기법)
void Acceptor::HandleFdExhaustion() {
close(reservedFd_);
int victim = accept(listenFd, nullptr, nullptr);
if (victim >= 0) close(victim); // 큐에서 하나 빼서 즉시 거절
reservedFd_ = open("/dev/null", O_RDONLY);
// + 메트릭 기록, 알람, 잠깐 backoff
}
포인트
accept4+ ET 루프: 한 이벤트에서 큐를 끝까지 비워 연결 누락 제거.SOCK_CLOEXEC/SOCK_NONBLOCK을 원자적으로 설정(별도fcntl의 fd-leak/TOCTOU 회피).- EMFILE 완충: 예약 fd 기법으로 고갈 시에도 큐를 비워 영구정지 방지 + 알람.
- per-IP 입장 제어: 동시 연결 상한 + (선택) 토큰버킷 rate limit 으로 폭주/봇 완화.
- errno 분기로 로그 폭주·오판 제거. EAGAIN 은 정상, ECONNABORTED 는 무시·재시도.
더 나은 설계
1) SO_REUSEPORT 멀티 acceptor
- 단일 acceptor 가 폭주 시 병목. 리슨 소켓을
SO_REUSEPORT로 여러 개 열어 커널이 연결을 acceptor 스레드들로 분산. 각 스레드가 독립 accept 루프. - 트레이드오프: 커널 버전 의존, 로드밸런싱이 완벽 균등은 아님.
2) accept 큐 크기·SYN 방어 튜닝
listen(backlog)를 충분히,net.core.somaxconn·tcp_max_syn_backlog상향,SYN cookies활성으로 SYN flood 완충. 폭주를 "큐"로 흡수.
3) 입장 제어를 계층화
- L4(방화벽/LB)에서 IP rate limit → L7(서버)에서 계정/디바이스 동시성 제한 → 앱 레벨 큐잉(대기열, "잠시 후 재시도" 응답). 한 곳에 의존하지 않음.
4) 연결 폭주 셰이핑
- 점검 종료 등 예측되는 폭주는 클라에 jitter 재접속(랜덤 지연)을 강제해 thundering herd 를 평탄화. 서버측 admission 과 함께 작동.
면접 포인트
- "엣지 트리거 epoll 의 리슨 소켓에서 accept 를 어떻게 호출해야 하나?"
→
EAGAIN/EWOULDBLOCK이 날 때까지 루프 accept. 한 번만 받으면 큐에 쌓인 연결을 놓치고 다음 엣지까지 묶인다. LT 면 한 번씩도 가능하지만 폭주 시 비효율. - "
accept가 EMFILE 로 실패하면? ET 에서 왜 위험한가?" → 완료된 연결이 큐에 readable 로 남아있는데 소비를 못 해 새 엣지가 안 와 정지. 예약 fd 를 닫아 슬롯을 확보해 큐를 비우고 알람·backoff. fd 한도 상향도 병행. - "연결 폭주/봇에서 정상 유저를 어떻게 지키나?" → per-IP/계정 동시 연결 상한 + rate limit(토큰버킷), L4/L7 계층 방어, SYN cookie, 클라 재접속 jitter, SO_REUSEPORT 분산. accept 단계에서 정중히 거절.