41강. 사용자 목록 동기화

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를 보냅니다.
접속자 목록을 보내는 흐름은 단순합니다.
입장이나 퇴장이 생길 때마다 지금 연결된 닉네임 전체를 리스트로 만들어 브로드캐스트합니다.
클라이언트는 이 목록을 받아서 화면을 통째로 갱신합니다.
클라이언트가 입장·퇴장 메시지를 직접 파싱해 목록을 직접 유지하는 방식은 쓰지 않습니다.
서버가 항상 정확한 목록을 내려주는 것이 더 단순하고 버그가 적습니다.
먼저 ConnectionManager에 get_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의 소켓을 찾아 그 한 명에게만 전송합니다. 목록에서 닉네임으로 소켓을 역조회하는 메서드가 하나 더 필요합니다.