55.1 실시간은 왜 돈 얘기부터 해야 해요?

일반 웹은 요청할 때만 서버를 두드려요. 페이지 한 번 열고 나면 조용하죠. 그런데 실시간은 달라요. 연결을 계속 열어둔 채, 시도 때도 없이 메시지가 오가요. 그래서 비용 구조가 완전히 다른 축으로 돌아가요.


관리형 서비스든 직접 굴리는 서버든, 실시간의 요금은 크게 두 가지로 매겨져요. 하나는 동시 연결 수(지금 이 순간 몇 명이 붙어 있나), 다른 하나는 메시지 수(초당 몇 개가 오가나)예요. 일반 웹처럼 "방문자 수"로만 생각하면 청구서를 보고 깜짝 놀라요. 저도 예전에 "접속자 얼마 안 되는데 왜 이렇게 나오지?" 하고 뜯어봤다가, 진짜 비용은 메시지 쪽에서 터지고 있던 걸 발견했어요. 그래서 실시간은 기능을 짜기 전에 "이게 규모가 커지면 얼마가 나올까"를 먼저 그려봐야 해요. 그 감이 없으면 잘 돌아가던 서비스가 사용자가 늘수록 적자로 돌아서거든요. 그 얘기를 순서대로 풀게요.

55.2 연결 하나가 왜 비용이에요?

먼저 동시 연결이에요. 실시간 연결은 열려 있는 내내 서버 자원을 붙잡아요. 아무 말도 안 오가는 유휴 연결(idle, 놀고 있는 연결)이라도, 그 연결을 유지하는 것 자체가 자원이에요. 관리형 서비스는 이 동시 연결 수에 요금을 매기는 경우가 많고요. 그것도 평균이 아니라 순간 최고치(peak, 하루 중 가장 많이 붙었던 순간)로 매기는 경우가 흔해서, 잠깐의 몰림도 그대로 요금이 돼요.


그래서 안 쓰는 연결을 얼마나 잘 정리하느냐가 비용을 좌우해요. 사용자가 탭을 열어두고 딴짓하는 유령 탭이나, 앞에서 본 죽은 연결이 안 치워지고 쌓이면, 아무도 안 쓰는데 요금만 나가요. 예를 들어 10분 넘게 조용한 연결은 정중히 끊기, 화면이 백그라운드로 가면 스스로 연결을 놓기 같은 정리 규칙 하나로 동시 연결 수가 눈에 띄게 줄어요.


상황: 화면이 안 보이면 연결을 놓고, 돌아오면 다시 붙어요.
document.addEventListener('visibilitychange', () => {
if (document.hidden) socket.close(); // 백그라운드면 연결 반납
else connect(); // 돌아오면 재연결
});


탭을 잠깐 가려둔 수천 명이 연결을 반납하면, 동시 연결 수가 그만큼 빠져요. 사용자는 돌아올 때 다시 붙기만 하면 되니 티도 안 나고요. 서버 쪽에서도 하트비트에 몇 번 답이 없는 연결은 미련 없이 끊어야, 순간 최고치가 실제 사용자 수에 가깝게 유지돼요.

55.3 메시지가 어떻게 돈이 되죠?

진짜 무서운 건 메시지 수예요. 여기서 초보들이 함정에 빠져요. "메시지 하나 = 요금 하나"라고 생각하거든요. 아니에요. 실시간 메시지는 받는 사람 수만큼 부풀어요. 이걸 팬아웃(fan-out, 하나를 여럿에게 퍼뜨리기)이라고 해요.


계산해볼게요. 1만 명이 있는 방에 누가 메시지 하나를 던지면요? 그 하나가 1만 명에게 배달돼요. 요금 기준으로는 1만 개의 메시지가 나간 거예요. 열 명이 동시에 수다를 떨면 10만 개죠. 방이 클수록 이 곱셈이 무섭게 커져요. 제가 청구서에서 놀란 게 정확히 이 팬아웃이었어요. 접속자는 얼마 안 됐는데 큰 방 하나에서 메시지가 폭발하고 있었던 거죠.


메시지만 그런 게 아니에요. 앞에서 만든 프레즌스(누가 접속했나)나 "입력 중" 표시도 결국 메시지라서 팬아웃을 타요. 큰 방에서 한 명이 타이핑을 시작할 때마다 수천 개의 typing 메시지가 나가면, 정작 대화는 몇 마디 안 했는데 요금은 대화의 몇 배가 나올 수 있어요. 부가 기능일수록 이 곱셈을 더 조심해야 해요.

55.4 비용을 어떻게 줄여요?

줄이는 열쇠는 "보내는 횟수를 줄이자"예요. 몇 가지 실전 기술이 있어요.


첫째, 배칭(batching, 몰아서 한 번에 보내기)이에요. 잦은 잔 업데이트를 모아서 한 번에 보내면 메시지 수가 확 줄어요. 예를 들어 커서 위치처럼 초당 수십 번 바뀌는 건, 50밀리초마다 한 번씩만 묶어 보내도 사람 눈엔 똑같이 부드러워요.


