기술 스택과 아키텍처 개요
실시간 채팅 시스템은 클라이언트와 서버 간의 지속적인 양방향 통신이 필수적입니다. 기존 HTTP는 요청-응답 패턴에 의존하기 때문에 실시간성이 부족하며, WebSocket 프로토콜은 서버가 클라이언트로 데이터를 능동적으로 전송할 수 있는 전이중 통신 채널을 제공합니다.
기술 스택 비교
| 기술 | 장점 | 적용 분야 |
|---|---|---|
| FastAPI | 고성능, 비동기 처리, 타입 힌트 | 실시간 API, 마이크로서비스 |
| React | 컴포넌트 기반, 가상 DOM, 풍부한 생태계 | 복잡한 사용자 인터페이스 |
| WebSocket | 저지연, 전이중 통신 | 실시간 채팅, 게임, 알림 시스템 |
FastAPI WebSocket 백엔드 구현
기본 WebSocket 서비스 구축
FastAPI 애플리케이션에 WebSocket 라우트를 추가하는 방법부터 시작합니다.
from fastapi import FastAPI, WebSocket
from fastapi.middleware.cors import CORSMiddleware
application = FastAPI()
# CORS 설정: 프론트엔드 연결 허용
application.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 현재 열려있는 WebSocket 연결 목록
live_connections = []
@application.websocket("/ws")
async def ws_handler(socket: WebSocket):
await socket.accept()
live_connections.append(socket)
try:
while True:
incoming = await socket.receive_text()
# 연결된 모든 클라이언트에게 메시지 전송
for conn in live_connections:
await conn.send_text(f"Echo: {incoming}")
except Exception as err:
print(f"오류 발생: {err}")
finally:
live_connections.remove(socket)
메시지 처리 및 브로드캐스트 시스템
실제 운영 환경에서는 구조화된 메시지 처리가 필요합니다. JSON 기반 메시지를 활용한 개선된 코드 예제입니다.
from typing import List, Dict
import json
from datetime import datetime
import uuid
# 연결 정보를 저장할 클래스
class ConnectionInfo:
def __init__(self, websocket: WebSocket):
self.ws = websocket
self.user_id = str(uuid.uuid4())
self.joined_at = datetime.utcnow()
# 메시지 형식 정의
class ChatMessage:
def __init__(self, sender_id: str, content: str, timestamp: str):
self.sender_id = sender_id
self.content = content
self.timestamp = timestamp
def to_dict(self) -> dict:
return {
"sender": self.sender_id,
"message": self.content,
"time": self.timestamp
}
# 채팅 방 관리
chat_rooms: Dict[str, List[ConnectionInfo]] = {}
@application.websocket("/ws/chat/{room_id}")
async def chat_room_handler(socket: WebSocket, room_id: str):
await socket.accept()
user_info = ConnectionInfo(socket)
if room_id not in chat_rooms:
chat_rooms[room_id] = []
chat_rooms[room_id].append(user_info)
try:
while True:
raw_data = await socket.receive_text()
received_msg = json.loads(raw_data)
# 응답 메시지 생성
response = ChatMessage(
sender_id=user_info.user_id,
content=received_msg.get("text", ""),
timestamp=datetime.utcnow().isoformat()
).to_dict()
# 같은 방에 있는 모든 사용자에게 전송
for participant in chat_rooms[room_id]:
if participant.ws != socket: # 자신을 제외
await participant.ws.send_json(response)
except Exception as error:
print(f"채팅 오류: {error}")
finally:
chat_rooms[room_id].remove(user_info)
# 방이 비었으면 삭제
if not chat_rooms[room_id]:
del chat_rooms[room_id]
React 프론트엔드에서 WebSocket 연결
React 컴포넌트에서 WebSocket을 관리하는 방법을 살펴보겠습니다. 커스텀 훅을 사용하여 연결 상태를 관리합니다.
import { useState, useEffect, useRef, useCallback } from 'react';
function useChatWebSocket(roomId) {
const [messages, setMessages] = useState([]);
const [status, setStatus] = useState('disconnected');
const socketRef = useRef(null);
useEffect(() => {
const wsUrl = `ws://localhost:8000/ws/chat/${roomId}`;
const connect = () => {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
setStatus('connected');
console.log('WebSocket 연결 성공');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setMessages(prev => [...prev, data]);
};
ws.onclose = () => {
setStatus('disconnected');
console.log('WebSocket 연결 종료');
// 재연결 로직
setTimeout(connect, 3000);
};
ws.onerror = (error) => {
console.error('WebSocket 오류:', error);
ws.close();
};
socketRef.current = ws;
};
connect();
return () => {
if (socketRef.current) {
socketRef.current.close();
}
};
}, [roomId]);
const sendMessage = useCallback((text) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
const payload = JSON.stringify({ text });
socketRef.current.send(payload);
} else {
console.warn('연결이 열려있지 않습니다.');
}
}, []);
return { messages, sendMessage, status };
}
채팅 인터페이스 컴포넌트
위에서 만든 훅을 활용하여 실제 채팅 UI를 구성합니다.
function ChatRoom({ roomId }) {
const { messages, sendMessage, status } = useChatWebSocket(roomId);
const [inputValue, setInputValue] = useState('');
const messagesEndRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
if (inputValue.trim()) {
sendMessage(inputValue);
setInputValue('');
}
};
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<div className="chat-container">
<div className="status-bar">
연결 상태: {status === 'connected' ? '✅ 연결됨' : '❌ 연결 끊김'}
</div>
<div className="messages-area">
{messages.map((msg, index) => (
<div key={index} className="message-item">
{msg.sender}:
{msg.message}
</div>
))}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSubmit} className="input-form">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="메시지를 입력하세요..."
disabled={status !== 'connected'}
/>
<button type="submit" disabled={status !== 'connected'}>
전송
</button>
</form>
</div>
);
}