Go Lang/Study

GoLang으로 느낌 있게 채팅 방 만들어보자

DSeung 2023. 8. 27. 19:54

Go Lang으로 느낌 있게 채팅방을 만들어보자!

아래는 결과물입니다.

주된 기능은

  • 여려명에서의 채팅
  • 입장/퇴장 시에 메시지 출력
  • 닉네임 중복 체크
  • 현재 참여인원 닉네임 표시

 

해당 프로젝트를 만들 때 알아야 할 것은 WebSocket입니다. 

 

WebSocket

Http와 같은 웹 기술로

Http는 사용자가 서버에 데이터를 요청하면 이에 대해 응답으로 데이터를 반환한다면 

WebSocket은 서버와 사용자가 계속 연결되어 데이터를 주고받을 수 있게 해 줍니다.

 

Http는 주고받고 끝이기에 비교적 크고 실시간이 필요하지 않은 데이터(웹 페이지, 이미지 동영상)에 사용하기 좋고 

WebSocket은 실시간으로 이루어져야 하는 게임이나 채팅에 사용됩니다.

 

환경

go version은 다음과 같음 : go version go1.20.5 windows/amd64

 

폴더 구조

아래 이미지처럼 프로젝트 내부 폴더 및 파일을 생성 두었습니다.

외부 라이브러리

gorilla 라이브러리의 mux와 websocket을 사했는데 아래의 go.mod의 내용을 복붙 후 go mod download 하거나

하나하나씩 go get github.com/... 해도 됩니다

module chat.com

go 1.20

require (
	github.com/gorilla/mux v1.8.0
	github.com/gorilla/websocket v1.5.0
)

 

정적파일

채팅방에서 사용할 html과 style을 만들어줍시다, go와 websocket이 목적이니 복붙 하셔도 무관합니다.

 

static/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" type="text/css" href="css/style.css">
    <title>Chat</title>
</head>
<body>
<div class="user-container">
    <form id="name-form">
        <span>닉네임</span>
        <input type="text" id="name-input"/>
        <button id="name-save">저장</button>
    </form>

    <div class="participant-container">
        <ul id="participants">

        </ul>
    </div>

</div>
<div class="chat-container hide">
    <ul id="messages">
    </ul>

    <form class="input-container" id="chat-form">
        <input type="text" class="input-box" placeholder="메시지를 입력하세요" id="chat-text">
        <button class="send-button">전송</button>
    </form>
</div>

<script src="https://code.jquery.com/jquery-3.7.0.min.js"
        integrity="sha256-2Pmvv0kuTBOenSvLm6bvfBSSHrUJ+3A7x6P5Ebd07/g=" crossorigin="anonymous"></script>
<!-- 여기에 스크립트 추가할 예정 -->
</body>
</html>

 

static/style.css

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
    margin: 0;
    padding: 0;
    background-color: #f2f3f5;
}

.hide{
    display: none;
}

.user-container {
    display: table;
    margin: 10px auto;
    text-align: center;
    background-color: #fff;
    padding: 10px 20px;
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);;
}

.user-container #nameInput{
    margin: 0 10px;
    border-radius: 10px;
    padding: 5px 7px;
    font-size: 14px;
}

.user-container button{
    font-size: 14px;
    padding: 5px 7px;
}

.chat-container {
    padding-top: 5px;
    margin: 20px auto;
    border-radius: 10px;
    background-color: #fff;
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
    max-width: 600px;
}

.participant-container{
    margin-top: 10px;
}

.participant-container ul {
    margin: 0px;
    padding: 0px;
}

.participant-container li {
    list-style: none;
    border:1px solid #444;
    border-radius: 10px;
    display: inline-block;
    padding: 1px 8px;
}

.message {
    margin: 10px;
    padding: 10px 15px;
    border-radius: 10px;
    font-size: 14px;
    max-width: 400px;
}

.user-message {
    margin-left: 200px;
    text-align: right;
    background-color: #007aff;
    color: #fff;
    align-self: flex-start;
}

.other-message {
    text-align: left;
    background-color: #dfe4ea;
    align-self: flex-end;
}

