실시간 3편 — SSE를 걷어내고 WebSocket으로 갈아탔다

잘 돌던 SSE를 665줄 삭제 PR로 걷어내고 socket.io로 갈아탔다. 열등해서가 아니라 요구사항이 양방향 축을 넘어가서다. 전환의 이유, envelope 설계, refetch 타협, 유실 복구까지 코드로 정리했다.

Backend·

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, RefreshBar
2. 도메인 발행 삭제 — resolver의 publish 호출 7곳, 발행용 분기
3. 인프라 삭제 — libs/sse 통째, HttpSessionAuthGuard

순서를 뒤집어서 인프라를 먼저 지우면, 중간 커밋에서 note.module.ts가 이미 사라진 SseModule을 import하는 상태가 된다. 빌드가 깨진다. 소비자부터 지우면 어느 커밋에서 체크아웃해도 빌드가 성립한다.

백엔드 — 게이트웨이 하나

socket.io 게이트웨이는 libs/websocket에 산다. 핵심은 이 클래스가 도메인을 모른다는 것이다. 노트가 뭔지, 댓글이 뭔지 모르고 wire 타입(RealtimeEvent)만 안다.

/**
* project 룸 기반 WebSocket 게이트웨이. 도메인 로직을 모르고 wire 타입(RealtimeEvent)만 안다.
*/
@NestWebSocketGateway({
path: '/api/ws',
cors: { origin: process.env.FRONTEND_URL, credentials: true },
serveClient: false,
})
export class WebSocketGateway implements OnGatewayInit {
/**
* 인증을 핸드셰이크 미들웨어에 건다.
*/
afterInit(server: Server): void {
server.use((socket, next) => {
void this.authenticate(socket).then(
() => next(),
(error: Error) => {
this.logger.warn(`[ws.unauthorized] reason=${error.message}`);
next(new Error('unauthorized'));
}
);
});
}
/**
* 세션 쿠키(APP_SID)만 검증하고 socket.data.userId 를 채운다.
*/
private async authenticate(socket: Socket): Promise<void> {
const sessionId = parseCookies(socket.handshake.headers.cookie)[SESSION_COOKIE_NAME];
if (!sessionId) throw new Error('no_session_cookie');
const session = await this.authHelper.validateSession(sessionId);
socket.data.userId = session.userId;
}
}

인증은 핸드셰이크 미들웨어에서 한 번. 연결이 성립하기 전에 거른다. 성립 후 매 메시지마다 검사하는 게 아니라, 문 앞에서 한 번 확인하고 통과한 소켓의 socket.data.userId를 신뢰한다. 실패는 unauthorized 하나로만 응답한다 — 클라이언트가 이 문자열 하나로 "재시도 무의미"를 판단하기 때문이다.

쿠키 파싱 유틸에는 작은 디테일이 있다.

export const parseCookies = (header = ''): Record<string, string> => {
const jar: Record<string, string> = {};
for (const pair of header.split(';')) {
// 값에 '=' 가 들어갈 수 있으므로 split 이 아니라 첫 '=' 기준으로 자른다.
const separator = pair.indexOf('=');
if (separator === -1) continue;
jar[pair.slice(0, separator).trim()] = decodeURIComponent(pair.slice(separator + 1).trim());
}
return jar;
};
export const projectRoom = (projectId: string) => `project:${projectId}`;

쿠키 값에는 =가 들어갈 수 있다(base64 패딩이 대표적이다). pair.split('=')을 쓰면 abc==가 abc로 잘린다. 그래서 첫 = 인덱스로만 자른다. 작은 코드지만 세션 ID가 잘리면 전부 unauthorized가 되니까, 정확해야 하는 코드다.

룸 입장 — 인증과 인가는 별개 관문

@SubscribeMessage('project.join')
async handleJoin(
@ConnectedSocket() client: Socket,
@MessageBody() projectId: string
): Promise<{ ok: boolean }> {
const userId = client.data.userId as string | undefined;
if (!userId) {
this.logger.error(`[ws.join_denied] reason=missing_user socket=${client.id}`);
return { ok: false };
}
try {
// project 멤버인지 확인
await this.projectMemberService.getProjectMemberByProjectIdAndUserId({ projectId, userId });
} catch {
this.logger.warn(`[ws.join_denied] user=${userId} project=${projectId}`);
return { ok: false };
}
// Set 순회 중 mutate 방지로 복사.
[...client.rooms].filter((room) => room !== client.id).forEach((room) => client.leave(room));
await client.join(projectRoom(projectId));
return { ok: true };
}

