41강 구현 중심 ⏱ 약 20분

 

0. 학습 목표

→ 누가 채팅방에 있는지 모든 클라이언트가 실시간으로 알 수 있게 합니다.

더보기

0.1 이번 글에서 다룰 내용

이번 글은 구현 중심 강의입니다.

 

40강까지 완성한 서버는 입장·퇴장 안내를 텍스트로 보내지만, 지금 채팅방에 누가 있는지는 알 수 없습니다.

이번 강의에서 입장·퇴장이 일어날 때마다 서버가 현재 접속자 목록을 전체에 브로드캐스트하도록 추가합니다.

클라이언트는 이 목록을 받아 화면 한쪽에 표시합니다.

추가되는 내용은 작습니다. 40강 프로토콜에 user_list 타입 하나가 더해지고, 서버 두 곳과 HTML 한 곳이 바뀝니다.

구분 내용
이해할 것 입장·퇴장 이벤트마다 목록을 전체 전달하는 방식 — 클라이언트가 목록을 직접 유지하지 않는 이유
만들 것 server/app.py 수정, server/test_client.html 수정
확인할 것 누군가 입장·퇴장할 때 모든 창의 접속자 목록이 동시에 갱신되는 것

 

0.2 이번 강의에서 수정하는 구조

fastapi-chat/
├── server/
│   ├── app.py              ← ✏️ get_nicknames() 추가, join·퇴장 처리에 user_list 브로드캐스트 추가
│   └── test_client.html    ← ✏️ 접속자 목록 표시 영역 추가, user_list 수신 처리 추가
└── desktop/                ← 43강에서 사용 예정

(✏️ 수정 · 표시 없음은 변경 없음)

 

1. 서버 - 목록 조회와 user_list 브로드캐스트 추가하기

→ ConnectionManager에 get_nicknames()를 추가하고, 입장·퇴장 두 곳에서 user_list를 보냅니다.

더보기

접속자 목록을 보내는 흐름은 단순합니다.

입장이나 퇴장이 생길 때마다 지금 연결된 닉네임 전체를 리스트로 만들어 브로드캐스트합니다.

클라이언트는 이 목록을 받아서 화면을 통째로 갱신합니다.

 

클라이언트가 입장·퇴장 메시지를 직접 파싱해 목록을 직접 유지하는 방식은 쓰지 않습니다.

서버가 항상 정확한 목록을 내려주는 것이 더 단순하고 버그가 적습니다.

먼저 ConnectionManagerget_nicknames()를 추가합니다.

class ConnectionManager:
    def __init__(self):
        self.active_connections: Dict[WebSocket, str] = {}

    async def connect(self, websocket: WebSocket):
        await websocket.accept()
        self.active_connections[websocket] = ""

    def disconnect(self, websocket: WebSocket):
        self.active_connections.pop(websocket, None)

    def set_nickname(self, websocket: WebSocket, nickname: str):
        self.active_connections[websocket] = nickname

    def get_nickname(self, websocket: WebSocket) -> str:
        return self.active_connections.get(websocket, "")

    def get_nicknames(self) -> list:                                    # ➕ 닉네임 목록 반환
        return [n for n in self.active_connections.values() if n]      # ➕ 빈 문자열(미등록) 제외

    async def broadcast(self, message: dict):
        for connection in self.active_connections:
            await connection.send_json(message)

if n 조건으로 빈 문자열을 거르는 이유가 있습니다. connect()에서 닉네임을 빈 문자열로 초기화하기 때문에, join을 아직 보내지 않은 클라이언트가 목록에 빈 항목으로 끼어들 수 있습니다.

닉네임이 확정된 접속자만 목록에 표시합니다.