.notice-message{
    text-align: center;
    min-width: 600px;
    margin: 0 auto;
}

.input-container {
    display: flex;
    border-top: 1px solid #ccc;
}

.input-box {
    flex: 1;
    padding: 10px 15px;
    font-size: 14px;
    border: none;
    outline: none;
}

.send-button {
    background-color: #007aff;
    color: #fff;
    border: none;
    padding: 10px 15px;
    border-radius: 5px;
    cursor: pointer;
    font-size: 14px;
}
.chat-container ul {
    list-style: none;
    padding: 0;
}

 

Go code

utils/utils.go : 프로젝트에 종속되지 않고 자주 쓰이는 코드는 utils/utils.go에 정리하는 걸 선호해서

아래와 같이 2개의 함수를 정의했습니다

// Package utils contains functions to be used across the application
package utils

import (
	"encoding/json"
	"log"
)

var logFn = log.Panic

// HandleErr : 에러가 발생시 처리
func HandleErr(err error) {
	if err != nil {
		logFn(err)
	}
}

// StructToBytes : Struct 데이터를 Bytes 로 변환
func StructToBytes(data interface{}) []byte {
	bytes, err := json.Marshal(data)
	HandleErr(err)
	return bytes
}

main.go : 나중에 여러 코드가 추가될 수 있으니 간단하게 하는 걸 선호합니다.

package main

import (
	"chat.com/route"
)

func main() {
	port := 8080
	route.Start(port)
}

 

peer.go : 사용자에 관련된 기능을 담당하는 함수들을 정의했습니다.

package p2p

import (
	"chat.com/utils"
	"encoding/json"
	"fmt"
	"github.com/gorilla/websocket"
	"log"
	"strconv"
	"sync"
)

// peers struct 정의 V에 map으로 Peer struct 저장
type peers struct {
	V map[string]*Peer
	m sync.Mutex // Mutex를 넣어야 unlock/lock 가능
}

// Peer struct 차례대로 : 구분값, 이름, websocket 연결 객체를 저장
type Peer struct {
	Key  string
	Name string
	Conn *websocket.Conn
}

// ChatMessage struct : 메시지의 struct
type ChatMessage struct {
	Author  string `json:"Author"`
	Message string `json:"Message"`
	Type    string `json:"Type"`
}

// Peers peers 로 변수 생성
var Peers = peers{
	V: make(map[string]*Peer),
}

// Peer Pointer receiver Read 메서드 정의
func (peer *Peer) Read() {
	// Peer 종료시 close 함수 실행
	defer peer.close()

	for {
		var chat ChatMessage
		var byteChat []byte
		var isDuplication bool

		// ReadMessage 는 새로운 메시지가 올 때 까지 기다림, 메세지가 오면 payload 에 저장
		messageType, payload, err := peer.Conn.ReadMessage()

		// conn 에서 에러가 날 경우 빠른 구분을 위해 아래 처럼 코딩, utils.HandleErr(err)로 변경 가능
		if err != nil {
			log.Printf("conn.ReadMessage: %v", err)
			return
		}

		// 중복되는 name 을 가진지 체크
		isDuplication = PeerNameDuplicationCheck(peer)

		// payload를 메시지 형태로 변경
		utils.HandleErr(json.Unmarshal(payload, &chat))
		// Type 이 1은 일반 메시지로, 0이면 관리자 메세지로 정의
		chat.Type = strconv.Itoa(1)

		// Peer 에 이름 추가
		if !isDuplication {
			peer.Name = chat.Author
		}

		// 메세지 전체 전송
		byteChat = utils.StructToBytes(chat)
		SendMessageToPeers(messageType, byteChat)
	}
}

// close : Peer 정리 및 퇴장 메시지 전송 함수
func (p *Peer) close() {
	// data race 보호를 위한 코드 추가
	Peers.m.Lock()
	defer func() {
		Peers.m.Unlock()
	}()
	p.Conn.Close()

	// 해당 Peer 를 Peers 에서 삭제
	delete(Peers.V, p.Key)

	// 퇴장 메시지 생성 및 전송
	var leaveChat ChatMessage

	leaveChat.Author = "admin"
	leaveChat.Message = fmt.Sprintf("%s님이 나갔습니다.", p.Name)
	leaveChat.Type = strconv.Itoa(0)

	byteChat := utils.StructToBytes(leaveChat)
	SendMessageToPeers(websocket.TextMessage, byteChat)
}