핸드셰이크 인증은 "너는 우리 서비스 사용자다"까지만 보장한다. 이 프로젝트를 볼 수 있는 사람인지는 별개 질문이다. 그래서 project.join에서 멤버십을 다시 검증한다. 인증(authentication)과 인가(authorization)는 관문이 다르다.

한 소켓은 한 룸만 유지한다. 사용자가 프로젝트 A에서 B로 이동하면 A의 이벤트를 받을 이유가 없으니, 입장 전에 기존 룸을 전부 떠난다. 여기서 [...client.rooms]로 복사한 뒤 순회하는 이유가 있는데, client.rooms는 Set이고 leave()가 그 Set을 mutate한다. 순회 중인 컬렉션을 순회하면서 지우면 안 된다.

발행 쪽 표면적은 메서드 하나다.

/**
* project 룸 전원에게 발행한다. 발행자 본인 소켓도 포함이므로 중복은 클라이언트가 거른다.
* socket.io 이벤트 이름은 하나뿐이고, 종류 구분은 envelope 의 type/subtype 이 한다.
*/
emitToProject(projectId: string, event: RealtimeEvent): void {
this.server.to(projectRoom(projectId)).emit(REALTIME_EVENT, event);
}

wire 포맷 — 이벤트 이름은 하나

여기가 설계 판단이 가장 뚜렷한 지점이에요. socket.io 이벤트 이름을 note.created, comment.deleted처럼 도메인별로 늘리지 않았다. 이벤트 이름은 'event' 하나뿐이고, 종류 구분은 payload의 type/subtype이 한다. 슬랙 wire 포맷과 같은 방식이다.

/**
* 서버→클라이언트 단일 socket.io 이벤트 이름. 이벤트 종류 구분은
* 페이로드(RealtimeEvent)의 type/subtype 이 한다.
*/
export const REALTIME_EVENT = 'event' as const;
/**
* 서버→클라이언트 실시간 이벤트 전체 union. 슬랙 와이어 포맷처럼
* 이벤트 이름 하나(REALTIME_EVENT)에 실리고 type/subtype 으로 판별한다.
*/
export type RealtimeEvent = NoteEvent | CommentEvent | ReactionEvent;

왜 이게 나은가. 이벤트 이름이 도메인별로 늘어나면 클라이언트도 socket.on을 도메인마다 붙여야 하고, 새 도메인이 생길 때마다 소켓 계층에 손을 대야 한다. 이름이 하나면 단일 dispatcher가 받아서 type으로 분기하면 끝이다. 리액션이 추가됐을 때 소켓 계층은 한 줄도 안 바뀌었어요. union에 타입 하나 늘고, dispatcher에 case 하나 는 게 전부다.

이 타입들은 @app/shared-schema 공유 패키지에 살고, 백엔드와 프론트가 같은 파일을 import한다. 서버가 보내는 것과 클라이언트가 기대하는 것의 계약이 한 곳에 있다. 서버가 필드를 바꾸면 프론트 빌드가 깨진다 — 런타임에 발견하는 것보다 훨씬 싸다.

discriminated union + intersection 트릭

노트 생성 이벤트의 payload는 두 갈래다. 노드를 실었거나(refetch: false), 다시 조회하라거나(refetch: true).

export type NoteCreatedPayload =
| { versionId: string; refetch: true }
| {
versionId: string;
refetch: false;
scope: NoteScope;
note: NoteNodePayload;
};
// 와이어 포맷: 단일 socket.io 이벤트에 실리는 envelope. type/subtype 이중 리터럴로
// 판별한다. union payload 는 extends 가 불가능하므로 intersection 으로 결합한다 —
// TS 가 분배해 주므로 narrowing 후에도 refetch 판별이 그대로 동작한다.
export type NoteCreatedEvent = {
type: 'note';
subtype: 'created';
} & NoteCreatedPayload;

interface NoteCreatedEvent extends NoteCreatedPayload를 쓰고 싶지만 union은 extends가 안 된다. 그래서 intersection(&)으로 결합한다. TypeScript는 intersection을 union의 각 멤버에 분배하므로, 결과는 {type, subtype, refetch: true, ...} | {type, subtype, refetch: false, ...} 두 갈래 union이 된다.
덕분에 dispatcher에서 event.type === 'note'로 좁힌 뒤에도 event.refetch로 다시 좁힐 수 있다. 판별이 두 단계로 중첩되는 거예요.

