gRPC를 사용하여 .Net Core에서 간단한 채팅 서버 구축하기

이 글에서는 간단한 동시성 gRPC 채팅 서버 애플리케이션을 만들어 보겠습니다. 이 채팅 서버 애플리케이션을 구축하기 위해 모듈식 프레임워크인 .NET Core를 사용하겠습니다. 다음 주제들을 다룰 것입니다.

  • A brief introduction to gRPC
  • gRPC 환경 설정 및 서비스 계약 정의
  • 채팅 서비스 구현 및 클라이언트 요청 처리
  • 비동기 프로그래밍을 사용하여 여러 클라이언트 동시 처리
  • 동일한 방에 연결된 모든 클라이언트에 채팅 메시지 방송

이 튜토리얼이 끝나면 gRPC를 사용하여 채팅 서버를 구축하는 방법을 이해하게 될 것입니다.

gRPC란?

gRPCGoogle Remote Procedure Calls의 약자입니다. 초기에는 Google에서 개발하였으며 현재는 Cloud Native Computing Foundation (CNCF)에서 관리하고 있습니다. gRPC를 사용하면 로컬 함수 호출과 마찬가지로 분산된 이기종 애플리케이션을 연결하고, 호출하고, 작동하고, 디버깅할 수 있습니다.

gRPC는 전송으로 HTTP/2를 사용하며 API 개발을 위한 계약 우선 접근 방식, 인터페이스 정의 언어로 Protocol Buffers (Protobuf)를 사용하고 기본 메시지 교환 형식으로도 사용합니다. 네 가지 유형의 API (Unary RPC, Server streaming RPC, Client streaming RPC, 그리고 Bidirectional streaming RPC)를 지원합니다. gRPC에 대해 자세히 알고 싶으시면 여기를 참조하세요.

시작하기

.NET 코어 설치를 진행하기 전에, 다음 필수 구성 요소를 설치해야 합니다:

  • Visual Studio Code, Visual Studio 또는 JetBrains Rider IDE
  • .NET Core
  • gRPC .NET
  • Protobuf

1단계: Visual Studio 또는 명령 행에서 gRPC 프로젝트 생성

  • 새 프로젝트를 생성하려면 다음 명령을 사용하세요. 성공적으로 생성되면, 지정한 디렉토리에 ‘ChatServer‘라는 이름으로 생성됩니다.
PowerShell

 

dotnet new grpc -n ChatServerApp

  • 선택한 에디터로 프로젝트를 여세요. 저는 Mac용 Visual Studio를 사용하고 있습니다.

2단계: Proto 파일에서 Protobuf 메시지 정의

Protobuf 계약:

  1. ‘protos’ 폴더 내에 server.proto라는 이름의 .proto 파일을 생성하세요. 이 proto 파일은 서비스의 구조를 정의하는 데 사용되며, 메시지 타입과 서비스가 지원하는 메소드를 포함합니다.
ProtoBuf

 

syntax = "proto3";

option csharp_namespace = "ChatServerApp.Protos";

package chat;

service ChatServer {
  
// 클라이언트와 서버 간의 양방향 통신 스트림
  rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);

}


// 클라이언트 메시지:
message ClientMessage {
  oneof content {
	ClientMessageLogin login = 1;
	ClientMessageChat chat = 2;
  }
}

message ClientMessageLogin {
  string chat_room_id = 1;
  string user_name = 2;
}


message ClientMessageChat {
  string text = 1;
}


// 서버 메시지
message ServerMessage {
  oneof content {
	ServerMessageLoginSuccess login_success = 1;
	ServerMessageLoginFailure login_failure = 2;
	ServerMessageUserJoined user_joined = 3;
	ServerMessageChat chat = 4;
  }
}

message ServerMessageLoginFailure {
  string reason = 1;
}

message ServerMessageLoginSuccess {
}

message ServerMessageUserJoined {
  string user_name = 1;
}

message ServerMessageChat {
  string text = 1;
  string user_name = 2;
}

  • ChatServer는 우리 채팅 애플리케이션의 기본 서비스를 정의하며, 클라이언트와 서버 간의 양방향 스트리밍을 위한 단일 RPC 메서드인 HandleCommunication를 포함합니다. 이 메서드는 ClientMessage의 스트림을 입력으로 받고 ServerMessage의 스트림을 출력으로 반환합니다. 
ProtoBuf

 

service ChatServer {
  // 클라이언트와 서버 간의 양방향 통신 스트림
  rpc HandleCommunication(stream ClientMessage) returns (stream ServerMessage);

}

  • ClientMessageLogin, 클라이언트가 보내는 메시지로, chat_room_id와 user_name이라는 두 필드를 가집니다. 이 메시지 유형은 클라이언트로부터 서버로 로그인 정보를 보내는 데 사용됩니다. chat_room_id 필드는 클라이언트가 참여하고자 하는 채팅방을 지정하며, user_name 필드는 채팅방에서 클라이언트가 사용하려는 사용자 이름을 지정합니다
