ksundev 님의 블로그

[WebSocket] 채팅방 데모버전 만들어보기 (backend) 본문

[개발] Go/중급 프로젝트

[WebSocket] 채팅방 데모버전 만들어보기 (backend)

ksundev 2025. 8. 26. 14:35

카테고리로 분류되는 채팅방과 각 채팅방에 접속한 유저들과 채팅으로 소통할 수 있는 앱을 만들어보았습니다.
프론트엔드 코드는 이 게시글에 없고, 백엔드 코드만 작성되었습니다.
이후 프론트엔드 코드까지 업로드하여 링크 달아놓겠습니다!

전체 코드

package main

import (
    "fmt"
    "net/http"
    "strings"
    "time"

    "github.com/gorilla/websocket"
)

type Message struct {
    Username  string    `json:"username"`
    Content   string    `json:"content"`
    Room      string    `json:"room"`
    Timestamp time.Time `json:"timestamp"`
}

type Client struct {
    Username string
    Room     string
    Conn     *websocket.Conn
    Send     chan Message
}

type Hub struct {
    Clients    map[*Client]bool
    Broadcast  chan Message
    Register   chan *Client
    Unregister chan *Client
}

var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool {
        return true
    },
}

func NewHub() *Hub {
    return &Hub{
        Clients:    make(map[*Client]bool),
        Broadcast:  make(chan Message),
        Register:   make(chan *Client),
        Unregister: make(chan *Client),
    }
}

func (h *Hub) Run() {
    for {
        select {
        case client := <-h.Register:
            h.Clients[client] = true
            fmt.Printf("✅ [%s] %s님이 입장했습니다 (총 %d명)\n",
                client.Room, client.Username, len(h.Clients))

        case client := <-h.Unregister:
            if _, ok := h.Clients[client]; ok {
                delete(h.Clients, client)
                close(client.Send)
                fmt.Printf("❌ [%s] %s님이 퇴장했습니다 (총 %d명)\n",
                    client.Room, client.Username, len(h.Clients))
            }

        case message := <-h.Broadcast:
            fmt.Printf("💬 [%s] %s: %s\n",
                message.Room, message.Username, message.Content)
            for client := range h.Clients {
                // 같은 방에 있는 클라이언트에게만 전송
                if client.Room == message.Room {
                    select {
                    case client.Send <- message:
                    default:
                        delete(h.Clients, client)
                        close(client.Send)
                    }
                }
            }
        }
    }
}

func (c *Client) ReadPump(hub *Hub) {
    defer func() {
        hub.Unregister <- c
        c.Conn.Close()
    }()

    for {
        var msg Message
        err := c.Conn.ReadJSON(&msg)
        if err != nil {
            break
        }

        msg.Username = c.Username
        msg.Room = c.Room
        msg.Timestamp = time.Now()
        hub.Broadcast <- msg
    }
}

func (c *Client) WritePump() {
    defer c.Conn.Close()

    for {
        select {
        case message, ok := <-c.Send:
            if !ok {
                c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
                return
            }
            c.Conn.WriteJSON(message)
        }
    }
}

func handleWebSocket(hub *Hub, w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        return
    }

    username := r.URL.Query().Get("username")
    if username == "" {
        username = fmt.Sprintf("User_%d", time.Now().Unix()%1000)
    }

    room := r.URL.Query().Get("room")
    if room == "" {
        room = "general"
    }

    client := &Client{
        Username: username,
        Room:     room,
        Conn:     conn,
        Send:     make(chan Message, 256),
    }

    fmt.Printf("🔌 연결 시도: %s -> %s방\n", username, room)

    hub.Register <- client

    go client.WritePump()
    go client.ReadPump(hub)
}

func main() {
    hub := NewHub()
    go hub.Run()

    http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
        handleWebSocket(hub, w, r)
    })

    fmt.Println("🚀 멀티룸 WebSocket 채팅 서버 시작!")
    fmt.Println("📡 연결 URL: ws://localhost:8080/ws?username=이름&room=방이름")
    fmt.Println("📋 예시: ws://localhost:8080/ws?username=홍길동&room=개발")
    fmt.Println(strings.Repeat("=", 30))
    http.ListenAndServe(":8080", nil)
}

main.go 코드 상세 설명

1️⃣ 패키지 및 임포트 (1-8줄)

package main

import (
    "fmt"           // 출력 함수 (Printf, Println 등)
    "net/http"      // HTTP 서버 기능
    "strings"       // 문자열 처리
    "time"          // 시간 관련 기능
    "github.com/gorilla/websocket"  // WebSocket 라이브러리
)

2️⃣ Message 구조체 (10-15줄)

type Message struct {
    Username  string    `json:"username"`   // 메시지 작성자
    Content   string    `json:"content"`    // 메시지 내용
    Room      string    `json:"room"`       // 채팅방 이름
    Timestamp time.Time `json:"timestamp"`  // 메시지 작성 시간
}
  • JSON 태그로 클라이언트와 데이터 교환 시 필드명 지정

3️⃣ Client 구조체 (17-22줄)

type Client struct {
    Username string                    // 클라이언트 사용자명
    Room     string                    // 현재 접속한 방
    Conn     *websocket.Conn          // WebSocket 연결 객체
    Send     chan Message              // 메시지 수신용 채널 (버퍼 256)
}

4️⃣ Hub 구조체 (24-29줄)

type Hub struct {
    Clients    map[*Client]bool       // 연결된 모든 클라이언트 목록
    Broadcast  chan Message           // 모든 클라이언트에게 전송할 메시지
    Register   chan *Client           // 새 클라이언트 등록 요청
    Unregister chan *Client           // 클라이언트 등록 해제 요청
}
  • 중앙 제어소: 모든 클라이언트와 메시지를 관리

