Design Slack, 수백만 팀이 동시에 대화하는 시스템은 어떻게 설계될까?
WebSocket으로 메시지를 보내는 것까지는 누구나 설명할 수 있습니다.
이번에는 글로벌 협업 툴인 Slack(슬랙) 같은 대규모 실시간 메시징 플랫폼을 설계하는 문제를 다룹니다.
슬랙은 사용자가 워크스페이스에 접속해 채널을 생성하고, 동료들과 메시지를 주고받으며, 파일을 공유하고, 특정 키워드로 과거 기록을 순식간에 검색하는 서비스입니다.
겉으로 보면 “채팅방 입장 → 텍스트 입력 → 전송”의 단순한 구조 같지만, 시스템 디자인 관점에서는 전혀 다릅니다.
사용자가 슬랙에 메시지를 입력하고 엔터를 치는 순간, 시스템은 이 텍스트를 데이터베이스에 안전하게 저장함과 동시에, 해당 채널에 접속해 있는 수백~수만 명의 다른 사용자 화면에 지연 없이(Real-time) 텍스트를 뿌려주어야 합니다.
이때 단순히 “메시지를 DB에 어떻게 넣을까?”가 아닙니다.
“10만 명이 있는 #general 채널에 메시지가 올라가면 이를 어떻게 동시에 전달할까?”
“수백만 명의 초록색 불(온라인 상태, Presence)을 서버 부하 없이 어떻게 정확히 유지할까?”
“내가 어디까지 읽었는지 나타내는 붉은 선(Watermark, Read Receipt)은 여러 기기 간에 어떻게 동기화할까?”
“수십억 건의 메시지 중 특정 키워드가 포함된 대화를 1초 안에 어떻게 찾아낼까?”
AI가 코드를 더 빠르게 작성하는 시대가 되면서, 개발자의 가치는 단순히 CRUD API를 구현하는 능력보다
“왜 실시간 통신에 WebSocket과 Pub/Sub 구조가 필수적인지”
“어떤 데이터는 Sharded RDBMS에 넣고, 어떤 데이터는 Search Engine으로 분리해야 하는지”
등과 같은 설계를 판단하는 능력으로 개발자의 가치가 평가되고 있습니다.
Slack 같은 엔터프라이즈 메시징 시스템은 이 역량을 훈련하기 가장 좋은 문제입니다. 겉으로는 단순해 보이지만 실제로는 Write-heavy logging, Read-heavy real-time fanout, Compute-heavy search, Event-heavy presence update가 동시에 얽혀 있습니다.
이 글을 끝까지 읽으면 다음 질문에 답할 수 있게 됩니다.
Slack 같은 실시간 메시징 시스템의 요구사항을 어떻게 정리할까?
10만 명이 접속한 채널에 지연 없이 메시지를 배포하기 위해 WebSocket과 Pub/Sub을 어떻게 설계할까?
수백만 명의 ‘온라인(초록불)’ 상태(Presence)를 관리할 때 발생하는 O(N^2) 브로드캐스팅 문제를 어떻게 해결할까?
메시지가 엄청나게 쌓이는 환경에서 DB 병목을 막기 위해 Sharding(샤딩) 전략을 어떻게 가져가야 할까?
안 읽은 메시지 수와 ‘여기까지 읽음(Watermark)’ 상태를 분산 환경에서 어떻게 일관성 있게 관리할까?
메시지를 전송하는 도중 네트워크가 끊겼을 때 중복 전송을 막는 멱등성은 어떻게 구현할까?
빅테크 인터뷰에서 면접관의 Follow-up 질문에 어떻게 답해야 할까?
Functional Requirements - 기능적 요구사항 (Slack)
사용자는 워크스페이스(Workspace)를 생성하고 가입할 수 있어야 합니다.
사용자는 1:1 메시지(DM) 및 다대다 그룹 채널(Channel)에서 텍스트와 미디어를 전송할 수 있어야 합니다.
사용자는 특정 메시지에 스레드(Thread)를 생성하여 답글을 달 수 있어야 합니다.
사용자는 특정 메시지, 파일, 사용자를 빠르게 검색할 수 있어야 합니다.
시스템은 사용자의 온라인/오프라인 상태(Presence)를 표시해야 합니다.
시스템은 사용자가 어디까지 메시지를 읽었는지(Read Receipt / Watermark) 추적하고 표시해야 합니다.
사용자는 메시지가 올 때 푸시 알림(Push Notification)을 받을 수 있어야 합니다.
다른 기기(Mobile, Desktop, Web)에서 접속해도 상태와 메시지가 완벽히 동기화되어야 합니다.
Non-Functional Requirements - 비기능적 요구사항
Slack은 트레이딩 시스템(Robinhood)만큼의 금전적 ACID 트랜잭션이 요구되진 않지만, 이벤트의 순서(Order)와 실시간성(Real-time)이 생명입니다.
또한, B2B 엔터프라이즈 타겟이므로 데이터 유실은 절대 허용되지 않습니다.
Low-latency Real-time Delivery
사용자가 메시지를 보낸 후 같은 채널의 다른 사용자가 이를 수신하기까지 수백 밀리초(ms) 이내여야 합니다.
High Availability & Durability (메시지 영역)
메시지는 절대 유실되어서는 안 됩니다. (Durability)
채팅 시스템은 일시적인 장애가 발생해도 오프라인 상태에서 앱을 사용할 수 있도록 로컬 캐싱 전략이 필요합니다.
Eventual Consistency (Presence 영역)
A 사용자가 오프라인이 되었을 때, B 사용자의 화면에 그 즉시 초록불이 꺼질 필요는 없습니다. 수 초의 지연(Eventual Consistency)은 허용됩니다.
Strict Order of Messages
같은 채널 내에서 메시지의 순서는 발송된 시간 순서대로 완벽히 정렬되어야 합니다. (Vector clock이나 분산 ID 생성기가 필요합니다.)
Idempotency (멱등성)
네트워크 장애 시 메시지가 중복 전송되는 것을 방지해야 합니다.
Scope 정리
In Scope
워크스페이스, 채널, 멤버십 관리
실시간 메시지 전송 및 수신 (1:1 및 채널)
Presence (온라인 상태 추적)
Read Receipts (어디까지 읽었는지 마킹)
과거 메시지 검색 (Search)
푸시 알림
Out of Scope
화상 통화 (Huddle / Video Call) - WebRTC 인프라는 별도 주제입니다.
타사 앱 연동 (Slack Apps / Webhooks 상세)
결제 및 엔터프라이즈 권한 관리 상세
가정하는 규모
DAU: 2,000만 명
Concurrent Users (Peak): 1,000만 명
Messages Sent per day: 15억 건
Peak QPS:
Message Write: 수만 ~ 수십만 QPS
Message Read: 수십만 QPS (대부분 WebSocket Push로 대체됨)
Presence Updates: 수백만 QPS
Latency: Message delivery < 200ms
Core Entities
Slack의 데이터 접근 패턴은 크게 세 가지로 구성됩니다.
1) 영구 보관용(RDBMS)
2) 실시간 라우팅용(In-Memory/PubSub)
3) 검색용(Search Engine)입니다
왜 RDB vs Search Engine vs Memory로 나뉘는지?
messages,channels,workspaces(영구 저장소)슬랙은 전통적으로 MySQL 기반의 Vitess(Sharded RDBMS)를 사용합니다. 채널이나 워크스페이스 ID를 기준으로 샤딩(Sharding)하여 트래픽을 분산하고 강한 정합성을 유지합니다.
Cassandra나 DynamoDB 같은 NoSQL도 가능하지만, 슬랙의 Enterprise 특성상 관계형 쿼리와 트랜잭션이 유리한 부분이 많아 Sharded MySQL을 널리 사용합니다.
message_search_index(검색)messages테이블에서 LIKE 쿼리로 수십억 건을 검색하면 DB가 죽습니다. 메시지가 작성되면 비동기로 Elasticsearch에 색인(Indexing)합니다.
presence_status,websocket_routing(상태 관리)초당 수백만 번 바뀌는 온라인 상태와 “누가 어느 웹소켓 서버에 연결되어 있는지”는 Redis 같은 초고속 인메모리 스토어에서 관리합니다.
workspaces워크스페이스(회사/조직) 기본 정보.
users사용자 정보.
channels채널 정보 (public, private 구분).
channel_members특정 채널에 속한 사용자 매핑 테이블. (메시지를 보낼 때 누구에게 Fan-out 할지 결정하는 핵심 데이터)
messages메시지 원본.
message_id(분산 생성된 순차 ID),channel_id,user_id,content,created_at등을 포함합니다. (channel_id로 샤딩되는 것이 핵심입니다.)
threads특정 메시지에 달린 답글들을 묶어주는 엔티티.
read_receipts(Watermarks)사용자가 특정 채널에서 “마지막으로 읽은 메시지 ID”를 저장합니다.
reactions메시지에 달린 이모지 반응.
API Signatures
1. Message APIs (메시지 전송 - 가장 중요한 Write Path)
메시지 전송 API는 멱등성(Idempotency) 보장과 비동기 처리(Asynchronous Processing)가 생명입니다. 클라이언트의 요청이 오면 유효성만 검사하고 DB에 저장하여 빠르게 응답한 뒤, 실제 채널 멤버들에게 메시지를 배포(Fan-out)하는 작업은 Kafka 기반의 백그라운드 엔진으로 넘깁니다.
Send Message (메시지 전송)
POST /v1/messages
Headers
Authorization: Bearer <JWT_TOKEN>Idempotency-Key: msg-req-550e8400-e29b-41d4-a716-446655440000(필수: 중복 전송 방지)
Request Body
{
"channel_id": "chan_12345",
"content": "시스템 디자인 면접 준비 중입니다.",
"thread_ts": null, // 스레드 답글일 경우 원본 메시지 타임스탬프
"attachments": [] // 이미지, 파일 등 메타데이터
}
Response (201 Created)
{
"message_id": "msg_999888",
"status": "sent", // 저장 완료. 다른 유저에게 도달했는지는 보장 안 함
"created_at": "2026-05-06T10:30:00Z"
}
이 API는 다른 채널 멤버들의 클라이언트에 메시지가 도달할 때까지 기다리지 않습니다. DB에 메시지를 영구 저장(Durability)한 뒤 즉시 응답합니다.
Idempotency-Key는 Redis와 DB Unique Index에 이중으로 체크되어, 모바일 네트워크 재시도 시에도 절대 같은 메시지가 두 번 전송되지 않도록 막습니다.
1. 멱등성(Idempotency) 보장, “전송 버튼을 두 번 눌러도 한 번만 전송되도록”
모바일 환경에서는 사용자가 터널을 지나가거나 엘리베이터를 탈 때 네트워크가 끊기는 일이 흔합니다. 사용자가 메시지 전송 버튼을 눌렀는데 응답을 받지 못해 불안한 마음에 버튼을 한 번 더 눌렀다고 가정해 보겠습니다.
모바일 환경이나 불안정한 네트워크에서 사용자가 “전송” 버튼을 여러 번 누르는 일은 하루에도 수백만 번씩 일어납니다. 시스템이 이를 방어하지 못하면 슬랙 채널은 똑같은 메시지로 도배될 것입니다.
이를 막는 기술이 멱등성(Idempotency)입니다.
클라이언트가 생성한 고유한 Idempotency-Key를 바탕으로, 대규모 트래픽을 견뎌내는 서버는 어떻게 성능(Performance)과 데이터 무결성(Integrity)을 동시에 챙기는지, 그 핵심인 1차/2차 해결안을 통해서 단계별 아키텍처를 낱낱이 알아보도록 합니다.
Idempotency-Key 생성
클라이언트(모바일 앱)는 메시지 전송 요청을 보낼 때 무작위 고유 식별자(UUID)를 생성하여 HTTP 헤더에 담아 보냅니다.
1차 해결안(Redis)
Message Service는 요청을 받자마자 Redis에 이 키가 존재하는지 확인합니다. 만약 없다면 원자적 연산(
SETNX)을 통해 키를 저장하고 전송 로직을 진행합니다.수천만 명의 유저가 쏟아내는 모든 메시지 전송 요청을 매번 메인 데이터베이스(RDBMS)에 찔러 중복 여부를 확인한다면 어떻게 될까요?
DB는 비싼 디스크 I/O와 락(Lock) 경합 때문에 순식간에 뻗어버릴 것입니다. 그래서 DB 앞단에 초고속 검문소를 세우는데, 이것이 Redis입니다.
원자적 연산 (
SETNX)Message Service는 요청을 받자마자 Redis에
SETNX (Set if Not eXists)명령어를 날립니다. 이 명령어는 키가 존재하지 않을 때만 값을 저장하고1을 반환하며, 이미 키가 존재하면0을 반환하는 원자적(Atomic) 연산입니다. 동시에 2개의 중복 요청이 서버의 서로 다른 스레드에 도착하더라도, Redis의 싱글 스레드 특성 덕분에 오직 단 하나의 요청만이1을 획득하여 통과합니다.
빠른 거절 (Early Return)
만약 Redis가
0을 반환했다면? 서버는 이 요청이 중복임을 즉시 알아채고, 무거운 DB 로직이나 Kafka 발행을 아예 하지 않습니다. 이전 요청의 결과값(또는 진행 중이라는 상태)을 바로 반환하여 서버 자원을 극적으로 아낍니다.
세부사항 - 인터뷰에서 중요합니다. (메타 기출) (TTL 설정)
영원히 Redis에 키를 남겨두면 메모리가 고갈(OOM)됩니다. 따라서
SETNX성공 시 반드시EXPIRE명령어를 통해 적절한 만료 시간(TTL, 예: 24시간)을 설정해야 합니다. 네트워크 지연으로 인한 중복 요청은 보통 수 초~수 분 내에 발생하므로 24시간이면 충분한 기간입니다.
2차 해결안 (RDB)
데이터베이스의
messages테이블이나 별도의idempotency_keys테이블에도 이 키를 Unique Index로 설정해 둡니다. Redis에 장애가 나더라도 DB 단에서 중복 저장을 원천 차단합니다.“Redis가 그렇게 빠르고 완벽하면 DB 검사는 안 해도 되는 거 아닌가요?”
주니어 엔지니어들이 자주 하는 착각입니다. Redis는 메모리 기반이기 때문에 휘발성(Volatility)이라는 치명적인 약점이 있습니다.
Redis의 한계 돌파
만약 트래픽 스파이크로 인해 Redis 인스턴스가 순간적으로 재시작되거나 메모리 부족으로 키가 조기 퇴거(Eviction)되었다고 가정해 봅시다. 이때 중복 요청이 들어오면 Redis 검문소는 뻥 뚫리게 됩니다.
Unique Index (고유 제약 조건)
이를 막기 위해 메인 DB의
messages테이블(또는 별도의idempotency_keys테이블)에idempotency_key컬럼을 만들고 Unique Index (고유 인덱스)를 걸어둡니다.
Duplicate Key Exception 처리
만약 Redis 방어선이 뚫려서 중복된 키가 DB까지 도달하더라도,
INSERT를 시도하는 순간 RDBMS가DuplicateKeyException에러를 뱉어내며 트랜잭션을 롤백시킵니다. 시스템은 이 에러를 캐치(Catch)하여 장애가 아니라 “중복 요청”으로 우아하게 처리(Graceful Handling)하고 클라이언트에게 기존 성공 응답을 보냅니다.
중복 요청 처리
만약 사용자가 버튼을 두 번 눌러 같은 키가 다시 들어오면, 서버는 에러를 뱉는 대신 “이미 정상적으로 접수된 첫 번째 메시지의 상태(Sent)”를 그대로 반환합니다.
2. 유효성 검사와 빠른 응답 (Synchronous Validation), “저장 및 채널 검증 처리”
비동기 처리를 한다고 해서 무작정 메시지를 Kafka로 던지면 안 됩니다. 사용자가 해당 비공개 채널(Private Channel)에 속해 있지도 않은데 메시지가 백그라운드 엔진으로 넘어가서 배포되면 안 되기 때문입니다.
따라서 클라이언트에게 응답을 주기 전, 반드시 동기적(Synchronously)으로 끝나야 하는 최소한의 작업이 있습니다.
권한 확인
사용자의
channel_members테이블을 조회하여 해당 채널에 메시지를 쓸 권한이 있는지 확인합니다.
메시지 저장 (Durability)
messages테이블(Sharded MySQL 등)에 해당 메시지를INSERT합니다. 엔터프라이즈 B2B 툴에서는 메시지 유실이 치명적이므로, 큐에 넣기 전에 RDBMS에 먼저 안전하게 기록합니다.
빠른 응답
이 DB 트랜잭션이 성공하면, 즉시 클라이언트에게 “메시지가 정상 전송되었습니다”라고 응답합니다.
3. 비동기 처리 (Asynchronous Processing), “수만 명을 위한 브로드캐스팅 분리”
만약 API 서버가 동기적으로(Synchronous) 10만 명이 있는 #general 채널의 모든 유저의 웹소켓을 찾아 메시지를 쏴주고 나서 응답한다면 어떻게 될까요?
전사 공지가 올라가는 순간 API 서버 스레드(Thread) 수십만 개가 멈춰버리고, 전체 슬랙 앱이 다운되는 연쇄 장애(Cascading Failure)로 이어집니다. 이를 막기 위해 Kafka 기반의 비동기 파이프라인이 필요합니다.
메시지 발행
Message Service는 DB에 메시지를 기록한 직후, Kafka의
messages-topic에 발행(Publish)합니다. API 서버의 역할은 여기서 끝납니다.
라우팅 워커 (Pub/Sub Worker)
백그라운드의 라우팅 워커들이 Kafka에서 메시지를 꺼내어 Redis Pub/Sub의 특정 채널(예:
channel:chan_12345)로 발행합니다.
팬아웃 (Fan-out on Read)
Redis를 구독하고 있던 수백 대의 WebSocket Gateway 서버들이 이 메시지를 받아, 현재 자신과 연결된 10만 명 중 해당 채널을 켜둔 유저들에게만 병렬로 패킷을 쏴줍니다.
사용자 알림 및 검색 색인
동시에 Notification Service가 이 Kafka 이벤트를 받아 멘션(@)된 사용자에게 푸시 알림을 보내고, Search Indexer는 Elasticsearch에 메시지 텍스트를 색인합니다.
2. Real-Time Messaging APIs (실시간 메시지 및 상태 - 고가용성 Read Path)
메시지 수신은 REST API 폴링(Polling)으로 처리하면 서버가 견딜 수 없습니다. WebSocket을 통한 실시간 스트리밍으로 모든 상태와 메시지를 밀어줍니다(Push).
Connect to Real-Time Messaging Stream (실시간 소켓 연결)
Upgrade: websocket
GET /v1/rtm/stream
Client -> Server (연결 시 초기화 및 상태 보고)
{
"action": "presence_update",
"status": "active"
}
Server -> Client (새 메시지 푸시)
{
"type": "message",
"channel_id": "chan_12345",
"message": {
"message_id": "msg_999888",
"user_id": "user_777",
"content": "시스템 디자인 면접 준비 중입니다.",
"created_at": "2026-05-06T10:30:00.123Z"
}
}
서버는 10만 명이 접속한 채널에 메시지가 올라왔을 때 DB에 10만 개의 Inbox 레코드를 쓰지 않습니다(Write Amplification 방지). 오직 인메모리 네트워크 라우팅(Redis Pub/Sub -> WebSocket)만을 사용하여 데이터를 복제(Fan-out on Read)함으로써 DB 병목을 완벽히 방어합니다.
1. 왜 REST API 폴링(Polling)은 불가능한가? (WebSocket의 필요성)
1,000만 명의 동시 접속자가 새 메시지가 있는지 확인하기 위해 1초에 한 번씩 REST API를 호출한다고 가정해 봅시다. 초당 1,000만 QPS의 HTTP 요청이 발생하며, 새 메시지가 없는 경우가 대부분인데 의미 없는 빈 응답을 위해 서버와 네트워크 인프라가 초토화됩니다.
Solution (WebSocket)
클라이언트가 슬랙 앱을 열 때 서버와 단 한 번 연결(Handshake)을 맺고 파이프를 열어둡니다. 이후 서버는 누군가 메시지를 보낼 때마다 무거운 HTTP 헤더 없이, JSON 형태의 순수 데이터만 클라이언트로 밀어냅니다(Push). 초저지연(Ultra-low latency)을 달성할 수 있습니다.
Deep Dive / 왜 REST API (Polling)로는 채팅을 만들 수 없을까?
슬랙이나 카카오톡 같은 실시간 채팅 앱을 만들 때, 가장 먼저 부딪히는 벽이 바로 “새 메시지가 왔는지 어떻게 알 수 있는가?”입니다. 이 문제를 해결하는 방식을 일상생활에 비유해 보겠습니다.
1. 안티 패턴, REST API 폴링 (Polling) - “ 1초마다 전화 걸기”
안티 패턴의 경우 로빈후드 컨텐츠에서도 언급했었지만,
가장 먼저 떠올릴 수 있는 단순한 방법은, 클라이언트(스마트폰)가 서버에 계속해서 물어보는 것입니다. 이를 폴링(Polling)이라고 부릅니다.
상황 비유
택배가 언제 올지 몰라서, 1초에 한 번씩 우체국에 전화를 걸어 “제 택배 왔나요?”라고 묻는 것과 같습니다. 우체국 직원은 매번 “아니요”라고 대답하고 전화를 끊습니다.
시스템적 재앙
1,000만 명의 슬랙 접속자가 새 메시지가 있는지 확인하기 위해 1초에 한 번씩 REST API(
GET /messages)를 호출한다고 가정해 봅시다.초당 1,000만 번의 요청(10M QPS)이 서버를 강타합니다. 게다가 HTTP 요청은 생각보다 무겁습니다. 매번 전화를 걸 때마다 “안녕하세요, 저는 캘리포니아에 사는 누구누구고, 비밀번호는 이거고...”라는 무거운 인사말(HTTP 헤더와 핸드쉐이크)을 주고받아야 하는 것이랑 동일해요.
결과적으로
정작 새 메시지가 온 경우는 1%도 안 되는데, 의미 없는 빈 응답(”새 메시지 없음”)을 주고받느라 회사의 서버와 네트워크 대역폭이 완전히 초토화됩니다. 스스로 시스템에 DDOS 공격을 퍼붓는 셈입니다.
2. Solution, WebSocket (웹소켓) - “전화기를 켜둔 채로 내려놓기”
이 끔찍한 비효율을 해결하기 위해 도입된 기술이 바로 WebSocket(웹소켓)입니다. 클라이언트가 서버에 계속 물어보는 대신, 서버가 새 소식이 있을 때 클라이언트에게 먼저 알려주는(Push) 방식입니다.
상황 비유
우체국과 전용 직통 전화선을 하나 개설해 두고, 전화를 끊지 않은 채로 수화기를 계속 내려놓고 있는 것과 같습니다. 택배가 도착하면 우체국 직원이 수화기 너머로 “택배 왔어요!”라고 바로 외쳐줍니다.
기술적 동작
클라이언트가 슬랙 앱을 처음 열 때, 서버와 단 한 번의 무거운 인사(Handshake)를 나눈 뒤 연결된 파이프를 끊지 않고 활짝 오픈 해 둡니다. 이후 누군가 채널에 메시지를 보내면, 서버는 무거운 HTTP 헤더 없이 JSON 형태의 순수한 메시지 데이터(알맹이)만 이 열려있는 파이프를 통해 클라이언트로 밀어냅니다(Push).
결과
불필요한 질문과 대답이 사라져 네트워크 낭비가 0에 가까워집니다. 서버가 메시지를 밀어내는 즉시 클라이언트 화면에 뜨기 때문에 초저지연(Ultra-low latency)의 완벽한 실시간 채팅이 완성됩니다.
3. 아키텍트, 그럼 웹소켓은 완벽하게 공짜인가요? (Trade-off)
면접관이 “그럼 폴링은 나쁘고 웹소켓이 무조건 정답이네요?”라고 물어볼 때, 시니어 엔지니어는 시스템의 단점(Trade-off)을 제시해야 합니다.
매우 중요한 부분이라고 할 수 있습니다. 웹소켓은 통신 비용을 극적으로 낮춰주지만, 반대로 서버의 메모리(RAM)를 엄청나게 소모합니다. 1,000만 명의 수화기를 끊지 않고 들고 있으려면, 서버 역시 1,000만 개의 열려있는 연결(Connection)을 메모리 위에서 계속 유지하고 관리해야 합니다. 사용자가 터널에 들어가서 연결이 끊기면 어떻게 다시 이어줄지, 연결만 해두고 채팅을 안 치는 유령 유저의 파이프를 언제 끊어버릴지(Ping-Pong) 관리하는 Connection Manager 서버를 매우 튼튼하게 구축해야 하는 인프라적 고충이 따릅니다.
정리해보면, 폴링(REST API)이 ’CPU와 네트워크 대역폭’을 태워서 데이터를 확인하는 방식이라면, 웹소켓(WebSocket)은 서버의 ’메모리(RAM)’를 담보로 내어주고 극강의 실시간성을 얻어내는 훌륭한 시스템 트레이드오프입니다.
2. 수만 명에게 동시에 뿌리기, Pub/Sub 아키텍처와 Connection Manager
10만 명이 있는 전사 공지 채널에 메시지가 1개 올라왔을 때, 서버는 이 10만 명을 어떻게 찾아내서 동시에 데이터를 보낼까요?
연결 관리자 (WebSocket Gateway)
클라이언트와 물리적인 연결을 맺고 있는 서버(Gateway)는 메모리 상에 “이 유저는 A, B, C 채널에 속해 있다”는 구독 지도(Subscription Map)를 유지합니다.
Redis Pub/Sub 또는 내부 버스 - (whatsapp 왓츠앱에서도 적용될 수 있습니다)
Gateway 서버 수십 대는 내부적으로 Redis의 특정 채널을 구독(Listen)하고 있습니다.
발행 (Publish)
Pub/Sub 워커가 Kafka에서 메시지를 받아 Redis의 특정 채널에 단 한 번 데이터를 던집니다.
브로드캐스트 (Broadcast)
이 채널을 듣고 있던 모든 Gateway 서버가 메시지를 수신한 뒤, 자신의 메모리 지도를 뒤져 해당 채널에 속한 클라이언트들에게만 병렬로 데이터를 뿌립니다.
3. O(N^2) 상태 업데이트 폭주 방어 (Presence System)
사용자 1명이 온라인으로 전환될 때 나머지 9,999명에게 “나 로그인했어!”라고 알려야 한다고 가정해 봅시다. 1만 명이 아침 9시에 동시에 접속하면 1억 번의 상태 업데이트 이벤트가 초당 발생합니다.
Solution (Lazy-Loading 및 Batching)
모든 상태 변화를 즉시 전체에게 보내지 않습니다.
동작 방식
사용자는 워크스페이스 전체 멤버가 아닌, “지금 내 화면(Viewport)에 보이는 사람”의 상태만 서버에 구독 요청합니다. 서버는 5초 동안 변경된 상태 이벤트를 모아두었다가 단 1번의 배열 형태로 묶어서 클라이언트에게 전송(Batching)합니다. (압도적인 가용성 확보).
슬랙 화면 좌측, 동료들의 프로필 옆에 켜지는 작은 ‘초록불(온라인 상태)’.
사용자 눈에는 그저 귀여운 UI 요소일 뿐이지만, 시스템 디자인에서 이 초록불은 메시지 전송보다 수백 배는 더 까다롭고 위험한 부분 입니다.
1. 안티 패턴, “나 로그인했어!” 모두에게 소리치기 (O(N^2)의 저주)
가장 단순한 구현은 누군가 온라인 상태가 되었을 때, 같은 워크스페이스에 있는 모든 사람에게 그 사실을 웹소켓으로 알려주는 것입니다.
상황 비유
1만 명이 모여 있는 거대한 체육관을 상상해 보세요. 누군가 체육관 문을 열고 들어올 때마다, 이미 안에 있는 9,239명 한 명 한 명에게 다가가서 “저 방금 왔습니다!”라고 외치는 행동이랑 똑같습니다.
시스템적 재앙
출근 시간인 아침 9시 정각을 떠올려 봅시다. 1만 명의 직원이 거의 동시에 슬랙을 켭니다.
1만 명이 각각 나머지 본인을 제외한 9,999명에게 상태 업데이트 이벤트를 쏴야 하므로, 서버는 순간적으로 10,000 * 10,000 = 1억 번의 메시지를 생성하고 라우팅해야 합니다. 사용자가 늘어날수록 부하가 제곱으로 폭증하는 이 현상을 O(N^2)병목이라고 부릅니다.
결과적으로는 네트워크 대역폭이 터지고, 서버 CPU가 녹아내리며, 정작 중요한 ‘채팅 메시지’는 초록불 이벤트의 홍수에 밀려 전송되지 않는 마비 사태가 벌어집니다.
이 끔찍한 연쇄 작용을 막기 위해 빅테크 기업들은 두 가지 천재적인 방안을 사용합니다.
2. 첫 번째, 지연 구독 (Lazy-Loading / Viewport Subscription)
“정말로 1만 명 모두의 상태를 실시간으로 알아야 할까?”라는 근본적인 질문에서 출발합니다.