ProtoBuf

 

message ClientMessageLogin {
  string chat_room_id = 1;
  string user_name = 2;
}


  • ClientMessageChat는 클라이언트로부터 서버로 채팅 메시지를 보내는 데 사용됩니다. 이 메시지는 text라는 단일 필드를 포함합니다.
ProtoBuf

 

message ClientMessageChat {
  string text = 1;
}


  • ClientMessage는 클라이언트가 서버에 보낼 수 있는 다양한 유형의 메시지를 정의합니다. 이 메시지는 oneof 필드를 포함하며, 이는 한 번에 하나의 필드만 설정될 수 있음을 의미합니다. oneof를 사용하면 생성된 C# 코드에는 어떤 필드가 설정되었는지 나타내는 열거형이 포함됩니다. 필드 이름은 “login“과 “chat“로, 각각 ClientMessageLoginClientMessageChat 메시지에 해당합니다.
ProtoBuf

 

message ClientMessage {
  oneof content {
	ClientMessageLogin login = 1;
	ClientMessageChat chat = 2;
  }
}

  • ServerMessageLoginFailure는 클라이언트가 채팅방에 로그인하지 못했을 때 서버가 보내는 메시지를 정의합니다. 실패 이유를 지정하는 이유 필드가 포함되어 있습니다.
ProtoBuf

 

message ServerMessageLoginFailure {
  string reason = 1;
}

  • ServerMessageLoginSuccess 는 클라이언트가 채팅방에 성공적으로 로그인했을 때 서버가 보내는 메시지를 정의합니다. 필드는 포함되지 않고 단순히 로그인이 성공했음을 알립니다. 클라이언트가 ClientMessageLogin 메시지를 보내면, 서버는 로그인이 성공했는지 여부에 따라 ServerMessageLoginSuccess 메시지 또는 ServerMessageLoginFailure 메시지를 응답합니다. 로그인이 성공하면, 클라이언트는 ClientMessageChat 메시지를 보내어 채팅 메시지를 시작할 수 있습니다.
ProtoBuf

 

message ServerMessageLoginSuccess {
}

  • 메시지 ServerMessageUserJoined 는 새 사용자가 채팅방에 참여할 때 서버가 클라이언트에게 보내는 메시지를 정의합니다.
ProtoBuf

 

message ServerMessageUserJoined {
  string user_name = 1;
}

  • 메시지 ServerMessageChat는 새 채팅 메시지가 수신되었음을 나타내는 서버가 보내는 메시지를 정의합니다. text 필드는 채팅 메시지의 내용을, user_name 필드는 메시지를 보낸 사용자의 이름을 지정합니다.
ProtoBuf

 

message ServerMessageChat {
  string text = 1;
  string user_name = 2;
}

  • 메시지 ServerMessage는 서버에서 클라이언트로 보낼 수 있는 다양한 유형의 메시지를 정의합니다. 여기에는 여러 옵션을 가진 oneof 필드인 content가 포함되어 있습니다. 필드 이름은 “login_success“, “login_failure“, “user_joined“, 그리고 “chat“로, 각각 ServerMessageLoginSuccess, ServerMessageLoginFailure, ServerMessageUserJoined, ServerMessageChat 메시지에 해당합니다.
ProtoBuf

 

message ServerMessage {
  oneof content {
	ServerMessageLoginSuccess login_success = 1;
	ServerMessageLoginFailure login_failure = 2;
	ServerMessageUserJoined user_joined = 3;
	ServerMessageChat chat = 4;
  }
}

3단계: ChatService 클래스 추가

ChatServerBase(server.proto 파일을 protoc gRPC 코드 생성기로 생성한 것에서 파생된)에서 파생된 ChatService 클래스를 추가합니다. 그런 다음 HandleCommunication 메서드를 재정의합니다. HandleCommunication 메서드의 구현은 클라이언트와 서버 간의 통신을 처리하는 데 책임이 있습니다.

C#

 

public class ChatService : ChatServerBase
{
    private readonly ILogger<ChatService> _logger;

    public ChatService(ILogger<ChatService> logger)
    {
        _logger = logger;
    }

    public override Task HandleCommunication(IAsyncStreamReader<ClientMessage> requestStream, IServerStreamWriter<ServerMessage> responseStream, ServerCallContext context)
    {
        return base.HandleCommunication(requestStream, responseStream, context);
    }
}

4단계: gRPC 구성

program.cs 파일에서:

C#

 

using ChatServer.Services;
using Microsoft.AspNetCore.Server.Kestrel.Core;

