40강. JSON 프로토콜과 닉네임

0. 학습 목표
→ 단순 문자열 메시지를 JSON 프로토콜로 바꾸고, 닉네임 입장·퇴장 처리를 추가합니다.
0.1 이번 글에서 다룰 내용
이번 글은 구현 중심 강의입니다.
39강에서 만든 서버는 메시지를 단순 문자열로 주고받습니다.
지금 상태로는 "누가 보낸 메시지인지", "시스템 안내인지 채팅인지"를 구분할 방법이 없습니다.
이번 강의에서 메시지 형식을 JSON으로 바꾸고, 첫 메시지로 닉네임을 등록하는 입장 처리를 추가합니다.
이 프로토콜이 41강 사용자 목록, 42강 귓속말, 그리고 43강에서 PySide6 앱이 서버와 대화할 때까지 그대로 쓰입니다.
| 구분 | 내용 |
| 이해할 것 | 메시지 타입(join · chat · leave)으로 서버가 상황을 구분하는 방식 |
| 만들 것 | server/app.py 수정, server/test_client.html 수정 |
| 확인할 것 | 닉네임 입장 안내가 전체에 전달되는 것, 이름이 붙은 채팅 메시지가 표시되는 것 |
0.2 이번 강의에서 수정하는 구조
fastapi-chat/
├── server/
│ ├── app.py ← ✏️ JSON 프로토콜 + 닉네임 처리 추가
│ └── test_client.html ← ✏️ 닉네임 입력 UI + JSON 파싱 처리 추가
└── desktop/ ← 43강에서 사용 예정
(✏️ 수정 · 표시 없음은 변경 없음)
1. 프로토콜 설계하기
→ 코드를 수정하기 전에 메시지 형식을 먼저 정합니다.
WebSocket은 전송 통로일 뿐, "어떤 형식으로 보낼지"는 직접 정해야 합니다.
39강에서는 아무 규칙 없이 문자열만 던졌습니다.
서버는 받은 내용이 채팅인지, 닉네임 등록인지, 퇴장 알림인지 알 방법이 없었습니다.
이번 강의부터는 메시지를 항상 JSON 객체로 보냅니다.
공통 필드는 type과 sender이고, 상황에 따라 text가 붙습니다.
// 클라이언트 → 서버: 닉네임 등록
{"type": "join", "sender": "홍길동"}
// 클라이언트 → 서버: 채팅 메시지
{"type": "chat", "sender": "홍길동", "text": "안녕하세요"}
// 서버 → 전체: 시스템 안내 (입장·퇴장)
{"type": "system", "text": "홍길동 님이 입장했습니다."}
// 서버 → 전체: 채팅 브로드캐스트
{"type": "chat", "sender": "홍길동", "text": "안녕하세요"}
type 필드 하나로 서버가 상황을 구분합니다. 클라이언트가 처음 연결하면 반드시 join 메시지를 먼저 보내도록 약속합니다. 이 약속이 프로토콜입니다. 41강에서 귓속말을 추가할 때 "type": "whisper"를 하나 더 넣으면 되고, 43강 PySide6 앱도 이 형식 그대로 사용합니다.
2. 서버 - JSON 수신과 닉네임 처리 추가하기
→ app.py를 수정해 JSON을 파싱하고 type별로 처리합니다.
39강 app.py에서 바뀌는 부분은 크게 두 곳입니다.
ConnectionManager에 닉네임을 기억하는 구조가 추가되고,
websocket_endpoint에서 메시지를 JSON으로 받아 type에 따라 분기합니다.
먼저 ConnectionManager를 수정합니다.
접속자 목록을 단순 리스트에서 dict로 바꿔, WebSocket 객체와 닉네임을 함께 기억합니다.
from fastapi import FastAPI, WebSocket
from typing import Dict # ✏️ List → 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) # ✏️ remove → pop으로 교체
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, "")
async def broadcast(self, message: dict): # ✏️ str → dict로 변경
for connection in self.active_connections:
await connection.send_json(message) # ✏️ send_text → send_json
접속자 목록을 List에서 Dict[WebSocket, str]로 바꾼 이유가 있습니다.
리스트는 소켓만 저장할 수 있어서, 닉네임을 연결하려면 별도 딕셔너리가 하나 더 필요합니다.
소켓을 키, 닉네임을 값으로 쓰면 한 구조에서 두 가지를 같이 관리할 수 있습니다.
이제 websocket_endpoint를 수정합니다. 문자열 대신 JSON을 받고, type에 따라 처리합니다.
manager = ConnectionManager()
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
try:
while True:
data = await websocket.receive_json() # ✏️ receive_text → 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} 님이 입장했습니다."
})
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} 님이 퇴장했습니다."
})
receive_json()은 FastAPI가 제공하는 메서드로, 받은 텍스트를 자동으로 파싱해 딕셔너리로 돌려줍니다. json.loads()를 직접 호출하는 것과 같은 결과입니다.
퇴장 처리 순서에 주의합니다. disconnect()를 먼저 호출하면 닉네임도 함께 사라집니다.
그래서 닉네임을 변수에 먼저 저장하고, 목록에서 제거한 뒤 퇴장 메시지를 브로드캐스트합니다.
순서를 바꾸면 닉네임이 빈 문자열로 나옵니다.
nickname = manager.get_nickname(websocket) # 1. 닉네임 먼저 저장
manager.disconnect(websocket) # 2. 목록에서 제거 (퇴장한 사람은 브로드캐스트 제외)
if nickname:
await manager.broadcast({...}) # 3. 나머지 접속자에게 퇴장 안내
3. 테스트 클라이언트 - 닉네임 UI와 JSON 처리 추가하기
→ test_client.html에 닉네임 입력창을 추가하고 JSON 메시지를 파싱해 표시합니다.

