C#/이모저모

WebSocket 채팅 시스템

Red_Horse 2026. 3. 22. 19:19

기반 구성 사항 : https://red-horse.tistory.com/167

 

WebSocket(Chat System)

WebSocket?HTTP는 요청-응답 구조입니다.클라이언트가 요청하면 서버가 응답하고 연결을 닫습니다.서버가 먼저 데이터를 보낼 방법이 없는데 WebSocket은 이 문제를 핸드세이크로 해결합니다.클라이

red-horse.tistory.com

 

채팅 시스템 구조
WebSocket/Chat/
├── ChatMessage.cs           ← 메시지 프로토콜 (JSON)
├── ChatRoomManager.cs       ← 룸 멤버십 관리
└── ChatWebSocketHandler.cs  ← 채팅 이벤트 처리

Components/Pages/Chat/
├── ChatPage.razor           ← 채팅 UI
├── ChatPageBase.cs          ← JS interop + Blazor 상태 관리
└── ChatPage.razor.css       ← 스타일

wwwroot/js/
└── chat.js                  ← 브라우저 WebSocket 클라이언트

 

메시지 프로토콜 설계

서버와 클라이언트가 주고받는 모든 메시지는 JSON 형식으로 통일했습니다.

type 필드로 메시지 종류를 구분합니다.

 

ChatMessage 모델

public class ChatMessage
{
    [JsonPropertyName("type")]
    public string Type { get; set; } = string.Empty;

    [JsonPropertyName("roomId")]
    public string? RoomId { get; set; }

    [JsonPropertyName("userId")]
    public string? UserId { get; set; }

    [JsonPropertyName("userName")]
    public string? UserName { get; set; }

    [JsonPropertyName("content")]
    public string? Content { get; set; }

    [JsonPropertyName("timestamp")]
    public string Timestamp { get; set; } = DateTime.UtcNow.ToString("o");
}

 

 

전체 메시지 흐름

클라이언트 연결
  └─ 서버 → 클라이언트: { "type": "connected", "content": "<connectionId>" }
  └─ 클라이언트 → 서버: { "type": "join", "roomId": "general", "userId": "...", "userName": "홍길동" }
  └─ 서버 → 룸 전체:   { "type": "joined", "roomId": "general", "userId": "...", "userName": "홍길동" }

채팅
  └─ 클라이언트 → 서버: { "type": "message", "content": "안녕하세요!" }
  └─ 서버 → 룸 전체:   { "type": "message", "roomId": "general", "userId": "...", "userName": "홍길동", "content": "안녕하세요!", "timestamp": "..." }

퇴장
  └─ 클라이언트 → 서버: { "type": "leave" }
  └─ 서버 → 룸 전체:   { "type": "left", "roomId": "general", "userId": "...", "userName": "홍길동" }

연결 유지 (30초 주기)
  └─ 서버 → 클라이언트: { "type": "ping" }
  └─ 클라이언트 → 서버: { "type": "pong" }  ← Blazor가 자동 응답

 

type 방향 설명
connected Server -> Client 연결 성공, content에 connectionId 포함
join Client -> Server 룸 입장 요청
joined Server -> Client 룸 전체에 입장 알림 브로드캐스트
message 양방향 채팅 메시지
leave Client -> Server 퇴장 요청
left Server -> Client 퇴장 알ㄹ미 브로드캐스트
ping / pong Server -> Client 연결 유지 확인
error Server -> Client 오류 메시지

 

public class ChatRoomManager
{
    // roomId → RoomEntry (멤버 집합 + lock 객체 묶음)
    private readonly ConcurrentDictionary<string, RoomEntry> _rooms = new();
    // connectionId → roomId (한 연결은 하나의 룸만 참여)
    private readonly ConcurrentDictionary<string, string> _connectionRoom = new();

    public void JoinRoom(string roomId, string connectionId)
    {
        var entry = _rooms.GetOrAdd(roomId, _ => new RoomEntry());
        lock (entry)
        {
            entry.Members.Add(connectionId);
        }
        _connectionRoom[connectionId] = roomId;
    }

