C#/이모저모

WebSocket

Red_Horse 2026. 3. 22. 18:52

WebSocket?

HTTP는 요청-응답 구조입니다.

클라이언트가 요청하면 서버가 응답하고 연결을 닫습니다.

서버가 먼저 데이터를 보낼 방법이 없는데 WebSocket은 이 문제를 핸드세이크로 해결합니다.

클라이언트 -> 서버: HTTP Upgrade 요청
서버 -> 클라이언트: 101 Switching Protocols

이후: TCP 연결 위에서 양방향 통신(연결 유지)

한 번 연결되면 서버도 먼저 데이터를 보낼 수 있고, 연결은 명시적으로 닫을 때까지 유지됩니다. TCP 위의 애플리케이션 프로토콜이라는 게 핵심입니다.

 

아키텍처 설계

단순히 WebSocket을 열고 닫는 것이 아니라, 여러 기능에서 재사용 가능한 인프라를 목표로 설계했습니다. 채팅뿐 아니라 나중에 운동 세션 실시간 공유, 알림 들을 추가할 때도 같은 구조를 쓸 수 있게 됩니다.

 

레이어 역할
WebSocketConnection 소켓 래퍼. Send 동시성 보호, LastActivity 추적
WebSocketConnecionManager 전체 연결 풀. Send/Broadcast/연결 수 제한
IWebSocketHandler 연결 해제 수신 이벤트 인터페이스
WebSocketHanlerBase 공통 로직 (DisconnectedAsync 시 풀에서 제거)
WebSocketMiddleware HTTP 업그레이드, 수신 루프, Ping/Pong, Origin 검증

 

public class WebSocketConnection : IDisposable
{
    public string ConnectionId { get; } = Guid.NewGuid().ToString();
    public System.Net.WebSockets.WebSocket Socket { get; init; } = null!;
    public string? UserId { get; set; }
    public string? UserName { get; set; }
    public DateTime ConnectedAt { get; } = DateTime.UtcNow;
    public DateTime LastActivity { get; private set; } = DateTime.UtcNow;

    public bool IsOpen => Socket.State == WebSocketState.Open;

    private readonly SemaphoreSlim _sendLock = new(1, 1);

    public void UpdateLastActivity() => LastActivity = DateTime.UtcNow;

    public async Task SendAsync(string message, CancellationToken ct = default)
    {
        if (!IsOpen) return;

        await _sendLock.WaitAsync(ct);
        try
        {
            if (!IsOpen) return;
            var buffer = Encoding.UTF8.GetBytes(message);
            await Socket.SendAsync(buffer, WebSocketMessageType.Text, true, ct);
        }
        finally
        {
            _sendLock.Release();
        }
    }

    public void Dispose() => _sendLock.Dispose();
}

SemaphoreSlim 필요성

WebSocket은 동시에 Send가 하나만 허용됩니다. 같은 소켓에 두 곳에서 동시에 SendAsync를 호출하면 런타임 예외가 납니다.

 - PingLoop: 30초마다 Ping 전송(좀비 PC 체크용)

 - BroadcastToRoom: 채팅 메시지를 룸 내 전체에 전송(관리자 전체 메시지 전송용)

SemaphoreSlim(1, 1)로 Socket당 Send를 직렬화해서 이 충돌을 막습니다.

 

※ SemaphoreSlim : 동시 접근제어를 위한 동시성 제어 기술(lock은 동기 코드로 비동기를 지원하는 SemaphoreSlim을 사용)

 

public class WebSocketConnectionManager
{
    private readonly ConcurrentDictionary<string, WebSocketConnection> _connections = new();
    private readonly int _maxConnections;

    public WebSocketConnectionManager(int maxConnections = 1000)
    {
        _maxConnections = maxConnections;
    }

    public WebSocketConnection? AddConnection(System.Net.WebSockets.WebSocket socket)
    {
        if (_connections.Count >= _maxConnections) return null; // 연결 수 제한
        var connection = new WebSocketConnection { Socket = socket };
        _connections[connection.ConnectionId] = connection;
        return connection;
    }

    public Task SendAsync(string connectionId, string message, CancellationToken ct = default)
    {
        if (_connections.TryGetValue(connectionId, out var connection))
            return connection.SendAsync(message, ct); // 내부 SemaphoreSlim으로 직렬화됨
        return Task.CompletedTask;
    }

    public Task BroadcastAsync(string message, string? excludeConnectionId = null, CancellationToken ct = default)
    {
        var tasks = _connections.Values
            .Where(c => c.IsOpen && c.ConnectionId != excludeConnectionId)
            .Select(c => c.SendAsync(message, ct));
        return Task.WhenAll(tasks);
    }
}

 

ConcurrentDictionary 사용 이유

 - 연결은 다수의 스레드에서 동시에 추가/제거됩니다. 일반 Dictionary에 lock 없이 접근하면 데이터 손상이 발생합니다.

 

public interface IWebSocketHandler
{
    Task OnConnectedAsync(WebSocketConnection connection);
    Task OnDisconnectedAsync(WebSocketConnection connection, Exception? exception);
    Task ReceiveAsync(WebSocketConnection connection, string message);
}

public abstract class WebSocketHandlerBase : IWebSocketHandler
{
    protected readonly WebSocketConnectionManager ConnectionManager;

    protected WebSocketHandlerBase(WebSocketConnectionManager connectionManager)
    {
        ConnectionManager = connectionManager;
    }

    public virtual Task OnConnectedAsync(WebSocketConnection connection) => Task.CompletedTask;

