[RADIO로 시스템 디자인하기 #3] 실시간 채팅 앱 설계 부숴보기
RADIO로 시스템 디자인하기 시리즈의 세 번째 글입니다. 서버가 먼저 말을 거는 실시간 채팅을 소재로, WebSocket 선택부터 낙관적 병합, 순서 보장, 가변 높이 가상화까지 RADIO 다섯 단계로 처음부터 끝까지 설계해봅니다.
TL;DR
자동완성 검색창은 클라이언트가 먼저 묻고 서버가 답하는 단방향 문제였습니다. 채팅은 정반대예요. 서버가 먼저 말을 겁니다. 상대가 언제 메시지를 보낼지 모르니 서버가 클라이언트로 밀어넣어야 하죠. 이 한 가지 차이에서 설계가 전부 갈라집니다.
채팅에는 어떤 고민들을 해야할까요?
- 서버가 먼저 보내는 이벤트를 어떻게 받을 것인가
- 전송 버튼을 눌렀을 때 왜 서버 응답을 기다리면 안 되는가
- 네트워크가 끊겼다 붙으면 못 보낸 메시지를 어떻게 할 것인가
- 메시지가 뒤죽박죽 도착하면 순서를 어떻게 맞출 것인가
- 몇 만 개 쌓인 대화를 어떻게 그릴 것인가
- 수만 명이 동시에 끊겼다 붙으면 무슨 일이 벌어지는가
이 글에서는 RADIO 프레임워크의 다섯 단계로 실시간 채팅 앱을 처음부터 끝까지 설계해봅니다.
| 단계 | 핵심 질문 | 채팅에서의 답 |
|---|---|---|
| R — Requirements | 무엇을 보장할 것인가? | 잃지 않고, 즉시 반응하고, 순서대로 |
| A — Architecture | 어떤 구조가 필요한가? | 지속 연결 + 전송 계층 추상화 |
| D — Data Model | 데이터는 어떤 형태인가? | 낙관적 병합 + clientMessageId / serverSeq |
| I — Interface | 경계의 계약은 무엇인가? | ChatEngine + Transport 추상화 |
| O — Optimization | 운영에서 무엇을 재는가? | 가변 높이 가상화 + 연결/전송 지표 |
R — Requirements (요구사항)
무엇을 만들 것인가가 아니라, 무엇을 보장할 것인가.
문제 정의
슬랙이나 카카오톡 같은 실시간 채팅을 설계한다고 가정해볼게요.
"메시지를 입력하고 전송 버튼을 누르면 상대방에게 전달된다." 이 한 줄이 전부처럼 보입니다. 하지만 이건 기능 목록에서 끝난 설계예요. 진짜 어려운 건 네트워크가 불안정할 때, 여러 사람이 동시에 보낼 때, 대화가 몇 만 개 쌓였을 때입니다.
기능 요구사항 (Functional)
- 메시지를 입력하고 전송하면 상대방에게 전달된다
- 상대방이 보낸 메시지가 실시간으로 내 화면에 나타난다
- 각 메시지의 전송 상태(전송 중 / 전송됨 / 실패)가 표시된다
- 과거 메시지를 위로 스크롤해서 불러올 수 있다
- 읽음 표시와 타이핑 중 표시를 지원한다
비기능 요구사항 (Non-Functional)
- 전송 버튼을 누르면 즉시 반응해야 한다 (서버 응답을 기다리지 않는다)
- 네트워크가 끊겨도 메시지를 잃지 않아야 한다
- 메시지 순서가 뒤섞이지 않아야 한다
- 같은 메시지가 중복 전송/표시되지 않아야 한다
- 몇 만 개 메시지가 쌓여도 스크롤이 부드러워야 한다
설계를 위한 질문
"전송 버튼을 누르면 언제 화면에 보여주나요?"
서버 응답을 기다렸다가 보여주면 네트워크가 느릴 때 몇 백 ms씩 말풍선이 안 뜹니다. 사용자는 "씹혔나?" 하고 다시 누르게 되고 그게 중복 전송으로 이어져요. 그래서 누르는 즉시 화면에 띄우는 낙관적 처리가 필요해집니다. 이 결정 하나가 아키텍처와 데이터 모델을 통째로 좌우해요.
"메시지 순서는 누가 정하나요?"
클라이언트 시각(createdAt)으로 정렬하면 기기 시계가 틀어졌을 때 순서가 깨집니다. 서버가 발급하는 순번(sequence)이 있어야 해요. 이게 데이터 모델을 결정합니다.
"단체방 최대 인원은?"
읽음 표시 데이터 구조를 결정합니다. 1:1이면 boolean 하나로 충분하지만 1000명짜리 방이면 "누가 읽었나"를 전체 리스트로 내려보내는 건 감당이 안 돼요.
"이 이벤트는 잃어버려도 되나요?"
채팅방을 오가는 이벤트가 전부 같은 등급이 아닙니다. 메시지·수정·삭제는 잃어버리면 안 되는 이벤트지만 "입력 중..." 표시나 접속 상태는 몇 개 유실돼도 아무도 모르죠. 이 등급을 요구사항에서 나눠두면 트래픽이 몰렸을 때 무엇을 지키고 무엇을 버릴지가 설계에 이미 정해져 있게 됩니다.
채팅의 "빠르게"는 하나가 아니다
채팅에서 사용자가 체감하는 시간은 세 가지로 나눠 볼 수 있습니다.
- 전송 버튼을 누른 뒤 내 말풍선이 뜨기까지: 낙관적 처리를 하면 네트워크와 무관하게 거의 0에 수렴해야 합니다
- 상대가 보낸 메시지가 내 화면에 도착하기까지: 서버와 네트워크 컨디션에 달려 있어요
- 채팅방에 들어가서 첫 메시지가 보이기까지: 방 진입의 체감 속도입니다
앞의 두 시간을 분리하는 게 포인트입니다. 내 말풍선이 뜨는 속도는 클라이언트가 통제할 수 있지만(낙관적 처리), 상대 메시지가 도착하는 속도는 서버와 네트워크에 달려 있거든요. 통제할 수 있는 것과 없는 것을 갈라두면 지표가 나빠졌을 때 어디를 봐야 할지도 명확해집니다.
A — Architecture (아키텍처)
서버가 먼저 말을 거는 순간, HTTP로는 부족하다.
왜 WebSocket인가
자동완성에서는 HTTP면 충분했습니다. 클라이언트가 물을 때만 서버가 답하면 됐으니까요. 채팅은 다릅니다. 상대방의 메시지는 내가 요청하지 않아도 도착해야 하거든요.
서버가 클라이언트로 데이터를 밀어넣는 방법은 몇 가지가 있습니다.
| 방식 | 특징 | 채팅 적합성 |
|---|---|---|
| Polling | 주기적으로 계속 물어봄 | 지연·낭비가 큼. 부적합 |
| Long Polling | 응답을 늦게 줌 | 과도기 기술 |
| SSE (Server-Sent Events) | 서버→클라 단방향 push | 수신만 필요하면 괜찮음 |
| WebSocket | 양방향 지속 연결 | 송·수신 다 필요한 채팅에 최적 |
채팅은 나도 보내고(송신) 상대도 보내는(수신) 양방향이라, WebSocket이 기본 선택지가 됩니다.
다만 실무에서는 WebSocket만 쓰이는 게 아니에요. 경량 pub/sub이 필요한 환경에서는 MQTT를, P2P 연결이 이미 열려 있는 통화 서비스에서는 WebRTC DataChannel을 쓰기도 합니다. 인프라 사정이나 서비스 확장 방향에 따라 실시간 메시징의 주체는 언제든 달라질 수 있어요.
모든 것을 실시간 연결 하나에 태울 필요도 없습니다. 실시간 수신은 WebSocket이 맡더라도 과거 메시지 조회는 HTTP가, 앱이 백그라운드로 내려가 연결이 끊긴 동안은 푸시 알림이 맡는 식으로 역할을 나누는 게 일반적이에요.
그래서 아키텍처 단계에서 확정할 것은 특정 프로토콜이 아니라 "지속 연결이 필요하다"는 성질입니다. 어떤 프로토콜이든 갈아 끼울 수 있는 구조를 어떻게 만드는지는 인터페이스(I)에서 다룰게요.
데이터 흐름
핵심은 송신 경로와 수신 경로가 만나는 지점입니다. 내가 낙관적으로 띄운 메시지와, 서버가 확정해서 되돌려준 같은 메시지를 하나로 합쳐야 해요. 여기서 중복이 생기면 말풍선이 두 개 뜹니다.
연결은 끊어진다고 가정해야 합니다
WebSocket의 진짜 어려움은 연결이 아니라 재연결입니다. 지하철, 엘리베이터, 와이파이 전환. 모바일에서는 연결이 수시로 끊겨요.
그래서 연결 상태를 isConnected 같은 boolean 하나로 다룰 수 없습니다. 소켓의 open 이벤트가 떴다고 채팅이 준비된 게 아니거든요. 인증을 마치고 끊긴 동안 놓친 메시지까지 동기화한 뒤에야 비로소 "준비됨"입니다.
재연결 설계에서 정할 것은 세 가지입니다.
- 못 보낸 메시지: 전송 큐(outbox)에 쌓아뒀다가 재연결 후 순서대로 재전송합니다
- 못 받은 메시지: "마지막으로 받은 serverSeq"를 커서로 기억해뒀다가, 재연결하면 그 이후 이벤트를 다시 받아옵니다. 커서가 너무 오래돼 서버의 보관 범위를 벗어났다면 최신 스냅샷을 통째로 받아 교체하고요
- 언제 다시 붙을 것인가: 끊기자마자 무조건 재접속하면 안 됩니다. 왜인지는 운영(O)에서 다뤄요
이걸 설계 단계에서 정해두지 않으면 나중에 "메시지가 가끔 사라져요" 같은 버그로 돌아옵니다.
D — Data Model (데이터 모델)
낙관적 메시지와 서버 메시지를 어떻게 하나로 합칠 것인가.
메시지 모델
type Message = {
clientMessageId: string; // 클라가 발급. 낙관적 표시 + 중복 제거 키
serverSeq: number | null; // 서버가 발급. 순서의 기준. 확정 전이면 null
senderId: string;
body: MessageBody;
status: "sending" | "sent" | "failed";
createdAt: string;
};두 개의 ID가 핵심입니다.
- clientMessageId: 전송하는 순간 클라이언트가 만듭니다(UUID). 낙관적으로 띄운 말풍선을 나중에 서버 확정 메시지와 매칭하는 열쇠이자 중복 제거의 기준이 돼요
- serverSeq: 서버가 채팅방 단위로 발급하는 단조 증가 순번입니다. 정렬은 무조건 이 값을 기준으로 합니다. 클라이언트 시각(createdAt)은 표시용이지 정렬 기준이 아니에요. ack 전이라 serverSeq가 null인 낙관적 메시지는 목록 맨 아래에 임시로 두고, ack가 오면 확정 순번 자리로 정렬합니다
낙관적 병합의 흐름
갱신 단계가 중요합니다. 서버가 되돌려준 메시지를 새로 추가하면 말풍선이 두 개 떠요. clientMessageId로 기존 항목을 찾아 갱신해야 합니다. 이래서 clientMessageId가 필요한 거예요.
sent로 바꾸는 시점도 명확히 해둡시다. 소켓의 send() 호출이 끝난 시점이 아니라 서버 ack가 도착한 시점입니다. send()는 브라우저 버퍼에 넣었다는 뜻일 뿐, 서버에 저장됐다는 보장이 아니거든요.
말풍선이 두 개 뜨는 흔한 버그입니다. 서버 브로드캐스트를 그대로 append하면, 내가 보낸 메시지가 (낙관적 1개 + 서버 브로드캐스트 1개) 두 번 뜹니다. 내가 보낸 메시지는 clientMessageId로 dedupe하고 남이 보낸 메시지는 serverSeq로 dedupe해야 합니다.
순서·중복·누락을 규칙 하나로
serverSeq가 채팅방 단위로 1씩 증가한다는 성질을 활용하면, 수신 이벤트 처리 규칙이 놀랄 만큼 단순해집니다.
정렬·중복 제거·누락 탐지가 전부 seq 하나로 풀립니다. 아키텍처(A)에서 말한 커서 동기화도 결국 이 규칙의 연장이에요. 재연결이란 "마지막 seq 이후를 전부 달라"는 아주 큰 gap 복구일 뿐이니까요.
이쯤 되면 채팅 클라이언트의 정체가 보입니다. 화면에 메시지를 그리는 UI라기보다, 서버의 이벤트 스트림을 로컬에 복제하고 어긋나면 다시 맞추는 동기화 클라이언트에 가까워요.
인원 수에 휘둘리지 않는 읽음 표시
투표(Poll) 모델과 같은 원리입니다. "누가 읽었나" 전체 리스트(readBy: User[])를 내려보내면 단체방 인원 N에 비례해 페이로드가 폭발해요. 화면이 실제로 필요로 하는 건 보통 안 읽은 사람 수뿐입니다.
type Message = {
// ...
unreadCount: number; // O(1). 인원 수와 무관
};올리는 쪽도 마찬가지입니다. 메시지 하나하나에 읽음 신호를 보내는 대신 "이 방에서 마지막으로 읽은 seq" 하나만 보내면, 읽음 처리 트래픽도 메시지 수와 무관해져요.
I — Interface (인터페이스)
UI는 WebSocket을 몰라야 한다.
ChatEngine — 전송 계층을 감추기
컴포넌트가 WebSocket 객체를 직접 만지면, 재연결·큐·순서 로직이 UI 곳곳에 흩어집니다. 전송 계층을 하나의 엔진 뒤로 숨겨야 해요.
interface ChatEngine {
connect(roomId: string): Promise<void>;
send(body: MessageBody): ClientMessageId; // 낙관적 id 즉시 반환
subscribe(event: ChatEvent, listener: Listener): Unsubscribe;
loadBefore(seq: number): Promise<Message[]>; // 과거 메시지 페이지네이션
disconnect(): void;
}
type ChatEvent =
| "message" // 새 메시지 수신
| "ack" // 내 메시지 서버 확정
| "receipt" // 읽음 표시 갱신
| "typing" // 타이핑 중
| "connection"; // 연결 상태 변화send가 서버 응답이 아니라 clientMessageId를 즉시 반환하는 게 포인트입니다. UI는 이 id로 낙관적 말풍선을 곧바로 그려요. 내부 transport가 WebSocket인지 SSE인지 UI는 몰라도 됩니다.
Transport 추상화 — 프로토콜은 언제든 바뀐다
ChatEngine이 UI로부터 전송 계층을 감췄다면, 이제 한 단계 더 내려가 볼 차례입니다.
아키텍처에서 이야기했듯 실시간 메시징의 주체는 언제든 달라질 수 있습니다. WebSocket으로 시작했다가 MQTT로 옮기기도 하고 통화 기능과 함께 WebRTC DataChannel을 쓰게 되기도 해요. 그때마다 ChatEngine 내부를 뜯어고쳐야 한다면 곤란합니다.
그래서 엔진 내부도 "어떤 기술을 쓰는지"가 아니라 "무엇을 할 수 있는지"로 추상화합니다.
// "어떤 기술인지"가 아니라 "무엇을 할 수 있는지"만 정의한다
abstract class BaseTransport {
protected messageHandler?: (data: unknown) => void;
public abstract connect(): Promise<void>;
public abstract send(data: unknown): void;
public abstract isConnected(): boolean;
public onMessage(handler: (data: unknown) => void) {
this.messageHandler = handler;
}
}
// WebSocket: 소켓 하나로 송신과 수신을 모두 처리
class WebSocketTransport extends BaseTransport {
private socket: WebSocket | null = null;
constructor(private url: string) {
super();
}
public async connect() {
this.socket = new WebSocket(this.url);
this.socket.onmessage = (e) => this.messageHandler?.(JSON.parse(e.data));
}
public send(data: unknown) {
this.socket?.send(JSON.stringify(data));
}
public isConnected() {
return this.socket?.readyState === WebSocket.OPEN;
}
}
// SSE: 수신은 EventSource로, 송신은 HTTP POST로 — 단방향이라 채널이 둘로 나뉜다
class SSETransport extends BaseTransport {
private source: EventSource | null = null;
constructor(
private streamUrl: string,
private sendUrl: string
) {
super();
}
public async connect() {
this.source = new EventSource(this.streamUrl);
this.source.onmessage = (e) => this.messageHandler?.(JSON.parse(e.data));
}
public send(data: unknown) {
fetch(this.sendUrl, { method: "POST", body: JSON.stringify(data) });
}
public isConnected() {
return this.source?.readyState === EventSource.OPEN;
}
}
// MQTT: 토픽에 publish하고 subscribe로 받는 pub/sub 모델
class MQTTTransport extends BaseTransport {
private client: MqttClient | null = null;
constructor(
private brokerUrl: string,
private topic: string
) {
super();
}
public async connect() {
this.client = mqtt.connect(this.brokerUrl);
this.client.subscribe(this.topic);
this.client.on("message", (_topic, payload) => this.messageHandler?.(JSON.parse(payload.toString())));
}
public send(data: unknown) {
this.client?.publish(this.topic, JSON.stringify(data));
}
public isConnected() {
return this.client?.connected ?? false;
}
}
// WebRTC DataChannel: 서버를 거치지 않는 P2P 채널
class DataChannelTransport extends BaseTransport {
constructor(private channel: RTCDataChannel) {
super();
}
public async connect() {
this.channel.onmessage = (e) => this.messageHandler?.(JSON.parse(e.data));
}
public send(data: unknown) {
this.channel.send(JSON.stringify(data));
}
public isConnected() {
return this.channel.readyState === "open";
}
}ChatEngine은 BaseTransport만 바라봅니다. WebSocket으로 시작했다가 MQTT로 갈아타든, 통화 기능이 붙어 DataChannel을 추가하든 상위 레이어는 한 줄도 바꿀 필요가 없어요. 새 프로토콜이 필요하면 BaseTransport를 구현한 클래스 하나만 더 만들면 됩니다.
정리하면 계층은 이렇게 쌓입니다.
이벤트 계약
채팅은 API 요청/응답보다 이벤트 스트림에 가깝습니다. 그래서 인터페이스의 중심이 "어떤 이벤트가 언제 오는가"라는 계약이 돼요. 위 ChatEvent 목록이 바로 그 계약입니다. 각 이벤트가 어떤 payload를 갖고 어떤 상태를 바꾸는지 정해두면, UI와 엔진이 독립적으로 개발·테스트됩니다.
이때 모든 이벤트를 { roomId, seq, type, payload } 같은 공통 구조로 통일해두면 좋습니다. 메시지 수정·삭제·리액션처럼 이벤트 종류가 늘어나도, 순서 정렬과 중복 제거는 바깥쪽 공통 필드만 보고 같은 규칙으로 처리할 수 있거든요.
스크린리더는 새 메시지를 어떻게 알까
시각으로는 새 말풍선이 뜨는 게 보이지만 스크린리더 사용자에게는 알려주지 않으면 아무 일도 일어나지 않은 것과 같습니다. 채팅의 접근성 계약은 여기서 출발해요.
- 새 메시지 도착을 스크린리더에 알립니다 (
aria-live="polite") - 메시지 목록은 논리적 순서를 유지하고 키보드로 탐색할 수 있어야 합니다
- 타이핑 표시 같은 부가 정보는 지나치게 자주 읽히지 않도록 조절합니다
O — Optimization & Operation (최적화와 운영)
긴 대화와 불안정한 연결, 둘 다 설계에 들어가 있어야 한다.
몇 만 개 쌓인 대화는 어떻게 그릴까
긴 목록을 가상화로 다루는 기법은 이미 익숙하실 텐데, 채팅은 난이도가 한 단계 위입니다. 이유는 두 가지예요.
- 높이가 제각각입니다. 한 줄 메시지, 열 줄 메시지, 이미지, 파일이 섞여 있어요. 아이템 높이를 미리 알 수 없습니다
- 위로 자랍니다. 과거 메시지를 위로 불러오면 스크롤 위치가 유지돼야 해요. 안 그러면 화면이 위로 튀어버립니다. 레이아웃이 밀리는 최악의 경우라 CLS(Cumulative Layout Shift) 지표가 곧장 나빠져요
그래서 채팅 가상화는 아이템 높이를 렌더 후 측정해 캐시하고 과거 메시지를 prepend할 때 스크롤 위치를 보정(anchoring)하는 로직이 추가로 필요합니다. @tanstack/react-virtual이라면 동적 측정은 measureElement가, prepend 시 스크롤 안정화는 anchorTo: "end" 옵션이 맡아요. 새 메시지가 왔을 때 하단에 붙어 따라가는 자동 스크롤은 별도 옵션인 followOnAppend가 담당합니다. 공식 문서도 채팅·로그 같은 역방향 피드에 이 조합을 쓰라고 안내합니다.
위로 스크롤해 과거를 불러오는 리버스 인피니트 스크롤도 사실 이 조합으로 완성됩니다. 데이터는 인터페이스(I)의 loadBefore(seq) 커서가, prepend 후 화면 튐 방지는 방금의 앵커링이 맡아요. 남는 건 트리거뿐입니다. 스크롤이 위쪽 임계에 닿으면 불러오되, 로딩 중에 또 부르지 않게 가드를 둡니다.
스크롤 정책도 함께 정합니다. 새 메시지가 왔다고 무조건 맨 아래로 끌어내리면 과거 대화를 읽던 사용자를 방해해요. 사용자가 하단 근처에 있을 때만 자동 스크롤하고 위를 읽는 중이면 위치를 유지한 채 "새 메시지 12개" 같은 버튼을 띄우는 식으로요. 방금의 followOnAppend를 조건부로 켜고 하단 여부를 isAtEnd()로 판별하면 이 정책이 그대로 구현돼요.
이동 중 Wi-Fi가 끊겨 LTE로 바뀐다면
요구사항에서 "네트워크가 끊겨도 메시지를 잃지 않아야 한다"고 정했죠. 이 조건이 실제로 지켜지는지, 구체적인 시나리오로 검증해볼게요. A와 B가 대화하던 중 이동하던 A의 Wi-Fi가 끊겨 LTE로 전환되는 사이 잠시 오프라인이 된 상황입니다.
A가 보내려던 메시지는 어떻게 될까요?
- 입력과 전송은 계속 가능해야 합니다. 메시지는 전송 큐(outbox)에 쌓이고 화면엔 sending 상태로 남아요. 큐는 새로고침에도 살아남도록 IndexedDB 같은 로컬 저장소에 둡니다
- 재연결되면 큐에 쌓인 메시지를 순서대로 재전송합니다
- 재전송이 겹쳐서 같은 메시지가 서버에 두 번 도착해도, 서버가 clientMessageId로 걸러내니 한 번만 저장돼요
- 다만 몇 시간씩 묵은 메시지를 그대로 자동 전송하면 사용자 의도와 어긋날 수 있어요. 오래된 큐는 자동 전송 대신 사용자에게 다시 물어보는 것도 제품 정책입니다
그 사이 B가 보낸 메시지는요?
- 서버가 보관하고 있다가, A가 재연결하면 "마지막으로 받은 serverSeq 이후"의 메시지를 통째로 동기화합니다 (gap sync)
- 오프라인이 길어지면 푸시 알림으로 폴백해요. 앱이 연결을 못 받는 동안에도 사용자가 대화를 놓치지 않도록요
그런데 애초에 끊긴 걸 어떻게 알까요?
- WebSocket 연결은 조용히 죽을 수 있습니다. Wi-Fi 신호가 사라져도 클라이언트는 한동안 연결돼 있다고 착각해요. 이른바 좀비 연결입니다
- 그래서 주기적인 ping/pong 하트비트로 생존을 확인하고 몇 번 무응답이면 끊긴 것으로 간주해 재연결을 시작합니다
이 시나리오를 관통하는 원칙은 하나입니다. 사용자의 메시지를 절대 조용히 버리지 않는다. 끝내 전송이 실패하면 failed 표시와 재전송 버튼을 남겨서 결정권을 사용자에게 돌려줍니다.
수만 명이 동시에 재접속한다면
네트워크 전환 시나리오는 사용자 한 명의 이야기였습니다. 그런데 서버 재배포나 장애로 수만 개의 연결이 한꺼번에 끊기면 어떻게 될까요? 모두가 동시에 "끊겼네? 다시 붙자"를 실행합니다. 인증 요청과 누락 동기화 요청이 일제히 몰리고 복구 중이던 서버가 그 부하로 다시 쓰러져요. 재접속 폭풍(reconnect storm)입니다.
방어의 시작은 재시도 간격에 무작위성을 넣는 겁니다.
const BASE_MS = 1_000;
const CAP_MS = 30_000;
const maxDelay = Math.min(CAP_MS, BASE_MS * 2 ** retryCount);
const delay = Math.random() * maxDelay; // full jitter지수 백오프만 쓰면 "1초 뒤 다 같이 재시도, 2초 뒤 다 같이 재시도"처럼 몰림이 그대로 반복됩니다. 구간 안에서 무작위로 흩어놓는 jitter가 반드시 함께 있어야 해요. 여기에 규칙을 몇 개 더합니다.
- 서버가 "n초 뒤에 와라"(retryAfter)를 내려주면 그 값을 우선합니다
- 자동 재시도는 네트워크 오류에만 합니다. 인증 만료 같은 실패는 재시도 대신 로그인으로 보내요
- 연결이 한동안 안정적으로 유지되면 retryCount를 초기화합니다
실제 서비스들도 이 문제를 프로토콜 차원에서 다룹니다. Slack은 연결을 교체하기 약 10초 전에 경고 이벤트를 보내기도 해서, 클라이언트가 새 연결을 미리 열어둘 수 있습니다. Discord는 첫 heartbeat 시점 자체를 무작위로 흩뿌린 뒤 재연결 시에는 seq 기반 resume으로 놓친 이벤트만 재생합니다. 결국 목표는 "절대 안 끊기는 연결"이 아니라 잘 끊기고 정확하게 복구되는 프로토콜이에요.
받는 쪽이 감당 못 할 만큼 쏟아진다면
수백 명이 활발한 단체방에서는 이벤트가 렌더링 속도보다 빨리 도착할 수 있습니다. 브라우저의 WebSocket API에는 "천천히 보내 달라"고 알릴 표준 수단이 없어서 받는 쪽이 스스로 흐름을 조절해야 해요.
- 이벤트 하나마다 렌더링하지 않고 프레임 단위로 모아서 한 번에 반영합니다
- 요구사항(R)에서 나눈 이벤트 등급이 여기서 일합니다. 타이핑 표시 같은 휘발성 이벤트는 최신 것만 남기고 버려도 되지만 메시지는 버리는 대신 동기화 경로로 돌립니다
중복률이 0이 아니면 설계가 샌 것
사용자 경험 지표| 지표 | 무엇을 알려주나 |
|---|---|
send_to_echo_ms | 전송 버튼부터 내 말풍선까지. 낙관적 처리가 잘 되는지 |
delivery_latency_ms | 상대 메시지가 도착하기까지. 서버·네트워크 건강도 |
time_to_first_message_ms | 방 진입부터 첫 메시지가 보이기까지 |
time_to_chat_ready_ms | 실제로 주고받을 수 있게 되기까지 |
앞의 세 지표는 요구사항(R)에서 나눈 세 가지 체감 시간을 그대로 옮긴 것이고, 여기에 time_to_chat_ready_ms 하나를 더했습니다. 이 지표의 기준이 중요합니다. 소켓이 열린 시점이 아니라 누락 동기화까지 끝난 시점이에요. 아키텍처(A)에서 본 "open과 ready는 다르다"를 지표 정의에도 그대로 반영하는 겁니다.
시스템 지표
| 지표 | 무엇을 알려주나 |
|---|---|
ws_reconnect_rate | 연결 안정성. 높으면 네트워크/서버 문제 |
message_send_failure_rate | 전송 실패율 |
duplicate_message_rate | dedupe가 새는지. 0이어야 정상 |
out_of_order_rate | serverSeq 정렬이 잘 되는지 |
session_resume_success_rate | 커서 복구 성공률. 낮으면 비싼 스냅샷 재동기화로 자주 빠진다는 뜻 |
duplicate_message_rate가 0이 아니라면 낙관적 병합(clientMessageId 매칭)에 구멍이 있다는 신호입니다. 관측이 다시 데이터 모델 설계로 돌아오는 루프예요.
도배와 스팸도 설계 대상
- 메시지 전송에 rate limit을 걸어 스팸·도배를 방어합니다
- 채팅은 남이 쓴 텍스트를 렌더링하는 UI입니다. HTML·마크다운을 지원한다면 sanitize 없이는 그리지 않습니다
- 탭을 다섯 개 열면 연결도 다섯 개가 됩니다. 규모가 커지면 SharedWorker로 연결 하나를 공유하는 선택지도 있어요. 다만 복잡도가 크니 실측 후에 도입합니다
- 새 기능(예: 메시지 편집)은 Feature Flag로 점진적으로 롤아웃합니다
- 재연결 백오프 파라미터는 실사용 데이터로 튜닝합니다
마무리
지금까지 실시간 채팅 앱을 RADIO 다섯 단계로 따라가 봤습니다. 설계 전체를 처음부터 끝까지 한 번에 훑으면 이렇게 돼요.
- 전송 버튼을 누르면 clientMessageId를 발급하고 즉시 낙관적 말풍선을 띄웁니다
- WebSocket으로 send하고 서버가 serverSeq를 발급해 ack합니다
- ack가 오면 clientMessageId로 기존 말풍선을 찾아 sent로 갱신합니다 (새로 추가하지 않음)
- 상대 메시지는 이벤트로 수신하고 serverSeq 기준으로 정렬해 병합합니다
- 연결이 끊기면 큐에 쌓아뒀다가, jitter를 섞은 백오프로 재연결해 순서대로 재전송하고 누락분을 동기화합니다
- 긴 대화는 가변 높이 가상화와 스크롤 앵커링으로 렌더합니다
- 배포 후엔 말풍선이 뜨기까지 걸린 시간, 재연결율, 중복률 같은 지표를 측정하며 튜닝합니다
즉시 반응·무손실·순서 보장을 요구사항으로 확정했고(R), 서버가 먼저 말을 걸기에 지속 연결을 골랐으며(A), clientMessageId와 serverSeq로 낙관적 병합과 정렬을 풀었고(D), ChatEngine과 Transport 추상화로 프로토콜을 갈아 끼울 수 있게 했으며(I), 가변 높이 가상화와 재접속 폭풍 방어, 연결 지표로 운영까지 준비했습니다(O).
자동완성 편과 비교해보면 재미있어요. 같은 RADIO를 돌렸는데 나온 답이 이렇게 다릅니다.
| 자동완성 | 채팅 | |
|---|---|---|
| 누가 먼저 말하나 | 클라이언트 | 서버 |
| 통신 | HTTP 요청-응답 | WebSocket 양방향 |
| 정확성 문제 | stale 응답 폐기 | 낙관적 병합·순서 보장 |
| 실패 시 | 조용한 폴백 (추천은 부가기능) | 재전송 큐 (메시지는 절대 안 버림) |
| 몰림 제어 | 디바운스 (입력이 몰릴 때) | 백오프 + jitter (재접속이 몰릴 때) |
소재가 바뀌니 답이 전부 바뀌었습니다. 그런데 질문의 순서는 한 번도 바뀌지 않았어요. 무엇을 보장할지(R)부터 물었을 뿐입니다. 이것이 프레임워크로 설계하는 이유예요.
참고자료
이런 글도 읽어보세요
[RADIO로 시스템 디자인하기 #2] 자동완성 검색창 설계 부숴보기
RADIO로 시스템 디자인하기 시리즈의 두 번째 글입니다. Input과 Dropdown뿐인 작은 UI 안에 디바운스, 캐시, race condition, 접근성까지 자동완성 검색창을 RADIO 다섯 단계로 처음부터 끝까지 설계해봅니다.
[RADIO로 시스템 디자인하기 #1] RADIO 프레임워크 뜯어보기
RADIO로 시스템 디자인하기 시리즈의 첫 글입니다. 프론트엔드 시스템 디자인을 구조적으로 풀어내는 RADIO 프레임워크를 Requirements부터 Optimization까지 다섯 단계로 나눠, 각 단계가 무엇을 묻고 왜 그 질문이 필요한지 차근차근 살펴봅니다.