WebSocket을 알아보자

woong·2024년 7월 14일
0

WebSocket이란 무엇일까?

WebSocket은 웹 브라우저와 서버 간의 실시간, 양방향, 전이중 통신을 가능하게 하는 프로토콜이다.
HTTP와는 달리, 연결을 유지하면서 지속적으로 데이터를 주고받을 수 있다.

WebSocket의 특징

  1. 실시간 양방향 통신: 클라이언트와 서버가 동시에 데이터를 주고받을 수 있다.
  2. 낮은 지연시간: 연결이 유지되므로 새로운 요청을 보낼 때마다 연결을 설정할 필요가 없다.
  3. 효율적인 프로토콜: 헤더 크기가 작아 데이터 전송 효율이 높다.
  4. 크로스 도메인 통신 지원: 다른 도메인 간의 통신이 가능하다.
  5. 표준화된 기술: W3C와 IETF에 의해 표준화되었있다.

WebSocket의 동작 과정

핸드셰이크

  1. 클라이언트가 서버에 HTTP 요청을 보내 WebSocket 연결을 요청한다.
  2. 서버가 이 요청을 수락하면 HTTP 응답을 보낸다.
  3. 이 과정에서 프로토콜이 HTTP에서 WebSocket으로 업그레이드된다.

데이터 전송

  1. 핸드셰이크가 완료되면 WebSocket 연결이 설정된다.
  2. 이제 클라이언트와 서버는 양방향으로 메시지를 주고받을 수 있다.
  3. 데이터는 프레임 단위로 전송된다.

연결 종료

  1. 클라이언트나 서버 중 어느 쪽에서든 연결 종료를 요청할 수 있다.
  2. 종료 프레임을 전송하여 연결을 정상적으로 닫는다.

React에서 WebSocket을 사용하기

1. 순수 WebSocket을 이용한 방법

먼저 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에 대한 깊은 이해가 필요한 프로젝트, 또는 가벼운 실시간 기능이 필요한 소규모 프로젝트에 적합하다고 본다. 더 복잡한 실시간 기능이 필요하거나, 개발 속도가 중요한 프로젝트의 경우 라이브러리 사용을 고려하는게 좋을것 같다.

2. 라이브러리를 이용하는 방법

나는 팀프로젝트 당시 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를 함께 사용할까?

  1. 호환성: SockJS의 폴백 메커니즘과 StompJS의 높은 수준의 메시징 프로토콜을 결합하여 광범위한 브라우저 지원과 풍부한 메시징 기능을 동시에 얻을 수 있다.

  2. 확장성: STOMP의 발행/구독 모델을 사용하여 대규모 실시간 시스템을 쉽게 구축할 수 있다.

  3. 유연성: 다양한 서버 기술(예: Spring WebSocket, RabbitMQ)과 쉽게 통합할 수 있다.

  4. 구조화된 통신: 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를 이용하여 작성한 커스텀훅이다.


순수 WebSoket VS SockJS + StompJS

복잡성

  • SockJS/StompJS: 더 높은 수준의 추상화를 제공하여 복잡한 기능을 쉽게 구현할 수 있다.
  • 순수 WebSocket: 더 단순하지만, 고급 기능을 직접 구현해야 한다.

성능

  • SockJS/StompJS: 추가적인 레이어로 인한 약간의 오버헤드가 있을 수 있다.
  • 순수 WebSocket: 직접적인 통신으로 잠재적으로 더 높은 성능을 제공할 수 있다.

유지보수

  • SockJS/StompJS: 표준화된 프로토콜과 라이브러리 사용으로 유지보수가 용이할 수 있다.
  • 순수 WebSocket: 커스텀 구현으로 인해 유지보수에 더 많은 노력이 필요할 수 있다.

확장성

  • SockJS/StompJS: 대규모 시스템에서의 확장성이 더 뛰어나다.
  • 순수 WebSocket: 확장을 위한 추가 구현이 필요할 수 있다.

SockJS와 StompJS를 사용한 접근 방식은 복잡한 실시간 애플리케이션, 특히 확장성과 호환성이 중요한 프로젝트에 더 적합하다. 반면, 순수 WebSocket 접근 방식은 간단한 실시간 기능이 필요하거나 리소스가 제한된 환경에 더 적합할 수 있다.

profile
안녕하세요! 👋

0개의 댓글