이제 websocket_endpoint에서 join 처리와 퇴장 처리 두 곳에 user_list 브로드캐스트를 추가합니다.

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await manager.connect(websocket)
    try:
        while True:
            data = await websocket.receive_json()
            msg_type = data.get("type", "")

            if msg_type == "join":
                nickname = data.get("sender", "익명")
                manager.set_nickname(websocket, nickname)
                await manager.broadcast({
                    "type": "system",
                    "text": f"{nickname} 님이 입장했습니다."
                })
                await manager.broadcast({                              # ➕ 입장 후 목록 전달
                    "type": "user_list",
                    "users": manager.get_nicknames()
                })

            elif msg_type == "chat":
                await manager.broadcast({
                    "type": "chat",
                    "sender": manager.get_nickname(websocket),
                    "text": data.get("text", "")
                })

    except Exception:
        nickname = manager.get_nickname(websocket)
        manager.disconnect(websocket)
        if nickname:
            await manager.broadcast({
                "type": "system",
                "text": f"{nickname} 님이 퇴장했습니다."
            })
            await manager.broadcast({                                  # ➕ 퇴장 후 목록 전달
                "type": "user_list",
                "users": manager.get_nicknames()
            })

입장 때는 set_nickname()이 끝난 뒤에 get_nicknames()를 호출해야 방금 입장한 사람이 목록에 포함됩니다.

퇴장 때는 disconnect()가 끝난 뒤에 호출해야 나간 사람이 목록에서 빠집니다.

두 경우 모두 순서가 바뀌면 목록이 한 박자 틀립니다.

입장 흐름:
  set_nickname()          ← 닉네임 등록 완료
  broadcast(system)       ← 입장 안내
  broadcast(user_list)    ← 새 목록 전달 (방금 입장한 사람 포함)

퇴장 흐름:
  get_nickname()          ← 닉네임 저장
  disconnect()            ← 목록에서 제거 완료
  broadcast(system)       ← 퇴장 안내
  broadcast(user_list)    ← 새 목록 전달 (나간 사람 제외)

 

2. 테스트 클라이언트 - 접속자 목록 표시 추가하기

→ user_list 메시지를 받으면 화면의 목록 영역을 갱신합니다.

더보기

HTML에서 바뀌는 부분은 두 곳입니다. 접속자 목록을 보여줄 <ul> 영역을 추가하고, ws.onmessage 안에 user_list 타입 처리를 추가합니다.

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>WebSocket 테스트</title>
</head>
<body>
  <h3>WebSocket 채팅 테스트</h3>

  <div id="join-area">
    <input id="nickname" type="text" placeholder="닉네임 입력" style="width:200px;">
    <button onclick="joinChat()">입장</button>
  </div>

  <div id="chat-area" style="display:none;">
    <div style="display:flex; gap:16px;">
      <div style="flex:1;">
        <div id="log" style="border:1px solid #ccc; height:200px; overflow-y:auto; padding:8px;"></div>
        <br>
        <input id="msg" type="text" placeholder="메시지 입력" style="width:260px;">
        <button onclick="sendMsg()">보내기</button>
      </div>
      <div style="width:140px;">                                      <!-- ➕ 목록 영역 -->
        <b>접속자</b>
        <ul id="user-list" style="padding-left:16px; margin-top:4px;"></ul>
      </div>
    </div>
  </div>

  <script>
    let ws;
    let myNickname = "";

    function joinChat() {
      myNickname = document.getElementById("nickname").value.trim();
      if (!myNickname) return;

      ws = new WebSocket("ws://127.0.0.1:8000/ws");

      ws.onopen = () => {
        ws.send(JSON.stringify({ type: "join", sender: myNickname }));
        document.getElementById("join-area").style.display = "none";
        document.getElementById("chat-area").style.display = "block";
      };

      ws.onclose = () => addLog("[연결 종료]");

      ws.onmessage = (event) => {
        const data = JSON.parse(event.data);

        if (data.type === "system") {
          addLog(`[시스템] ${data.text}`);
        } else if (data.type === "chat") {
          addLog(`${data.sender}: ${data.text}`);
        } else if (data.type === "user_list") {              // ➕ 목록 수신 처리
          updateUserList(data.users);
        }
      };
    }

    function updateUserList(users) {                         // ➕ 목록 갱신 함수
      const ul = document.getElementById("user-list");
      ul.innerHTML = users.map(u => `<li>${u}</li>`).join("");
    }

    function sendMsg() {
      const input = document.getElementById("msg");
      if (input.value.trim()) {
        ws.send(JSON.stringify({ type: "chat", sender: myNickname, text: input.value }));
        input.value = "";
      }
    }

    function addLog(text) {
      const log = document.getElementById("log");
      log.innerHTML += text + "<br>";
      log.scrollTop = log.scrollHeight;
    }

    document.addEventListener("keydown", (e) => {
      if (e.key === "Enter") {
        if (document.getElementById("chat-area").style.display === "block") {
          sendMsg();
        } else {
          joinChat();
        }
      }
    });
  </script>
