본문 바로가기
자습

React + Gin + Emqx Chat App

by litaro 2021. 2. 27.

Chat App 과 같이 서로 통신하는 Web App을 만든다면 어떻게 해야할지 궁금하던 차에 여러 블로그를 통해 아래의 구조가 가능하다는 것을 알게 되었다. 그래서 간단한 Chat App을 만들어 보기로 했다.

front 는 React (+bootstrap), backend는 Gin으로, 그리고 front와 backend 는 websocket 통신을 하는 web app을 만들고, Gin server가 EMQX Broker를 통해 각자의 Topic 을 보면서 app A/B 와 통신하는 구조다. 사실 굳이 이렇게 websocket 과 emqx 를 둘다 사용해서 할 필요는 없는데, 잘 모르는 기술들을 다 한번씩 써보고 싶어서 ^^;; 이런 구조를 잡았다.

환경 설정

EMQX

Emqx Broker 를 사용하기 위해 local 에 Emqx Broker 를 설치 www.emqx.io/downloads#broker (mac)

> brew tap emqx/emqx
> brew install emqx
> emqx start

 

Source Tree 생성

Front 만들기 (React)

> mkdir chat
> npx create-react-app front

기본적인 React 앱이 생성된다. 브라우저에서 http://localhost:3000 오픈

> npm run start

참고: React 개발 툴을 chrome web store 에서 찾아서 설치하면 유용하다.

Backend 서버 만들기 (Gin + emqx)

> cd chat
> mkdir backend
> go mod init backend

Backend에서 front를 띄우도록 변경해야하니 아래와 같이 main.go 코드를 작성한다.

package main

import (
	"github.com/gin-gonic/contrib/static"
	"github.com/gin-gonic/gin"
)

func main() {
	router := gin.Default()
	router.Use(static.Serve("/", static.LocalFile("../front/build", true)))

	router.Run(":3030")
}

그리고 shell 파일 만들어서 한번에 실행하게 하면, http://localhost:3030 에서 오픈

#!/bin/bash

path=$( cd "$(dirname "$0")" ; pwd )
cd $path/front
npm install
npm run build

cd $path/backend
go run main.go

 

이제 기본 Chat 코드 준비가 완료 되었으니 다음을 진행하자

  • front
    • Chatting 메세지를 입력하고 보여주는 화면을 만들기
    • websocket 으로 backend 와 연결해서 메세지 주고 받기
  • backend
    • websocket 으로 front 와 연결해서 메세지 주고 받기
    • emqx library 로 emqx broker 와 연결해서 topic 을 통해 다른 앱과 통신하기

Step 1. Websocket 을 통한 backend 와 front 통신

Front 구현

UI 가 항상 많은 시간을 필요로 한다. 그러기에 최소한 채팅 개념만 보여주는 UI를 만들자.

이왕이면 저번에 공부한 bootstrap 도 사용해보고~

React 를 대강 이라도 이해하려면 reactjs.org/tutorial/tutorial.html 를 한번씩 따라해보면 된다. 

불필요한 코드를 제거하고 

websocket.js

  • 자신의 서버 /ws 로 websocket url 을 설정
  • websocket 으로 들어오는 메세지를 UI에 전달하기 위해 addListner 추가 
  • websocket 으로 메세지를 보내기 위해 sendMessage 추가
class websocketConn {
    constructor() {
        this.websocket = new WebSocket('ws://127.0.0.1:3030/ws')
        this.connect()
    }

    connect() {
        this.websocket.onopen = function () {
            console.log('Connected');
        };
        this.websocket.onclose = function () {
            console.log('Closed');
        };
        this.websocket.onmessage = function (message) {
            let data = JSON.parse(message.data)
            console.log('Message Received: ', data)
        }
    }

    addListener(listner) {
        console.log('addListener')
        this.websocket.addEventListener("message", (message) => {
            let data = JSON.parse(message.data)
            listner(data)
        })
    }

    sendMessage(message) {
        console.log('sendMessage')
        this.websocket.send(message)
    }
}

export default websocketConn; 

index.js

  • websocket 초기화 작업 추가
import React from 'react';
import ReactDOM from 'react-dom';
import '../node_modules/bootstrap/dist/css/bootstrap.css'
import './index.css';
import App from './App';
import websocketConn from './websocket'

console.log('Create websocket connection ...')
const websocket = new websocketConn();
console.log('Create websocket connection Success!')

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

export default websocket;

App.js

  • React Component 는 간단하게 아래 세가지로 구성 
    • Name : 채팅할 내 이름 UI
    • Chat : 채팅 메세지 보여주는 UI
    • Input : 나의 채팅 메세지 입력 UI
  • websocket 사용하기 위해 import
  • websocket 의 event 받기 위해 componentDidMount() 에서 websocket.addListener 호출
  • message list를 업데이트해서 변경된 리스트를 Chat 에 전달
  • message 받기: messageListner
    • message list 에 들어온 메세지 추가
  • message 보내기: handleMessage
    • message list 에 보내는 메세지 추가
  • 참고: form 을 사용해서 input을 받는데 이때 화면 전체가 refresh 되는 이슈가 있다. 그걸 방지하기 위해서 보통 onSubmit(e) 에서 e.preventDefault()를 추가한다.
