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>
마무리
오타 및 피드백(개선방안 또는 다른 스타일의 코드)은 언제나 환영입니다.
감사합니다.
'Go Lang > Study' 카테고리의 다른 글
[GoLang] 자료형을 초과한 큰 수를 계산해보자 (0) | 2023.09.01 |
---|---|
[GoLang] 정규식으로 URL 분석기 만들기 (0) | 2023.08.30 |
Go로 CRUD REST API 만들기 (3) - Bolt DB 연결 (0) | 2023.07.10 |
Go로 CRUD REST API 만들기 (2) (0) | 2023.07.06 |
Go로 CRUD REST API 만들기 (1) (0) | 2023.07.05 |