    public string? LeaveRoom(string connectionId)
    {
        if (!_connectionRoom.TryRemove(connectionId, out var roomId)) return null;

        if (_rooms.TryGetValue(roomId, out var entry))
            lock (entry) { entry.Members.Remove(connectionId); }

        return roomId;
    }

    public IReadOnlyList<string> GetRoomMembers(string roomId)
    {
        if (_rooms.TryGetValue(roomId, out var entry))
            lock (entry) { return entry.Members.ToList(); }
        return [];
    }

    private sealed class RoomEntry
    {
        public HashSet<string> Members { get; } = [];
    }
}

Room 멤버십 관리

 - Chat Room접속 관리에 대한 전반적인 프로세스를 관리합니다.

 

추후 개선 방향성

 - 현재 User(1) : Room(1) 연결 방식 1:N으로 구성 변경

 - DB 구성을 통한 과거 채팅 내역 관리 구조 적용

 

핵심 처리 로직

public class ChatWebSocketHandler : WebSocketHandlerBase
{
    private readonly ChatRoomManager _roomManager;
    private readonly ConcurrentDictionary<string, (int count, DateTime windowStart)> _rateLimit = new();

    private const int MaxContentLength = 1000;
    private const int MaxRoomIdLength = 50;
    private const int MaxUserNameLength = 30;
    private const int RateLimitPerSecond = 5;

    public override async Task ReceiveAsync(WebSocketConnection connection, string message)
    {
        // 1. Rate Limiting 체크
        if (IsRateLimited(connection.ConnectionId))
        {
            await SendErrorAsync(connection.ConnectionId, "Too many messages. Slow down.");
            return;
        }

        // 2. JSON 파싱
        var msg = JsonSerializer.Deserialize<ChatMessage>(message, JsonOptions);
        if (msg is null) return;

        // 3. 타입별 분기
        switch (msg.Type)
        {
            case "join":    await HandleJoinAsync(connection, msg);    break;
            case "message": await HandleMessageAsync(connection, msg); break;
            case "leave":   await HandleLeaveAsync(connection);        break;
        }
    }
}

WebSocketHandlerBase를 상속하고, 메시지 타입별로 처리합니다.

 

1. Join 처피 - UserId / UserName 서버 측 저장

private async Task HandleJoinAsync(WebSocketConnection connection, ChatMessage msg)
{
    // 입력 검증
    if (string.IsNullOrWhiteSpace(msg.RoomId) || msg.RoomId.Length > MaxRoomIdLength)
    {
        await SendErrorAsync(connection.ConnectionId, "Invalid roomId");
        return;
    }
    if (string.IsNullOrWhiteSpace(msg.UserName) || msg.UserName.Length > MaxUserNameLength)
    {
        await SendErrorAsync(connection.ConnectionId, "Invalid userName");
        return;
    }

    // 이미 다른 룸에 있으면 먼저 나가기
    if (_roomManager.GetUserRoom(connection.ConnectionId) is not null)
        await HandleLeaveAsync(connection);

    // UserId와 UserName을 서버(connection 객체)에 저장
    // 이후 메시지에서 클라이언트가 보내는 값은 무시
    connection.UserId = msg.UserId ?? connection.ConnectionId;
    connection.UserName = msg.UserName.Trim();

    _roomManager.JoinRoom(msg.RoomId, connection.ConnectionId);

    await BroadcastToRoomAsync(msg.RoomId, new ChatMessage
    {
        Type = "joined",
        RoomId = msg.RoomId,
        UserId = connection.UserId,
        UserName = connection.UserName  // 서버에 저장된 값 사용
    });
}

connection.UserName = msg.UserName에서 클라이언트 값을 서버에 저장하고, 이후 모든 메시지에서는 connection.UserName(서버 저장값)을 사용합니다. 클라이언트가 메시지마다 다른 이름을 보내도 무시됩니다.

 

2. Message 처리(브로드캐스트)