import './App.css';
import React from 'react';
import Chat from './Chat';
import websocket from './index'

let nameInput = React.createRef();
let messageInput = React.createRef();

class Input extends React.Component {
  ...
}

class Name extends React.Component {
  ...
}

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      visible: false,
      messageList: [],
    }
  }

  componentDidMount() {
    setTimeout(() => {
      websocket.addListener(this.messageListener)
    }, 250)
  }

  addMessage = (message) => {
    let messageList = this.state.messageList.slice()
    messageList.push(message)
    this.setState({
      messageList: messageList
    })
  }

  messageListener = (message) => {
    this.addMessage(message)
  }

  handleMessage = (e) => {
    e.preventDefault();
    let messageList = this.state.messageList.slice()
    let message = {
      payload: messageInput.current.value,
      sender: nameInput.current.value
    }
    messageList.push(message)
    this.setState({
      messageList: messageList
    })
    websocket.sendMessage(JSON.stringify(message))
  }

  handleStart = (e) => {
    e.preventDefault();
    this.setState({
      name: nameInput.current.value,
      visible: true
    })
  }

  render() {
    return (
      <div className="container-fluid" >
        <div className="container-fluid text-white" style={{ backgroundColor: 'rgb(43, 38, 44)' }}>
          <h1 style={{ color: '#fffdc2' }}> Let's Chat</h1>
          <Name onSubmit={this.handleStart} />
          {this.state.visible ? < Chat visible={this.state.visible} messageList={this.state.messageList} myName={nameInput.current.value} /> : null}
          {this.state.visible ? <Input visible={this.state.visible} onSubmit={this.handleMessage} /> : null}
        </div>
      </div>
    );
  }
}

export default App;

chat.js

  • Chat component는 아래 두 가지 component로 되어 있다.
    • Receiver : message list의 message sender가 내 이름인 경우
    • Sender: message list 의 message sender가 내 이름이 아닌 경우
  • 참고: 자동 스크롤을 하기 위해서 보이지 않는 div 를 추가해서 scrollIntoView 함수로 구현한다. 

 

import React from 'react';

class Receiver extends React.Component {
	...
}

class Sender extends React.Component {
	...
}


class Chat extends React.Component {
    scrollToBottom = () => {
        this.messagesEnd.scrollIntoView({ behavior: "smooth" });
    }

    componentDidMount() {
        this.scrollToBottom();
    }

    componentDidUpdate() {
        this.scrollToBottom();
    }

    render() {
        let messageList = this.props.messageList
        let myName = this.props.myName
        const chatList = []
        if (this.props.visible) {
            var key
            for (key in messageList) {
                let message = messageList[key]
                if (message.sender == myName) {
                    chatList.push(<Receiver name={message.sender} message={message.payload} />)
                } else {
                    chatList.push(<Sender name={message.sender} message={message.payload} />)
                }
            }
        }

        return (
            <div id="chat" className="p-3 container-fluid bg-white" style={{ height: '500px', overflow: 'auto', visibility: this.props.visible }}>
                {chatList}
                <div style={{ float: "left", clear: "both" }}
                    ref={(el) => { this.messagesEnd = el; }}>
                </div>
            </div>
        );
    }
}


export default Chat;

Server 구현

main.go

  • melody 라이브러리를 이용해서 websocket 을 구현한다.
  • 자신이 받은 메세지를 그대로 m.Broadcast(msg) 로 다시 전달 ( ^^; 일단 동작확인을 위해..)
package main

import (
	...
	"gopkg.in/olahol/melody.v1"
)

func main() {
	...
	m := melody.New()

	router.GET("/ws", func(c *gin.Context) {
		m.HandleRequest(c.Writer, c.Request)
	})

	m.HandleMessage(func(s *melody.Session, msg []byte) {
		m.Broadcast(msg)
	})
	...
}

동작 확인

아래와 같이 자기 자신이 websocket 으로 보낸 메세지를 다시 전달받아서 중복해서 표시되는것을 확인할 수 있다.

Step 2. Emqx 를 통한 앱 간 통신

두 사용자 "kiki"와 "jiji" 에게 Topic 과 port 할당해서 앱을 실행해서 채팅을 해보자

name port topic
kiki 3030 topic/client_kiki
jiji 3000 topic/client_jiji

Server 구현

iot.go 

go 예제 www.emqx.io/blog/how-to-use-mqtt-in-golang 를 참고하여 작성

  • 내 local 의 emqx broker 를 이용하기 위해 "127.0.0.1:1883" 에 연결
  • 내 subscribe topic 으로 들어온 메세지를 websocket 으로 전달
  • websocket 으로 들어온 내 메세지를 상대방 topic으로 publish
package iot

import (
	"fmt"
	"time"

	mqtt "github.com/eclipse/paho.mqtt.golang"
	"gopkg.in/olahol/melody.v1"
)

