WebSocket은 웹 브라우저와 서버 간의 실시간, 양방향, 전이중 통신을 가능하게 하는 프로토콜이다.
HTTP와는 달리, 연결을 유지하면서 지속적으로 데이터를 주고받을 수 있다.
먼저 WebSocket 연결 설정을 해야한다. 코드의 재사용성과 관심사의 분리를 위해 커스텀 훅으로 작성해보자.
import { useState, useEffect, useCallback } from 'react';
// WebSocket 훅의 반환 타입 정의
interface WebSocketHook {
socket: WebSocket | null;
messages: string[];
isConnected: boolean;
sendMessage: (message: string) => void;
}
export function useWebSocket(url: string): WebSocketHook {
// WebSocket 인스턴스를 저장할 상태
const [socket, setSocket] = useState<WebSocket | null>(null);
// 수신된 메시지들을 저장할 상태
const [messages, setMessages] = useState<string[]>([]);
// 연결 상태를 저장할 상태
const [isConnected, setIsConnected] = useState<boolean>(false);
// WebSocket 연결 함수 (useCallback을 사용하여 불필요한 재생성 방지)
const connect = useCallback(() => {
const ws = new WebSocket(url);
ws.onopen = () => {
console.log('WebSocket 연결 성공');
setIsConnected(true);
};
ws.onmessage = (event: MessageEvent) => {
// 새 메시지를 기존 메시지 배열에 추가
setMessages((prevMessages) => [...prevMessages, event.data]);
};
ws.onerror = (error: Event) => {
console.error('WebSocket 에러:', error);
};
ws.onclose = () => {
console.log('WebSocket 연결 종료');
setIsConnected(false);
// 연결이 끊어지면 5초 후에 재연결 시도
setTimeout(connect, 5000);
};
setSocket(ws);
}, [url]);
// 컴포넌트가 마운트될 때 WebSocket 연결 시작
useEffect(() => {
connect();
// 컴포넌트가 언마운트될 때 WebSocket 연결 종료
return () => {
if (socket) {
socket.close();
}
};
}, [connect]);
// 메시지 전송 함수
const sendMessage = useCallback((message: string) => {
if (socket && isConnected) {
socket.send(message);
}
}, [socket, isConnected]);
// 훅의 반환값
return { socket, messages, isConnected, sendMessage };
}
이제 위에 작성한 커스텀훅을 이용하여 채팅을 위한 컴포넌트를 만들고 적용시켜보자
import React, { useState, ChangeEvent } from 'react';
import { useWebSocket } from './useWebSocket';
const ChatComponent = () => {
// WebSocket 훅 사용
const { messages, isConnected, sendMessage } = useWebSocket('ws://example');
// 입력 필드의 현재 값을 저장할 상태
const [inputMessage, setInputMessage] = useState<string>('');
// 입력 필드 변경 핸들러
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputMessage(e.target.value);
};
// 메시지 전송 핸들러
const handleSendMessage = () => {
if (inputMessage) {
sendMessage(inputMessage);
setInputMessage('');
}
};
return (
<div>
{/* 메시지 목록 표시 */}
<div>
{messages.map((message, index) => (
<p key={index}>{message}</p>
))}
</div>
{/* 메시지 입력 필드 */}
<input
type="text"
value={inputMessage}
onChange={handleInputChange}
/>
{/* 전송 버튼 (연결 상태에 따라 비활성화) */}
<button onClick={handleSendMessage} disabled={!isConnected}>전송</button>
{/* 연결 상태 표시 */}
<p>{isConnected ? '연결됨' : '연결끊김'}</p>
</div>
);
};
export default ChatComponent;
위의 코드는 브라우저의 네이티브 WebSocket API를 직접 사용하여 실시간 양방향 통신을 구현하고 있다. 이는 별도의 라이브러리나 프레임워크 없이 순수한 웹 표준 기술만을 활용한 방법이다.
특징으로는, WebSocket 연결의 전체 생명주기를 직접 관리하고 있다. 연결 설정(onopen), 메시지 수신(onmessage), 오류 처리(onerror), 연결 종료(onclose) 등의 이벤트를 각각 처리하고 있다.
장점으로는 먼저, 추가적인 의존성 없이 가볍고 빠른 구현이 가능하다는 것이다. 브라우저 내장 API를 사용하므로 별도의 라이브러리 설치가 필요 없어 번들 크기를 줄일 수 있다. 또한, WebSocket의 저수준 동작을 직접 제어할 수 있어 특정 요구사항에 맞는 세밀한 제어가 가능하다. 커스텀 훅으로 구현되어 있어 재사용성이 높다.
반면 단점으로는, 재연결 로직, 메시지 큐 관리 등 고급 기능을 직접 구현해야 한다. 또한, 브라우저 간 호환성 이슈를 직접 처리해야 할 수 있으며, 대규모 애플리케이션에서는 더 복잡한 상태 관리가 필요할 수 있다. 보안 관련 기능(SSL/TLS)도 별도로 구현해야 한다.
이 접근 방식은 WebSocket에 대한 깊은 이해가 필요한 프로젝트, 또는 가벼운 실시간 기능이 필요한 소규모 프로젝트에 적합하다고 본다. 더 복잡한 실시간 기능이 필요하거나, 개발 속도가 중요한 프로젝트의 경우 라이브러리 사용을 고려하는게 좋을것 같다.
나는 팀프로젝트 당시 SockJS와 StompJS를 이용하였다.
SockJS
특징
1. WebSocket의 대체제로 설계되었다.
2. 브라우저와 서버 간의 낮은 지연 시간, 전이중, 크로스 도메인 통신을 제공한다.
3. WebSocket을 지원하지 않는 브라우저에서도 동작한다.
주요 기능
1. 폴백 메커니즘: WebSocket이 지원되지 않을 경우 자동으로 다른 전송 방식(예: long polling, streaming)으로 전환한다.
2. 크로스 브라우저 지원: 다양한 브라우저 버전에서 일관된 동작을 보장한다.
3. 자동 재연결: 네트워크 문제로 연결이 끊어졌을 때 자동으로 재연결을 시도한다.
StompJS
특징
1. STOMP(Simple Text Oriented Messaging Protocol) 프로토콜의 JavaScript 구현체이다.
2. 메시징 서비스와의 통신을 위한 간단하고 가벼운 프로토콜을 제공한다.
3. WebSocket 또는 SockJS 위에서 동작할 수 있다.
주요 기능
1. 발행/구독 패턴: 클라이언트가 특정 주제(topic)를 구독하고 해당 주제로 메시지를 발행할 수 있다.
2. 메시지 헤더: 각 메시지에 메타데이터를 포함할 수 있어 복잡한 통신 시나리오를 지원한다.
3. 트랜잭션 지원: 여러 메시지를 그룹화하여 원자적으로 처리할 수 있다.
4. 오류 처리: 프로토콜 레벨에서 오류를 처리하고 보고할 수 있다.
5. 인증 지원: STOMP 프레임에 인증 정보를 포함할 수 있다.
호환성: SockJS의 폴백 메커니즘과 StompJS의 높은 수준의 메시징 프로토콜을 결합하여 광범위한 브라우저 지원과 풍부한 메시징 기능을 동시에 얻을 수 있다.
확장성: STOMP의 발행/구독 모델을 사용하여 대규모 실시간 시스템을 쉽게 구축할 수 있다.
유연성: 다양한 서버 기술(예: Spring WebSocket, RabbitMQ)과 쉽게 통합할 수 있다.
구조화된 통신: STOMP의 프레임 기반 통신을 통해 메시지 형식과 라우팅을 명확하게 정의할 수 있다.
이 두 라이브러리를 함께 사용하면 확장 가능한 실시간 웹 애플리케이션을 구축할 수 있다. 특히 복잡한 메시징 요구사항이 있거나, 다양한 클라이언트 환경을 지원해야 하는 프로젝트에 적합한것 같다.
import { useEffect, useState } from 'react'
import SockJS from 'sockjs-client'
import { CompatClient, Stomp } from '@stomp/stompjs'
const SOCKET_URL = `${import.meta.env.VITE_HOOPS_CHAT_API}/ws`
export const useWebSocket = (
chatRoomId: string,
accessToken: string,
nickName: string
) => {
const [messages, setMessages] = useState<
{ sender: string; content: string }[]
>([])
const [client, setClient] = useState<CompatClient | null>(null)
useEffect(() => {
if (!chatRoomId || !accessToken) {
console.error('토큰값과 룸 아이디가 없음')
return
}
setMessages([])
const newClient = Stomp.over(() => new SockJS(SOCKET_URL))
const headers = {
Authorization: `Bearer ${accessToken}`,
gameId: String(chatRoomId),
}
const connectCallback = () => {
console.log('웹소켓 연결 성공!')
// subscribe 메서드를 connectCallback 함수 내부에서 호출하도록 변경
newClient.subscribe(
`/topic/${chatRoomId}`,
(response) => {
const message = JSON.parse(response.body)
setMessages((prevMessages) => [...prevMessages, message])
},
headers
)
newClient.send('/app/loadMessages/' + chatRoomId, headers)
}
const errorCallback = (error: unknown) => {
console.error('웹소켓 연결 오류:', error)
}
newClient.connect(headers, connectCallback, errorCallback)
setClient(newClient)
return () => {
// 연결 해제 로직을 명시적으로 disconnect 메서드 호출 전에 확인
if (client && client.connected) {
client.disconnect(() => {
console.log('웹소켓 연결 해제 성공')
})
}
}
}, [chatRoomId])
const sendMessage = (message: string) => {
// client 상태를 직접 사용하고 옵셔널 체이닝 연산자로 안전하게 확인
if (client?.connected) {
const data = { sender: nickName, content: message, type: 'CHAT' }
client.send(
`/app/sendMessage/${chatRoomId}`,
{
Authorization: `Bearer ${accessToken}`,
gameId: String(chatRoomId),
},
JSON.stringify(data)
)
}
}
return { messages, sendMessage }
}
위의 코드는 팀 프로젝트를 진행하며 SockJS와 StompJS를 이용하여 작성한 커스텀훅이다.
SockJS와 StompJS를 사용한 접근 방식은 복잡한 실시간 애플리케이션, 특히 확장성과 호환성이 중요한 프로젝트에 더 적합하다. 반면, 순수 WebSocket 접근 방식은 간단한 실시간 기능이 필요하거나 리소스가 제한된 환경에 더 적합할 수 있다.