← 문제로

7. C# accept 루프와 연결 폭주(connection storm) 방어

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

해설 — C# accept 루프와 연결 폭주(connection storm) 방어

난이도: 중

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

요약

acceptor 가 루프마다 한 번만 accept 하고, 핸들 고갈/폭주/IP 제한을 전혀 처리하지 않는다. 핵심 결함:

  1. 직렬 1-by-1 accept = 처리량 병목 — 한 번에 하나만 받고 그 사이 분배까지 기다려 폭주 시 accept 큐가 쌓이고 클라가 타임아웃·연결 거부.
  2. 핸들 고갈(EMFILE/너무 많은 핸들) 미처리 — 핸들이 동나면 AcceptAsync 가 계속 실패하는데, 그 실패를 일시/치명 구분 없이 즉시 returnbusy accept 실패 루프.
  3. per-IP/계정 동시 연결 제한 부재 — 봇/공격이 핸들·메모리를 싹쓸이.
  4. 에러 분류 부재 + fire-and-forget — 일시 오류와 치명 오류를 같게 처리하고, 분배 태스크의 예외를 아무도 관측하지 않는다.

문제점

(B) 루프마다 한 번만 accept → 처리량 병목 / 누적 (분류: 정확성/성능)

  • 증상: RunAsyncawait AcceptOnceAsync() 를 직렬로 돈다. 한 연결을 받고 세션 생성·분배 호출까지 마친 뒤에야 다음 AcceptAsync 로 돌아간다. 폭주로 accept 큐(backlog)에 수천 연결이 쌓여 있어도 한 번에 하나씩, 직렬로만 빼내므로 처리 속도가 못 따라가 backlog 가 가득 차면 커널이 이후 SYN 을 거부(연결 실패)한다.
  • 재현조건: 점검 종료/이벤트 오픈 시 수만 동시 SYN. 큐에 쌓인 연결이 지연·타임아웃.
  • 근본원인: accept 와 후속 처리(세션 생성·분배)가 한 직렬 경로에 묶여 있고, accept 자체를 병렬/배치로 빼내는 구조가 없다.

(B)/(C) 핸들 고갈 시 busy 실패 루프 (분류: 견고성)

  • 증상: 프로세스 핸들 한도에 도달하면 AcceptAsyncSocketException (예: TooManyOpenSockets/EMFILE 류)으로 실패한다. 그러나 커널 accept 큐에는 완료된 연결이 그대로 남아 곧바로 다시 accept 가능 상태. 코드는 실패를 찍고 즉시 returnRunAsync 가 바로 다시 호출 → 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) 를 충분히, OS SOMAXCONN 상향, SYN cookie 활성으로 SYN flood 완충. 폭주를 "큐"로 흡수.

3) 입장 제어를 계층화

  • L4(방화벽/LB)에서 IP rate limit → L7(서버)에서 계정/디바이스 동시성 제한 → 앱 레벨 큐잉(대기열, "잠시 후 재시도" 응답). 한 곳에 의존하지 않음.

4) 연결 폭주 셰이핑

  • 점검 종료 등 예측되는 폭주는 클라에 jitter 재접속(랜덤 지연)을 강제해 thundering herd 를 평탄화. 서버측 admission 과 함께 작동.

면접 포인트

  1. "AcceptAsync 를 직렬 1-by-1 로 돌리면 폭주 시 무슨 일이 생기나?" → 후속 처리까지 기다리느라 accept 속도가 못 따라가 backlog 가 차고 커널이 SYN 을 거부. accept 와 처리를 분리하고 여러 acceptor 로 병렬화해야 한다.
  2. "accept 가 핸들 고갈로 실패하면? 즉시 return 하면 왜 위험한가?" → 큐에 연결이 남아 있어 바로 다시 실패 → CPU 를 태우는 busy 실패 루프. 짧은 backoff + 알람 + 핸들 한도 상향. 에러 코드로 일시/고갈/치명을 구분해야 한다.
  3. "연결 폭주/봇에서 정상 유저를 어떻게 지키나?" → per-IP/계정 동시 연결 상한 + rate limit, L4/L7 계층 방어, SYN cookie, 클라 재접속 jitter. accept 단계에서 정중히 거절하고, fire-and-forget 대신 예외를 관측해 누수 차단.