</body>
</html>

updateUserList()ul.innerHTML을 통째로 교체합니다. 항목을 하나씩 추가·제거하는 방식 대신, 서버에서 받은 목록으로 화면을 매번 다시 그립니다. 목록이 크지 않고 갱신 빈도도 낮아서 이 방식이 더 단순하고 실수가 없습니다.

 

3. 실행 결과 확인하기

→ 브라우저 두 개로 입장·퇴장하면서 접속자 목록이 실시간으로 갱신되는지 확인합니다.

더보기

서버를 실행하고 test_client.html을 두 창에서 엽니다. 첫 번째 창에서 "홍길동"으로 입장하면 오른쪽 접속자 영역에 이름이 나타납니다. 두 번째 창에서 "김철수"로 입장하면 두 창 모두 목록이 갱신됩니다.

[창 1 — 홍길동 입장 직후]       [창 2 — 김철수 입장 직후]
접속자                           접속자
• 홍길동                         • 홍길동
                                 • 김철수

[창 1 — 김철수 입장 후]
접속자
• 홍길동
• 김철수

탭 하나를 닫으면 나머지 창의 목록에서 해당 닉네임이 사라집니다.

✔ 확인 기준: 누군가 입장할 때 이미 접속해 있던 창의 목록도 함께 갱신되면 완료. 탭을 닫았을 때 목록에서 즉시 사라지면 퇴장 처리도 정상. 목록이 한 박자 늦게 갱신되거나 나간 사람이 여전히 보이면 disconnect()get_nicknames() 호출 순서를 확인하세요.

 

4. 최종 코드 정리하기

→ 이번 강의에서 완성한 두 파일 전체를 정리합니다.

더보기

4.1 server/app.py

from fastapi import FastAPI, WebSocket
from typing import Dict

app = FastAPI()

class ConnectionManager:
    def __init__(self):
        self.active_connections: Dict[WebSocket, str] = {}

    async def connect(self, websocket: WebSocket):
        await websocket.accept()
        self.active_connections[websocket] = ""

    def disconnect(self, websocket: WebSocket):
        self.active_connections.pop(websocket, None)

    def set_nickname(self, websocket: WebSocket, nickname: str):
        self.active_connections[websocket] = nickname

    def get_nickname(self, websocket: WebSocket) -> str:
        return self.active_connections.get(websocket, "")

    def get_nicknames(self) -> list:
        return [n for n in self.active_connections.values() if n]

    async def broadcast(self, message: dict):
        for connection in self.active_connections:
            await connection.send_json(message)

manager = ConnectionManager()

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await manager.connect(websocket)
    try:
        while True:
            data = await websocket.receive_json()
            msg_type = data.get("type", "")

            if msg_type == "join":
                nickname = data.get("sender", "익명")
                manager.set_nickname(websocket, nickname)
                await manager.broadcast({
                    "type": "system",
                    "text": f"{nickname} 님이 입장했습니다."
                })
                await manager.broadcast({
                    "type": "user_list",
                    "users": manager.get_nicknames()
                })

            elif msg_type == "chat":
                await manager.broadcast({
                    "type": "chat",
                    "sender": manager.get_nickname(websocket),
                    "text": data.get("text", "")
                })

    except Exception:
        nickname = manager.get_nickname(websocket)
        manager.disconnect(websocket)
        if nickname:
            await manager.broadcast({
                "type": "system",
                "text": f"{nickname} 님이 퇴장했습니다."
            })
            await manager.broadcast({
                "type": "user_list",
                "users": manager.get_nicknames()
            })

 