private async Task HandleMessageAsync(WebSocketConnection connection, ChatMessage msg)
{
    var roomId = _roomManager.GetUserRoom(connection.ConnectionId);
    if (roomId is null)
    {
        await SendErrorAsync(connection.ConnectionId, "Join a room first");
        return;
    }

    if (string.IsNullOrWhiteSpace(msg.Content)) return;
    if (msg.Content.Length > MaxContentLength)
    {
        await SendErrorAsync(connection.ConnectionId, $"Message too long (max {MaxContentLength})");
        return;
    }

    await BroadcastToRoomAsync(roomId, new ChatMessage
    {
        Type = "message",
        RoomId = roomId,
        UserId = connection.UserId,
        UserName = connection.UserName,  // 클라이언트 값 무시
        Content = msg.Content.Trim()
    });
}

private async Task BroadcastToRoomAsync(string roomId, ChatMessage msg, string? exclude = null)
{
    var json = JsonSerializer.Serialize(msg);
    var tasks = _roomManager.GetRoomMembers(roomId)
        .Where(id => id != exclude)
        .Select(id => ConnectionManager.SendAsync(id, json));

    await Task.WhenAll(tasks); // 룸 내 전체 전송을 병렬로
}

 

 

3. Rate Liniting(초당 메시지 제한)

private bool IsRateLimited(string connectionId)
{
    var now = DateTime.UtcNow;
    var state = _rateLimit.GetOrAdd(connectionId, _ => (0, now));

    int newCount;
    DateTime windowStart;

    if ((now - state.windowStart).TotalSeconds >= 1)
    {
        // 1초 창이 지나면 카운터 초기화
        newCount = 1;
        windowStart = now;
    }
    else
    {
        newCount = state.count + 1;
        windowStart = state.windowStart;
    }

    _rateLimit[connectionId] = (newCount, windowStart);
    return newCount > RateLimitPerSecond; // 초당 5개 초과 시 차단
}

슬라이딩 윈도우 대신 고정 윈도우 방식입니다. 구현이 단순하고 채팅같은 UX에서는 충분합니다. 초당 5개 초과 메시지를 보내면 오류 메시지를 받고 해당 메시지는 무시됩니다. 연결을 끊지 않기 때문에 정상 사용자가 실수로 빠르게 입력하는 경우에 친화적입니다.

 

 

프론트엔트 - Blazor + JavaScript

JavaScript를 사용해야 하는 이유?

Blazor Server는 이미 내부적으로 SignalR WebSocket을 사용해 서버와 통신합니다. C# 코드에서 두 번째 WebSocket 연결을 직접 열 수 없습니다. 브라우저에서 WebSocket을 다루는 것은 JavaScript의 영역입니다.

 

구조
Blazor(C#) ←── JS Interop ───→ chat.js ←── WebSocket ───→ 서버

 

Blazor에서 JS 함수를 호출하거, JS에서 서버로부터 메시지를 받으면 다시 Blazor 메서드를 호출합니다.

 

chat.js - 브라우저 WebSocket 클라이언트

window.ChatWs = (() => {
    let ws = null;
    let dotnetRef = null;
    let wsUrl = null;
    let reconnectTimer = null;
    let reconnectDelay = 1000;       // 초기 재연결 대기 1초
    const maxReconnectDelay = 30000; // 최대 30초
    let manualClose = false;

    function connect(dotnetObjRef, url) {
        if (ws && ws.readyState !== WebSocket.CLOSED) {
            manualClose = true;
            ws.close();
        }
        dotnetRef = dotnetObjRef;
        wsUrl = url;
        manualClose = false;
        _open();
    }

    function _open() {
        ws = new WebSocket(wsUrl);

        ws.onopen = () => {
            reconnectDelay = 1000; // 성공 시 딜레이 초기화
        };

        ws.onmessage = (event) => {
            if (dotnetRef) {
                dotnetRef.invokeMethodAsync('OnMessageReceived', event.data);
            }
        };

        ws.onclose = () => {
            dotnetRef?.invokeMethodAsync('OnMessageReceived',
                JSON.stringify({ type: 'disconnected' }));

            // 자동 재연결 (지수 백오프)
            if (!manualClose) {
                reconnectTimer = setTimeout(() => {
                    _open();
                    reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay);
                }, reconnectDelay);
            }
        };
    }

    function send(message) {
        if (ws && ws.readyState === WebSocket.OPEN) ws.send(message);
    }

    function disconnect() {
        manualClose = true;
        clearTimeout(reconnectTimer);
        if (ws) { ws.close(); ws = null; }
        dotnetRef = null;
    }

    return { connect, send, disconnect };
})();

