-
Next.js와 Node.js 기반 실시간 대용량 편집기 Part2. 구현 및 성능 최적화Tip 2025. 5. 3. 00:14728x90반응형
websockets 1편에서는 대용량 데이터를 처리하는 협업 편집기의 시스템 아키텍처와 프로토콜 선택 기준을 심층적으로 다뤘습니다.
이번 2편에서는 실제 Next.js 프론트엔드와 Node.js 백엔드를 사용하여 이러한 시스템을 구현하는 방법과, 성능을 최적화하는 다양한 기법을 살펴보겠습니다.
1. Next.js 클라이언트에서의 데이터 스트리밍 처리
Next.js 클라이언트에서는 실시간 편집 데이터를 주고받기 위해 주로 WebSocket API를 사용합니다.
Next.js는 React 기반이므로, 보통 페이지가 로드되면 useEffect 훅 등을 통해 WebSocket 연결(new WebSocket("wss://..."))을 열고 이벤트 리스너를 설정합니다.
useEffect(() => { const socket = new WebSocket(wsUrl); socket.onopen = () => {/* 인증 토큰 전송 등 */}; socket.onmessage = (event) => { const msg = JSON.parse(event.data); // 메시지 타입에 따라 텍스트 편집 적용 또는 이미지 청크 처리 }; return () => socket.close(); }, []);
이런 형태로 구현하여, 컴포넌트 마운트 시 연결을 열고 언마운트 시 닫도록 합니다.
상태 관리는 중요 포인트인데, 여러 컴포넌트에서 편집기 상태를 공유하므로 React Context나 Zustand 같은 전역 상태 관리 라이브러리를 써서 WebSocket에서 수신된 변경 내용을 저장하고 UI에 반영합니다.
수신된 텍스트 변경은 예컨대 setDocState(prev => applyEdit(prev, edit)) 식으로 문서 상태를 업데이트하고, React가 재렌더링하여 화면에 반영합니다.
DOM 조작 최소화를 위해, 커서 위치나 선택 영역 정보도 함께 관리하여 업데이트 후 커서가 원래 위치 근처에 머물도록 처리합니다.
클라이언트 측 데이터 스트리밍의 다른 한 축은 대용량 파일 수신입니다.
웹 브라우저는 기본적으로 WebSocket을 통해 받은 바이너리 데이터를 Blob 또는 ArrayBuffer 형태로 제공하므로, 이미지를 조각으로 받을 때는 ArrayBuffer를 누적하거나 Blob Part로 모아 최종 Blob을 생성할 수 있습니다.
const imageBuffers: Record<number, Uint8Array[]> = {}; socket.onmessage = async (event: MessageEvent) => { const data = event.data; if (typeof data !== 'string') { // Blob 또는 ArrayBuffer로 온 파일 조각 처리 const view = new Uint8Array(await new Response(data).arrayBuffer()); // 파일 ID 및 데이터 조각 분리 const fileId: number = view[0]; const chunkData: Uint8Array = view.slice(1); if (!imageBuffers[fileId]) { imageBuffers[fileId] = []; } imageBuffers[fileId].push(chunkData); // 마지막 조각이라면 파일 조립 if (isLastChunk(view)) { const blob = new Blob(imageBuffers[fileId]); displayImage(URL.createObjectURL(blob)); delete imageBuffers[fileId]; } } else { // 문자열 데이터(JSON) 처리 const msg: unknown = JSON.parse(data); handleMessage(msg); } };
위와 같은 로직을 통해, 텍스트와 파일 스트림을 모두 하나의 WebSocket 연결에서 처리할 수 있습니다.
파일 청크는 바이너리로 오고 텍스트는 JSON 문자열로 오도록 약속하면, typeof data로 구분하여 각각 처리하는 식입니다.
브라우저 환경에서는 Streaming API(Streams API)도 사용할 수 있는데, 예를 들어 ReadableStream으로 WebSocket 메시지를 읽는 식은 아직 일반적이지 않으므로 대부분 수동 버퍼링 방식을 택합니다.
클라이언트에서 중요한 것은 UI 렌더링 성능입니다.
100명이 동시에 편집하면 초당 수십~수백 개의 변경 이벤트가 올 수 있는데, 이를 매번 DOM 업데이트하면 렌더링 병목이 생길 수 있습니다.
React의 배치 업데이트와 최소 diff 계산을 활용하도록, 수신된 변경을 일정 주기(예: 16ms)로 모아서 상태를 한 번에 업데이트하거나, 혹은 이미 Virtual DOM이 효율을 주지만 추가로 Canvas 기반 렌더링을 검토할 수도 있습니다.
다만 일반 문서 편집은 DOM/Text 노드 수준에서도 충분히 처리 가능하므로, 불필요한 리렌더를 줄이는 방향 (예: 글자 하나 입력마다 전체 문서를 다시 그리지 않고 해당 텍스트 노드만 수정)이 중요합니다.
또 다른 클라이언트 이슈는 오프라인 대응입니다.
일시적으로 네트워크가 끊겨도 사용자가 편집을 계속할 수 있어야 하며, 재연결 시 변경분을 동기화해야 합니다.
이를 위해 Next.js 클라이언트에 오프라인 버퍼를 둘 수 있습니다.
IndexedDB나 LocalStorage에 사용자의 오프라인 중 편집 내용을 임시 저장해 두고, 온라인 복귀 시 서버에 전송합니다.
예를 들어 Yjs 같은 CRDT를 사용한다면, Yjs는 자동으로 오프라인 중 로컬 업데이트를 기록하고 나중에 sync 메시지를 보내주므로 개발자가 수동으로 처리할 필요가 줄어듭니다.
이런 기능 덕분에 사용자는 인터넷이 잠깐 끊겨도 끊김 없는 UX를 누릴 수 있습니다.
2. Node.js 서버의 스트림 핸들링 구현
백엔드 Node.js 서버에서는 실시간 통신 및 데이터 중계를 효율적으로 수행해야 합니다.
구현 면에서, WebSocket을 처리하기 위해 ws 모듈이나 Socket.IO, uWebSocket.js 등의 라이브러리를 사용할 수 있습니다.
import WebSocket, { WebSocketServer } from 'ws'; // WebSocket 서버 생성 const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', (socket: WebSocket) => { socket.on('message', async (data: WebSocket.RawData) => { if (typeof data !== 'string') { // Binary data (file chunk) handling handleFileChunk(socket, data); } else { try { const msg: unknown = JSON.parse(data); handleMessage(socket, msg); } catch (error) { console.error('Invalid JSON received:', error); } } }); }); // 핸들러 함수 타입 예시 function handleFileChunk(socket: WebSocket, data: WebSocket.RawData): void { // 파일 청크 처리 로직 작성 } function handleMessage(socket: WebSocket, msg: unknown): void { // 메시지 처리 로직 작성 }
이처럼 각 연결별로 메시지를 수신하여 유형에 따라 분기 처리합니다.
handleMessage 함수에서는 편집기 동작 (삽입/삭제 등)을 받아 해당 문서의 상태를 업데이트하고, 같은 문서를 보고 있는 다른 소켓들에게 브로드캐스트 합니다.
브로드캐스트 시에는 wss.clients 목록을 돌면서 조건에 맞는 소켓에 socket.send(...)를 호출합니다.
이때 이진 데이터의 경우 Buffer 또는 ArrayBuffer를 그대로 send 하면 WebSocket 프레임의 opcode 0x2 (binary)로 전송됩니다.
Node.js Streams와의 결합도 구현 고려사항입니다.
예컨대 사용자가 이미지를 업로드하면, 서버에서는 그 바이너리 데이터를 받아 스트림으로 처리해 저장하면서 동시에 다른 클라이언트로 중계할 수 있습니다.
Node.js에서 웹소켓 자체도 내부적으로 스트림처럼 동작하지만 (socket._socket은 net.Socket), 응용단에서는 보통 이벤트 기반으로 다룹니다.
하지만 파일을 디스크에 쓸 때는 fs.createWriteStream으로 스트림을 열고, 수신한 청크를 writeStream.write(chunk)하여 저장할 수 있습니다.
반대로 서버가 클라이언트들에게 파일을 보내줄 때, 만약 파일이 이미 서버에 존재하는 것을 여러 사람에게 동시에 보내는 상황이면, 하나의 파일 읽기 스트림을 여러 WebSocket으로 브로드캐스트 하는 형태도 가능합니다.
각 WebSocket은 독립적인 소켓 스트림이라서 한 스트림을 여러 대상에 pipe는 직접 안 되지만, 청크를 읽어서 루프로 각 소켓에 보내는 식으로 처리하면 됩니다.
Node.js는 비동기 I/O가 기본이므로, 한 소켓 전송 중에도 다른 소켓 전송을 동시에 진행할 수 있습니다.
백엔드 로직 구현에서 핵심은 동기화 알고리즘 적용입니다.
편집 충돌을 방지하려면 중앙 서버에서 CRDT 또는 OT 알고리즘을 운영해야 하는데, 이 부분은 3편에서 자세히 다룹니다.
여기서는 성능 관점에서, 예를 들어 CRDT(예: Yjs)를 서버에 도입하면, 서버 메모리상에 문서 객체를 유지하면서 변경이 올 때마다 Yjs 문서에 applyUpdate를 호출하고, encodeUpdate로 다른 클라이언트에게 줄 binary update를 생성해 보내는 식으로 구현합니다.
Node.js는 단일 스레드이므로, CPU 연산(예: 대형 문서 머지) 중엔 다른 처리가 block 되지 않도록 해야 합니다.
필요하면 편집 연산 적용을 WebWorker나 별도 프로세스로 분산하는 것도 고려할 수 있습니다.
그러나 Yjs 등의 구현은 내부적으로 상당히 최적화되어 있어 수십만 문자 정도의 문서 편집 적용은 밀리초 단위로 끝납니다.
마지막으로, Node.js 서버에서 상태 관리도 신경 써야 합니다.
수백 명의 사용자가 여러 문서를 편집하면, 각 문서마다 접속자 리스트와 현재 문서 상태를 서버가 알고 있어야 합니다.
흔히 문서 ID를 키로 한 자료구조 (예: Map<docId, DocumentSession>)를 두고, DocumentSession 내에 현재 문서의 CRDT 상태 또는 OT 버퍼, 그리고 참가자 소켓 목록을 관리합니다.
WebSocket 연결이 끊어지면 해당 세션에서 제거하고, 새 연결이 오면 문서 ID에 따라 세션에 추가합니다.
이 세션은 일종의 룸(Room) 개념으로 생각할 수 있고, Socket.IO의 rooms 기능으로 쉽게 구현할 수도 있습니다.
이렇게 구조화하면 한 서버 내에서 동시 여러 방의 협업이 독립적으로 처리됩니다.
3. 백프레셔(Backpressure) 관리 전략
Backpressure(역압)이란 송신 측이 수신 측보다 너무 빨리 데이터를 보내는 경우, 수신 측 버퍼에 데이터가 쌓이면서 생기는 압력을 말합니다.
이를 관리하지 않으면 메모리 누수나 응용 프로그램 지연이 발생할 수 있습니다.
Node.js에서는 스트림을 사용하면 백프레셔 처리가 자동으로 되는 편이지만, WebSocket처럼 이벤트 기반으로 사용할 때도 이런 개념을 명시적으로 고려해야 합니다.
3.1 서버 -> 클라이언트 방향
서버가 한 번에 많은 메시지를 보낼 때, 특히 대용량 파일을 수십 명에게 동시에 브로드캐스트 하면 네트워크 인터페이스나 각 프로세스의 송신 버퍼가 꽉 찰 수 있습니다.
Node.js ws 모듈의 경우 socket.send(data, callback)을 호출하면 내부적으로 버퍼가 차있을 경우 false를 반환합니다.
이때 이 소켓에 대해서는 drain 이벤트를 듣고 있다가 버퍼가 비워지면 다시 전송을 이어가는 식으로 구현합니다.
또는 더 간단히, socket.bufferedAmount (bytes 단위)를 체크해서 일정 임계치 이상이면 해당 소켓으로의 전송을 잠시 멈추는 방법도 있습니다.
예를 들어 각 소켓별로 1MB 이상의 데이터가 bufferedAmount로 대기 중이면 추가 전송을 중단하고, 그것이 줄어들 때까지 기다립니다.
이렇게 하면 느린 클라이언트가 있을 때 그쪽으로 너무 많은 데이터가 몰리지 않게 조절할 수 있습니다.
3.2 클라이언트 -> 서버 방향
클라이언트가 파일을 업로드할 때, 서버에서 socket.on('message')로 받는 속도보다 클라이언트가 보내는 속도가 빠르면 서버 네트워크 버퍼에 쌓일 수 있습니다.
일반적으로 TCP 계층에서 어느 정도 윈도우 조절을 하지만, 애플리케이션 레벨에서도 대응할 수 있습니다.
클라이언트 구현 시 파일을 잘게 나눠 socket.send(chunk) 할 때, 마찬가지로 브라우저 WebSocket의 bufferedAmount를 확인해 가며 전송하는 게 좋습니다.
예컨대 브라우저 socket.bufferedAmount가 0이 될 때까지 setTimeout으로 대기한 후 다음 청크를 보내도록 하여, 브라우저 송신 버퍼를 비워가며 전송하면 네트워크 혼잡을 완화합니다.
Socket.IO 등을 쓰면 내부적으로 ack를 받거나 하여 조절할 수도 있습니다.
메시지 우선순위도 백프레셔와 관련됩니다.
편집기에서는 텍스트 변경(소량 데이터)은 지연에 민감하지만, 대형 이미지 전송은 몇 초 느려도 무방합니다.
따라서 만약 버퍼가 꽉 찬 상황이라면 텍스트 업데이트 메시지를 우선 전송하고, 파일 청크는 후순위로 밀거나 일시 정지했다가 전송합니다.
이러한 논리를 서버에서 구현하여, 특정 타입 메시지는 별도 큐로 관리하는 것도 고려할 수 있습니다.
Node.js에서는 Cluster나 Worker Threads를 이용해 CPU 바운드 작업과 I/O 작업을 분리함으로써 한쪽 작업 지연이 다른 쪽에 영향을 덜 주도록 할 수 있습니다.
예를 들어 압축이나 이미지 처리 같은 무거운 작업은 워커 스레드로 보내고, 메인이벤트루프는 가벼운 메시지 전송만 처리하게 하는 식입니다.
이는 백프레셔 그 자체라기보다 전체적인 응답 속도 보장을 위한 전략입니다.
요약하면, 실시간 스트림 처리에서 백프레셔를 잘 다루기 위해서는 Node.js 스트림의 장점을 활용하고, WebSocket 전송 시 버퍼 상태를 확인하여 속도 조절, 큐잉 전략을 적용하는 것이 중요합니다.
이를 통해 수백 명 사용자 환경에서도 과부하 없이 안정적인 실시간 전송을 유지할 수 있습니다.
4. 수백 명 동시 접속을 위한 로드 밸런싱 및 확장성
혼합 데이터를 다루는 실시간 편집 서비스는 사용자 수가 증가해도 수평 확장을 통해 성능을 유지해야 합니다.
Kubernetes 및 AWS 인프라 상에서 확장성과 로드 밸런싱을 고려할 때, WebSocket 연결의 특성을 반영해야 합니다.
4.1 로드 밸런싱 전략
일반적인 웹 요청은 짧은 연결이므로 아무 서버로나 분산해도 되지만, WebSocket은 장시간 지속되는 연결입니다.
쿠버네티스의 서비스 로드밸런서는 기본적으로 세션 지속성을 보장하지 않기 때문에, 한 사용자의 WebSocket이 연결을 맺었다가 다른 요청처럼 다른 서버로 라우팅 되면 연결이 끊어질 수 있습니다.
따라서 WebSocket을 사용할 때는 Sticky Session(세션 고정)이 필요합니다.
AWS의 경우 Application Load Balancer(ALB)를 사용할 경우, Target Group 설정에서 로드 밸런서 지속성(예: 쿠키 기반)을 활성화하여 동일한 클라이언트는 항상 같은 백엔드 서버로 연결되도록 합니다.
이때 세션 식별은 보통 로드밸런서가 쿠키를 심거나, IP 해싱을 사용하는 방법이 있습니다.
다만 IP만으로 분배하면 여러 사용자가 같은 NAT IP로 들어올 경우 한 서버로 몰릴 수 있어, 쿠키 기반이 더 균등합니다.
ALB는 WebSocket을 지원하므로 설정만 해주면 되고, Network Load Balancer(NLB)를 쓸 경우에는 L4 레벨에서만 처리해 세션 지속성이 자동은 아니지만, 사실상 최초 맺은 TCP가 계속 유지되는 식으로 동작합니다.
4.2 확장성 고려
한 대의 Node.js 서버로 감당할 수 있는 WebSocket 연결 수에는 한계가 있습니다.
Node.js 자체는 비동기 I/O 덕분에 커넥션 수만수천~수만까지도 유지 가능하지만, 활발한 메시지 교환이 이뤄지면 CPU 사용이 늘고 메모리 사용도 증가합니다 (각 소켓에 버퍼 등이 할당).
대략 한 프로세스당 수천 명 수준에서 성능 한계가 올 수 있으므로, 사용자가 수백 명 이상이라면 여러 인스턴스로 분산해야 합니다.
Kubernetes에서는 동일한 Node.js 앱의 복제본을 늘려서 Pod 2개, 4개,... 식으로 확장합니다.
이때 문제는 문서 별로 사용자들이 다른 서버에 흩어질 수 있다는 점입니다.
예를 들어 문서 A 편집자 10명 중 5명은 서버 1에, 5명은 서버 2에 연결되어 있다면, 서버 1에서 받은 편집 업데이트를 서버 2에도 전달해 줘야 다른 5명에게 반영됩니다.
이를 해결하는 일반적인 방법은 중앙 메시지 허브를 두는 것입니다.
Redis 같은 인메모리 DB의 Pub/Sub 기능을 사용하면, 서버 1이 문서 A 채널에 publish 하면 서버 2가 subscribe 하여 그 메시지를 받아 자국 클라이언트들에게 보낼 수 있습니다.
Socket.IO에서는 Redis 어댑터를 제공하여 자동으로 이 기능을 해줍니다.
만약 CRDT를 사용한다면, 각 서버에서 CRDT 업데이트(증분)를 받았을 때 그것을 Redis나 DB를 통해 다른 서버와 동기화해서 결국 모든 서버의 해당 문서 상태가 동일하도록 할 수도 있습니다.
다른 방식으로는 문서 단위로 서버를 지정하는 것입니다.
예를 들어 문서 ID의 해시(mod N)를 구해 N대 서버 중 한 대로 담당을 정하고, 그 문서에 관한 WebSocket은 모두 해당 서버로 연결시키는 것입니다.
이렇게 하면 단일 문서 세션이 한 서버에만 몰리게 되어 cross-server sync가 필요 없어지지만, 로드밸런서 수준에서 그 기능을 구현해야 하므로 보통은 애플리케이션 계층(예: 연결 후 첫 메시지에서 “나는 문서 A 편집자”라고 하면 서버가 판단하여 만약 이 서버가 담당이 아니면 다른 서버 주소를 알려주고 재접속시키는 등)에서 처리하게 됩니다.
구현 난이도가 높기 때문에, Redis Pub/Sub 같은 방식을 많이 사용합니다.
4.3 AWS 환경
AWS에서는 실시간 사용자 수에 따라 자동 확장을 구성할 수 있습니다.
Kubernetes의 Horizontal Pod Autoscaler(HPA)를 CPU 사용률 기준으로 걸어두면, 예를 들어 평균 CPU 60%를 넘으면 Pod를 하나 늘리는 식으로 대응합니다.
또는 커스텀 메트릭으로 현재 활성 WebSocket 연결 수를 써서, 일정 수 이상이면 스케일 아웃하도록 만들 수도 있습니다.
AWS에서는 Application Auto Scaling을 통해 Kubernetes와 연동하거나, AWS 서비스로는 AWS AppSync(GraphQL 실시간)나 AWS API Gateway WebSocket API 같은 PaaS도 있지만, 커스터마이즈 된 동작을 위해서는 직접 구현하는 Node.js 서버가 더 유연합니다.
4.4 모니터링
많은 사용자가 접속한 시스템은 모니터링과 운영 안정성이 중요합니다.
각 서버별 연결 수, 메시지 처리량, 레이턴시를 추적하여 병목을 찾아내고 조치해야 합니다.
3편에서 모니터링을 자세히 언급하겠지만, 예를 들어 AWS CloudWatch에 사용자수, CPU, 메모리 지표를 올리거나, Sentry 등의 오류 모니터링 도구로 예외를 추적하면 문제가 생겼을 때 빠르게 알아낼 수 있습니다.
요약하면, 수백 명 동시 접속을 처리하기 위해 Sticky session 기반 로드밸런싱으로 WebSocket 연결을 안정시키고, 다수 서버 간 세션 동기화를 위해 Redis Pub/Sub 등의 메커니즘을 사용하며, 쿠버네티스의 오토스케일링으로 유연한 확장을 준비해야 합니다.
Kubernetes 환경에서 WebSocket을 사용할 때 직면하는 이슈(연결 쏠림 등)는 Sticky 세션 없이는 해결되지 않으므로, 반드시 로드밸런서 설정을 확인해야 합니다.
이러한 인프라 구성을 통해, 사용자가 폭증해도 서비스를 무중단 확장하고 실시간 협업 성능을 유지할 수 있습니다.
반응형'Tip' 카테고리의 다른 글
Next.js와 Node.js 기반 실시간 대용량 편집기 Part1. 아키텍처 & 프로토콜 (0) 2025.04.27 스키마 유효성 검사 라이브러리 비교(feat. Zod vs Yup vs Joi) (1) 2025.01.05 C4 Model for Visualizing Frontend Architecture (Feat. FSD) (2) 2024.12.29 HTTP 클라이언트 라이브러리의 변화 Axios에서 Got, Ky로 (1) 2024.12.08 실무에서 유용한 MSW(Mock Service Worker) 활용 가이드 (2) 2024.11.30