// PeerNameDuplicationCheck : 파라미터로 온 값이 Peers 에 존재 여부 반환
func PeerNameDuplicationCheck(peer *Peer) bool {
	for _, p := range Peers.V {
		if p.Name != "" && p.Name == peer.Name {
			return true
		}
	}
	return false
}

// SendMessageToPeers : Peers 의 전체 peer 에 메세지 전달
func SendMessageToPeers(messageType int, byteChat []byte) {
	for _, p := range Peers.V {
		if err := p.Conn.WriteMessage(messageType, byteChat); err != nil {
			log.Printf("conn.WriteMessage: %v", err)
			continue
		}
	}
}

route.go : http 요청이나 websocket 요청을 받는 핸들러 함수들을 정의했습니다.

package route

import (
	"chat.com/p2p"
	"chat.com/utils"
	"encoding/json"
	"fmt"
	"github.com/gorilla/mux"
	"github.com/gorilla/websocket"
	"log"
	"net/http"
	"strconv"
)

// upgrader : http 연결을 websocket 연결로 업그레이드하는 데 사용, 안에 내용은 버퍼 사이즈 정의
var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
}

// Peers map 에서 사용할 키 값
var peerKey = 1

// Start : 각 요청별 핸들러 함수 정의
func Start(port int) {
	router := mux.NewRouter()

	// 닉네임 입력시 사용
	router.HandleFunc("/login", loginHandler).Methods("POST")
	// 현재 유저 리스트 가져올 때 사용
	router.HandleFunc("/getUsers", getUsersHandler).Methods("GET")
	// js에서 Websocket 객체 만들때 사용
	router.HandleFunc("/ws", socketHandler).Methods("POST", "GET")
	// index.html 페이지를 루트 페이지로 보여줌
	router.PathPrefix("/").Handler(http.FileServer(http.Dir("./static/")))

	log.Printf("Listening on localhost:%d", port)
	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", port), router))
}

// socketHandler : 앞단에서 js로 Websocket 객체를 만들 때 한번 실행 (처음 연결 후 계속 연결이 유지됨)
func socketHandler(w http.ResponseWriter, r *http.Request) {
	// upgrader로 http 연결을 websocket 연결 객체로 변경
	conn, err := upgrader.Upgrade(w, r, nil)
	utils.HandleErr(err)

	// 새로운 연결 추가
	p := &p2p.Peer{
		Conn: conn,
		Key:  strconv.Itoa(peerKey),
	}
	p2p.Peers.V[strconv.Itoa(peerKey)] = p
	peerKey++

	// peer의 Read 함수를 고루틴으로 실행
	go p.Read()
}

