실시간 주문 조회
실시간 주문 조회에서는 사용자의 주문이 실시간으로 반영 되어야 한다. 실시간으로 데이터를 주고받기 위해 소켓 통신을 구현해야 했다.
소켓 통신 구현 방식
현재 ‘실시간 주문 조회’ 페이지에서 사용중인 웹 소켓은 STOMP로만 구현이 되어있다.
STOMP는 Simple Text Oriented Messaging Protocol로서 간단한 문자열 기반의 메시징 프로토콜이다.
웹 소켓은 HTTP 프로토콜을 통해 연결을 설정한 후, 지속적인 연결을 유지하면서 데이터를 주고받을 수 있다. 그러나 웹 소켓의 메시지 형식은 정해져있지 않아 클라이언트-서버 간에 별도로 형식을 합의하여 통신해야 한다.
반면, STOMP는 프레임 형식으로 사전에 정의된 형태로 웹 소켓기반 통신을 진행한다.

STOMP의 요청 프레임 구조는 다음과 같다. 프레임 종류는 [STOMP]Client_Frames, [STOMP]Server_Frames 링크들에서 확인할 수 있다.
COMMAND header1:value1 header2:value2 Body^@
header, Body에는 원하는 정보(로그인 정보 등..)들을 담아 보낼 수 있다.
실시간 통신 플로우
‘실시간 주문 조회’ 화면에서는 사용자의 실시간 주문 정보들을 받아와야 한다. 그래서 다음과 같은 플로우를 구상해보았다.
- Client에서 ‘sub/order/{workspaceId}’에 해당하는 정보를 구독(SUBSCRIBE)한다. 이때 Client의 구독 id를 같이 보낸다.

- Server에서는 Client의 id를 기억하고 있다가 새로운 주문이 들어오면 ‘sub/order/{workspaceId}’를 구독하던 구독자들에게 새로운 주문 정보를 담은 메시지(MESSAGE)를 보낸다.