payload에 무엇을 싣는가 — refetch 타협

이 전환에서 가장 실용적인 인사이트가 여기 있다. 모든 이벤트에 데이터를 다 싣지 않는다.

/**
* 부속물(첨부·드로잉)이 없는 신규 노트만 노드를 실어 보낸다.
* @param hasExtras 첨부나 드로잉이 붙었는지 — true 면 클라이언트가 refetch 한다
*/
async emitCreated(note: NoteEntity, hasExtras: boolean): Promise<void> {
if (note.status !== NoteStatus.SUBMITTED) return;
const { versionId, projectId } = await this.noteService.findEventTargetByNoteId(
note.noteId
);
const event: NoteCreatedEvent = hasExtras
? { type: 'note', subtype: 'created', versionId, refetch: true }
: {
type: 'note',
subtype: 'created',
versionId,
refetch: false,
scope: note.scope,
note: NoteEvents.toNodePayload(note),
};
this.webSocketGateway.emitToProject(projectId, event);
}

규칙은 이렇다.

  • 신규 노트에 첨부·드로잉이 하나도 없으면 노드를 통째로 실어 보낸다 → 클라이언트가 캐시에 그대로 꽂는다. 재요청 0회.
  • 첨부·드로잉이 붙었거나, 일괄 제출·승인처럼 여러 건이 한 번에 바뀌면 refetch: true만 보낸다 → 클라이언트가 해당 버전을 다시 조회한다.

왜 전부 다 싣지 않았을까요. 첨부·드로잉까지 페이로드로 표현하려면 서버가 GraphQL 응답의 형태를 wire 포맷에서 재현해야 한다. 클라이언트 캐시에 꽂히는 노드는 GraphQL 쿼리가 정의한 모양인데, 그 모양을 이벤트 발행 코드가 한 번 더 조립하는 순간 두 곳이 어긋나기 시작한다. 필드 하나 추가될 때마다 양쪽을 맞춰야 하는 유지비가 생기죠.
그래서 표현이 단순한 것(부속물 없는 노트 한 건)만 싣고, 나머지는 정확성을 위해 refetch로 넘기는 타협을 했다. 완벽한 실시간이 아니라, 흔한 경우를 빠르게 하고 복잡한 경우를 정확하게 하는 설계다.

두 가지 디테일이 더 있다.

DRAFT는 아예 안 뿌린다. note.status !== SUBMITTED면 return. DRAFT 노트는 작성자 외에는 조회 자체가 안 되고, 작성자 본인은 mutation 응답으로 이미 갖고 있다. 룸에 뿌릴 이유가 없다.

삭제 이벤트에는 함정이 있다. 룸을 찾으려면 노트에서 projectId를 역추적해야 하는데, 행이 사라진 뒤에는 그 역추적이 안 된다. 그래서 emitDeleted의 시그니처가 다른 메서드와 다르다.

