WebSocket: 클라이언트와 서버 간 양방향 실시간 통신을 가능하게 하는 프로토콜
기본적으로 HTTP를 업그레이드(Upgrade Header 사용)하여 WebSocket 연결을 설정한 뒤, 지속적인 통신 채널을 유지
- 클라이언트가 서버에 WebSocket 연결 요청 전송
- 서버가 연결을 승인하면 HTTP 프로토콜이 WebSocket으로 업그레이드
- 이 후 양방향 통신 진행
STOMP(Simple or Streaming Text Oriented Messaging Protocol): 메시지 브로커와 클라이언트 간의 통신을 위한 텍스트 기반 메시징 프로토콜 (WebSocket이나 TCP 위에서 작동하며, 메시지 전송과 구독을 위한 규격화된 방식과 구조를 제공)
- 클라이언트와 메시지 브로커 간의 통신을 간단하고 효율적으로 수행할 수 있도록 설계되었음.
- Websocket을 사용하여 클라이언트와 서버 간의 메시지 교환을 구조화하고 표준화하는 데 사용됨.
- 복잡한 WebSocket 프로토콜을 직접 구현하지 않고도 손쉽게 구축할 수 있도록 도움.
STOMP는 WebSocket 위에서 동작하는 메시징 프로토콜로 메시지 발행, 구독, 라우팅 기능을 제공함.
클라이언트는 SockJS
와 stomp.js
라이브러리를 사용해 WebSocket과 STOMP 프로토콜을 처리한다.
new SockJS();
new Client()
onConnect
subscribe()
publish()
이 외에도 다양한 기능들이 있다. (버전에 따라서 달라짐)
- WebSocket 연결
- STOMP 연결 초기화(
onConnect
를 통해서 초기 작업 처리)- 특정 Topic 구독(
subscribe()
)- 클라이언트가 서버로 메시지 전송(
publish()
)- 서버가 메시지를 클라이언트로 브로드캐스트
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
const connectStomp = async () => {
// SockJS 인스턴스를 생성합니다.
const socket = new SockJS('/ws'); // WebSocket 연결 생성
// STOMP 클라이언트를 생성합니다.
const stompClient = new Client({
webSocketFactory: () => socket, // SockJS 연결 적용
debug: (str) => console.log(str), // 디버깅 메시지 출력
reconnectDelay: 5000, // 재연결 지연 시간 (5초)
heartbeatIncoming: 4000, // 서버에서 클라이언트로 하트비트 주기 (4초)
heartbeatOutgoing: 4000, // 클라이언트에서 서버로 하트비트 주기 (4초)
});
// 연결을 열고 대기합니다.
stompClient.onConnect = (frame) => {
console.log('Connected:', frame);
// 메시지 구독
stompClient.subscribe('/topic/messages', (message) => {
console.log('Received message:', message.body);
});
// 메시지 발행
stompClient.publish({
destination: '/app/chat',
body: 'Hello, STOMP!',
});
};
// 연결 오류 시 처리
stompClient.onStompError = (frame) => {
console.error('Broker reported error:', frame.headers['message']);
console.error('Additional details:', frame.body);
};
// STOMP 클라이언트를 활성화합니다.
stompClient.activate();
};
// STOMP 연결 함수 실행
connectStomp();
우리가 만들고자 하는 서비스는 방장이 방을 생성하고, 다른 사용자가 해당 방에 접속해 게임을 진행하는 서비스였다. (만들고자 하는 서비스에 따라서 구현은 조금 달라질 것이다)
소켓 통신에 대한 기능을 캡슐화해 역할을 확실하게 나누어서 재사용성을 높이고 유지보수성을 향상시키고자 했다.
import { Client, IFrame, IMessage } from '@stomp/stompjs';
import { NavigateFunction } from 'react-router-dom';
import SockJS from 'sockjs-client';
import { RoomDataProps } from "../types/RoomData.type.ts";
import { GameStateDataProps } from "../types/GameStateData.type.ts";
type GameStateUpdater = (state: RoomDataProps) => void;
type GameReadyUpdater = (state: GameStateDataProps) => void;
type ShowMessageFunction = (message: string) => void;
type ShowRoomChiefLeaveMessageFunction = (isChiefLeft: boolean) => void;
type ShowResultMessageFunction = (isGameEnded: boolean) => void;
type WebSocketManagerParams = {
roomId: string;
nickname: string;
updateGameState: GameStateUpdater;
updateGameReadyState: GameReadyUpdater;
navigate: NavigateFunction;
showMessage: ShowMessageFunction;
showRoomChiefLeaveMessage: ShowRoomChiefLeaveMessageFunction;
};
class WebSocketManager {
private static instance: WebSocketManager | null = null;
private client: Client | null = null;
private roomId!: string;
private nickname!: string;
private updateGameState!: GameStateUpdater;
private updateGameReadyState!: GameReadyUpdater
private navigate!: NavigateFunction
private showMessage!: ShowMessageFunction
private showRoomChiefLeaveMessage!: ShowRoomChiefLeaveMessageFunction
private showResultMessage!: ShowResultMessageFunction
init(params: WebSocketManagerParams) {
this.roomId = params.roomId;
this.nickname = params.nickname;
this.updateGameState = params.updateGameState;
this.updateGameReadyState = params.updateGameReadyState;
this.navigate = params.navigate;
this.showMessage = params.showMessage;
this.showRoomChiefLeaveMessage = params.showRoomChiefLeaveMessage;
}
public static getInstance(
): WebSocketManager{
if (!WebSocketManager.instance) {
WebSocketManager.instance = new WebSocketManager();
}
return WebSocketManager.instance;
}
// 웹 소켓 연결
connect(): Promise<void> {
if (this.client && this.client.connected) {
console.log('WebSocket is already connected.');
return Promise.resolve(); // 이미 연결되어 있으면 바로 성공 처리
}
return new Promise((resolve, reject) => {
this.client = new Client({
webSocketFactory: () => new SockJS(import.meta.env.VITE_API_URL + '/api/ws/connect'),
debug: (str: string) => console.log(`[STOMP Debug] ${str}`),
reconnectDelay: 5000,
onConnect: (frame: IFrame) => {
console.log('WebSocket connected:', frame);
// 방 정보 구독
this.subscribe(`/topic/room/${this.roomId}`, (message) => {
this.processData(message);
});
// 방 입장 요청
this.roomEnterRequest();
resolve(); // 연결 성공 시 프라미스 해결
},
onStompError: (error: any) => {
console.error('WebSocket STOMP Error:', error);
reject(new Error('WebSocket STOMP Error')); // 에러 발생 시 프라미스 거부
},
onDisconnect: () => {
console.log('WebSocket disconnected.');
},
});
this.client.activate();
console.log('WebSocket Client activated.');
});
}
// stomp 메세지 데이터 처리 함수
processData(message: any): void {
switch (message.type) {
case 'ROOM':
console.log(`${message.type} 처리`);
this.updateGameState(message.data);
break;
case 'GAME_READY':
console.log(`${message.type} 처리`);
this.updateGameReadyState(message.data);
// 플레이어 준비 요청
this.playerReadyRequest();
break;
case 'GAME_START':
console.log(`${message.type} 처리`);
this.updateGameReadyState(message.data);
// game 사이트로 이동
this.navigate(`/game/${this.roomId}`);
break;
case 'GAME_PROGRESS':
console.log(`${message.type} 처리`);
this.updateGameState(message.data);
break;
case 'ROOM_LEAVE':
console.log(`${message.type} 처리`);
this.updateGameState(message.data.data);
this.showMessage(`${message.data.target}님이 나갔습니다.`);
break;
case 'ROOM_ENTER':
this.showMessage(`${message.data.target}님이 입장하였습니다.`);
break;
case 'GAME_END':
console.log(`${message.type} 처리`);
this.updateGameState(message.data);
this.showResultMessage(true);
break;
case 'ROOM_CHIEF_LEAVE':
this.showRoomChiefLeaveMessage(true);
break;
default:
console.log(`다른 type${message.type} ${message.data.message}`);
}
}
setShowResultMessage(showResultMessage: (state: any) => void): void {
this.showResultMessage = showResultMessage;
}
// 메시지 전송 메소드
sendMessage(destination: string, body: Object = ''): void {
if (!this.client || !this.client.connected) {
console.error('Cannot send message: WebSocket is not connected.');
return;
}
try {
this.client.publish({
destination,
body: typeof body === 'string' ? body : JSON.stringify(body),
});
console.log(`Message sent to ${destination}:`, body);
} catch (error) {
console.error('Failed to send message:', error);
}
}
// stomp 프로토콜 구독
subscribe(destination: string, callback: (message: any) => void): void {
if (!this.client || !this.client.connected) {
console.error(`Cannot subscribe to ${destination}: WebSocket is not connected`);
return;
}
this.client.subscribe(destination, (message: IMessage) => {
const parsedBody = JSON.parse(message.body);
callback(parsedBody);
});
console.log(`Subscribed to ${destination}`); // 후에 삭제
}
roomEnterRequest() {
if (this.roomId && this.nickname) {
const requestBody = {
roomId: this.roomId,
nickname: this.nickname,
};
this.sendMessage('/app/room/enter', requestBody);
} else {
console.error('Room ID or Nickname is not set');
}
}
// 방장 게임 시작 요청
startGameRequest() {
if (this.roomId) {
this.sendMessage(`/app/start/${this.roomId}`);
} else
console.error('Room ID is not set');
}
// 플레이어 준비 요청
playerReadyRequest() {
if (this.roomId) {
this.sendMessage(`/app/start/${this.roomId}/${this.nickname}`);
} else {
console.error('Room ID is not set');
}
}
// 클릭 이벤트 전송
sendClickEvent() {
if (this.roomId) {
this.sendMessage(`/app/click/${this.roomId}/${this.nickname}`);
} else {
console.error('Room ID is not set');
}
}
// 팀 이동
moveTeamRequest(targetTeamName: string, currentTeamName: string) {
const requestBody = {
nickname: this.nickname,
targetTeamName: targetTeamName,
currentTeamName: currentTeamName,
};
if (this.roomId) {
this.sendMessage(`/app/room/${this.roomId}/move`, requestBody);
} else {
console.error('Room ID is not set');
}
}
// 웹 소켓 연결 종료
disconnect(): void {
if (this.client) {
if (this.client.connected) {
console.log('Disconnecting WebSocket...');
}
this.client.deactivate();
this.client = null;
console.log('WebSocket disconnected.');
} else {
console.warn('WebSocket is already disconnected.');
}
}
}
export default WebSocketManager;
WebSocketManager
라는 클래스를 생성해서 소켓 통신과 관련된 기능을 캡슐화했다.
싱글톤 패턴을 적용해 하나의 인스턴스만 생성되도록 하고 재사용하도록 했다.
init()
이라는 메소드를 통해서 WebSocket 연결에 필요한 정보를 설정하도록 했다.
connect()
라는 메소드를 통해서 WebSocket 연결을 하도록 했다.
subscribe()
를 통해서 STOMP의 subscribe()를 처리하도록 했다.
processData()
를 통해서 처리하도록 했다.processData()
에서 callback의 type에 따라서 받은 메시지를 처리하도록 했다.sendMessage()
를 통해서 STOMP의 publish()를 처리하도록 했다.
sendMessage()
를 활용해 그 외에 필요한 기타 기능을 처리하는 메소드를 만들었다.// 웹 소켓 연결
connect(): Promise<void> {
if (this.client && this.client.connected) {
console.log('WebSocket is already connected.');
return Promise.resolve(); // 이미 연결되어 있으면 바로 성공 처리
}
return new Promise((resolve, reject) => {
this.client = new Client({
webSocketFactory: () => new SockJS(import.meta.env.VITE_API_URL + '/api/ws/connect'),
debug: (str: string) => console.log(`[STOMP Debug] ${str}`),
reconnectDelay: 5000,
onConnect: (frame: IFrame) => {
console.log('WebSocket connected:', frame);
// 방 정보 구독
this.subscribe(`/topic/room/${this.roomId}`, (message) => {
this.processData(message);
});
// 방 입장 요청
this.roomEnterRequest();
resolve(); // 연결 성공 시 프라미스 해결
},
onStompError: (error: any) => {
console.error('WebSocket STOMP Error:', error);
reject(new Error('WebSocket STOMP Error')); // 에러 발생 시 프라미스 거부
},
onDisconnect: () => {
console.log('WebSocket disconnected.');
},
});
this.client.activate();
console.log('WebSocket Client activated.');
});
}
Promise
를 생성해 WebSocket 연결 작업을 비동기로 처리
웹소켓 연결 상태 확인
Promise.resolve()
반환웹소켓 연결된 상태가 아니라면 Promise
생성(비동기 작업을 처리하기 위해)
초기화 된 웹소켓 정보를 바탕으로 웹소켓 연결 처리
subscribe()
메소드에서 STOMP의 subscribe()를 처리하고 받은 메시지를 callbackprocessData()
메소드에서 받은 callback을 type에 따라서 상태를 업데이트 또는 동작 수행 // stomp 프로토콜 구독
subscribe(destination: string, callback: (message: any) => void): void {
if (!this.client || !this.client.connected) {
console.error(`Cannot subscribe to ${destination}: WebSocket is not connected`);
return;
}
this.client.subscribe(destination, (message: IMessage) => {
const parsedBody = JSON.parse(message.body);
callback(parsedBody);
});
console.log(`Subscribed to ${destination}`); // 후에 삭제
}
// stomp 메세지 데이터 처리 함수
processData(message: any): void {
switch (message.type) {
case 'ROOM':
console.log(`${message.type} 처리`);
console.log(message);
this.updateGameState(message.data);
break;
case 'GAME_READY':
console.log(`${message.type} 처리`);
this.updateGameReadyState(message.data);
break;
case 'GAME_START':
console.log(`${message.type} 처리`);
this.updateGameReadyState(message.data);
// game 사이트로 이동
this.navigate(`/game/${this.roomId}`);
break;
case 'GAME_PROGRESS':
console.log(`${message.type} 처리`);
this.updateGameState(message.data);
break;
case 'ROOM_LEAVE':
console.log(`${message.type} 처리`);
this.updateGameState(message.data.data);
this.showMessage(`${message.data.target}님이 나갔습니다.`);
break;
case 'ROOM_ENTER':
this.showMessage(`${message.data.target}님이 입장하였습니다.`);
break;
case 'GAME_END':
console.log(`${message.type} 처리`);
this.updateGameState(message.data);
this.showResultMessage(true);
break;
case 'ROOM_CHIEF_LEAVE':
this.showRoomChiefLeaveMessage(true);
break;
default:
console.log(`다른 type${message.type} ${message.data.message}`);
}
}
import WebSocketManager from './path/to/WebSocketManager';
import { useNavigate } from 'react-router-dom';
import { useRecoilState } from 'recoil';
const webSocketManager = WebSocketManager.getInstance();
const [user, setUser] = useRecoilState<RoomClientProps>(userState);
const [game, setGame] = useRecoilState<RoomDataProps | null>(gameState);
const [gameReady, setGameReady] = useRecoilState<GameStateDataProps>(gameReadyState);
const [roomChiefModal, setRoomChiefModal] = useState<boolean>(false);
const navigate = useNavigate();
const playerRoomEnter = async () => {
try {
webSocketManager.init(
{
roomId: user.roomId!,
nickname : user.nickname!,
updateGameState : setGame,
updateGameReadyState : setGameReady,
navigate : navigate,
showMessage : showMessage,
showRoomChiefLeaveMessage : setRoomChiefModal
}
)
await webSocketManager.connect();
setIsConnected(true);
} catch (err) {
console.error('Failed to enter room:', err);
setIsConnected(false);
}
};
async
키워드를 사용해 함수를 비동기로 선언- 함수 내에서
await
키워드를 사용해Promise
가 해결될 때까지 대기
init()
메소드를 통해서 websocket과 관련된 정보(방ID, 닉네임, 상태 업데이트 함수)를 전달해 초기화connect()
가 반환하는 Promise가 완료되면 다음 코드가 실행 됨.connect()
가 성공적으로 되면 setIsConnected() 실행.