var builder = WebApplication.CreateBuilder(args);


/*

// macOS에서 gRPC를 성공적으로 실행하기 위해 추가 구성이 필요합니다.

// macOS에서 Kestrel 및 gRPC 클라이언트를 구성하는 방법에 대한 지침은

// https://go.microsoft.com/fwlink/?linkid=2099682를 방문하세요.

   To avoid missing ALPN support issue on Mac. To work around this issue, configure Kestrel and the gRPC client to use HTTP/2 without TLS.
   You should only do this during development. Not using TLS will result in gRPC messages being sent without encryption.
   
   https://learn.microsoft.com/en-us/aspnet/core/grpc/troubleshoot?view=aspnetcore-7.0
*/
builder.WebHost.ConfigureKestrel(options =>
{
    
// TLS 없이 HTTP/2 엔드포인트 설정.
    options.ListenLocalhost(50051, o => o.Protocols =
        HttpProtocols.Http2);
});



// 컨테이너에 서비스 추가.
builder.Services.AddGrpc();
builder.Services.AddSingleton();

var app = builder.Build();


// HTTP 요청 파이프라인 구성.
app.MapGrpcService();
app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");

Console.WriteLine($"gRPC server about to listening on port:50051");

app.Run();

참고: ASP.NET Core gRPC 템플릿 및 샘플은 기본적으로 TLS를 사용합니다. 그러나 개발 목적으로는 Kestrel과 gRPC 클라이언트를 HTTP/2를 무선 TLS로 구성합니다.

5단계: ChatRoomService를 생성하고 HandleCommunication에서 필요한 다양한 메서드를 구현하세요.

ChatRoomService 클래스는 채팅방과 클라이언트를 관리하고, 클라이언트 간에 보내지는 메시지를 처리하는 역할을 합니다. 이 클래스는 ConcurrentDictionary를 사용하여 채팅방을 저장하고, 각 방에 대한 ChatClient 객체의 목록을 유지합니다. AddClientToChatRoom 메서드는 새 클라이언트를 채팅방에 추가하고, BroadcastClientJoinedRoomMessage 메서드는 새 클라이언트가 방에 참여할 때 해당 방의 모든 클라이언트에게 메시지를 보냅니다. BroadcastMessageToChatRoom 메서드는 메시지를 보낸 클라이언트를 제외한 방의 모든 클라이언트에게 메시지를 전송합니다.

ChatClient 클래스는 클라이언트에 메시지를 작성하기 위한 StreamWriter 객체와 클라이언트를 식별하기 위한 UserName 속성을 포함합니다.

C#

 

using System;
using ChatServer;
using Grpc.Core;
using System.Collections.Concurrent;

namespace ChatServer.Services
{
    public class ChatRoomService
    {
        private static readonly ConcurrentDictionary> _chatRooms = new ConcurrentDictionary>();

        
        /// 클라이언트에서 단일 메시지를 읽습니다.
        /// 
        /// 
        /// 
        public async Task ReadMessageWithTimeoutAsync(IAsyncStreamReader requestStream, TimeSpan timeout)
        {
            CancellationTokenSource cancellationTokenSource = new();

            cancellationTokenSource.CancelAfter(timeout);

            try
            {
                bool moveNext = await requestStream.MoveNext(cancellationTokenSource.Token);

                if (moveNext == false)
                {
                    throw new Exception("connection dropped exception");
                }

                return requestStream.Current;
            }
            catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled)
            {
                throw new TimeoutException();
            }
        }

        

        /// 
        /// 
        /// 
        /// 

        public async Task AddClientToChatRoom(string chatRoomId, ChatClient chatClient)
        {
            if (!_chatRooms.ContainsKey(chatRoomId))
            {
                _chatRooms[chatRoomId] = new List { chatClient };
            }
            else
            {
                var existingUser = _chatRooms[chatRoomId].FirstOrDefault(c => c.UserName == chatClient.UserName);
                if (existingUser != null)
                {
                    // 같은 사용자 이름이 채팅室内에 이미 존재합니다
                    throw new InvalidOperationException("User with the same name already exists in the chat room");
                }
                _chatRooms[chatRoomId].Add(chatClient);
            }

            await Task.CompletedTask;
        }
        