var connectHandler mqtt.OnConnectHandler = func(client mqtt.Client) {
	fmt.Println("Connected")
}

var connectLostHandler mqtt.ConnectionLostHandler = func(client mqtt.Client, err error) {
	fmt.Printf("Connect lost: %v", err)
}

type IotClient struct {
	mqttClient     mqtt.Client
	publishTopic   string
	subscribeTopic string
	websocket      *melody.Melody
}

func IoTClient(clientId string, targetId string, websocket *melody.Melody) (*IotClient, error) {
	var broker = "127.0.0.1"
	var port = 1883
	opts := mqtt.NewClientOptions()
	opts.AddBroker(fmt.Sprintf("mqtt://%s:%d", broker, port))
	opts.SetClientID(clientId)
	opts.SetConnectTimeout(30 * time.Second)
	opts.OnConnect = connectHandler
	opts.OnConnectionLost = connectLostHandler
	client := mqtt.NewClient(opts)
	if token := client.Connect(); token.Wait() && token.Error() != nil {
		panic(token.Error())
	}
	iotClient := &IotClient{
		mqttClient:     client,
		publishTopic:   "topic/" + targetId,
		subscribeTopic: "topic/" + clientId,
		websocket:      websocket,
	}
	return iotClient, nil
}

func (i *IotClient) ListenMessage() {
	var messageSubHandler mqtt.MessageHandler = func(client mqtt.Client, msg mqtt.Message) {
		i.websocket.Broadcast(msg.Payload())
	}
	if token := i.mqttClient.Subscribe(i.subscribeTopic, 0, messageSubHandler); token.Wait() && token.Error() != nil {
		panic(token.Error())
	}
}

func (i *IotClient) SendMessage(payload []byte) error {
	if token := i.mqttClient.Publish(i.publishTopic, 0, false, payload); token.Wait() && token.Error() != nil {
		return token.Error()
	}
	return nil
}

main.go

  • mqtt 통신을 위한 IoTClient 생성시 아래 정보 전달
    • 내 topic : client_kiki
    • 상대방 topic : client_jiji
    • websocket 
  • websocket 으로 front 에 입력한 메세지가 들어오면 상대방 topic 으로 SendMessage()
package main

import (
	"backend/iot"
    ...
)

func main() {
	...
	iotClient, _ := iot.IoTClient("client_kiki", "client_jiji", websocket)
	iotClient.ListenMessage()
    
    ...
	websocket.HandleMessage(func(s *melody.Session, msg []byte) {
		iotClient.SendMessage(msg)
	})

	...
}

 

MQTTX 로 동작 테스트

MQTTX 

mqtt 를 테스트하기 위한 편리한 앱 mqttx.app/

 

MQTT X - An Elegant Cross-platform MQTT 5.0 Client

To run MQTT Broker locally, EMQ X is recommended. EMQ X is a fully open source, highly scalable, highly available distributed MQTT 5.0 broker for IoT, M2M and mobile applications. Install EMQ X by using Docker: docker run -d --name emqx -p 1883:1883 -p 808

mqttx.app

설치하고 emqx broker 에 사용자 jiji 로 client 생성하여 연결

MQTTX 는 "jii" 앱처럼 동작하도록 설정한다.

  • 자신의 topic "topic/client_jiji" 을 subscribe 하여 "kiki" 가 보낸 메세지를 확인한다.
  • 자신이 보내는 메세지가 kiki의 topic "topic/client_kiki" 로 잘 보내졌는지 확인한다.

chat App 에서는 주고 받는 메세지가 정상적으로 찍히는것을 알 수 있다~ 동작확인 끝~!!

Step3. 최종 테스트

두 개의 별도 앱을 각각의 port 와 clientID, topic으로 설정해서 실행한다~

 

Summary

  • Chat 용 Web app을 만들기 위해 front 는 bootstrap + React 로 backend 는 gin 으로 구현했다. front가 event를 받기 위해서는 websocket 이 필요해서 front 와 backend 는 websocket 으로 통신하도록 구현해서 동작을 확인해 보았다.
  • 두개의 Chat Web app 이 서로 통신하기 위해서 emqx broker 를 이용했고 각자의 topic 을 만들어 해당 topic 을 통해 서로 메세지를 주고 받도록 구현했다. 이때 emqx 연결은 심플하게 mqtts (tls) 가 아닌 mqtt (tcp) 로 했다.
  • MQTTX 라는 훌륭한 mqtt client 가 있어 내가 구현한 emqx 코드 동작 확인을 빠르게 할수 있었다. 앞으로 mqtt 관련 디버깅에 매우 유용할것 같다.
  • tls 로 emqx broker 를 사용해보는것은 다음에 한번 정리해야겠다. 

 

'자습' 카테고리의 다른 글

Zipkin Go  (0) 2021.07.17
Localstack 살펴보기  (0) 2021.07.16
Go OAuth2.0 서버  (0) 2021.01.31
node-forge를 이용한 RSA 암호화, CSR 생성  (0) 2021.01.16
Electron + Bootstrap 초보의 간단 앱 만들기  (0) 2021.01.09