39강. FastAPI WebSocket 채팅 서버

0. 학습 목표
→ 이번 글에서 무엇을 이해하고, 무엇을 만들고, 무엇을 확인할지 먼저 정리합니다.
0.1 이번 글에서 다룰 내용
이번 글은 구현 중심 강의입니다.
38강에서 FastAPI와 WebSocket의 개념을 정리했습니다. 이번 강의에서는 그 개념을 실제 코드로 옮겨, 브라우저에서 동작하는 간단한 채팅 서버를 만듭니다. 목표는 기존 소켓 프로젝트를 바꾸는 것이 아니라, 같은 채팅 기능이 WebSocket 방식에서는 어떻게 더 짧게 구현되는지 비교하며 확인하는 것입니다.
FastAPI 앱 생성
→ WebSocket 연결 받기
→ 접속자 목록 관리
→ 메시지 수신
→ 전체 접속자에게 브로드캐스트
→ 연결 종료 시 목록에서 제거

| 구분 | 내용 |
| 이해할 것 | WebSocket 방식에도 접속자 목록과 브로드캐스트 구조가 필요하다는 것 |
| 만들 것 | FastAPI WebSocket 기반 채팅 서버 websocket-chat-demo/app.py |
| 확인할 것 | 여러 브라우저 창에서 메시지가 실시간으로 전달되는지 |
0.2 이번 강의에서 직접 다루는 구조
38강에서 예고한 대로 기존 소켓 프로젝트는 손대지 않고, 별도 폴더에 WebSocket 예제를 만듭니다.
chat_server/ ← 기존 소켓 프로젝트 (그대로 유지)
├── protocol.py
└── server.py
chat_client/ ← 기존 소켓 프로젝트 (그대로 유지)
├── protocol.py
├── client.py
└── main.py
websocket-chat-demo/
└── app.py ← ➕ 이번 강의에서 생성
(➕ 새로 생성 · ✏️ 수정 · 표시 없음은 변경 없음)
기존 소켓 프로젝트와 이번 WebSocket 예제는 같은 채팅 문제를 다른 방식으로 푼 것입니다. 아래 대응을 머릿속에 두고 코드를 보면 낯설지 않습니다.
| 구분 | 기존 소켓 프로젝트 | 이번 WebSocket 예제 |
| 서버 파일 | server.py |
app.py |
| 클라이언트 | PySide6 GUI | 웹 브라우저 |
| 동시 처리 | threading |
async/await |
| 메시지 수신 | recv() |
receive_text() |
1. 실습 준비하기
→ FastAPI와 Uvicorn을 설치하고 예제 폴더를 만듭니다.
직접 만든 소켓 서버는 표준 라이브러리 socket만 썼지만, FastAPI WebSocket은 외부 패키지가 필요합니다. fastapi(서버 프레임워크)와 uvicorn(서버 실행기)을 함께 설치합니다.
pip install fastapi uvicorn
설치가 끝나면 두 패키지를 불러올 수 있는지 확인합니다.
python -c "import fastapi, uvicorn; print('FastAPI 준비 완료')"
FastAPI 준비 완료
예제를 둘 폴더와 파일을 만듭니다. 기존 프로젝트와 섞이지 않도록 새 폴더에 둡니다.
mkdir websocket-chat-demo
touch websocket-chat-demo/app.py
✔ 확인 기준: import 오류 없이 FastAPI 준비 완료가 출력되고 websocket-chat-demo/app.py를 열 수 있으면 완료. 실패하면 현재 Python 환경에 fastapi·uvicorn이 설치됐는지 확인하세요.
2. 서버 - FastAPI 앱과 접속자 목록 만들기
→ 가장 작은 FastAPI 앱으로 실행을 확인하고 접속자 목록을 준비합니다.
2.1 가장 작은 FastAPI 앱 실행 확인
먼저 WebSocket 없이, 서버가 뜨는지부터 확인합니다.
@app.get("/")는 브라우저가 첫 페이지를 요청할 때 실행되는 부분입니다.
from fastapi import FastAPI
app = FastAPI()
@app.get("/") # 첫 페이지 요청을 처리
def home():
return {"message": "FastAPI WebSocket 채팅 예제"}
uvicorn으로 실행합니다. app:app은 "app.py 파일 안의 app 객체"라는 뜻입니다.
uvicorn app:app --reload
✔ 확인 기준: http://127.0.0.1:8000에서 {"message":"FastAPI WebSocket 채팅 예제"}가 보이면 완료. 실패하면 터미널 위치가 app.py가 있는 폴더인지 확인하세요.
2.2 접속자 목록 준비하기
WebSocket 채팅에서도 "지금 누가 접속해 있는지"를 담는 목록이 필요합니다. 기존 소켓 서버의 clients 리스트와 똑같은 역할이며, 담기는 대상만 소켓에서 WebSocket으로 바뀝니다.
connected_clients = [] # 접속 중인 WebSocket 목록 (소켓의 clients와 동일 역할)
이 목록을 순회하며 메시지를 보내는 것이 곧 브로드캐스트입니다. 다음 섹션에서 연결 처리와 함께 완성합니다.
3. 서버 - WebSocket 연결과 브로드캐스트 만들기
→ 브로드캐스트 함수와 WebSocket 연결 처리 함수를 작성하고 흐름을 이해합니다.
3.1 브로드캐스트 함수 작성하기
접속자 목록을 순회하며 모두에게 같은 메시지를 보내는 함수입니다. 기존 서버의 broadcast()와 구조가 같고, send_text() 앞에 await가 붙는 점만 다릅니다.
async def broadcast(message): # 비동기 함수
for client in connected_clients: # 접속자 목록 순회
await client.send_text(message) # 각 WebSocket에 텍스트 전송
3.2 WebSocket 연결 처리 함수 작성하기
브라우저가 /ws 주소로 연결을 요청하면 이 함수가 실행됩니다. 연결 수락 → 목록 추가 → 입장 알림 → 메시지 수신·전달 반복 → 종료 시 목록에서 제거 흐름입니다. 기존 handle_client()와 같은 뼈대입니다.
@app.websocket("/ws") # WebSocket 연결 주소
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept() # 연결 수락 (소켓의 accept)
connected_clients.append(websocket) # 접속자 목록에 추가
await broadcast("[시스템] 새 사용자가 입장했습니다.")
try:
while True: # 연결을 유지하며 계속 수신
message = await websocket.receive_text() # 메시지 도착까지 대기 (recv)
await broadcast(message) # 받은 메시지를 전체에 전달
except WebSocketDisconnect: # 브라우저가 연결을 끊으면 발생
connected_clients.remove(websocket) # 목록에서 제거
await broadcast("[시스템] 사용자가 퇴장했습니다.")
소켓 서버에서는 연결 끊김을 recv()가 빈 값을 돌려주는지로 판단했지만, WebSocket에서는 WebSocketDisconnect 예외로 깔끔하게 잡습니다.
3.3 기존 TCP 서버와 비교
| 기존 TCP 서버 | WebSocket 서버 |
send_message(client_socket, message) |
await client.send_text(message) |
clients 리스트 순회 |
connected_clients 리스트 순회 |
recv()가 빈 값 → 종료 |
WebSocketDisconnect 예외 → 종료 |
일반 함수 + threading |
비동기 함수 async def + await |
✔ 확인 기준: 연결 수락 → 목록 추가 → 수신 → 브로드캐스트 → 종료 시 제거 흐름을 말로 설명할 수 있으면 완료. 이 흐름이 기존 소켓 서버와 같은 뼈대라는 점을 확인하세요.
4. 클라이언트 - 브라우저 채팅 화면 붙이기
→ 첫 페이지로 내려줄 HTML 채팅 화면을 만들고 app.py에 연결합니다.
4.1 브라우저 채팅 화면 HTML