/**
* 삭제는 행이 사라진 뒤라 좌표를 다시 못 찾는다. 호출부가 삭제 전에 잡아 둔
* findEventTargetByNoteId 결과를 넘겨야 한다.
*/
emitDeleted(target: { versionId: string; projectId: string }, note: NoteEntity): void {

호출부가 삭제 전에 타깃을 조회해서 넘겨야 한다. 시그니처 자체가 호출 순서를 강제하는 셈이에요.

프론트 — 연결·룸·수신, 3계층

프론트는 관심사를 세 층으로 갈랐다.

연결 (1개) SocketProvider — 로그인 후 connect, 로그아웃 시 disconnect
└ 룸 (라우트당 1개) useProjectRoom — join/leave만, 연결은 안 건드림
└ 수신 (1개) useProjectDetailRealTime — 단일 dispatcher, 캐시 반영

소켓 인스턴스는 모듈 레벨 싱글턴이고 autoConnect: false다.

export const socket: AppSocket = io(config.WS_ORIGIN, {
path: '/api/ws',
withCredentials: true,
autoConnect: false,
});
// 인증 거부는 재시도해도 결과가 같다. 자동 재연결이 붙어야 하는 네트워크 끊김과 구분해서 멈춘다.
socket.on('connect_error', (error) => {
if (error.message === 'unauthorized') socket.disconnect();
});

연결은 인증 영역에서 한 번만 만든다. 핸드셰이크가 세션 쿠키에 의존하니까 로그인 이후여야 하고, 그래서 인증된 레이아웃을 감싸는 SocketProvider가 연결을 소유한다. 도메인 훅은 useSocket()으로 가져다 쓸 뿐 connect/disconnect를 못 건드린다.

룸 입·퇴장은 연결과 분리돼 있다.

/**
* project 룸 입·퇴장. 연결 자체는 건드리지 않는다.
*/
export const useProjectRoom = (projectId: string) => {
const socket = useSocket();
useEffect(() => {
const join = () => socket.emit('project.join', projectId);
if (socket.connected) join();
socket.on('connect', join);
return () => {
socket.off('connect', join);
socket.emit('project.leave', projectId);
};
}, [socket, projectId]);
};

socket.on('connect', join)이 핵심이다. 서버는 소켓이 죽으면 룸 멤버십도 잃는다. in-memory Set이니까. 재연결에 성공해도 서버 입장에선 처음 보는 소켓이라, 클라이언트가 connect가 뜰 때마다 다시 입장해야 한다. 이걸 빼먹으면 재연결 후 이벤트가 조용히 안 오는, 디버깅하기 아주 고약한 버그가 된다.

수신은 단일 dispatcher다.

export const useProjectDetailRealTime = () => {
const queryClient = useQueryClient();
const socket = useSocket();
useEffect(() => {
const handleEvent = (event: RealtimeEvent) => {
switch (event.type) {
case 'note':
applyNoteRealtimeEvent(queryClient, event);
return;
case 'comment':
if (event.subtype === 'indicator_updated') {
// 발행은 comment 모듈이지만 소비자는 노트 목록의 배지 캐시다.
applyNoteRealtimeEvent(queryClient, event);
return;
}
applyCommentRealtimeEvent(queryClient, event);
return;
case 'reaction':
syncReactionRealtimeCache(queryClient, event);
}
};
socket.on(REALTIME_EVENT, handleEvent);
return () => {
socket.off(REALTIME_EVENT, handleEvent);
};
}, [queryClient, socket]);
};

재밌는 지점이 indicator_updated예요. 이벤트를 발행하는 쪽은 댓글 모듈인데, 소비하는 쪽은 노트 목록의 댓글 배지 캐시다. 도메인 소유권(누가 발행하나)과 캐시 소유권(누가 소비하나)이 다를 수 있고, dispatcher가 그 라우팅을 담당한다. 이벤트 이름을 도메인별로 늘렸다면 이 라우팅이 리스너 등록 위치에 흩어졌을 거다.

캐시 반영은 setQueriesData와 invalidateQueries의 분기다.

case 'created': {
const { payload } = event;
if (payload.refetch) {
void queryClient.invalidateQueries({ queryKey: notesQueryKey(payload.versionId) });
return;
}
queryClient.setQueriesData<NotesByVersionQuery>(
{ queryKey: notesQueryKey(payload.versionId, payload.scope) },
(cache) => upsertNote(cache, payload.note)
);
return;
}

refetch: false면 invalidate 없이 캐시를 직접 갱신한다. SSE 시절엔 신호를 받고 무조건 재조회했는데, 이제 흔한 경로에선 네트워크 왕복이 아예 없다. 이벤트가 캐시로 직행한다.

query key도 한 줄이지만 짚을 게 있다.

/** scope가 없으면 해당 버전의 모든 scope 캐시를 부분 매칭한다. */
export const notesQueryKey = (versionId: string, scope?: NoteScope) =>
useNotesByVersionQuery.getKey({ input: scope ? { versionId, scope } : { versionId } });

refetch: true 이벤트는 scope를 싣지 않는다 — 여러 건이 바뀌었으니 어느 scope가 영향받았는지 서버도 모른다. 그래서 scope 없는 key로 해당 버전의 모든 scope 캐시를 부분 매칭으로 잡아 전부 무효화한다. TanStack Query의 query key 부분 매칭이 이 fallback을 공짜로 만들어 준다.

끊김과 유실 — SSE와 가장 크게 갈린 지점

재연결은 socket.io 기본 백오프를 그대로 쓴다. 1초에서 시작해 5초 상한, 지터 포함. 2편에서 SSE 재연결을 직접 다뤘던 것과 달리 여기선 한 줄도 안 짰어요. 라이브러리가 잘하는 일은 라이브러리에 맡긴다.

문제는 끊긴 사이에 놓친 이벤트다. 서버는 미확인 이벤트를 버퍼링하지 않는다. 룸에 없던 소켓에게 과거 이벤트를 다시 줄 방법이 없으므로 델타 복구가 불가능하다. 1편에서 말한 SSE의 Last-Event-ID — "여기까지 받았으니 그 다음부터 주세요" — 를 WebSocket으로 오면서 잃었다

대신 전량 재조회로 갈음한다.

// 최초 connect 는 라우트 로더가 막 받아온 직후라 무효화할 갭이 없다. Manager 의 reconnect 만 잡는다.
const handleReconnect = () => {
void queryClient.invalidateQueries();
toast.success('실시간 연결이 복구되었어요.', { id: CONNECTION_TOAST_ID, duration: 3000 });
};

reconnect에서 invalidateQueries() — 인자 없이, 전부다. 끊긴 동안 무엇이 바뀌었는지 모르니 전체를 다시 읽어 갭을 덮는다. 무식하지만 정확하다.
주석의 디테일도 짚을 만해요. connect가 아니라 Manager의 reconnect만 잡는다. 최초 연결 시점은 라우트 로더가 데이터를 막 받아온 직후라 무효화할 갭이 없다. 거기서도 invalidate하면 페이지 진입마다 이중 조회가 된다.

인증 거부는 재시도하지 않는다. 위의 socket.ts에서 봤듯 unauthorized는 재시도해도 결과가 같으므로 disconnect()로 백오프 루프를 끊는다. 세션이 만료된 채로 1초마다 핸드셰이크를 두드리는 건 서버에도 클라이언트에도 낭비다.

마지막으로, 연결 상태를 사용자에게 노출한다.

const handleDisconnect = (reason: Socket.DisconnectReason) => {
if (reason === 'io client disconnect') return;
toast.loading('실시간 연결이 끊겼습니다. 재연결 중…', {
id: CONNECTION_TOAST_ID,
duration: Infinity,
});
};

토스트는 같은 id로 덮어써서 항상 한 개만 뜬다. 끊김 → 복구가 반복돼도 화면에 토스트가 쌓이지 않는다. io client disconnect — 로그아웃 같은 의도적 종료 — 는 필터해서 조용히 지나간다.

목적을 정확히 말하면 이렇다. 실시간이 조용히 죽으면 사용자는 stale 화면을 최신이라고 믿는다. 협업 툴에서 이게 제일 위험해요. 남이 이미 지운 노트에 답을 달고, 이미 승인된 버전을 놓고 회의를 한다. 끊김을 숨기는 것보다 보여주는 게 낫다.

남은 한계

정직하게 적어 둔다.

  • 룸이 in-memory다. 서버 인스턴스가 여러 대면 A 서버의 발행이 B 서버에 붙은 소켓에게 안 간다. socket.io Redis adapter가 필요하다. 2편의 SseBus가 가진 것과 정확히 같은 종류의 한계가 그대로 남아 있다 — 전송 방식을 바꿔도 fan-out 문제는 안 사라진다.
  • 전량 재조회 방식의 유실 복구는 데이터가 커지면 비용이 커진다. 지금은 프로젝트 하나의 데이터가 작아서 괜찮지만, 이벤트 시퀀스 번호 기반의 델타 복구가 언젠가 필요할 수 있다.
  • 오프라인 큐잉은 없다. 끊긴 동안 사용자가 한 행동을 쌓아뒀다 재전송하는 장치는 만들지 않았다.

3부작을 닫으며

SSE에서 WebSocket으로 간 것은 업그레이드가 아니라 요구사항이 이동한 결과다. "새 글이 있다"는 신호만 필요할 때 SSE는 충분했고, 지금도 그 요구라면 SSE를 쓸 거예요. 룸 입장이라는 클라이언트 발화가 필요해지고, 캐시에 꽂을 데이터를 실어야 하는 순간 축이 넘어갔을 뿐이다.

그럼 SSE를 먼저 만든 건 낭비였을까. 저는 아니라고 생각해요. SSE 구현이 남긴 건 665줄의 삭제된 코드가 아니라, 재연결·인증·유실 복구를 한 바퀴 돌아본 경험이다. WebSocket 전환 PR에 "끊김·유실 처리" 섹션이 처음부터 들어갈 수 있었던 건 SSE에서 같은 문제를 이미 겪었기 때문이다. Last-Event-ID를 잃는다는 걸 알고 잃는 것과 모르고 잃는 것은 다르다.
다만 하나는 인정한다. 요구사항이 "화면에 즉시 반영"까지 갈 거라는 걸 처음부터 내다봤다면, 처음부터 WebSocket이었을 거다. 그걸 못 내다본 건 실력이고, 걷어내는 결정을 미루지 않은 건 다행이었다.