39강 HTML은 메시지를 문자열 그대로 표시했습니다.
이제 서버가 JSON을 보내므로, 받은 텍스트를 파싱해 type에 따라 다르게 표시해야 합니다. 닉네임 입력창도 추가합니다.
<!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 id="log" style="border:1px solid #ccc; height:200px; overflow-y:auto; padding:8px; margin-top:8px;"></div>
<br>
<input id="msg" type="text" placeholder="메시지 입력" style="width:300px;">
<button onclick="sendMsg()">보내기</button>
</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 })); // join 메시지 전송
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); // JSON 파싱
if (data.type === "system") {
addLog(`[시스템] ${data.text}`); // 입장·퇴장 안내
} else if (data.type === "chat") {
addLog(`${data.sender}: ${data.text}`); // 채팅 메시지
}
};
}
function sendMsg() {
const input = document.getElementById("msg");
if (input.value.trim()) {
ws.send(JSON.stringify({ // chat 메시지 전송
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>
연결 시점과 join 메시지 전송 시점이 분리되어 있습니다. WebSocket은 연결 즉시 메시지를 보낼 수 없고 onopen이 호출된 뒤에야 전송할 수 있습니다. 그래서 joinChat()에서 소켓을 만들고, ws.onopen 안에서 join을 보냅니다.
4. 실행 결과 확인하기
→ 두 브라우저에서 각각 닉네임으로 입장해 메시지와 퇴장 안내를 확인합니다.
서버를 실행하고 test_client.html을 브라우저 두 개에서 엽니다. 첫 번째 창에서 "홍길동"으로 입장하면 두 창 모두에 시스템 메시지가 나타납니다.
[창 1 — 홍길동] [창 2 — 김철수]
닉네임: 홍길동 → [입장]
[시스템] 홍길동 님이 입장했습니다.
닉네임: 김철수 → [입장]
[시스템] 김철수 님이 입장했습니다. [시스템] 김철수 님이 입장했습니다.
홍길동: 안녕하세요 → 홍길동: 안녕하세요
← 김철수: 반갑습니다
김철수: 반갑습니다
탭 하나를 닫으면 나머지 창에 퇴장 안내가 표시됩니다.
[창 1 — 홍길동]
[시스템] 김철수 님이 퇴장했습니다.
✔ 확인 기준: 입장 안내에 닉네임이 표시되고, 채팅 메시지 앞에 보낸 사람 이름이 붙고, 탭을 닫으면 퇴장 안내가 나머지 창에 표시되면 완료. 서버 콘솔에 JSON 파싱 오류가 없는지도 확인하세요.
5. 최종 코드 정리하기
→ 이번 강의에서 완성한 두 파일 전체를 정리합니다.
5.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, "")
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} 님이 입장했습니다."
})
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} 님이 퇴장했습니다."
})
5.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 id="log" style="border:1px solid #ccc; height:200px; overflow-y:auto; padding:8px; margin-top:8px;"></div>
<br>
<input id="msg" type="text" placeholder="메시지 입력" style="width:300px;">
<button onclick="sendMsg()">보내기</button>
</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}`);
}
};
}
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>
5.3 최종 확인 표
| 코드 | 역할 |
Dict[WebSocket, str] |
소켓과 닉네임을 한 구조에서 관리 |
set_nickname(websocket, nickname) |
join 수신 시 닉네임 등록 |
get_nickname(websocket) |
채팅·퇴장 처리 시 닉네임 조회 |
await websocket.receive_json() |
텍스트 수신 + JSON 파싱을 한 번에 |
await connection.send_json(message) |
dict를 JSON 문자열로 변환해 전송 |
data.get("type", "") |
type 필드로 메시지 상황 분기 |
| 퇴장 처리 순서 (get → disconnect → broadcast) | 닉네임 저장 후 제거, 나머지에게만 퇴장 안내 전달 |
ws.onopen 안에서 join 전송 |
연결 완료 후에만 메시지 전송 가능 |
→ 다음 강의 (41강): 누가 채팅방에 있는지 목록을 실시간으로 공유합니다. 입장·퇴장 때마다 서버가 {"type": "user_list", "users": [...]}를 브로드캐스트하고, 클라이언트가 이를 받아 접속자 목록을 갱신합니다.