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 해야 예외가 올라옵니다.
'C# > 이모저모' 카테고리의 다른 글
| WebSocket 채팅 시스템 (0) | 2026.03.22 |
|---|---|
| 웹 애플리케이션(.NET) (0) | 2025.11.30 |
| 임시 파일 자동정리(.NET TimerQueue & .NET IHostedService) (0) | 2025.11.27 |
| IIS(Internet Information Services) (0) | 2025.11.27 |
| 파일 생성 및 다운로드 관리(물리 경로 vs 가상 경로)_Web (0) | 2025.11.27 |