// loginHandler : 로그인 핸들러 함수
func loginHandler(w http.ResponseWriter, r *http.Request) {
	// post 데이터로 name을 받음
	userName := r.PostFormValue("name")

	// 이름이 존재한다면 에러 반환
	for _, p := range p2p.Peers.V {
		if p.Name == userName {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
	}

	// 이름이 존재하지 않는 peer 에 해당 이름을 매칭
	for _, p := range p2p.Peers.V {
		if p.Name == "" {
			p.Name = userName
			break
		}
	}

	// 참가 메시지 생성, Type : 0은 관리자 메시지
	var admissionChat = p2p.ChatMessage{
		Author:  "admin",
		Message: fmt.Sprintf("%s님이 참가했습니다.", userName),
		Type:    strconv.Itoa(0),
	}

	// []byte로 변경 후 전체 peer 에게 메세지 발송
	byteChat := utils.StructToBytes(admissionChat)
	p2p.SendMessageToPeers(websocket.TextMessage, byteChat)

	// http 성공 코드 반환
	w.WriteHeader(http.StatusCreated)
}

// getUsersHandler : 전체 유저 리스트 요청 핸들러 함수
func getUsersHandler(w http.ResponseWriter, r *http.Request) {
	var userNames []string

	// 전체 peer를 돌며 이름을 []string에 저장
	for _, p := range p2p.Peers.V {
		userNames = append(userNames, p.Name)
	}

	// json 으로 변환
	jsonUserNames, err := json.Marshal(userNames)
	utils.HandleErr(err)

	// http 성공 코드 및 json 데이터를 반환
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	w.Write(jsonUserNames)
}

 

Go 코드 실행 부 ( Script)

index.html에 스크립트 추가 부분에 아래 내용을 추가합시다

...

<script src="https://code.jquery.com/jquery-3.7.0.min.js"
        integrity="sha256-2Pmvv0kuTBOenSvLm6bvfBSSHrUJ+3A7x6P5Ebd07/g=" crossorigin="anonymous"></script>
<script>
    $(function () {
        const webSocketScheme = window.location.protocol == "https:" ? 'wss://' : 'ws://';
        const baseURI = window.location.hostname + (location.port ? ':' + location.port : '');

        // New WebSocket을 실행한 순간 go의 socketHandler가 실행됨
        const websocket = new WebSocket(webSocketScheme + baseURI + '/ws');
        let nameInput = $("#name-input");
        let userName

        // data에 따른 메시지 태그 추가 함수
        function log(data) {
            let message = ""

            switch (Number(data.Type)) {
                case 0 :
                    message = `<li class="message notice-message">${data.Message}</li>`;
                    break
                case 1 :
                    if (data.Author === userName) {
                        message = `<li class="message user-message">${data.Author} : ${data.Message}</li>`
                    } else {
                        message = `<li class="message other-message">${data.Author} : ${data.Message}</li>`
                    }
                    break
                default :
                    message = `<li class="message notice-message">내부 문제가 발생했습니다.</li>`;
            }
            $('#messages').append(message)
        }

        // GET 요청으로 현재 참여중인 유저의 리스트를 가져오고 적용
        function getUsers() {
            $.get("/getUsers")
                .done(function (response) {
                    $("#participants").empty()
                    response.forEach(name =>{
                        if (name !== ""){
                            $("#participants").append(`<li>${name}</li>`)
                        }
                    })
                })
                .fail(function (jqXHR, textStatus, errorThrown) {
                    console.log(jqXHR)
                    console.log("API 요청 실패:", textStatus, errorThrown);
                });
        }

        // websocket에 메시지가 왔을 때 실행
        websocket.onmessage = function (e) {
            getUsers()
            log(JSON.parse(e.data))
        };

        // websocket에 에러가 발생했을 때 실행
        websocket.onerror = function (e) {
            log('에러 발생');
            console.log(e);
        };

        // 이름 입력 시 실행
        $("#name-form").submit(function (e) {
            e.preventDefault()
            $.post("/login", {name: nameInput.val() })
                .done(function () {
                    userName = nameInput.val()
                    $("#name-save").hide()
                    nameInput.prop("disabled", true);
                    $(".chat-container").removeClass("hide");
                })
                .fail(function (jqXHR, textStatus, errorThrown) {
                    console.log(jqXHR)
                    console.log("API 요청 실패:", textStatus, errorThrown);
                    alert("중복되는 닉네임입니다.")
                });
        })

        // 메시지 입력시 실행
        $('#chat-form').submit(function (e) {
            e.preventDefault();
            let data = $('#chat-text').val();
            if (data) {
                // websocket.send으로 연결중인 웹 소캣에 메세지 전송
                // 현재 연결중인 websocket connection 실행
                // socketHandler에서 계속 돌고 있는 go p.Read() 안에 peer.Conn.ReadMessage()에 메세지가 들어오고 다음 코드 실행
                websocket.send(
                    JSON.stringify({
                        Author: userName,
                        Message: data
                    }));
                window.scrollTo(0, document.body.scrollHeight)
                $('#chat-text').val('');
            }
        });
    });
</script>
</body>
</html>

 

마무리

오타 및 피드백(개선방안 또는 다른 스타일의 코드)은 언제나 환영입니다.

감사합니다. 

반응형