각각은 있어도 이렇게 조합된 걸 찾지 못한 데다가
간단한 채팅 샘플이 있었으면 해서 만들었습니다.
완성된 프로젝트 : github - dang-gun/AspDotNetSamples/SignalRWebpack/
프로젝트 구성은 다음과 같습니다
ASP.NET Core 6.0
Webpack 5.76
TypeScript 4.9.5
'ASP.NET Core 6.0 웹 API' 프로젝트를 생성합니다.
프론트 엔드는 'ClientApp'폴더에 넣었습니다.
따로 참조를 추가할 건 없습니다.
클라이언트를 관리하기 위한 목적의 유저리스트를 만들기 위한 모델입니다.
유저 모델
/// <summary> /// 유저 /// </summary> public class UserModel { /// <summary> /// 시그널R에서 생성한 고유아이디 /// </summary> public string ConnectionId { get; set; } = string.Empty; /// <summary> /// 채팅으로 사용중인 이름 /// </summary> public string Name { get; set; } = string.Empty; }
유저 리스트를 관리하기 위한 클래스
public class UserList { public List<UserModel> Users { get; set; } = new List<UserModel>(); public void Add(string sConnectionId) { Users.Add(new UserModel { ConnectionId = sConnectionId }); } public UserModel? Find(string sName) { return this.Users.FirstOrDefault(x => x.Name == sName); } public UserModel? FindConnectionId(string ConnectionId) { return this.Users.FirstOrDefault(x => x.ConnectionId == ConnectionId); } public void Remove(string sConnectionId) { UserModel? userModel =this.FindConnectionId(sConnectionId); if (userModel != null) { Users.Remove(userModel); } } }
이 클래스는 전역변수(Static)로 생성하여 사용합니다.
시그널R은 서버와 클라이언트가 데이터를 주고받습니다.
이 데이터를 구조화하기 위한 모델입니다.
이 프로젝트에서는 JSON으로 변환하여 데이터를 주고받습니다.
/// <summary> /// 시그널r에서 데이터 주고 받기용 모델 /// </summary> /// <remarks> /// 타입스크립트와 동일해야 한다. /// </remarks> public class SignalRSendModel { /// <summary> /// 보내는 사람 /// </summary> public string Sender { get; set; } = string.Empty; /// <summary> /// 특정 유저한테 메시지를 보낼때 대상 아이디(없으면 전체) /// </summary> public string To { get; set; } = string.Empty; /// <summary> /// 전달할 명령어 /// </summary> public string Command { get; set; } = string.Empty; /// <summary> /// 보내는 메시지 /// </summary> public string Message { get; set; } = string.Empty; }
시그널R은 허브를 구현하여 사용합니다.
이렇게 구현된 허브가 서버 역할을 하게 됩니다.
/// <summary> /// 체팅 허브 /// </summary> /// <remarks> /// 시그널R의 동작을 구현한다. /// </remarks> public class ChatHub : Hub { public ChatHub() { } /// <summary> /// 유저 접속 처리 /// </summary> /// <returns></returns> public override Task OnConnectedAsync() { //Guid로 id를 생성할 필요가 없다. //Console.WriteLine("--> Connection Established" + Context.ConnectionId); Debug.WriteLine("--> Connection Established : " + Context.ConnectionId); Clients.Client(Context.ConnectionId).SendAsync("ReceiveConnID", Context.ConnectionId); //유저 리스트에 추가 GlobalStatic.UserList.Add(Context.ConnectionId); return base.OnConnectedAsync(); } /// <summary> /// 접속 끊김 처리 /// </summary> /// <param name="exception"></param> /// <returns></returns> public override Task OnDisconnectedAsync(Exception? exception) { //유저 리스트에 제거 GlobalStatic.UserList.Remove(Context.ConnectionId); Debug.WriteLine("Disconnected : " + Context.ConnectionId); return base.OnDisconnectedAsync(exception); } /// <summary> /// 클라이언트에서 서버로 전달된 메시지 처리 /// </summary> /// <param name="message"></param> /// <returns></returns> public async Task SendMessageAsync(string message) { Debug.WriteLine("Message Received on: " + Context.ConnectionId); SignalRSendModel? sendModel = JsonConvert.DeserializeObject<SignalRSendModel>(message); if(null == sendModel) { return; } switch(sendModel.Command) { case "Login"://아이디 입력 요청 { UserModel? findUserName = GlobalStatic.UserList.Find(sendModel.Message); if(null != findUserName) { await this.SendUser_Me(new SignalRSendModel() { Sender = "server" , Command = "LoginError_Duplication" , Message = "이미 사용중인 아이디 입니다." }); await this.OnDisconnectedAsync(null); } else { findUserName = GlobalStatic.UserList.FindConnectionId(Context.ConnectionId); if(null != findUserName) { //전달받은 이름을 넣고 findUserName.Name = sendModel.Message; await this.SendUser_Me(new SignalRSendModel() { Sender = "server" , Command = "LoginSuccess" , Message = findUserName.Name }); } else { //여기서 일치하는게 없다는건 리스트에추가되지 않았다는 의미이므로 //재접속을 요구한다. await this.SendUser_Me(new SignalRSendModel() { Sender = "server" , Command = "LoginError_Reconnect" , Message = "다시 접속해 주세요" }); await this.OnDisconnectedAsync(null); } } } break; case "MsgSend"://메시지 전달 요청 { UserModel? findUserName = GlobalStatic.UserList.FindConnectionId(Context.ConnectionId); if(null != findUserName) { sendModel.Sender = findUserName.Name; if (sendModel.To == string.Empty) { await this.SendUser_All(sendModel); } else { UserModel? findToUserName = GlobalStatic.UserList.Find(sendModel.To); if(null != findToUserName) { await this.SendUser(findToUserName.ConnectionId, sendModel); } } } } break; } } /// <summary> /// 요청한 대상한테 메시지 전달 /// </summary> /// <param name="signalRSendModel"></param> /// <returns></returns> private async Task SendUser_Me(SignalRSendModel signalRSendModel) { await this.SendUser(Context.ConnectionId, signalRSendModel); } /// <summary> /// 지정된 이름의 유저를 찾아 메시지를 전달한다. /// </summary> /// <param name="sName"></param> /// <param name="signalRSendModel"></param> /// <returns></returns> private async Task SendUser_Name( string sName , SignalRSendModel signalRSendModel) { UserModel? findUserName = GlobalStatic.UserList.Find(sName); if (null != findUserName) { await this.SendUser(findUserName.ConnectionId, signalRSendModel); } } /// <summary> /// 지정한 대상한테 메시지 전달 /// </summary> /// <param name="sConnectionId"></param> /// <param name="signalRSendModel"></param> /// <returns></returns> private async Task SendUser( string sConnectionId , SignalRSendModel signalRSendModel) { string sSendModel = JsonConvert.SerializeObject(signalRSendModel); await Clients .Client(sConnectionId) .SendAsync("ReceiveMessage", sSendModel); } /// <summary> /// 모든 접속자에게 메시지 전달 /// </summary> /// <param name="signalRSendModel"></param> /// <returns></returns> private async Task SendUser_All(SignalRSendModel signalRSendModel) { string sSendModel = JsonConvert.SerializeObject(signalRSendModel); await Clients .All .SendAsync("ReceiveMessage", sSendModel); } }
시그널R은 서비스에서 동작하기 때문에 동작 중인 개체에 접근하려면 컨택스트를 넘겨받아야 합니다.
이렇게 넘겨받은 컨택스트를 관리하기 위한 클래스입니다.
/// <summary> /// 서버가 시그널R과 통신하기위한 클래스 /// </summary> public class ChatHubContext { private readonly IHubContext<ChatHub> _hubContext; public ChatHubContext(IHubContext<ChatHub> hubContext) { _hubContext = hubContext; } /// <summary> /// 요청한 대상한테 메시지 전달 /// </summary> /// <param name="signalRSendModel"></param> /// <returns></returns> public async Task SendUser_Name( string sName , SignalRSendModel signalRSendModel) { UserModel? findUserName = GlobalStatic.UserList.Find(sName); if (null != findUserName) { await this.SendUser(findUserName.ConnectionId, signalRSendModel); } } /// <summary> /// 지정한 대상한테 메시지 전달 /// </summary> /// <param name="sConnectionId"></param> /// <param name="signalRSendModel"></param> /// <returns></returns> public async Task SendUser( string sConnectionId , SignalRSendModel signalRSendModel) { string sSendModel = JsonConvert.SerializeObject(signalRSendModel); await _hubContext.Clients .Client(sConnectionId) .SendAsync("ReceiveMessage", sSendModel); } /// <summary> /// 모든 접속자에게 메시지 전달 /// </summary> /// <param name="signalRSendModel"></param> /// <returns></returns> public async Task SendUser_All(SignalRSendModel signalRSendModel) { string sSendModel = JsonConvert.SerializeObject(signalRSendModel); await _hubContext.Clients .All .SendAsync("ReceiveMessage", sSendModel); } }
이 코드에서는 넘겨받은 컨택스트로 유저들에게 메시지를 전달하는 역할만 합니다.
이제 ASP.NET Core 서버가 실행될 때 시그널R을 구성하고 웹 소켓을 열고 대기하도록 코드를 작성해야 합니다.
(이 프로젝트에서는 'Program.cs'파일에서 구성하는 방식을 사용하고 있습니다.)
서비스에 시그널R을 사용한다고 알리고
//시그널R 설정 //.AddControllers보다 앞에 와야 한다. builder.Services.AddSignalR();
크로스 도메인 설정을 추가 해줍니다.
이때 접속을 허용할 프론트엔드의 주소를 설정합니다.
//시그널R 설정 CORS 설정 builder.Services.AddCors(options => { options.AddDefaultPolicy( builder => { //builder.WithOrigins("[접속 허용할 프론트엔드의 주소]") builder.WithOrigins("http://localhost:9500") .AllowAnyHeader() .WithMethods("GET", "POST") .AllowCredentials(); }); });
크로스 도메인 사용을 알리고
// MapHub 보다 앞에 와야 한다. app.UseCors();
위에서 만든 채팅용 허브를 등록해 줍니다.
//MapControllers 보다 앞에 와야 한다. app.MapHub<ChatHub>("/chatHub"); //app.MapHub가 없는경우 아래와 같이 사용한다. //app.UseEndpoints(endpoints => //{ // endpoints.MapHub<ChatHub>("/chatHub"); //});
컨트롤러는 접속한 유저를 관리하기 위한 용도입니다.
이 프로젝트에서는 접속한 유저에게 메시지를 보내는 동작을 합니다.
컨트롤러가 생성될 때 채팅 허브 컨택스트를 전달받아 저장해 둡니다.
public class TestController : Controller { private readonly ChatHubContext _ChatHubContext; public TestController(IHubContext<ChatHub> hubContext) { this._ChatHubContext = new ChatHubContext(hubContext); } }
이렇게 하여 서비스에서 동작 중인 시그널R 허브 개체에 접근할 수 있습니다.
메시지를 보내는 기능은 아래와 같이 구현합니다.
/// <summary> /// 지정된 유저에게 메시지를 보낸다. /// </summary> /// <param name="sTo"></param> /// <param name="sMessage"></param> /// <returns></returns> [HttpGet] public ActionResult MessageTo(string sTo, string sMessage) { ObjectResult apiresult = new ObjectResult(200); apiresult = StatusCode(200, "성공!"); this.NewMessage(sTo, sMessage); return apiresult; } /// <summary> /// /// </summary> /// <param name="sTo"></param> /// <param name="sMessage"></param> private async void NewMessage(string sTo, string sMessage) { if(string.Empty == sTo) { await this._ChatHubContext.SendUser_All( new SignalRSendModel() { Sender = "Server" , To = "" , Command = "MsgSend" , Message = sMessage }); } else { await this._ChatHubContext.SendUser_Name( sTo , new SignalRSendModel() { Sender = "Server" , To = "" , Command = "MsgSend" , Message = sMessage }); } }
웹 팩과 타입스크립트를 설치하고
시그널R 라이브러리를 이용하여 서버와 통신합니다.
패키지에(package.json) 타입스크립트와 웹팩(webpack), 시그널R을 추가합니다.
"devDependencies": { .... 중략 ..... "typescript": "4.9.5", "webpack": "5.76.1", "webpack-cli": "5.0.1", "webpack-dev-server": "^4.9.3" }, "dependencies": { "@microsoft/signalr": "^7.0.4", "@types/node": "^18.15.3" }
웹팩은 아래와 같이 구성하였습니다.
이것은 예제로 자신에게 맞게 수정하여 사용하면 됩니다.
const path = require("path"); const webpack = require('webpack'); const HtmlWebpackPlugin = require("html-webpack-plugin"); const { CleanWebpackPlugin } = require("clean-webpack-plugin"); const MiniCssExtractPlugin = require("mini-css-extract-plugin"); module.exports = (env, argv) => { //릴리즈(프로덕션)인지 여부 const EnvPrductionIs = argv.mode === "production"; console.log("*** Mode = " + argv.mode); return { /** 서비스 모드 */ mode: EnvPrductionIs ? "production" : "development", devtool: "inline-source-map", entry: "./src/index.ts", output: { path: path.resolve(__dirname, "../wwwroot"), filename: "[name].[chunkhash].js", publicPath: "/", }, resolve: { extensions: [".js", ".ts"], }, module: { rules: [ { test: /\.ts$/, use: "ts-loader", }, { test: /\.css$/, use: [MiniCssExtractPlugin.loader, "css-loader"], }, ], }, plugins: [ new webpack.SourceMapDevToolPlugin({}), new CleanWebpackPlugin(), new HtmlWebpackPlugin({ template: "./src/index.html", }), new MiniCssExtractPlugin({ filename: "css/[name].[chunkhash].css", }), ], devServer: { /** 서비스 포트 */ port: "9500", /** 출력파일의 위치 */ static: [path.resolve("./", "build/development/")], /** 브라우저 열지 여부 */ open: true, /** 핫리로드 사용여부 */ hot: true, /** 라이브 리로드 사용여부 */ liveReload: true }, }; }
백엔드의 시그널R과 통신하도록 구현합니다.
체팅을 위한 HTML 코드입니다.
index.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <base href=""/> <title>ASP.NET Core SignalR with TypeScript and Webpack</title> </head> <body> <div> <div> <input type="text" id="txtServerUrl" placeholder="server url" value="https://localhost:7282/chatHub" /> <button id="btnConnect">Connect</button> <button id="btnDisconnect">Disconnect</button> <button id="btnApiCall">Api Call test</button> </div> <div> <input type="text" id="txtId" class="input-zone-input" placeholder="ID Inupt" /> <button id="btnLogin">Login</button> </div> <br /> <br /> To : <input type="text" id="txtTo" style="width:50px;" /> Message : <input type="text" id="txtMessage" /> <button id="btnSend">Send</button> </div> <br /> <br /> <div id="divLog"> </div> </body> </html>
백엔드에서 만든 통신용 모델과 동일한 모양으로 만들어 줍니다.
export interface SignalRSendModel { /** 보내는 사람 */ Sender: string; /** 특정 유저한테 메시지를 보낼때 대상 아이디 */ To: string; /** 전달할 명령어 */ Command: string; /** 보내는 메시지 */ Message: string; }
시그널R과 UI를 처리하기 위한 구현입니다.
index.ts
import * as signalR from "@microsoft/signalr"; import "./css/main.css"; import { SignalRSendModel } from "./SignalRSendModel"; export default class App { /** 서버 주소 */ txtServerUrl: HTMLInputElement = document.querySelector("#txtServerUrl"); /** 연결 버튼 */ btnConnect: HTMLButtonElement = document.querySelector("#btnConnect"); /** 연결 끊기 버튼 */ btnDisconnect: HTMLButtonElement = document.querySelector("#btnDisconnect"); /** id입력창 */ txtId: HTMLInputElement = document.querySelector("#txtId"); /** 로그인 버튼 */ btnLogin: HTMLButtonElement = document.querySelector("#btnLogin"); txtTo: HTMLInputElement = document.querySelector("#txtTo"); txtMessage: HTMLInputElement = document.querySelector("#txtMessage"); btnSend: HTMLButtonElement = document.querySelector("#btnSend"); /** 로그 출력위치 */ divLog: HTMLDivElement = document.querySelector("#divLog"); /** 시그널r 연결 개체 */ connection: signalR.HubConnection; constructor() { this.btnConnect.onclick = this.ConnectClick; this.btnLogin.onclick = this.LoginClick; this.btnSend.onclick = this.SendClick; //시그널r 연결 this.connection = new signalR.HubConnectionBuilder() .withUrl(this.txtServerUrl.value) .build(); //메시지 처리 연결 this.connection.on("ReceiveMessage", this.ReceivedMessage); //서버 끊김 처리 this.connection.onclose(error => { this.LogAdd("서버와 연결이 끊겼습니다."); this.UI_Disconnect(); }); this.LogAdd("준비 완료"); this.UI_Disconnect(); } // #region UI 관련 /** 연결이 되지 않은 상태*/ UI_Disconnect = () => { this.txtServerUrl.disabled = false; this.btnConnect.disabled = false; this.btnDisconnect.disabled = true; this.txtId.disabled = true; this.btnLogin.disabled = true; this.txtTo.disabled = true; this.txtMessage.disabled = true; this.btnSend.disabled = true; } /** 연결만 된상태 */ UI_Connect = () => { this.txtServerUrl.disabled = true; this.btnConnect.disabled = true; this.btnDisconnect.disabled = false; this.txtId.disabled = false; this.btnLogin.disabled = false; this.txtTo.disabled = true; this.txtMessage.disabled = true; this.btnSend.disabled = true; } /** 로그인 까지 완료 */ UI_Login = () => { this.txtServerUrl.disabled = true; this.btnConnect.disabled = true; this.btnDisconnect.disabled = false; this.txtId.disabled = true; this.btnLogin.disabled = true; this.txtTo.disabled = false; this.txtMessage.disabled = false; this.btnSend.disabled = false; } // #endregion /** * 로그 출력 * @param sMsg */ LogAdd = (sMsg: string) => { //요청 시간 let dtNow = new Date(); //출력할 최종 메시지 let sMsgLast = sMsg; //로그개체 생성 let divItem: HTMLElement = document.createElement("div"); //출력 내용 지정 divItem.innerHTML = `<label>[${dtNow.getHours()}:${dtNow.getMinutes()}:${dtNow.getSeconds()}]</label> <label>${sMsgLast}</label>`; //내용 출력 this.divLog.appendChild(divItem); } // #region 연결 관련 /** * 연결 클릭 * @param event */ ConnectClick = (event) => { let objThis = this; this.connection.start() .then(() => { objThis.LogAdd("연결 완료!"); objThis.UI_Connect(); }) .catch((err) => { objThis.LogAdd("ConnectClick : " + err); objThis.UI_Disconnect(); }); }; /** * 연결 클릭 * @param event */ DisconnectClick = (event) => { this.Disconnect(); }; /** 시그널r 끊기 시도*/ Disconnect = () => { let objThis = this; this.connection.stop() .then(() => { objThis.LogAdd("연결 끊김"); }) .catch((err) => { objThis.LogAdd("DisconnectClick : " + err); }); objThis.UI_Disconnect(); } // #endregion SendModel = (sendModel: SignalRSendModel) => { let sSendModel: string = JSON.stringify(sendModel); this.connection .send("SendMessageAsync", sSendModel) .then(() => { }); } // #region 로그인 관련 /** * 로그인 시도 * @param event */ LoginClick = (event) => { this.SendModel({ Sender: "" , Command: "Login" , Message: this.txtId.value , To: "" }); } // #endregion ReceivedMessage = (sSendModel: string) => { //전달받은 모델을 파싱한다. let sendModel: SignalRSendModel = JSON.parse(sSendModel); //debugger; switch (sendModel.Command) { case "LoginSuccess": this.LogAdd("로그인 성공 : " + sendModel.Message); this.UI_Login(); break; case "LoginError_Duplication": this.LogAdd("이미 사용중인 아이디 입니다."); this.UI_Disconnect(); break; case "LoginError_Reconnect": this.LogAdd("다시 접속해 주세요."); this.UI_Disconnect(); break; case "MsgSend"://메시지 전달받음 this.LogAdd(sendModel.Sender + " : " + sendModel.Message); break; } } SendClick = (event) => { this.SendModel({ Sender: "" , Command: "MsgSend" , Message: this.txtMessage.value , To: this.txtTo.value }); } } (window as any).app = new App();
인터페이스는 아래와 같습니다.
완성된 프로젝트 : github - dang-gun/AspDotNetSamples/SignalRWebpack/
MS Learn - ASP.NET Core SignalR JavaScript 클라이언트
MS Learn - ASP.NET SignalR Hubs API 가이드 - 서버(C#)
github - dotnet/AspNetCore.Docs/aspnetcore/signalr/javascript-client/samples/6.x/SignalRChat/
Dotnet Playbook - Which is best? WebSockets or SignalR
시그널R은 웹 소켓을 이용한 서버/클라이언트 라이브러리인데.....
왜 이런 구성의 기본이 되는 체팅 샘플이 없는 걸까요?
ASP.NET Core + 웹팩(Webpack) + 타입스크립트(TypeScript) + 시그널R(signalR)로 엮어서 사용하는 사람들이 많을 거 같은데 말이죠.....