Skip to main content
← 블로그

SSE에서 WebSocket으로 — 받은 메시지를 버리지 못해서

VauDium ·

실시간 동기화를 SSE로 잘 만들어 뒀는데, XHR이 받은 걸 전부 쌓아두는 바람에 메모리가 샜다. 그래서 WebSocket으로 갈아탄 이야기.

SSE에서 WebSocket으로 — 받은 메시지를 버리지 못해서

Fecit은 한 기기에서 바꾼 게 다른 기기에 곧바로 반영됩니다. 폰에서 할 일을 완료하면 데스크톱 화면이 바로 갱신되고, 다나를 충전하면 잔액이 몇 초 안에 올라갑니다. 이걸 처음엔 SSE(Server-Sent Events) 로 만들었습니다. 서버가 한 방향으로 이벤트를 밀어주는, 가볍고 단순한 방식이죠. 잘 돌았습니다.

그런데 어느 날, 앱을 오래 켜 두면 메모리가 슬금슬금 올라갔습니다.

범인: 받은 걸 버리지 못하는 XHR

모바일(React Native)에는 브라우저 같은 EventSource가 없어서, SSE를 XHR(XMLHttpRequest) 스트리밍으로 직접 읽고 있었습니다. 문제는 여기에 있었습니다.

XHR은 스트림으로 데이터가 들어올 때마다 responseText계속 이어 붙입니다. 우리는 새로 들어온 부분만 잘라 쓰면 되는데, XHR 입장에선 “지금까지 받은 전체”를 한 문자열로 들고 있어야 합니다. 즉, 연결이 살아있는 내내 받은 모든 이벤트가 메모리에 그대로 쌓입니다.

실시간 연결은 몇 시간씩 유지됩니다. 그동안 흘러간 이벤트가 전부 responseText 안에 남아 있는 겁니다. 우리가 다 처리하고 잊어버린 메시지들까지, XHR은 꿋꿋이 기억하고 있었습니다.

WebSocket은 받고 버린다

해결책은 transport를 바꾸는 것이었습니다. WebSocket. WebSocket은 메시지를 하나 던져주고는, 그걸로 끝입니다. onmessage로 받아서 처리하고 나면 그 메시지는 사라집니다. 누적이 없습니다.

ws.onmessage = (e) => {
    if (typeof e.data !== "string") return;
    this.handleMessage(e.data); // 처리하고 끝. 쌓이지 않는다.
};

핵심은, transport만 갈아끼우고 위는 그대로 두는 것이었습니다. 화면 곳곳에서 on("achiever_updated", ...) 같은 코드가 이 클라이언트를 구독하고 있었거든요. 공개 API(connect/disconnect/on/connectionId)와 이벤트 타입을 똑같이 유지했더니, 소비자 코드는 한 줄도 안 바꿔도 됐습니다. 엔진만 바꾸고 핸들은 그대로.

그리고 데스크톱

모바일을 옮긴 뒤, 데스크톱은 한동안 그대로 SSE를 쓰고 있었습니다. 데스크톱은 XHR이 아니라 fetch + ReadableStream으로 읽어서 — 받은 청크를 처리하고 버리니까 — 같은 누수가 없었거든요. 굳이 안 바꿔도 됐습니다.

그래도 양쪽 transport가 다른 건 마음에 걸려서, 결국 데스크톱도 WebSocket으로 통일했습니다. 여기서 웹만의 함정 몇 개를 만났습니다.

헤더를 못 붙인다. 브라우저(그리고 Tauri)의 WebSocket 생성자는 커스텀 헤더를 지원하지 않습니다. 평소처럼 Authorization 헤더로 토큰을 넘길 수가 없어서, 쿼리 파라미터로 넘겼습니다.

const url = `${wsBase}/api/achiever/events/stream-ws?token=${encodeURIComponent(token)}`;

프록시 경로가 겹친다. 개발 환경에선 Vite 프록시를 통해 백엔드로 보내는데, WebSocket을 포워딩하려면 ws: true가 필요합니다. 그런데 새 경로 /…/stream-ws는 기존 SSE 경로 /…/stream의 프리픽스와 겹칩니다. 더 구체적인 -ws먼저 등록하지 않으면, SSE 프록시가 가로채서 WebSocket 핸드셰이크가 깨집니다.

끊겨도 놓치지 않게

WebSocket은 끊길 수 있습니다. 와이파이가 잠깐 나가거나, 서버가 재시작하거나. 그래서 끊기면 3초 뒤 자동으로 다시 연결합니다.

문제는 끊겨 있던 동안 일어난 변경입니다. 그 사이 다른 기기에서 할 일을 바꿨다면? 그래서 연결될 때마다 서버가 connected 메시지로 연결 ID를 건네주고, 재연결이면 그걸 신호 삼아 “마지막으로 본 시점 이후”를 다시 당겨와 빈 구간을 메웁니다. 끊김은 있어도 누락은 없게.

교훈

  1. 스트리밍에서 “받고 버리는가”는 중요하다. XHR은 받은 걸 전부 들고 있고, WebSocket은 버린다. 오래 사는 연결일수록 이 차이가 메모리로 쌓인다.

  2. transport와 그 위 로직을 분리해 두면 갈아타기 쉽다. 공개 API만 동일하게 유지하면, 엔진을 통째로 바꿔도 소비자는 무사하다.

  3. 같은 코드라도 환경마다 함정이 다르다. RN은 헤더를 받지만 웹은 못 받고, 누수도 XHR에서만 났다. “모바일에서 됐으니 웹도 되겠지”는 자주 틀린다.

실시간은 잘 돌 때는 보이지 않다가, 안 될 때만 존재감을 드러냅니다. 메모리도 그렇고요.