각각은 있어도 이렇게 조합된 걸 찾지 못한 데다가
간단한 채팅 샘플이 있었으면 해서 만들었습니다.
완성된 프로젝트 : 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)로 엮어서 사용하는 사람들이 많을 거 같은데 말이죠.....