5️⃣ WebSocket 업그레이더 (31-35줄)

var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool {
        return true  // 모든 Origin 허용 (개발용)
    },
}
  • HTTP 연결을 WebSocket으로 업그레이드
  • CheckOrigin: true는 보안상 위험하지만 개발 편의를 위해 사용

6️⃣ NewHub 함수 (37-43줄)

func NewHub() *Hub {
    return &Hub{
        Clients:    make(map[*Client]bool),    // 빈 맵 생성
        Broadcast:  make(chan Message),        // 메시지 채널 생성
        Register:   make(chan *Client),        // 등록 채널 생성
        Unregister: make(chan *Client),        // 해제 채널 생성
    }
}

7️⃣ Hub.Run 메서드 (45-75줄)

func (h *Hub) Run() {
    for {  // 무한 루프로 이벤트 대기
        select {
        case client := <-h.Register:           // 새 클라이언트 등록
            h.Clients[client] = true
            fmt.Printf("✅ [%s] %s님이 입장했습니다 (총 %d명)\n",
                client.Room, client.Username, len(h.Clients))

        case client := <-h.Unregister:         // 클라이언트 등록 해제
            if _, ok := h.Clients[client]; ok {
                delete(h.Clients, client)      // 맵에서 제거
                close(client.Send)              // 채널 닫기
                fmt.Printf("❌ [%s] %s님이 퇴장했습니다 (총 %d명)\n",
                    client.Room, client.Username, len(h.Clients))
            }

        case message := <-h.Broadcast:         // 메시지 브로드캐스트
            fmt.Printf("💬 [%s] %s: %s\n",
                message.Room, message.Username, message.Content)
            for client := range h.Clients {
                if client.Room == message.Room {  // 같은 방 사용자에게만
                    select {
                    case client.Send <- message:  // 메시지 전송
                    default:                       // 전송 실패 시
                        delete(h.Clients, client)  // 클라이언트 제거
                        close(client.Send)
                    }
                }
            }
        }
    }
}

8️⃣ Client.ReadPump 메서드 (77-95줄)

func (c *Client) ReadPump(hub *Hub) {
    defer func() {  // 함수 종료 시 실행
        hub.Unregister <- c    // Hub에서 제거
        c.Conn.Close()         // 연결 종료
    }()

    for {
        var msg Message
        err := c.Conn.ReadJSON(&msg)  // 클라이언트로부터 JSON 메시지 읽기
        if err != nil {
            break  // 오류 시 루프 종료
        }

        msg.Username = c.Username     // 사용자명 설정
        msg.Room = c.Room            // 방 이름 설정
        msg.Timestamp = time.Now()   // 현재 시간 설정
        hub.Broadcast <- msg         // Hub로 메시지 전송
    }
}

9️⃣ Client.WritePump 메서드 (97-109줄)

func (c *Client) WritePump() {
    defer c.Conn.Close()

    for {
        select {
        case message, ok := <-c.Send:  // Hub로부터 메시지 수신
            if !ok {                   // 채널이 닫혔으면
                c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
                return
            }
            c.Conn.WriteJSON(message)  // 클라이언트에게 JSON 메시지 전송
        }
    }
}

🔟 handleWebSocket 함수 (111-139줄)

func handleWebSocket(hub *Hub, w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)  // HTTP → WebSocket 업그레이드
    if err != nil {
        return
    }

    username := r.URL.Query().Get("username")  // URL에서 사용자명 추출
    if username == "" {
        username = fmt.Sprintf("User_%d", time.Now().Unix()%1000)  // 기본값
    }

    room := r.URL.Query().Get("room")          // URL에서 방 이름 추출
    if room == "" {
        room = "general"  // 기본 방
    }

    client := &Client{                         // 새 클라이언트 생성
        Username: username,
        Room:     room,
        Conn:     conn,
        Send:     make(chan Message, 256),     // 256개 메시지 버퍼
    }

    fmt.Printf("연결 시도: %s -> %s방\n", username, room)

    hub.Register <- client                     // Hub에 클라이언트 등록

    go client.WritePump()                     // 고루틴으로 메시지 전송 시작
    go client.ReadPump(hub)                   // 고루틴으로 메시지 수신 시작
}

1️⃣1️⃣ main 함수 (141-162줄)

func main() {
    hub := NewHub()                           // Hub 생성
    go hub.Run()                              // 고루틴으로 Hub 실행

    http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
        handleWebSocket(hub, w, r)            // /ws 경로로 WebSocket 연결 처리
    })

    fmt.Println("멀티룸 WebSocket 채팅 서버 시작!")
    fmt.Println("연결 URL: ws://localhost:8080/ws?username=이름&room=방이름")
    fmt.Println("예시: ws://localhost:8080/ws?username=홍길동&room=개발")
    fmt.Println(strings.Repeat("=", 30))

    http.ListenAndServe(":8080", nil)        // 8080 포트에서 서버 시작
}

전체 동작 흐름 요약:

  1. 서버 시작 → Hub 생성 및 실행
  2. 클라이언트 연결 → HTTP → WebSocket 업그레이드
  3. 클라이언트 등록 → Hub에 추가
  4. 메시지 송수신 → ReadPump(송신) + WritePump(수신)
  5. 방별 메시지 전송 → 같은 방 사용자에게만 브로드캐스트
  6. 연결 종료 → 클라이언트 제거 및 정리