- Client에서는 새로운 주문 정보를 기존 주문 정보들에 추가한다.
subscription = client.subscribe(`/sub/order/${workspaceId}`, (response) => { const orderWebsocket: OrderWebsocket = JSON.parse(response.body); const order = orderWebsocket.data; // ... addOrder(order); // ... });
이렇게 간단하게 원하는 실시간 주문 조회 로직을 구성할 수 있었다!
사용자의 로그인 여부는 어떻게 확인하지?
라는 의문이 들 수 있다.
사용자의 로그인 여부는 웹 소켓을 연결(onConnect)할 때 JWT를 살펴보고 판단한다. 그래서 인증이 되지 않았다면 연결 자체를 구성하지 않는다.
그렇다면 연결이 완료된 시점에서 인증이 만료된다면?
MESSAGE 프레임을 보낼때 별도의 인증을 확인하지 않고 있기에 사용자는 데이터를 받아올 수 있다. 그러나 주문의 상태를 업데이트를 하기 위해선 서버 요청이 필수적이기에 아무것도 하지 않고 오로지 주문이 쌓이는 것만 볼 수 있다.
보안 관점에서 주문 정보에는 개인정보가 담겨있지 않고, 수정도 불가하므로 현재 상태도 괜찮다고 판단했다.
만약, 보안을 강화한다면, 애초에 인증이 만료되었다면 해당 시점에서 웹 소켓 연결을 해제하는게 좋아보인다.
중복된 Stomp Client 생성…
현재 코드는 웹 소켓을 구독중인 컴포넌트가 리렌더링 될 때마다 useOrdersWebsocket훅이 실행되면서 웹 소켓 클라이언트가 새로 생성되는 이슈가 존재한다.
function useOrdersWebsocket(workspaceId: string | undefined) { // ... const client = new StompJs.Client({ brokerURL: url }); console.log('WebSocket 생성됨!'); // ... return { subscribeOrders, unsubscribeOrders }; }
function AdminOrderRealtime() { // ... const { subscribeOrders, unsubscribeOrders } = useOrdersWebsocket(workspaceId); // ... useEffect(() => { subscribeOrders(); // ... return () => { unsubscribeOrders(); }; }, []);
이렇게 여러개의 웹 소켓 클라이언트가 생성되면, 중복 구독이 일어나지 않을까?? 다행히도 그런일은 발생하지 않았다.
왜? 중복 구독은 일어나지 않았을까?
결론부터 말하자면 useEffect의 빈 의존성 배열 덕분이다. 현재 훅으로 분리된 코드를 하나로 합쳐서 작성하면 다음과 같은 모습이 된다.
function AdminOrderRealtime() { const client = new StompJs.Client(...options); const subscribeOrders = () => { client.onConnect = function () { client.subscribe(`/sub/order/${workspaceId}`, (response) => { // ... }); }; client.activate(); }; const unsubscribeOrders = () => { client.unsubscribe(`/sub/order/${workspaceId}`); client.deactivate(); }; useEffect(() => { subscribeOrders(); return () => { unsubscribeOrders(); }; }, []);
AdminOrderRealtime컴포넌트가 마운트되어 렌더링되었을때 웹 소켓 클라이언트 객체를 생성한다. 그리고 useEffect 콜백 함수가 실행되어 subscribeOrders가 호출되며 웹 소켓 연결 및 구독을 시작한다.
그 이후 컴포넌트가 리렌더링되면 웹 소켓 클라이언트가 새로 생성된다. 그러나 useEffect 내부 콜백은 실행되지 않고, 구독(Subscribe)은 맨 처음에 생성된 클라이언트로만 이루어져있다.
따라서 새로 생성된 웹 소켓 클라이언트는 별도의 웹 소켓 연결을 잇지 못한채 GC에 의해 메모리가 해제된다.
클라이언트 중복 생성 문제 해결
아무리 웹 소켓 클라이언트가 중복된 연결을 잇지 못해 금방 메모리 해제된다고 해도, 사용하지 않는 객체를 생성하는건 불필요한 일이다. 이를 어떻게 막을 수 있을까?
1. useEffect로 생명주기 관리
클라이언트를 생성하는 부분을 useEffect 내부로 옮겨서 컴포넌트의 생명주기에 맞춰 관리하는 방법이 있다.
function useOrdersWebsocket(workspaceId: string | undefined) { useEffect(() => { const client = new StompJs.Client({ brokerURL: url }); // client를 사용한 구독 로직... }, []); }
2. useMemo로 메모이제이션
메모이제이션을 통해 웹 소켓 클라이언트가 하나만 생성되도록 제한하는 방법이 있다.
const client = useMemo( () => new StompJs.Client({ brokerURL: url, webSocketFactory: () => new SockJS(sockJSUrl), // ... }), [], // 빈 의존성 배열 );
나는 2번 방법을 선택했다. 그 이유는 리렌더링이 발생하더라도 웹 소켓 클라이언트 인스턴스를 단 한 번만 생성하려는 목적과 가장 잘 맞기 때문이다.
useMemo는 특정 값의 생성 비용이 비쌀 때, 그 결과값을 메모이제이션 해두고 재사용하는 훅이다. 여기서 new StompJs.Client()라는 '연산'을 통해 만들어진 '값'(클라이언트 객체)을 컴포넌트의 생명주기 동안 유지하는 데 최적화되어 있다.
이는 Side Effect를 다루는 useEffect와 역할을 명확히 분리하는 효과도 가져온다. 즉, 값의 생성은 useMemo가, 생성된 값을 사용한 연결 및 구독 관리는 useEffect가 담당하게 함으로써 코드의 의도를 명확하게 만들고 가독성을 높일 수 있다.
만약 웹 소켓이 없는 환경이라면?
현재는 STOMP로 웹 소켓 통신을 구현하고 있다. 그러나 만약 웹 소켓을 사용할 수 없는 환경이라면? 이에 대해서 어떻게 대응해야 할까?
이를 위한 라이브러리가 바로 SockJS이다. SockJS는 웹 소켓을 기반으로 작동하다가 웹 소켓 장애가 발생할 시 자동으로 HTTP 기반 방식으로 전환시켜서 안정적인 연결을 이어나가게 해준다.
SockJS로 안정적인 웹 소켓 통신을 보장하고, STOMP를 통해 형식을 갖춘 통신을 가능하게 구성할 수 있다.
SockJS + STOMP
이 둘을 적용하는 방법은 아주 쉽다. STOMP client의 option 중 webSocketFactory이 있는데, 해당 옵션으로 SockJS 인스턴스를 반환하는 콜백함수를 넘겨주면 된다.
new StompJs.Client({ // ... webSocketFactory: () => new SockJS(sockJSUrl), // ... }),
이때 유의할 점은 SockJS를 위한 url은 http(또는 https)프로토콜을 이용해야 한다는 점이다.
기존 STOMP에선 ws(또는 wss)로 직접 웹소켓에 접근하는 방식이었지만, SockJS는 http(s)프로토콜 기반으로 먼저 접근한 뒤 웹 소켓을 구성하기 때문에 url을 신경써줘야 한다.
// before const url = isDEV ? 'ws://localhost:8080/ws' : 'wss://api.kio-school.com/ws'; // after const sockJSUrl = isDEV ? 'http://localhost:8080/ws' : 'https://api.kio-school.com/ws';
참고자료
stomp.github.io
stomp.github.io
The Simple Text Oriented Messaging Protocol
TISTORYWeb Socket과 STOMP 이해하기
Web Socket과 STOMP 이해하기
Web Socket 클라이언트와 서버 간의 메시지를 교환하기 위한 통신 방법 중의 하나 2011년 RFC 6455에 의해 표준화 Web Socket을 지원하는 브라우저는 Web Socket Protocol을 지원 Web Socket의 특징 양방향 통신 (Full-Duplex) 데이터 송/수신을 동시에 처리할 수 있음 클라이언트가 요청을 보낼 때만 서버가 응답하는 단방향 통신인 HTTP 통신과는 달리, 클라이언트와 서버가 서로 원하는 순간에 데이터를 주고받을 수 있음 실시간 네트워킹 (Real-Time Networking) 연속된 데이터를 빠르게 노출시킬 수 있음 여러 단말기에 빠르게 데이터를 교환할 수 있음 Web Socket의 주 사용처: 주식, 게임, 채팅, 영상 등 Web Socket 이전의 HTT..