상황: 쏟아지는 갱신을 50밀리초 단위로 묶어 한 번만 보내요.
let pending = null, timer = null;
function queueUpdate(pos) {
pending = pos; // 최신 값만 남겨둠
if (timer) return;
timer = setTimeout(() => {
socket.send(JSON.stringify(pending));
timer = null;
}, 50);
}


여기서 스로틀(throttle, 정해진 간격마다 딱 한 번)과 디바운스(debounce, 잠잠해질 때까지 기다렸다 한 번)를 구분하면 좋아요. 커서처럼 계속 흐르는 값은 스로틀로 일정 간격마다 보내고, 검색어 입력처럼 손을 멈추는 값은 디바운스로 멈춘 뒤에만 보내요. 상황에 맞게 골라야 헛메시지가 안 나가요.


둘째, 채널을 잘게 나누기예요. 모두를 한 방에 몰아넣고 전체에 뿌리면 팬아웃이 폭발해요. 관심 있는 사람만 해당 채널에 넣으면, 메시지가 딱 필요한 만큼만 배달돼요. 셋째, 델타만 보내기예요. 통째로 다시 보내지 말고 바뀐 부분만요. 넷째, 조용한 방엔 아예 안 보내기예요. 아무도 안 보는 방에 갱신을 계속 뿌리는 건 순수한 낭비라, 구독자가 없으면 발송 자체를 건너뛰면 됩니다.

55.5 성능은 어디서 무너져요?

비용과 성능은 같은 뿌리예요. 팬아웃이 돈을 태우는 그 지점이, 성능도 무너뜨리는 지점이거든요. 한 방에 사람이 너무 많으면, 메시지 하나 뿌리는 데 수만 번의 전송이 필요해서 서버가 헐떡여요.


그래서 큰 방은 샤딩(sharding, 하나를 여러 조각으로 쪼개기)을 해요. 1만 명 방을 1천 명짜리 열 개로 나누면, 각 조각은 훨씬 가볍게 돌아요. 라이브 방송 채팅이 딱 이래요. 그리고 페이로드를 작게 유지하세요. 메시지마다 안 쓰는 필드를 잔뜩 실으면, 그 무게에 연결 수를 곱한 만큼 대역폭이 나가요. 작은 방심이 큰 청구서로 돌아와요.


한 가지 더, 연결당 처리도 무시 못 해요. 서버가 메시지 하나를 뿌릴 때 연결마다 직렬화(JSON 문자열로 만들기)를 새로 하면, 연결이 많을수록 그 일이 곱으로 늘어요. 그래서 한 번 만든 문자열을 재사용해 모두에게 같은 걸 보내는 식의 작은 최적화가, 큰 방에선 체감이 커요. 성능이 무너지는 건 대개 하나의 무거운 일보다 가벼운 일에 큰 수를 곱한 쪽이거든요.

55.6 관리형이랑 직접 구축은 비용이 어떻게 달라요?

같은 규모라도 어디에 맡기느냐에 따라 비용의 모양이 달라요. 관리형 서비스는 연결과 메시지 단위로 또박또박 요금이 붙어서, 작을 땐 거의 공짜인데 커질수록 비례해서 올라가요. 대신 서버 운영에 드는 사람 손과 장애 대응이 요금에 포함된 셈이라, 인건비를 아끼는 거죠.


반대로 직접 구축(예를 들어 Durable Objects나 내 서버)은 고정비 성격이 강해요. 어느 규모까지는 같은 값에 버티다가, 한계에 닿으면 서버를 늘리며 계단식으로 뛰어요. 그래서 트래픽이 크고 꾸준하면 직접 구축이 대개 싸고, 작거나 들쭉날쭉하면 관리형이 마음 편해요. 정답은 없고, 지금 내 서비스가 어느 구간에 있는지를 보고 고르면 돼요.

55.7 오늘 내용을 정리해볼게요

실시간의 성능과 비용을 같이 훑었어요. 둘은 한 몸이었죠.


요금은 동시 연결 수메시지 수 두 축으로 매겨져요. 연결은 안 쓰는 걸 잘 정리해 줄이고, 메시지는 팬아웃(받는 사람 수만큼 부푸는 것)을 이해하는 게 먼저예요. 줄이는 기술은 배칭으로 횟수 줄이기, 채널 분리로 필요한 사람에게만 보내기, 델타 전송으로 페이로드 줄이기, 큰 방은 샤딩으로 쪼개기예요.


실시간을 만들 땐 기능이 "되는지"만 보기 쉬운데, 규모가 커지면 "얼마나 자주, 몇 명에게 보내나"가 서비스의 생사를 갈라요. 방 하나에 접속자를 잔뜩 붙여놓고, 메시지를 마구 던졌을 때 청구서와 서버 부하가 어떻게 뛰는지 작게라도 재보세요. 그 감각이 있으면 설계가 달라져요.