실시간 3편 — SSE를 걷어내고 WebSocket으로 갈아탔다
잘 돌던 SSE를 665줄 삭제 PR로 걷어내고 socket.io로 갈아탔다. 열등해서가 아니라 요구사항이 양방향 축을 넘어가서다. 전환의 이유, envelope 설계, refetch 타협, 유실 복구까지 코드로 정리했다.
2편에서 만든 SSE(Server-Sent Events)는 잘 돌았어요. 연결도 안정적이었고, 새 노트가 오면 배너도 잘 떴다. 그런데 몇 달 뒤 그 코드를 전부 지웠다. 665줄을 삭제하는 PR을 올리면서 새 코드는 한 줄도 넣지 않았다.
이 글은 그 전환의 기록이다. 왜 걷어냈는지, 무엇을 얻고 무엇을 잃었는지, 코드로 짚는다.
왜 갈아탔는가
먼저 분명히 할 것. SSE가 고장 나서 바꾼 게 아니다. 요구사항이 바뀌었다. 네 가지였어요.
첫째, 신호에서 데이터로. 2편의 SSE가 보낸 payload는 { ts } 하나였다. 타임스탬프 하나. UI는 그걸 받아서 "새 노트가 있습니다" 새로고침 배너를 띄웠다.
그런데 요구가 올라갔어요. "새 글이 있다는 알림"이 아니라 "화면에 즉시 반영"이 필요해졌다. 다른 사람이 노트를 쓰면 내 화면 목록에 그 노트가 바로 나타나야 한다. 그러려면 변경된 노드 자체를 이벤트에 실어 보내서 클라이언트 캐시에 직접 꽂아야 한다. { ts }로는 안 된다.
둘째, 클라이언트가 말을 해야 했다. 프로젝트 룸에 입장하고(project.join), 떠나고, ack를 받는 흐름이 필요해졌다. SSE는 서버→클라 단방향이라 구독 대상을 바꾸려면 EventSource를 닫고 다른 URL로 다시 여는 수밖에 없다. 룸을 옮겨 다니는 UI에서 이건 연결 뒤집기의 반복이다.
셋째, 인증 우회물이 남아 있었다. 2편에서 만든 HttpSessionAuthGuard는 EventSource가 커스텀 헤더를 못 붙여서 쿠키만 검증하려고 만든 가드였다. 다른 사용처가 하나도 없었어요. 전송 방식 하나의 제약 때문에 인증 계층에 전용 분기가 생긴 것이다. 이 가드는 PR에서 통째로 삭제됐다.
넷째, 도메인이 늘었다. 노트에 이어 댓글, 리액션, 댓글 indicator까지 실시간이 필요해졌다. 도메인마다 SSE 스트림과 contributor를 늘리는 것보다, 룸 하나에 이벤트를 흘리는 모델이 맞았다.
지우는 것부터 — 삭제 PR의 커밋 순서
전환의 첫 PR은 신규 코드 0줄, 665줄 삭제만 하는 PR이었다.
두 전송 방식을 동시에 유지하면 인증·재연결·구독 장부가 이중으로 존재하게 된다. 어느 쪽이 진실인지 아무도 모르는 상태가 되죠. 그래서 WebSocket을 올리기 전에 SSE를 먼저 완전히 걷어냈다.
재밌는 건 커밋 순서예요. 커밋을 소비자(프론트) → 도메인 발행 → 인프라 3개로 쪼갰다.
1. 프론트 소비자 삭제 — SseProvider, useProjectSse, RefreshBar2. 도메인 발행 삭제 — resolver의 publish 호출 7곳, 발행용 분기3. 인프라 삭제 — libs/sse 통째, HttpSessionAuthGuard순서를 뒤집어서 인프라를 먼저 지우면, 중간 커밋에서 note.module.ts가 이미 사라진 SseModule을 import하는 상태가 된다. 빌드가 깨진다. 소비자부터 지우면 어느 커밋에서 체크아웃해도 빌드가 성립한다.