4.2 server/test_client.html

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>WebSocket 테스트</title>
</head>
<body>
  <h3>WebSocket 채팅 테스트</h3>

  <div id="join-area">
    <input id="nickname" type="text" placeholder="닉네임 입력" style="width:200px;">
    <button onclick="joinChat()">입장</button>
  </div>

  <div id="chat-area" style="display:none;">
    <div style="display:flex; gap:16px;">
      <div style="flex:1;">
        <div id="log" style="border:1px solid #ccc; height:200px; overflow-y:auto; padding:8px;"></div>
        <br>
        <input id="msg" type="text" placeholder="메시지 입력" style="width:260px;">
        <button onclick="sendMsg()">보내기</button>
      </div>
      <div style="width:140px;">
        <b>접속자</b>
        <ul id="user-list" style="padding-left:16px; margin-top:4px;"></ul>
      </div>
    </div>
  </div>

  <script>
    let ws;
    let myNickname = "";

    function joinChat() {
      myNickname = document.getElementById("nickname").value.trim();
      if (!myNickname) return;

      ws = new WebSocket("ws://127.0.0.1:8000/ws");

      ws.onopen = () => {
        ws.send(JSON.stringify({ type: "join", sender: myNickname }));
        document.getElementById("join-area").style.display = "none";
        document.getElementById("chat-area").style.display = "block";
      };

      ws.onclose = () => addLog("[연결 종료]");

      ws.onmessage = (event) => {
        const data = JSON.parse(event.data);
        if (data.type === "system") {
          addLog(`[시스템] ${data.text}`);
        } else if (data.type === "chat") {
          addLog(`${data.sender}: ${data.text}`);
        } else if (data.type === "user_list") {
          updateUserList(data.users);
        }
      };
    }

    function updateUserList(users) {
      const ul = document.getElementById("user-list");
      ul.innerHTML = users.map(u => `<li>${u}</li>`).join("");
    }

    function sendMsg() {
      const input = document.getElementById("msg");
      if (input.value.trim()) {
        ws.send(JSON.stringify({ type: "chat", sender: myNickname, text: input.value }));
        input.value = "";
      }
    }

    function addLog(text) {
      const log = document.getElementById("log");
      log.innerHTML += text + "<br>";
      log.scrollTop = log.scrollHeight;
    }

    document.addEventListener("keydown", (e) => {
      if (e.key === "Enter") {
        if (document.getElementById("chat-area").style.display === "block") {
          sendMsg();
        } else {
          joinChat();
        }
      }
    });
  </script>
</body>
</html>

 

4.3 최종 확인 표

코드 역할
[n for n in self.active_connections.values() if n] 닉네임이 확정된 접속자만 목록에 포함 (빈 문자열 제외)
broadcast({"type": "user_list", "users": [...]}) 입장·퇴장 직후 전체에 현재 목록 전달
입장: set_nickname → broadcast(system) → broadcast(user_list) 닉네임 등록 후 목록 전달 → 새 접속자 포함
퇴장: get_nickname → disconnect → broadcast(system) → broadcast(user_list) 제거 후 목록 전달 → 퇴장한 사람 제외
ul.innerHTML = users.map(u => <li>${u}</li>).join("") 받은 목록으로 화면을 통째로 교체

→ 다음 강의 (42강): 특정 사람에게만 메시지를 보내는 귓속말을 추가합니다. 프로토콜에 {"type": "whisper", "target": "닉네임", "text": "..."}를 추가하고, 서버가 target의 소켓을 찾아 그 한 명에게만 전송합니다. 목록에서 닉네임으로 소켓을 역조회하는 메서드가 하나 더 필요합니다.