/ 개발
ACS #2 — WebSocket 도입과 STOMP 전환
로봇 데이터를 실시간으로 받을 때 어떤 식으로 받을지 결정해야했고, 그 과정을 정리했다.
시작할 때 정리한 요구사항:
- 수신 — 로봇 위치·상태가 서버에서 계속 밀려온다. 상한 가정은 앞 글과 같은 초당 3,000건.
- 송신 — 로봇 제어 명령을 보내야 한다. 특히 조이스틱 수동 제어 모드가 있어서, 버튼으로 조작 시 빠르게 명령을 전송해야 한다.
- 환경 제약 — 온프레미스 배포다. 공장 네트워크의 방화벽·프록시 사정을 미리 알 수 없었고, 로봇이 몇 대 들어갈지도 기준이 없다.
왜 처음부터 WebSocket인가 — SSE와의 비교
실시간 수신만 보면 후보는 둘이다. SSE(Server-Sent Events)와 WebSocket. 이전 프로젝트에서 SSE로 실시간 알림을 받아본 경험이 있어서, 이번엔 두 기술을 요구사항에 대입해보는 것부터 시작했다.
| 항목 | SSE | WebSocket |
|---|---|---|
| 방향성 | 단방향 (서버 → 클라) | 양방향 (Full Duplex) |
| 데이터 | 텍스트 전용 | 텍스트 + 바이너리 |
| 프로토콜 | HTTP 기반 스트림 | 핸드셰이크 후 자체 프레임 |
| 재연결 | 브라우저 내장 | 직접 구현 |
| 적합한 곳 | 알림·단순 상태 갱신 | 실시간 관제·제어 명령 |
결정적인 건 방향성이었다. 이 서비스는 받기만 하는 게 아니라 로봇에게 명령을 보내야 한다. SSE를 쓰면 송신은 결국 HTTP 요청으로 따로 가야 하는데, 그 구조를 조이스틱에 대입하면:
- 즉각성 — 스틱을 움직일 때마다 매번 새 HTTP 요청을 만든다. 요청마다 연결 수립과 헤더 처리가 반복되고, 그만큼 지연이 붙는다.
- 상태 관리 — 명령은 HTTP로 나가고 그 결과는 SSE로 돌아온다. 명령과 응답이 별도 채널로 갈라져서, "내가 보낸 명령이 어떻게 됐는지"를 짝지어 관리하는 로직이 복잡해진다.
- 반복 비용 — 로봇 수가 늘수록 요청 수가 늘고, 요청마다 헤더 읽기·인증 검증이 반복된다.
이미 열려 있는 소켓에 명령을 흘려보내는 쪽이 세 문제를 해결할 수 있기 때문에, WebSocket을 사용하기로 했다.
첫 구조 설계 — 단일 엔드포인트 + type 분기
처음 잡은 구조는 서버가 모든 실시간 데이터를 한 채널로 push하고, 메시지의 type 필드로 클라이언트가 분기하는 것이다.
client → ws://host/ws 연결
{ type: "ROBOT_STATUS", data: [...] }
{ type: "ALARM", data: {...} }
{ type: "MAP_UPDATE", data: {...} }
WebSocket 객체는 useRef에 담는다. useState에 담으면 연결 상태가 바뀔 때마다 리렌더링이 돌고, 핸들러가 오래된 state를 캡처하는 클로저 문제도 생긴다. 소켓 객체 자체는 UI가 아니므로 렌더링 사이클 밖(useRef)에 두고, UI에 사용될 연결 여부(isConnected)만 별도 state로 뺀다.
connect 함수는 useCallback으로 고정한다. 매 렌더링마다 새 함수가 만들어지면 useEffect 의존성이 계속 바뀌어서 연결-해제-재연결이 무한 반복된다. 실제로 처음엔 이걸로 무한 재연결을 겪었다.
여기에 재연결 로직(끊기면 일정 간격으로 n회 재시도)을 얹으면 기본 훅이 완성된다.
export const useWebSocket = (url: string, options: UseWebSocketOptions = {}) => { const ws = useRef<WebSocket | null>(null); const reconnectCount = useRef(0); const [isConnected, setIsConnected] = useState(false); const connect = useCallback(() => { ws.current = new WebSocket(url); ws.current.onopen = () => { setIsConnected(true); reconnectCount.current = 0; }; ws.current.onmessage = (event) => options.onMessage?.(event); ws.current.onclose = () => { setIsConnected(false); if (reconnectCount.current < reconnectAttempts) { reconnectCount.current += 1; setTimeout(connect, reconnectInterval); } }; }, [url /* ... */]); useEffect(() => { connect(); return () => ws.current?.close(); }, [connect]); return { isConnected };};
인증 문제 발생
이 구조로 개발을 진행하다 인증 부분에서 문제를 만났다. 브라우저의 WebSocket API는 HTTP 요청처럼 커스텀 헤더를 실을 수 없다 — new WebSocket(url) 생성자에 헤더 옵션 자체가 없다. 즉 다른 API 요청처럼 Authorization: Bearer <token> 헤더를 붙이는 방법이 핸드셰이크 단계에는 존재하지 않는다. (WebSocket에 대해 더 자세히 공부하지 못했던 탓... 당연히 제공하는 줄 알았다.)
STOMP 전환
해결책으로 잡은 게 STOMP(Simple Text Oriented Messaging Protocol)다. WebSocket 위에 올리는 서브 프로토콜로, 메시지를 COMMAND / Headers / Body 구조의 프레임으로 규격화한다.
- COMMAND:
CONNECT·SEND·SUBSCRIBE·UNSUBSCRIBE·DISCONNECT등 - Headers: key-value 메타데이터 — 인증 토큰이 여기 실린다
- Body: 실제 데이터
STOMP 연결 흐름
WebSocket 핸드셰이크 후, STOMP가 자기 핸드셰이크(CONNECT → CONNECTED)를 한 번 더 하고, 필요한 토픽을 구독하면 서버가 데이터가 생길 때마다 MESSAGE 프레임을 push한다.
sequenceDiagram participant C as 클라 (stompjs) participant S as 서버 (Spring broker) C->>S: [HTTP] GET /ws + Upgrade S->>C: 101 Switching Protocols Note over C,S: WS 파이프 개통 — 이하 전부 STOMP 프레임 C->>S: CONNECT (Authorization, heart-beat) S->>C: CONNECTED Note over C: onConnect 발화 C->>S: SUBSCRIBE ×N (응답 없음) loop 데이터 발생 시 S->>C: MESSAGE (subscription id + JSON body) end Note over C,S: 10초마다 양방향 heart-beat

실제 네트워크 창을 띄워보면 순서대로 주고 받는 것을 확인할 수 있다.
이 전환으로 얻은 게 두 가지다.
첫째, 인가가 가능해졌다. CONNECT 프레임의 헤더에 Authorization: Bearer <accessToken>을 실을 수 있다.
둘째, type 분기가 구독 모델로 바뀌었다. 기존에는 한 채널로 다 받아서 클라이언트가 type으로 스위치 분기했는데, STOMP에서는 성격이 다른 스트림을 토픽 단위로 나눠 필요한 것만 구독한다. 메시지 라우팅을 프로토콜이 해준다.
결론
기술 선정은 요구사항에 적절했다고 생각하나, 인가에 대한 예측이 덜 되어 코드를 한 번 엎었다. 요구사항을 "무엇을 주고받을 것인가"까지만 정리했고, 어떻게 연결하고 유지할 것인가는 구현하면서 만나는 문제로 미뤄둔 탓이다. 기술 선정에 대한 고민을 할 때는 눈 앞의 태스크 뿐만 아니라 과정 또한 어느 정도 예측이 필요하다는 것을 알게 되었다.