NodeJS 장애 격리를 위한 마이크로서비스화
하나의 Node 프로세스에 REST API와 CRDT 서버를 함께 두면 안 되는 이유
안녕하세요. AI 기반 프로젝트 관리 플랫폼을 개발하고 있는 풀스택 개발자입니다.
이번 포스팅에서는 진행 중인 프로젝트를 기반으로 실시간 협업 서버를 메인 API 서버에서 분리한 과정을 다뤄보려고 해요.
처음에는 Yjs/Hocuspocus 기반 협업 기능도 '서버 기능 중 하나'라고 판단했어요. NestJS 메인 서버에 기본 API, 인증, 문서 협업까지 한 프로세스에 넣으면 구조가 단순하고 관리하기 편하다고 생각했거든요. 하지만 실제로 운영하면서 이 구조가 전체 시스템 가용성에 문제를 일으킬 수 있다는 걸 알게 됐고, 결국 apps/collaboration을 새로 만들어 Hocuspocus 실행과 CRDT 문서 저장을 별도 프로세스로 옮겼습니다.
목표는 단순한 코드 정리가 아니라 장애 범위를 줄이는 것이었어요. 이 글에서는 두 런타임을 한 프로세스에 뒀을 때 생기는 위험을 짚어보고, 분리 과정에서 마주한 데이터 정합성 문제를 어떻게 해결했는지 공유하려고 해요.
같은 Node 프로세스에 두면 어떤 일이 생길까요
메인 서버는 이미 태스크 관리, 인증, 알림, 검색 이벤트, AI 기능, DB 쓰기 흐름까지 많은 비즈니스 책임을 지고 있었어요. 여기에 Hocuspocus까지 같이 두면 서버의 안정성 기준이 섞입니다.
REST API와 실시간 협업 서버는 작동 방식과 리소스 사용 패턴이 완전히 달라요.
- REST API 서버: HTTP 요청 단위의 예측 가능성이 핵심이에요. 요청이 들어오면 트랜잭션을 처리하고 즉시 응답을 반환하고, 문제가 생기면 해당 요청만 실패하거나 재시도하면 됩니다.
- 실시간 협업 서버: WebSocket 연결 단위의 지속성이 핵심입니다. 사용자는 문서를 열어둔 채 계속 편집하고, 서버는 문서 상태를 메모리에서 다루면서 Hocuspocus Database 익스텐션을 통해 문서를 불러오고 저장해요. 이 흐름은 CPU, 메모리, 연결 수, 저장 타이밍에 민감합니다.
결국 협업 모듈에 과부하가 걸리면 같은 Node 프로세스의 싱글 스레드를 공유하던 메인 API(로그인, 결제 등)까지 함께 블로킹되는 구조였어요.
컨테이너로 런타임을 분리한 이유
장애 범위를 격리하기 위해 세 가지 선택지를 검토했어요.
[선택지 1: 단일 프로세스 유지] ➡️ 배포가 편하지만, 협업 장애가 메인 서버 전체로 번짐
[선택지 2: 하이브리드 분리] ➡️ Hocuspocus만 분리, 메인 서버가 CRDT 상태 일부를 여전히 관리
[선택지 3: 컨테이너 분리] ➡️ CRDT 문서 조작을 협업 서버로 완전히 위임, 메인 서버는 비즈니스 로직에만 집중 (★ 선택)
첫 번째는 가용성 리스크가 너무 컸어요. 두 번째는 코드를 덜 고쳐도 된다는 장점이 있었지만, 하나의 문서 상태를 두 프로세스가 같이 다루다 보니 "최종 쓰기 권한이 어디에 있는가"가 모호해져서 데이터가 꼬일 위험이 있었습니다.
세 번째를 선택했어요. 모든 CRDT/Yjs 문서 조작은 apps/collaboration에서 수행하고, 메인 서버는 REST API, 비즈니스 로직, DB 변경, 이벤트 발행을 담당하는 구조로 나눈 거예요. 프로세스를 나누면 인증 토큰 전달, 네트워크 실패 처리, 배포 순서까지 관리해야 하는 복잡도가 생기지만, 협업 기능 장애가 메인 서버로 번지는 걸 막기 위한 트레이드오프로 감수하기로 했습니다.
격리된 협업 서버, 문서 제어권을 완전히 넘기다
독립된 협업 서버는 이제 메인 서버의 간섭 없이 실시간 동기화와 자체 저장 레이어만 신경 씁니다. apps/collaboration/src/server.ts에서 Hocuspocus 서버를 직접 실행하고, 같은 포트에서 WebSocket과 일부 HTTP API를 함께 처리해요.
const hocuspocus = new HocuspocusServer({
port,
debounce: 500, // 0.5초간의 편집을 모아서 한 번에 저장합니다
unloadImmediately: true, // 편집이 끝나면 메모리에서 바로 내립니다
onAuthenticate: async ({ token }) => {
const { userId } = await authenticate(token);
return { userId };
},
extensions: [
new Database({
fetch: async ({ context, documentName }) => {
const userId = (context as { userId?: string })?.userId;
// 이 부분이 핵심입니다: 문서 상태 로드는 협업 서버의 몫입니다
return await persistenceService.loadDocumentState(documentName, userId);
},
store: async ({ context, documentName, state }) => {
const userId = (context as { userId?: string })?.userId ?? null;
await persistenceService.storeDocumentState(documentName, userId, state, "hocuspocus");
},
}),
],
});
인증, 문서 로드, 문서 저장이 협업 서버 안으로 완전히 들어갔습니다. 메인 서버는 이 무거운 상태를 직접 들고 있지 않아도 되고요.
분리 이후 메인 서버는 협업 문서를 직접 다루지 않고 CollaborationProxyService를 통해 협업 서버 HTTP API를 호출해요. 프록시 요청이 실패하면 BadGatewayException, ServiceUnavailableException 같은 NestJS 예외로 변환해서 메인 서버의 오류 모델 안으로 다시 가져오는 구조입니다.
협업 서버에는 전용 /health 엔드포인트도 열었어요. 현재 활성 WebSocket 연결 수를 포함한 응답을 반환하는데, Hocuspocus가 메인 서버 내부 모듈일 때보다 "협업 서버만 따로 살아 있는지" 확인하기 훨씬 쉬워졌습니다.
프로세스를 나누면 데이터는 어떻게 맞출까요
서버를 나눴다고 안정성이 자동으로 생기지는 않아요. 오히려 새로운 실패 지점이 생깁니다. 메인 서버가 태스크 메타데이터(제목, 담당자 등)를 DB에 저장했는데 협업 서버 호출이 실패할 수 있고, 협업 서버는 살아 있지만 이벤트 브리지가 죽어 있을 수도 있어요.
두 작업을 같은 실패 단위로 묶으면, 협업 서버가 배포 중일 때 태스크 저장 자체가 실패하게 됩니다. 분리한 이유가 무색해지는 거죠. 고민 끝에 데이터의 강한 일치보다 가용성을 우선하는 방향을 택했어요.
// 1. 메인 비즈니스 로직은 먼저 성공 처리합니다
const updated = await this.taskRepository.update(taskId, data);
// 2. 협업 문서 반영은 실패해도 메인 저장을 되돌리지 않습니다
// 내부적으로 최대 3번 재시도하며, 최종 실패 시 outbox 테이블에 기록합니다
this.applyMetadataBridgeSafely(updated.id, bridgeMetadata, userId, "update");
협업 서버가 잠깐 죽어도 사용자의 태스크 저장은 중단 없이 성공해요. 대신 아주 짧은 순간 DB 데이터와 화면의 문서 상태가 어긋날 수 있다는 단점이 생깁니다.
"분리해서 모든 문제가 해결됐다"고 하면 과장이에요. 장애 범위를 격리하고, 데이터가 어긋나는 상황을 시스템이 추적할 수 있는 별도 경로로 빼낸 것에 가깝습니다. outbox가 기록만 하고 재처리 경로가 부족하다면 운영자가 직접 확인해야 할 부채로 남는다는 점도 아직 해결해야 할 숙제예요.
마무리하며
이 분리로 가장 크게 달라진 건 책임의 경계가 분명해졌다는 점이에요.
apps/server는 비즈니스 API 서버입니다. 태스크를 만들고, DB를 갱신하고, 알림과 검색 이벤트를 발행해요. apps/collaboration은 실시간 협업 서버입니다. Hocuspocus를 실행하고, Yjs 문서를 로드하고 저장하며, 댓글 스레드와 snapshot API를 다뤄요.
이 경계 덕분에 장애를 보는 방식도 달라졌습니다. 예전에는 "서버가 느리다"는 하나의 문제로 뭉뚱그려 보였을 수 있어요. 이제는 "메인 API가 느린가", "협업 연결이 많은가", "문서 저장이 실패하는가", "bridge가 실패하는가"로 나눠서 볼 수 있습니다.
서버 안정성은 모든 모듈이 한 프로세스 안에서 부하를 무한정 버티게 만드는 게 아니에요. 특정 기능이 실패했을 때 그 영향이 어디까지만 번지게 할 것인지 경계를 치는 것, 그게 더 안정적인 설계라고 생각합니다.