        /// 클라이언트가 방에 참여한 메시지를 벌어칩니다.
        /// 
        /// 
        /// 
        /// 
        public async Task BroadcastClientJoinedRoomMessage(string userName, string chatRoomId)
        {
            if (_chatRooms.ContainsKey(chatRoomId))
            {
                var message = new ServerMessage { UserJoined = new ServerMessageUserJoined { UserName = userName } };

                var tasks = new List();

                foreach (var stream in _chatRooms[chatRoomId])
                {
                    if (stream != null && stream != default)
                    {
                        tasks.Add(stream.StreamWriter.WriteAsync(message));
                    }
                }

                await Task.WhenAll(tasks);
            }
        }

        
        /// 
        /// 
        /// 
        /// 
        /// 
        public async Task BroadcastMessageToChatRoom(string chatRoomId, string senderName, string text)
        {
            if (_chatRooms.ContainsKey(chatRoomId))
            {
                var message = new ServerMessage { Chat = new ServerMessageChat { UserName = senderName, Text = text } };

                var tasks = new List();
                var streamList = _chatRooms[chatRoomId];
                foreach (var stream in _chatRooms[chatRoomId])
                {
                    //이 senderName은 각 사용자에게 고유한 Id가 될 수 있습니다.
                    if (stream != null && stream != default && stream.UserName != senderName)
                    {
                        tasks.Add(stream.StreamWriter.WriteAsync(message));
                    }
                }

                await Task.WhenAll(tasks);
            }
        }
    }

    public class ChatClient
    {
        public IServerStreamWriter StreamWriter { get; set; }
        public string UserName { get; set; }
    }
}

Step 6: 마침내, 3단계에서 gRPC HandleCommunication 메서드를 구현합니다.

HandleCommunication는 클라이언트에서 requestStream을 수신하고 클라이언트로 responseStream을 보냅니다. 이 메서드는 클라이언트에서 메시지를 읽고, 사용자 이름과 chatRoomId를 추출한 후, 로그인 케이스와 채팅 케이스 두 가지를 처리합니다. 

  • 로그인 경우에는 메서드가 사용자 이름과 chatRoomId가 유효한지 확인하고 그에 따라 클라이언트에 응답 메시지를 보냅니다. 로그인이 성공하면 클라이언트가 채팅방에 추가되고, 채팅방의 모든 클라이언트에게 방송 메시지가 전송됩니다. 
  • 채팅 경우에는 메서드가 채팅방의 모든 클라이언트에게 메시지를 방송합니다. 
C#

 

using System;
using ChatServer;
using Grpc.Core;

namespace ChatServer.Services
{
    public class ChatService : ChatServer.ChatServerBase
    {
        private readonly ILogger _logger;
        private readonly ChatRoomService _chatRoomService;

        public ChatService(ChatRoomService chatRoomService, ILogger logger)
        {
            _chatRoomService = chatRoomService;
            _logger = logger;
        }

        public override async Task HandleCommunication(IAsyncStreamReader requestStream, IServerStreamWriter responseStream, ServerCallContext context)
        {
            var userName = string.Empty;
            var chatRoomId = string.Empty;
            
            while (true)
             {
                //클라이언트로부터 메시지를 읽습니다.
                var clientMessage = await _chatRoomService.ReadMessageWithTimeoutAsync(requestStream, Timeout.InfiniteTimeSpan);

                switch (clientMessage.ContentCase)
                {
                    case ClientMessage.ContentOneofCase.Login:

                        var loginMessage = clientMessage.Login;
                        //클라이언트 메시지에서 사용자 이름과 채팅방 ID를 가져옵니다.
                        chatRoomId = loginMessage.ChatRoomId;
                        userName = loginMessage.UserName;

                        if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(chatRoomId))
                        {
                            //로그인 실패 메시지를 보냅니다.
                            var failureMessage = new ServerMessage
                            {
                                LoginFailure = new ServerMessageLoginFailure { Reason = "Invalid username" }
                            };

                            await responseStream.WriteAsync(failureMessage);

                            return;
                        }

                        //로그인 성공 메시지를 클라이언트에 보냅니다.
                        var successMessage = new ServerMessage { LoginSuccess = new ServerMessageLoginSuccess() };
                        await responseStream.WriteAsync(successMessage);

                        //클라이언트를 채팅방에 추가합니다.
                        await _chatRoomService.AddClientToChatRoom(chatRoomId, new ChatClient
                        {
                            StreamWriter = responseStream,
                            UserName = userName
                        });

                        break;

                    case ClientMessage.ContentOneofCase.Chat:

                        var chatMessage = clientMessage.Chat;

                        if (userName is not null && chatRoomId is not null)
                        {
                            //메시지를 방에 방송합니다.
                            await _chatRoomService.BroadcastMessageToChatRoom(chatRoomId, userName, chatMessage.Text);
                        }

                        break;
                }
            }
        }
    }
}

완성된 프로젝트 디렉토리:

이것으로 1부는 마칩니다. 다음 2부에서는  이 채팅 애플리케이션을 완성하기 위해 클라이언트 구현을 포함한 클라이언트 프로젝트를 작성할 것입니다.

Source:
https://dzone.com/articles/create-a-concurrent-grpc-chat-messaging-in-net-7