지수 백오프(Exponential Backoff): 네트워크 일시 끊김 후 재연결을 시도할 때, 서버가 부하 상태라면 모든 클라이언트가 동시에 재연결을 시도해 부하를 더 키웁니다. 대기 시간을 1초 → 2초 → 4초 → ... → 30초로 늘려가면서 이를 방지합니다.

 

manualClose 플래그사용자가 "나가기버튼을 눌러 명시적으로 연결을 닫는 경우와네트워크 문제로 연결이 끊기는 경우를 구분합니다전자는 재연결하지 않고후자만 재연결합니다.

 

ChatPageBase.cs - Blazor 상태 관리

public class ChatPageBase : ComponentBase, IAsyncDisposable
{
    [Inject] private IJSRuntime JS { get; set; } = null!;
    [Inject] private NavigationManager Nav { get; set; } = null!;

    protected string UserName { get; set; } = string.Empty;
    protected string RoomId { get; set; } = "general";
    protected string InputText { get; set; } = string.Empty;
    protected string? ConnectionId { get; private set; }
    protected bool IsConnected { get; private set; }
    protected List<ChatDisplayMessage> Messages { get; } = [];

    private DotNetObjectReference<ChatPageBase>? _dotnetRef;

    protected async Task ConnectAsync()
    {
        _dotnetRef = DotNetObjectReference.Create(this);

        // http:// → ws://, https:// → wss:// 자동 변환
        var wsUrl = Nav.BaseUri
            .Replace("https://", "wss://")
            .Replace("http://", "ws://")
            .TrimEnd('/') + "/ws/chat";

        await JS.InvokeVoidAsync("ChatWs.connect", _dotnetRef, wsUrl);
    }

    // JS → C# 콜백 (JSInvokable)
    [JSInvokable]
    public async Task OnMessageReceived(string json)
    {
        var msg = JsonSerializer.Deserialize<ChatMessage>(json, ...);
        if (msg is null) return;

        switch (msg.Type)
        {
            case "connected":
                ConnectionId = msg.Content;
                IsConnected = true;
                // 연결 직후 자동으로 룸 입장 요청
                await SendWsAsync(new ChatMessage
                {
                    Type = "join",
                    RoomId = RoomId,
                    UserId = ConnectionId,
                    UserName = UserName
                });
                break;

            case "joined":
            case "left":
            case "message":
                Messages.Add(new ChatDisplayMessage(msg));
                await ScrollToBottomAsync();
                break;

            case "ping":
                await SendWsAsync(new ChatMessage { Type = "pong" });
                return; // UI 갱신 불필요

            case "pong":
                return; // 무시

            case "disconnected":
                IsConnected = false;
                ConnectionId = null;
                break;

            case "error":
                Messages.Add(new ChatDisplayMessage(new ChatMessage
                {
                    Type = "system",
                    Content = $"[오류] {msg.Content}"
                }));
                break;
        }

        await InvokeAsync(StateHasChanged); // Blazor UI 갱신
    }

    public async ValueTask DisposeAsync()
    {
        if (IsConnected) await DisconnectAsync(); // 페이지 벗어날 때 자동 정리
        _dotnetRef?.Dispose();
    }
}

 

DotNetObjectReference: JS에서 C# 메서드(OnMessageReceived)를 호출하기 위한 핸들입니다. Dispose를 반드시 호출해야 메모리 누수가 없습니다. DisposeAsync에서 처리했습니다.

InvokeAsync(StateHasChanged): WebSocket 메시지는 Blazor 렌더링 스레드 외부에서 도착합니다. InvokeAsync 렌더링 스레드에 UI 갱신을 예약해야 합니다.