← Writing

WebSocket 두 개를 한 앱에 — presence와 DM을 분리한 이유

Plan Do!에는 두 종류의 실시간 기능이 있습니다. 사용자가 지금 접속 중인지(presence)DM 채팅이에요. 처음에는 하나의 WebSocket 연결로 묶었다가, 결국 두 개로 분리했습니다. 그 이유와 과정을 기록해둡니다.

출발점 — 왜 실시간이 필요했나

Plan Do!는 혼자만 쓰는 도구가 아니라 팀이 함께 쓰는 워크스페이스 기반 앱입니다. 그래서 두 가지 실시간 요구가 자연스럽게 생겼어요.

  • 누가 지금 접속 중인가 — 팀원의 온라인 상태
  • DM에서 즉시 도착하는 메시지 — 새로고침 없는 채팅

처음에는 polling으로 갈까 잠깐 고민했지만, presence와 DM은 지연이 곧 사용자 경험이라 WebSocket이 자연스러운 답이었습니다.

STOMP를 고른 이유

socket.io도 후보였지만, 결국 STOMP + sockjs 조합으로 갔습니다.

후보장점우리가 안 고른 이유
socket.ioDX 좋음, 자동 재연결Spring 백엔드와 짝이 어색함, Node 전제
native WebSocket가볍고 표준메시지 라우팅·인증을 직접 다 짜야 함
STOMP + sockjsSpring WebSocket이 1급 지원, pub/sub destination이 깔끔(선택)

Spring Boot 백엔드를 쓰고 있었기 때문에 서버에서 destination 기반 라우팅이 그대로 지원되는 게 결정적이었어요. /topic/..., /app/...만 잡아두면 클라이언트는 거기 맞춰 구독·발행만 하면 됩니다.

sockjs를 같이 쓴 이유는 단순합니다 — 일부 사용자 네트워크 환경(회사 방화벽 등)에서 순수 WebSocket이 막힐 수 있어서, HTTP long-polling으로 자동 폴백되는 안전망이 필요했어요.

처음엔 하나의 연결로 시작

가장 자연스러운 출발은 로그인 후에 한 번 연결, 모든 채널을 그 위에서였습니다.

[ 로그인 ]

[ STOMP 연결 ]
   ├─ /topic/presence 구독
   ├─ /topic/dm/{roomId} 구독 (방 진입 시)
   ├─ /topic/dm/{anotherRoom} 구독
   └─ ...

이게 흔한 패턴이고, 처음엔 잘 동작했어요. 문제는 DM 방을 자주 들락거릴 때 나타났습니다.

가장 자주 부딪힌 건 DM 방을 빠르게 옮겨다닐 때였어요. A 방에 있다가 B 방으로 넘어가면, 가끔 A 방의 메시지가 B 방 화면에 잠깐 보이거나 지운 메시지가 한 번 더 깜빡이는 경우가 있었습니다. 연결 자체는 잘 있는데 구독이 깔끔하게 정리되지 않는 게 원인이었어요.

재연결이 한 번 일어나면 더 꼬였습니다. 살아있는 구독이 정확히 어떤 상태인지 머릿속으로 추적하기가 점점 어려워졌어요. 메시지 id로 중복을 거르는 가드 (prev.some(m => m.id === incoming.id))를 넣어 일단 막긴 했는데, 이건 증상을 가리는 것이지 원인을 푸는 게 아니었습니다.

두 개로 분리하기로 한 이유

분리한 결정의 핵심은 라이프사이클이 다르다는 점이었습니다.

  • Presence — 로그인부터 로그아웃까지 항상 살아 있어야 함
  • DM — 방에 들어갔을 때만 살아 있고, 나오면 정리되어야 함

라이프사이클이 다른 두 기능을 한 연결에 묶으면, 한쪽의 재연결·정리 로직이 다른 쪽에 영향을 줍니다. 그래서 두 개의 STOMP 클라이언트를 두기로 했어요.

[ 로그인 ]

[ Global STOMP ] ─── /topic/presence (앱 종료까지 살아 있음)

[ DM 방 진입 ]

[ Room STOMP   ] ─── /topic/dm/{roomId} (방 나가면 disconnect)

연결 비용을 한 번 더 쓰는 대신 코드 복잡도와 추적 가능성을 얻은 셈입니다.

구현 — Global STOMP (presence)

로그인 직후 한 번 연결되고, 앱을 닫을 때까지 살아 있는 hook으로 만들었어요.