소켓 프로젝트에서는 PySide6 GUI가 클라이언트였지만, WebSocket 예제에서는 브라우저가 클라이언트입니다.
서버가 첫 페이지로 내려줄 HTML을 문자열로 준비합니다.
브라우저의 JavaScript가 ws://127.0.0.1:8000/ws로 연결해 메시지를 주고받습니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>WebSocket 채팅</title>
</head>
<body>
<h2>FastAPI WebSocket 채팅</h2>
<textarea id="chat" rows="15" cols="50" readonly></textarea><br>
<input type="text" id="message" placeholder="메시지 입력" />
<button onclick="sendMessage()">보내기</button>
<script>
const chat = document.getElementById("chat");
const messageInput = document.getElementById("message");
const socket = new WebSocket("ws://127.0.0.1:8000/ws"); // 서버에 연결
socket.onmessage = function(event) { // 메시지 도착 시
chat.value += event.data + "\n"; // 채팅창에 추가
};
function sendMessage() {
const message = messageInput.value.trim();
if (!message) { return; } // 빈 메시지 방지
socket.send(message); // 서버로 전송
messageInput.value = "";
}
messageInput.addEventListener("keydown", function(event) {
if (event.key === "Enter") { sendMessage(); } // 엔터로 전송
});
</script>
</body>
</html>
4.2 HTML을 첫 페이지로 연결하기
위 HTML을 app.py 안에 html 문자열로 두고, @app.get("/")가 그 문자열을 HTMLResponse로 돌려주도록 바꿉니다. 2.1에서 JSON을 돌려주던 부분을 화면으로 교체하는 것입니다.
from fastapi.responses import HTMLResponse
@app.get("/")
async def home():
return HTMLResponse(html) # JSON 대신 채팅 화면을 돌려줌
✔ 확인 기준: http://127.0.0.1:8000에 접속했을 때 입력창과 보내기 버튼이 있는 채팅 화면이 보이면 완료. JavaScript의 WebSocket 주소가 ws://127.0.0.1:8000/ws로 되어 있는지 확인하세요.
5. 실행 결과 확인하기
→ 브라우저 창 두 개로 메시지가 실시간으로 공유되는지 확인합니다.
5.1 정상 실행 확인