    public virtual Task OnDisconnectedAsync(WebSocketConnection connection, Exception? exception)
    {
        ConnectionManager.RemoveConnection(connection.ConnectionId);
        return Task.CompletedTask;
    }

    public abstract Task ReceiveAsync(WebSocketConnection connection, string message);
}

 

추후 신규 기능을 추가할 시에 WebSocketHanlerBase을 상속하고 ReceiveAsync만 구현하여 사용

* 연결 풀 관리, Ping, 수신 루프 같은 인프라 코드는 손댈 필요가 없습니다.

 

★WebSocketMiddlewawre(핵심 루프)

1. HTTP -> WebSocket Upgreade + 보안 체크

public async Task InvokeAsync(HttpContext context, IServiceProvider serviceProvider)
{
    if (!context.WebSockets.IsWebSocketRequest)
    {
        await _next(context);
        return;
    }

    // Origin 검증: 허용된 도메인이 아니면 403 반환
    if (_options.AllowedOrigins.Count > 0)
    {
        var origin = context.Request.Headers.Origin.ToString();
        if (!_options.AllowedOrigins.Contains(origin))
        {
            context.Response.StatusCode = 403;
            return;
        }
    }

    // 연결 수 초과 거부
    var socket = await context.WebSockets.AcceptWebSocketAsync();
    var connection = connectionManager.AddConnection(socket);
    if (connection is null)
    {
        await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Server full", CancellationToken.None);
        return;
    }
    // ...
}

 

2. 수신 루프 -> 멀티프레임 조합

private async Task ReceiveLoopAsync(WebSocketConnection connection, IWebSocketHandler handler, CancellationToken ct)
{
    var buffer = ArrayPool<byte>.Shared.Rent(_options.BufferSize); // GC 부담 감소
    using var messageBuilder = new MemoryStream();                  // 프레임 조합용

    try
    {
        while (!ct.IsCancellationRequested && connection.IsOpen)
        {
            // 수신 타임아웃 (기본 2분)
            using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
            timeoutCts.CancelAfter(_options.ReceiveTimeout);

            var result = await connection.Socket.ReceiveAsync(buffer, timeoutCts.Token);

            connection.UpdateLastActivity();

            if (result.MessageType == WebSocketMessageType.Close) break;
            if (result.MessageType != WebSocketMessageType.Text) continue;

            // 메시지 크기 제한 (기본 64KB)
            if (messageBuilder.Length + result.Count > _options.MaxMessageSize)
            {
                messageBuilder.SetLength(0);
                await connection.SendAsync("{\"type\":\"error\",\"content\":\"Message too large\"}", ct);
                continue;
            }

            messageBuilder.Write(buffer, 0, result.Count);

            // EndOfMessage가 true일 때만 처리 (멀티프레임 조합 완료)
            if (!result.EndOfMessage) continue;

            var message = Encoding.UTF8.GetString(messageBuilder.ToArray());
            messageBuilder.SetLength(0);

            await handler.ReceiveAsync(connection, message);
        }
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer); // 반드시 반환
    }
}

멀티 프레임을 신경 써야 하는 이유?

 - 메시지가 버퍼 크기(4KB)를 초과하면 WebSocket은 자동으로 여러 프레임으로 나눠 보냅니다.

각 프레임을 독립적으로 처리하면 잘린 JSON이 파싱에 실패합니다. MemoryStream으로 프레임을 조합하다가 EndOfMessage = true인 마지막 프레임이 왔을 때 한 번에 처리합니다.

 

ArrayPool <byte>

 - new byte [4096]은 연결마다 GC 힙에 새 객체를 할당합니다. 연결이 수천 개면 GC 압박이 생깁니다. ArrayPool은 사용이 끝난 배열을 풀에 반납하고 재사용합니다.

 

 

3. Ping 푸프 - 좀비 연결 감지

private async Task PingLoopAsync(WebSocketConnection connection, CancellationToken ct)
{
    while (!ct.IsCancellationRequested && connection.IsOpen)
    {
        await Task.Delay(_options.PingInterval, ct); // 30초 대기

        // 마지막 활동이 60초 이상 없으면 좀비로 판단하고 연결 종료
        if (DateTime.UtcNow - connection.LastActivity > _options.PingInterval * 2)
            break;

        await connection.SendPingAsync(ct); // SemaphoreSlim 보호 하에 전송
    }
}

좀비 연결?

 - 클라이언트가 WI-FI를 끊거나 앱을 강제 종료하면 TCP FIN 패킷이 전달되지 않습니다. 서버는 연결이 살아있다고 착각하고 _connections에 영원히 남겨둡니다. Ping을 주기적으로 보내서 응답이 없는 연결을 탐지하고 정리합니다.

 

4. Task.WhenAny - 두 루프 동시 실행

var receiveTask = ReceiveLoopAsync(connection, handler, cts.Token);
var pingTask = PingLoopAsync(connection, cts.Token);

var completed = await Task.WhenAny(receiveTask, pingTask);
await completed; // 먼저 끝난 Task를 다시 await → 예외 전파 보장

수신 루프와 Ping 루프를 동시에 실행합니다. 둘 중 하나가 먼저 종료되면 (예: 클라이언트 연결 해제, Ping 타임아웃) finally 블록에서 나머지도 CancellationToken으로 종료시킵니다.

 

await completed의 중요성

 - Task.WhenAny는 먼저 완료된 Task 객체를 반환할 뿐, 그 Task의 예외를 전파하지 않습니다. 반드시 다시 await 해야 예외가 올라옵니다.