2023. 8. 22. 15:30

각각은 있어도 이렇게 조합된 걸 찾지 못한 데다가 

간단한 채팅 샘플이 있었으면 해서 만들었습니다.

완성된 프로젝트 : github - dang-gun/AspDotNetSamples/SignalRWebpack/

 

 

 

0. 프로젝트 생성

프로젝트 구성은 다음과 같습니다

ASP.NET Core 6.0

Webpack 5.76

TypeScript 4.9.5

 

 

'ASP.NET Core 6.0 웹 API' 프로젝트를 생성합니다.

 

프론트 엔드는 'ClientApp'폴더에 넣었습니다.

 

 

1. 백엔드 구현

따로 참조를 추가할 건 없습니다.

 

1-1. 유저 모델 및 유저 리스트

클라이언트를 관리하기 위한 목적의 유저리스트를 만들기 위한 모델입니다.

 

유저 모델

/// <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)로 생성하여 사용합니다.

 

 

1-2. 시그널R(SignalR) 통신용 모델

시그널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;
}

 

 

1-3. 채팅 허브 구현

시그널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);
    }
}

 

 

1-4. 백엔드에서 시그널R에 접근하기 위해 사용하는 컨택스트(Context)

시그널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);
    }
}

 

이 코드에서는 넘겨받은 컨택스트로 유저들에게 메시지를 전달하는 역할만 합니다.

 

 

1-5. 시그널R 설정

이제 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");
//});

 

 

1-6. 컨트롤러 구현

컨트롤러는 접속한 유저를 관리하기 위한 용도입니다.

이 프로젝트에서는 접속한 유저에게 메시지를 보내는 동작을 합니다.

 

컨트롤러가 생성될 때 채팅 허브 컨택스트를 전달받아 저장해 둡니다.

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
            });
    }
}

 

 

 

2. 프론트엔드 설정

웹 팩과 타입스크립트를 설치하고

시그널R 라이브러리를 이용하여 서버와 통신합니다.

 

 

2-1. 패키지 구성

패키지에(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"
  }

 

2-2. 웹팩 설정

웹팩은 아래와 같이 구성하였습니다.

이것은 예제로 자신에게 맞게 수정하여 사용하면 됩니다.

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
        },
    };
    
}

 

 

3. 프론트엔드 구현

백엔드의 시그널R과 통신하도록 구현합니다.

 

3-1. HTML 코드

체팅을 위한 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>

 

 

3-2. 시그널R 통신용 모델

백엔드에서 만든 통신용 모델과 동일한 모양으로 만들어 줍니다.

export interface SignalRSendModel
{
    /** 보내는 사람 */
    Sender: string;
    /** 특정 유저한테 메시지를 보낼때 대상 아이디 */
    To: string;

    /** 전달할 명령어 */
    Command: string;
    /** 보내는 메시지 */
    Message: string;
}

 

 

3-3. 프론트엔드 구현

시그널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();

 

 

4. 테스트

인터페이스는 아래와 같습니다.

 

 

 

 

마무리

완성된 프로젝트 : 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)로 엮어서 사용하는 사람들이 많을 거 같은데 말이죠.....