uvicorn app:app --reload
브라우저에서 http://127.0.0.1:8000을 두 창으로 엽니다. 한 창에서 메시지를 입력하면 두 창 모두에 같은 메시지가 표시됩니다.
[시스템] 새 사용자가 입장했습니다.
첫 번째 창 입력: 안녕하세요 WebSocket입니다
두 번째 창 표시: 안녕하세요 WebSocket입니다
한 창을 닫으면 다른 창에 퇴장 메시지가 표시됩니다.
[시스템] 사용자가 퇴장했습니다.
5.2 흔한 오류와 해결 방법
| 오류 상황 | 원인과 해결 방법 |
ModuleNotFoundError: fastapi |
pip install fastapi uvicorn을 실행한다 |
uvicorn 명령어를 찾을 수 없음 |
python -m uvicorn app:app --reload로 실행한다 |
| 브라우저에서 접속이 안 됨 | 서버가 실행 중이 아니거나 주소가 다름. http://127.0.0.1:8000으로 접속한다 |
| WebSocket 연결 오류가 남 | JavaScript 주소를 ws://127.0.0.1:8000/ws로 확인한다 |
| 메시지가 한 창에만 보임 | connected_clients를 순회하며 send_text()를 호출하는지 확인한다 |
| 사용자가 나간 뒤 오류가 남 | WebSocketDisconnect에서 connected_clients.remove(websocket)을 호출한다 |
| 포트가 이미 사용 중 | uvicorn app:app --reload --port 8001처럼 다른 포트를 쓴다 |
6. 최종 코드 정리하기
→ 이번 강의에서 완성한 app.py 전체 코드를 한곳에 정리합니다.
6.1 websocket-chat-demo/app.py
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
app = FastAPI()
connected_clients = []
html = """
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>WebSocket 채팅</title>
</head>
<body>
<h2>FastAPI WebSocket 채팅</h2>
<textarea id="chat" rows="15" cols="50" readonly></textarea><br>
<input type="text" id="message" placeholder="메시지 입력" />
<button onclick="sendMessage()">보내기</button>
<script>
const chat = document.getElementById("chat");
const messageInput = document.getElementById("message");
const socket = new WebSocket("ws://127.0.0.1:8000/ws");
socket.onmessage = function(event) {
chat.value += event.data + "\n";
};
function sendMessage() {
const message = messageInput.value.trim();
if (!message) { return; }
socket.send(message);
messageInput.value = "";
}
messageInput.addEventListener("keydown", function(event) {
if (event.key === "Enter") { sendMessage(); }
});
</script>
</body>
</html>
"""
@app.get("/")
async def home():
return HTMLResponse(html)
async def broadcast(message):
for client in connected_clients:
await client.send_text(message)
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
connected_clients.append(websocket)
await broadcast("[시스템] 새 사용자가 입장했습니다.")
try:
while True:
message = await websocket.receive_text()
await broadcast(message)
except WebSocketDisconnect:
connected_clients.remove(websocket)
await broadcast("[시스템] 사용자가 퇴장했습니다.")
실행 명령어는 다음과 같습니다. uvicorn이 직접 실행되지 않으면 python -m을 앞에 붙입니다.
uvicorn app:app --reload
# 또는
python -m uvicorn app:app --reload
6.2 최종 확인 표
| 코드 | 역할 |
connected_clients = [] |
접속 중인 WebSocket 목록 (소켓의 clients) |
@app.get("/") + HTMLResponse |
브라우저에 채팅 화면을 내려줌 |
broadcast() |
접속자 목록을 순회하며 전체에 메시지 전송 |
@app.websocket("/ws") |
브라우저의 WebSocket 연결을 처리 |
await websocket.accept() |
연결 수락 (소켓의 accept()) |
await websocket.receive_text() |
메시지 수신 (소켓의 recv()) |
except WebSocketDisconnect |
연결 종료 감지 후 목록에서 제거 |
→ 다음 강의 (40강): 39강까지 소켓 채팅과 WebSocket 확장 예제를 모두 완성했습니다. 40강에서는 최종 발표를 준비합니다. README.md에 실행 방법을 정리하고, 소켓 GUI 채팅(전체 채팅·귓속말·파일 전송) 시연과 WebSocket 확장 예제 실행, 두 방식의 차이 설명까지 발표 흐름을 만듭니다.