// 개념적으로는 이런 모양입니다 (실제 코드와는 다를 수 있음)
useEffect(() => {
  if (!isLoggedIn) return;

  const client = new Client({
    webSocketFactory: () => new SockJS(`${baseUrl}/ws`),
    connectHeaders: { Authorization: `Bearer ${token}` },
    reconnectDelay: 5000,
    onConnect: () => {
      client.subscribe('/topic/presence', (msg) => {
        // 온라인 상태 업데이트
      });
    },
  });

  client.activate();
  return () => client.deactivate();
}, [isLoggedIn]);

구현 — Room STOMP (DM)

DM 방에 들어갈 때만 생성되고, 나갈 때 자동 정리됩니다.

useEffect(() => {
  if (!roomId) return;

  const client = new Client({ /* ... */ });
  client.onConnect = () => {
    client.subscribe(`/topic/dm/${roomId}`, (frame) => {
      const msg = JSON.parse(frame.body);
      handleIncoming(msg);
    });
  };

  client.activate();
  return () => client.deactivate();
}, [roomId]);

function sendMessage(content) {
  client.publish({
    destination: '/app/dm.send',
    body: JSON.stringify({ roomId, content }),
  });
}

JWT를 WebSocket에 어떻게 넘기나

HTTP API는 axios 인터셉터가 Authorization 헤더를 알아서 넣어주지만, WebSocket은 그렇지 않습니다. STOMP의 connectHeaders를 통해 연결 시점에 한 번 넣어주면 됩니다.

connectHeaders: {
  Authorization: `Bearer ${token}`,
}

토큰이 갱신되면 연결을 다시 맺어야 한다는 점이 함정이에요. silent refresh 후에는 두 클라이언트 모두 재연결시키는 로직을 별도로 잡아둬야 합니다.

Capacitor 환경 — 프로토콜 변환

웹 PWA에선 https로 통신하지만, iOS 네이티브 앱(Capacitor)에서는 WebView가 capacitor://localhost에서 동작합니다. 그래서 API base URL을 기준으로 프로토콜을 동적으로 변환해야 합니다.

const wsUrl = baseUrl.replace(/^http/, 'ws');
// http  → ws
// https → wss

작은 한 줄이지만, 이게 빠지면 웹은 되는데 앱은 안 되는 가장 짜증나는 종류의 버그가 됩니다.

메시지 중복 — 의외로 자주 부딪힘

서버에서 한 번만 보내는데도 클라이언트에서 메시지가 두 번 보이는 상황이 종종 생겼어요. 원인은 다양했습니다 — 재연결 시 일시적인 이중 구독, 낙관적 UI 업데이트 후 서버 응답이 다시 도착, React 18의 strict mode에서 effect 두 번 실행 등.

해결은 단순했어요. 수신 측에서 id로 중복을 거른다.

setMessages((prev) =>
  prev.some((m) => m.id === incoming.id) ? prev : [...prev, incoming]
);

서버 신뢰성과 별개로, 클라이언트가 멱등하게 동작하도록 만드는 게 안전하다는 걸 배웠습니다.

메시지 타입 시스템

DM에서 오가는 건 단순 텍스트만이 아닙니다. 이런 이벤트들을 한 채널에서 처리해요.

  • MESSAGE — 새 메시지
  • MESSAGE_UPDATED — 수정
  • MESSAGE_DELETED — 삭제 (수신 측이 필터링)
  • READ_RECEIPT — 읽음 표시
  • REACTION_UPDATED — 이모지 반응

각 타입에 대해 상태를 어떻게 머지할지가 일관된 패턴을 따르도록 했습니다. 새 타입을 추가할 때 reducer 한 곳만 고치면 되도록.

배운 것

  1. 연결 하나에 모든 걸 묶지 않기. 라이프사이클이 다르면 분리하는 게 결국 단순하다.
  2. 수신 측의 멱등성이 서버 재시도·재연결의 절반을 막아준다.
  3. Capacitor 환경 대응은 작은 한 줄에서 시작한다 (http → ws). 환경 분기를 한 곳에 모아두는 게 안전.
  4. STOMP의 destination 모델은 좋은 추상화다 — 채널 이름만 잘 잡으면 클라이언트와 서버가 느슨하게 연결된다.

더 해보고 싶은 것

  • 메시지 큐 + 오프라인 큐잉 — 네트워크가 끊겼을 때 보낸 메시지를 로컬에 쌓아뒀다가 재연결 시 전송
  • presence의 미세한 상태 — 온라인/오프라인 외에 입력 중 같은 transient 상태
  • 재연결 backoff — 현재는 고정 5초인데, exponential backoff + jitter로 옮기기

WebSocket은 한 번 잘 깔아두면 그 위에 모든 실시간 기능이 자라기 쉬워요. Plan Do!의 다음 협업 기능들(공동 편집·실시간 알림 등)도 이 기반 위에 얹을 예정입니다.