WebSocket 실시간 통신
WebSocket은 HTTP와 달리 한 번 연결이 맺어지면 서버와 클라이언트가 양방향으로 자유롭게 메시지를 주고받을 수 있는 프로토콜입니다. 채팅, 실시간 알림, 주가 스트리밍, 온라인 게임 등에 사용됩니다. Go의 gorilla/websocket 라이브러리는 WebSocket 구현의 사실상 표준입니다.
WebSocket 개념
HTTP 요청/응답 (단방향, 비연결성):
클라이언트 ──GET /data──▶ 서버
클라이언트 ◀──응답────── 서버
(연결 종료)
WebSocket (양방향, 연결 유지):
클라이언트 ──HTTP Upgrade──▶ 서버
클라이언트 ◀────── 핸드셰이크 완료 ──── 서버
클라이언트 ◀──────────── 메시지 ──────▶ 서버 (전이중 통신)
클라이언트 ◀──────────── 메시지 ──────▶ 서버
(연결 유지)
HTTP 업그레이드 핸드셰이크:
클라이언트 요청:
GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
서버 응답:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
기본 사용법
go get github.com/gorilla/websocket
package main
import (
"fmt"
"log"
"net/http"
"github.com/gorilla/websocket"
)
// Upgrader HTTP 연결을 WebSocket으로 업그레이드
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// 모든 Origin 허용 (프로덕션에서는 반드시 제한할 것)
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func echoHandler(w http.ResponseWriter, r *http.Request) {
// HTTP → WebSocket 업그레이드
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("업그레이드 실패: %v", err)
return
}
defer conn.Close()
log.Printf("새 연결: %s", conn.RemoteAddr())
for {
// 메시지 수신
messageType, message, err := conn.ReadMessage()
if err != nil {
// 연결 종료 감지
if websocket.IsUnexpectedCloseError(err,
websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("예상치 못한 종료: %v", err)
}
break
}
log.Printf("수신: %s", message)
// 에코 응답
err = conn.WriteMessage(messageType, message)
if err != nil {
log.Printf("쓰기 실패: %v", err)
break
}
}
}
func main() {
http.HandleFunc("/ws", echoHandler)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "WebSocket 에코 서버 - /ws로 연결하세요")
})
log.Println("서버 시작: http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
메시지 타입
package main
import (
"encoding/json"
"log"
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
// ChatMessage JSON 메시지 프로토콜
type ChatMessage struct {
Type string `json:"type"` // "message", "join", "leave", "error"
Room string `json:"room"`
Sender string `json:"sender"`
Content string `json:"content"`
}
func jsonHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for {
// 메시지 타입별 처리
mt, data, err := conn.ReadMessage()
if err != nil {
break
}
switch mt {
case websocket.TextMessage:
var msg ChatMessage
if err := json.Unmarshal(data, &msg); err != nil {
// JSON 파싱 실패 시 에러 응답
errMsg, _ := json.Marshal(ChatMessage{Type: "error", Content: "잘못된 메시지 형식"})
conn.WriteMessage(websocket.TextMessage, errMsg)
continue
}
log.Printf("[%s] %s: %s", msg.Room, msg.Sender, msg.Content)
// 응답 전송
resp, _ := json.Marshal(ChatMessage{
Type: "message",
Room: msg.Room,
Sender: "서버",
Content: "메시지 수신: " + msg.Content,
})
conn.WriteMessage(websocket.TextMessage, resp)
case websocket.BinaryMessage:
// 바이너리 데이터 처리 (파일, 이미지 등)
log.Printf("바이너리 메시지 수신: %d bytes", len(data))
conn.WriteMessage(websocket.BinaryMessage, data)
case websocket.CloseMessage:
log.Println("클라이언트 종료 요청")
return
}
}
}
Ping/Pong 심장 박동(Heartbeat)
package main
import (
"log"
"net/http"
"time"
"github.com/gorilla/websocket"
)
const (
writeWait = 10 * time.Second // 쓰기 타임아웃
pongWait = 60 * time.Second // Pong 대기 시간
pingPeriod = 54 * time.Second // Ping 전송 주기 (pongWait보다 짧게)
maxMessageSize = 512 * 1024 // 최대 메시지 크기 (512KB)
)
var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
type Client struct {
conn *websocket.Conn
send chan []byte
}
func (c *Client) readPump() {
defer func() {
c.conn.Close()
close(c.send)
}()
c.conn.SetReadLimit(maxMessageSize)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
// Pong 수신 시 읽기 데드라인 갱신
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err,
websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("읽기 오류: %v", err)
}
break
}
log.Printf("수신: %s", message)
c.send <- message // 에코
}
}
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
case <-ticker.C:
// 주기적으로 Ping 전송
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
func serveWs(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
client := &Client{
conn: conn,
send: make(chan []byte, 256),
}
// read/write를 별도 고루틴으로 분리
go client.writePump()
go client.readPump()
}
실전 예제: 멀티룸 실시간 채팅 서버
package main
import (
"encoding/json"
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
)
// Message 채팅 메시지
type Message struct {
Type string `json:"type"` // "message", "join", "leave", "system"
Room string `json:"room"`
Sender string `json:"sender"`
Content string `json:"content"`
Time time.Time `json:"time"`
}
// Client 연결된 클라이언트
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
username string
room string
}
// Hub 룸별 클라이언트 관리
type Hub struct {
mu sync.RWMutex
rooms map[string]map[*Client]bool // room → clients
join chan *Client
leave chan *Client
message chan *RoomMessage
}
type RoomMessage struct {
room string
message []byte
}
func NewHub() *Hub {
return &Hub{
rooms: make(map[string]map[*Client]bool),
join: make(chan *Client),
leave: make(chan *Client),
message: make(chan *RoomMessage, 256),
}
}
func (h *Hub) Run() {
for {
select {
case client := <-h.join:
h.mu.Lock()
if h.rooms[client.room] == nil {
h.rooms[client.room] = make(map[*Client]bool)
}
h.rooms[client.room][client] = true
h.mu.Unlock()
// 입장 알림
msg, _ := json.Marshal(Message{
Type: "system",
Room: client.room,
Content: client.username + "님이 입장했습니다",
Time: time.Now(),
})
h.broadcast(client.room, msg, nil)
case client := <-h.leave:
h.mu.Lock()
if clients, ok := h.rooms[client.room]; ok {
delete(clients, client)
if len(clients) == 0 {
delete(h.rooms, client.room)
}
}
h.mu.Unlock()
close(client.send)
// 퇴장 알림
msg, _ := json.Marshal(Message{
Type: "system",
Room: client.room,
Content: client.username + "님이 퇴장했습니다",
Time: time.Now(),
})
h.broadcast(client.room, msg, nil)
case rm := <-h.message:
h.broadcast(rm.room, rm.message, nil)
}
}
}
// broadcast 룸의 모든 클라이언트에게 메시지 전송
func (h *Hub) broadcast(room string, message []byte, except *Client) {
h.mu.RLock()
defer h.mu.RUnlock()
for client := range h.rooms[room] {
if client == except {
continue
}
select {
case client.send <- message:
default:
// 버퍼가 꽉 찬 경우 연결 제거
go func(c *Client) { h.leave <- c }(client)
}
}
}
func (c *Client) readPump() {
defer func() { c.hub.leave <- c }()
c.conn.SetReadLimit(512 * 1024)
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
_, data, err := c.conn.ReadMessage()
if err != nil {
break
}
var incoming Message
if err := json.Unmarshal(data, &incoming); err != nil {
continue
}
outgoing, _ := json.Marshal(Message{
Type: "message",
Room: c.room,
Sender: c.username,
Content: incoming.Content,
Time: time.Now(),
})
c.hub.message <- &RoomMessage{room: c.room, message: outgoing}
}
}
func (c *Client) writePump() {
ticker := time.NewTicker(54 * time.Second)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true },
}
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
username := r.URL.Query().Get("username")
room := r.URL.Query().Get("room")
if username == "" || room == "" {
http.Error(w, "username과 room 파라미터가 필요합니다", http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("업그레이드 실패: %v", err)
return
}
client := &Client{
hub: hub,
conn: conn,
send: make(chan []byte, 256),
username: username,
room: room,
}
hub.join <- client
go client.writePump()
go client.readPump()
}
func main() {
hub := NewHub()
go hub.Run()
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
serveWs(hub, w, r)
})
// 룸 목록 API
http.HandleFunc("/rooms", func(w http.ResponseWriter, r *http.Request) {
hub.mu.RLock()
rooms := make(map[string]int)
for room, clients := range hub.rooms {
rooms[room] = len(clients)
}
hub.mu.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(rooms)
})
log.Println("채팅 서버 시작: ws://localhost:8080/ws?username=홍길동&room=general")
// 테스트: wscat -c "ws://localhost:8080/ws?username=홍길동&room=general"
log.Fatal(http.ListenAndServe(":8080", nil))
}
고수 팁
1. read/write 고루틴 분리: WebSocket 연결은 동시에 읽기/쓰기가 발생하므로 반드시 별도 고루틴으로 처리하세요
2. Origin 검증: 프로덕션에서는 CheckOrigin을 반드시 구현해 CSRF를 방지하세요
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
return origin == "https://example.com"
},
3. 메시지 크기 제한: conn.SetReadLimit()으로 대용량 메시지 폭탄을 방지하세요
4. 버퍼 채널 크기 조정: make(chan []byte, 256)에서 256은 클라이언트별 최대 큐 크기입니다. 느린 클라이언트가 서버를 차단하지 않도록 적절히 설정하세요
5. 구조화된 메시지 프로토콜: 문자열 대신 JSON 메시지 타입을 정의해 클라이언트/서버 간 프로토콜을 명확히 하세요