카카오톡급 메신저 시스템 설계: 메시지 순서·멀티 디바이스·푸시·미디어
휴대전화에는 서버 접수가 떴는데 같은 계정의 PC(Personal Computer, 데스크톱·노트북용 앱)에는 아직 전송 중이 남아 있다. 상대방 휴대전화에는 본문보다 푸시 알림이 먼저 도착했다. 카카오톡급 메신저 시스템 설계에서 풀어야 할 질문은 이처럼 서로 다른 길과 시각으로 도착한 한 메시지를 어떻게 같은 순서와 읽음 상태로 모으느냐다.
이 글은 그 한 메시지를 전송 버튼부터 PC 재접속, 상대방 읽음, 미디어 분기까지 추적한다. 모바일·태블릿·PC·웹의 1:1·그룹 대화를 다루며 검색, 음성·영상 통화, 결제와 공개 채널 추천은 제외한다. 공개 표준과 공식 문서는 설계 근거로 사용하고, 카카오톡의 비공개 구조나 실제 성능은 주장하지 않는다.
20초 핵심 요약
- 무엇: 메시지를 대화별 확정 기록에 한 번 저장하고 각 기기가 같은 순서 번호로 동기화하는 설계다.
- 왜: 연결 재시도와 푸시 지연을 기록 자체로 취급하면 중복, 순서 역전, 오래된 읽음 상태와 메시지 누락이 사용자 화면에 남는다.
- 어떻게: 보내기·재접속·미디어 공유의 요청 흐름을 따라 저장 기준, 병목, 복구, 암호화와 확장 선택을 연결한다.
세 화면이 어긋나는 한 메시지
사용자는 세 가지 행동을 한다. 첫째, 한 기기에서 텍스트를 보내고 전송 중, 서버 접수, 상대 기기 도착, 읽음이 구분되기를 기대한다. 둘째, 오프라인이었던 기기나 새 PC를 열어 빠진 메시지와 읽음 상태를 이어 받는다. 셋째, 사진이나 동영상을 올리고 처리 중인지 재생할 수 있는지 확인한다.
이 행동에는 네 가지 정확성 약속이 붙는다.
- 전 세계 메시지 전체가 아니라 한 대화 안에서만 순서를 확정한다.
- 네트워크가 같은 요청을 재전송해도 메시지는 한 번만 반영한다.
- 한 사용자의 각 기기는 독립적으로 연결되고 동기화되지만, 최종 대화 상태는 같아진다.
- 푸시 알림과 실시간 연결이 실패해도 저장된 메시지는 다시 받을 수 있다.
사용자가 보는 상태도 구분해야 한다. accepted는 서버가 메시지를 복제 저장해 접수했다는 뜻이다. delivered는 특정 기기에 도착했다는 뜻이고, read는 사용자가 화면에서 해당 위치까지 읽었다는 뜻이다. 서버 접수를 곧바로 상대의 읽음으로 표시해서는 안 된다.
개인정보 기대도 기능 요구사항이다. 전송 구간은 암호화하고 잠금 화면에 민감한 본문을 숨길 선택을 제공한다. 서버도 본문을 읽을 수 없는 종단간 암호화를 적용할지는 검색, 신고, 백업, 새 기기의 과거 기록 복원 정책과 함께 결정해야 한다.
이 장면을 제품 요구와 규모로 바꾸면
아래 값은 카카오톡의 운영 수치가 아니다. 설계가 어느 지점에서 달라지는지 확인하려고 둔 입력이다. p95는 요청 100개 가운데 95개가 이 시간 안에 끝난다는 목표다. KB(Kilobyte, 킬로바이트)와 MB(Megabyte, 메가바이트)는 각각 메시지 기록과 미디어 파일의 크기를 나타내며 저장·전송 비용을 계산하려고 쓴다.
| 설계 입력 | 가정 | 계산 결과와 쓰임 |
|---|---|---|
| 월간 활성 사용자 | 5,000만 | 사용자·기기 테이블의 상한 입력 |
| 일간 활성 사용자 | 2,000만 | 연결과 동기화 부하 입력 |
| 동시 온라인 비율 | 10% | 평균 200만, 피크 400만 장기 연결 가정 |
| 텍스트·메타 메시지 | 하루 10억 건 | 평균 약 11,574건/초, 10배 피크 약 115,740건/초 |
| 메시지 기록 평균 | 1KB | 원본 약 1TB/일, 3중 복제 약 3TB/일 |
| 기록 보존 | 180일 | 3중 복제 약 540TB, 인덱스·압축·백업 제외 |
| 미디어 비율·평균 크기 | 5%·1MB | 원본 약 50TB/일, 변환본·복제·전송 제외 |
| 메시지당 전달 기기 | 평균 3대 | 하루 약 30억 건의 기기 전달 작업 |
계산은 10억 ÷ 86,400 ≈ 11,574건/초, 10억 × 1KB ≈ 1TB/일, 5,000만 × 1MB ≈ 50TB/일이다. 1KB는 계산을 단순하게 하려고 1,000바이트로 잡았다. 실제 용량 계획에는 압축률, 인덱스, 삭제 표시, 백업과 이진 단위를 더해야 한다.
목표 역시 가정으로 정한다. 온라인 텍스트 메시지는 서버 접수 응답 p95 300ms 이내, 같은 지역 온라인 기기 도착 p95 500ms 이내로 둔다. 재접속 때 첫 화면의 최근 대화 목록은 p95 2초 이내에 보여주고, 오래 밀린 기록은 페이지로 이어 받는다. accepted를 돌려준 메시지는 단일 서버 장애로 사라지지 않도록 복제 저장한 뒤 응답한다.
가정이 달라지면 설계도 바뀐다. 피크가 평균의 20배라면 접수 경로는 약 23만 건/초를 견뎌야 한다. 평균 그룹 크기가 커지면 메시지 저장보다 기기별 전달 작업이 먼저 폭증한다. 평균 미디어가 5MB로 커지면 원본만 하루 250TB여서 메시지 데이터베이스보다 객체 저장과 전송 비용이 지배한다.
서버가 먼저 확정해야 할 두 값
API(Application Programming Interface, 앱이 서버에 요청하고 답을 받는 규칙)는 전송 입력과 응답 형식을 고정해 재시도 결과가 달라지지 않게 한다. 아래 요청의 client_message_id는 재전송해도 유지하는 발신자 식별자이고, 응답의 sequence는 서버가 한 대화 안에서 확정한 증가 번호다. 두 값을 함께 써야 중복을 막으면서 모든 기기가 같은 순서로 정렬할 수 있다.
POST /v1/conversations/{conversation_id}/messages
Authorization: Bearer <redacted>
Idempotency-Key: 018f...client-generated-id
Content-Type: application/json
{
"client_message_id": "018f...",
"sender_device_id": "device-7",
"type": "text",
"ciphertext_or_body": "...",
"reply_to_message_id": null
}
서버 응답은 접수된 메시지와 대화 안의 확정 위치를 알려준다.
{
"message_id": "msg-9a...",
"conversation_id": "conv-42",
"sequence": 12831,
"accepted_at": "2026-08-16T10:20:30.123Z",
"status": "accepted"
}
멱등성은 같은 요청을 반복해도 결과가 한 번만 반영되는 성질이다. 이 흐름에서는 응답이 사라져 사용자가 재전송해도 같은 말이 중복 저장되지 않게 하며, (sender_user_id, client_message_id)를 고유 키로 두는 이유다.
- 앱은 로그인 뒤
WebSocket을 연다. WebSocket은 한 연결에서 서버와 클라이언트가 양쪽으로 계속 데이터를 보낼 수 있는 통신 방식이며, 새 메시지를 빠르게 주고받으려고 사용한다. 연결을 받은 접점은 이 기기가 현재 어느 연결에 붙어 있는지만 관리한다. - 사용자가 전송을 누르면 앱은
client_message_id를 한 번 만들고 화면에sending을 표시한다. 요청을 다시 보내도 이 값은 바꾸지 않는다. - 메시지 접수 API는 로그인 상태, 대화 참여 권한, 크기와 차단 정책을 검사한다. 같은 멱등 키가 이미 있으면 새 메시지를 만들지 않고 이전 결과를 돌려준다.
- 서버는
sequence, 즉 한 대화 안에서 서버가 확정한 증가 번호를 배정한다. 서로 다른 기기의 시계 대신 이 번호를 써야 동시에 보낸 메시지도 모든 화면에서 같은 순서로 보인다. - 메시지와 번호를
원장, 즉 메시지가 존재하며 어느 순서인지 판정하는 기준 기록에 복제 저장한다. 전달용 임시 큐가 깨져도 이 기록에서 다시 만들 수 있어야 한다. - 저장이 끝나면 발신자에게
accepted를 답한다. 동시에outbox, 즉 저장한 메시지의 후속 전달 이벤트가 빠지지 않게 같은 저장 경계에 두는 발송 대기표에MessageAccepted를 남긴다. fan-out, 즉 메시지 한 건을 받아야 할 사용자와 기기별 전달 작업으로 펼치는 과정이 시작된다. 상대 기기뿐 아니라 발신자의 다른 기기도 포함해야 어느 화면에서나 같은 기록이 보인다.- 온라인 기기는 WebSocket으로 받고
delivered_sequence를 올린다. 이미 본message_id는 버리고, 12829 다음에 12831이 왔다면 빠진 12830을 동기화 API로 다시 요청한다.
WebSocket 표준은 양방향 연결과 한 메시지를 구성하는 조각의 전송 순서를 정의한다. 여러 기기와 여러 연결에서 동시에 들어온 대화 이벤트의 전체 순서를 정해주지는 않는다. 따라서 WebSocket은 빠른 전달로, 서버 sequence는 대화 순서의 기준으로 역할을 나눈다.
아래 그림은 왼쪽의 사용자가 전송한 뒤, 중앙에서 멱등 검사와 sequence 확정, 원장 저장을 위에서 아래로 읽는다. 그다음 오른쪽의 실시간 연결·오프라인 동기화·운영체제 푸시 세 갈래와 아래쪽 미디어 경로를 따라가면 된다.

세 전달 경로 가운데 하나가 늦거나 같은 이벤트를 반복해도, 기기는 원장의 sequence와 동기화 위치를 이용해 같은 상태로 수렴한다. 그래서 그림의 중심은 연결 서버가 아니라 원장이다.
세 전달 경로가 다시 만나는 법
오프라인 기기는 마지막으로 반영한 위치부터 변경 사항을 요청한다.
GET /v1/sync?cursor=<opaque_device_cursor>&limit=500
{
"events": [
{"type":"message", "conversation_id":"conv-42", "sequence":12831, "message_id":"msg-9a..."},
{"type":"read_cursor", "conversation_id":"conv-42", "user_id":"user-b", "last_read_sequence":12820}
],
"next_cursor": "opaque-next",
"has_more": true,
"snapshot_required": false
}
여기서 동기화 cursor는 기기가 서버 변경 내역을 어디까지 반영했는지 보여주는 위치다. 사용자가 대화에서 어디까지 읽었는지 나타내는 last_read_sequence와는 다른 값이다.
- 앱은 기기에 저장한 불투명한 커서를 보낸다. 서버 내부 위치를 클라이언트가 해석하거나 만들어내지 못하게 한다.
- 서버는 대화 참여 변경, 새 메시지, 수정·삭제와 읽음 위치를 순서 있는 페이지로 돌려준다.
- 기기는 각 대화의
sequence가 이어지는지 확인한다. 빈 번호가 있으면 해당 대화 범위를 다시 받는다. - 커서가 서버의 변경 내역 보존 기간보다 오래됐다면
snapshot_required=true를 답한다. 기기는 현재 대화 목록과 최근 메시지 구간을 새 스냅샷으로 받은 뒤 새 커서에서 시작한다. - 과거 변경을 반영한 뒤 실시간 구독을 활성화한다. 동기화와 실시간 연결 사이의 틈은 연결 시작 위치를 커서로 고정하거나, 먼저 연결하고 커서 뒤의 이벤트를 잠시 모아 두는 방식으로 막는다.
한 사용자의 휴대전화, 태블릿, PC는 각각 독립된 device_id, 연결 세대, 동기화 커서와 푸시 토큰을 갖는다. Signal Sesame 명세도 비동기 멀티 디바이스에서 사용자별 기기 집합, 기기별 연결 상태와 보관함, 기기 추가·삭제를 별도 상태로 다룬다. 특정 제품 구현을 복제하자는 뜻이 아니라 사용자 계정 하나만 저장해서는 지연·중복·기기 폐기를 다룰 수 없다는 근거다.
읽음 위치는 다음처럼 전진만 허용한다.
PUT /v1/conversations/{conversation_id}/read-cursor
Content-Type: application/json
{"last_read_sequence": 12820, "device_id": "device-7"}
서버는 last_read_sequence = max(stored, requested)로 갱신한다. 늦게 도착한 12810 요청이 이미 반영한 12820을 되돌리지 못한다. “한 기기에서 읽으면 계정 전체에서 읽음”이라는 제품 정책이라면 사용자·대화별 값을 기준으로 두고, 기기별 값은 동기화 문제를 찾는 진단 정보로만 남긴다.
사진은 왜 별도 길로 보내는가
큰 파일을 텍스트 메시지 API로 통과시키면 업로드가 느려질 때 메시지 접수까지 막힌다. 먼저 제한된 업로드 세션을 만든다. 요청의 SHA-256(Secure Hash Algorithm 256-bit, 파일 내용으로 만든 고정 길이 지문)은 업로드 전후 파일이 같은지 확인하려고 보낸다.
POST /v1/media/uploads
Content-Type: application/json
{"content_type":"video/mp4", "size_bytes":7340032, "sha256":"..."}
{
"media_id":"media-77",
"upload_url":"https://object-store.example/...signed...",
"expires_at":"2026-08-16T10:35:00Z",
"required_headers":{"content-type":"video/mp4"}
}
- 앱이 크기, 형식과 파일 해시를 보내면 서버는 사용자 할당량과 대화 권한을 확인한다. 충돌하지 않는 객체 키와 짧게 유효한 업로드 권한을 발급한다.
- 앱은 채팅 서버가 아니라 객체 저장소에 파일을 직접 올린다.
사전 서명 URL(Uniform Resource Locator, 웹 자원의 위치를 나타내는 주소)은 장기 클라우드 인증정보를 앱에 주지 않고 정해진 작업을 잠시 허용하는 주소이며, 큰 파일이 채팅 서버의 대역폭과 메모리를 차지하지 않게 하려고 쓴다. Amazon S3(Simple Storage Service, 파일을 객체 단위로 보관하는 저장 서비스) 공식 문서에 따르면 같은 객체 키를 쓰면 기존 파일을 덮어쓸 수 있으므로 키는 서버가 발급해야 한다. - 완료 요청을 받으면 서버는 실제 객체 크기와 해시를 검사하고 상태를
uploaded로 바꾼다. 앱이 선언한 값만 믿지 않는다. - 악성 파일과 형식을 검사한다.
트랜스코딩은 원본 동영상을 여러 기기에서 재생할 수 있는 코덱과 해상도의 파일로 바꾸는 작업이며, 무거운 계산이 텍스트 전송을 늦추지 않게 별도 작업으로 실행한다. Amazon Web Services(AWS, 아마존의 클라우드 서비스) MediaConvert 공식 설명도 입력 파일을 설정에 따라 하나 이상의 출력으로 바꾸는 작업으로 정의한다. ready가 된media_id를 메시지에서 참조한다. 빠른 화면 반응이 필요하면 메시지를 먼저processing상태로 확정하고 준비 이벤트가 같은 첨부 상태를 바꾸게 할 수 있다.- 수신자는 짧게 유효한 다운로드 권한이나
CDN(Content Delivery Network), 즉 사용자와 가까운 위치에서 파일을 전달하는 분산 캐시를 통해 받는다. 원본 객체 주소를 영구 공개하지 않는다.
미디어의 종단간 암호화는 별도 결정이다. 앱이 원본을 암호화해 올리면 서버는 내용을 보지 못하지만 악성 파일 검사, 썸네일 생성과 트랜스코딩도 할 수 없다. 앱이 변환본까지 만들거나, 제한된 신뢰 환경에서 처리하거나, 미디어에는 종단간 암호화를 적용하지 않는 선택 가운데 제품 약속을 명시해야 한다.
세 경로가 공유하는 저장 기준
| 데이터 | 핵심 키·인덱스 | 갱신·보존 규칙 | 역할 |
|---|---|---|---|
Conversation |
conversation_id |
유형, 생성 시각, 현재 구성 버전 | 대화의 기준 정보 |
ConversationMember |
(conversation_id, user_id) |
가입·탈퇴 sequence, 역할 | 참여 권한의 기준 |
Message |
conversation_id로 분할, sequence로 정렬 |
본문 또는 암호문, 발신자, 유형, 삭제 이벤트 | 존재와 순서의 기준 |
IdempotencyRecord |
(sender_user_id, client_message_id) 고유 |
메시지 재시도 기간보다 길게 보존 | 중복 접수 방지 |
Device |
(user_id, device_id) |
키, 상태, 등록·폐기 시각 | 허가된 기기의 기준 |
ConnectionSession |
device_id와 연결 세대 |
짧은 사용권과 주기적인 생존 신호로 갱신 | 현재 연결을 찾는 휘발 정보 |
DeviceSyncCursor |
(user_id, device_id) |
마지막 반영 변경 위치 | 복구 시작점 |
ReadCursor |
(conversation_id, user_id) |
큰 last_read_sequence만 유지 |
읽음 상태의 기준 |
DeliveryTask |
(message_id, device_id) |
재시도 횟수와 다음 시각 | 원장에서 다시 만들 수 있는 전달 작업 |
PushToken |
(device_id, provider, app) |
회전하거나 무효 응답 때 폐기 | 외부 알림 주소 |
MediaAsset |
media_id, 객체 키 |
업로드·검사·준비·실패, 해시와 파생본 | 미디어 상태의 기준 |
TTL(Time To Live)은 데이터가 자동으로 만료되기까지의 유효 기간이다. 전달 작업, 멱등 키와 업로드 세션처럼 영구 보존할 필요가 없는 상태의 비용을 제한하려고 사용한다. 메시지 원장에 같은 TTL을 무심코 적용하면 오래 오프라인이었던 사용자의 복구가 깨지므로 메시지 보존과 전달 작업 보존은 분리한다.
메시지는 한 대화의 범위 조회가 쉬운 형태로 저장한다. 아래 PK(Partition Key, 데이터를 같은 저장 구역으로 묶는 키)는 대화별 조회를 모으고, SK(Sort Key, 묶음 안의 정렬 키)는 메시지를 서버 확정 순서대로 읽게 한다.
PK: conversation_id = conv-42
SK: sequence = 12831
message_id = msg-9a...
sender_user_id = user-a
sender_device_id = device-7
client_message_id = 018f...
kind = text | media | edit | delete
content = body_or_ciphertext
accepted_at = server_time
membership_version = 93
대화별 저장은 순서와 누락 조회가 단순하다. 대신 초대형 그룹 하나에 쓰기가 몰리면 핫키, 즉 특정 키 하나가 저장 처리량을 독점하는 병목이 생긴다. 기본안은 대화별 단일 순서를 유지하면서 큰 그룹만 전용 순서 처리와 저장 구역으로 격리한다.
Apache Kafka 공식 문서는 같은 키의 이벤트를 같은 파티션, 즉 순서를 유지하며 함께 저장하고 읽는 구역에 기록하면 그 안에서 기록 순서대로 읽는다고 설명한다. conversation_id를 이벤트 흐름의 분류 키로 삼는 근거는 되지만 Kafka가 메시지 원장이나 API의 멱등성을 대신하지는 않는다. 전송자 설정 문서에는 재시도 설정에 따라 순서가 달라질 수 있는 조건도 있으므로 키 선택과 중복 방지를 함께 검토해야 한다.
가장 붐빌 때 무엇부터 무너지는가
연결 경로에서는 피크 400만 연결 자체보다 통신 장애가 풀린 직후의 재접속 폭풍이 더 위험하다. 수많은 기기가 동시에 로그인, 동기화와 구독을 시도하기 때문이다. 앱은 재시도 간격을 점점 늘리고 무작위 지연을 섞는다. 서버는 현재 연결 수뿐 아니라 초당 연결 수립, 연결 유지 신호 누락, 재접속률과 기기별 연결 세대 충돌을 본다.
순서 경로에서는 대화별 초당 쓰기, sequence 배정 p95와 p99, 즉 요청 100개 중 99개가 끝나는 시간의 상한 목표, 조건부 쓰기 충돌과 상위 핫키의 부하 비중을 본다. p99는 소수의 매우 느린 요청이 사용자에게 반복해서 보이는지 찾기 위해 필요하다. 입력이 저장과 전달 속도를 넘으면 타이핑 표시처럼 없어져도 되는 이벤트부터 버리고, 메시지는 숨기지 말고 사용자에게 지연 상태를 표시한다.
일반 대화의 fan-out-on-write, 즉 메시지를 저장할 때 받을 기기별 전달 작업까지 미리 만드는 방식은 이후 읽기를 빠르게 하려고 쓴다. 그룹이 커지면 메시지 수 × 구성원 기기 수만큼 쓰기가 늘어난다. 반대인 fan-out-on-read, 즉 원본은 한 번만 저장하고 각 사용자가 열 때 필요한 메시지를 골라 읽는 방식은 쓰기 폭증을 피하려고 쓴다. 대신 대화 목록과 안 읽은 수 계산이 무거워진다. 일반 대화는 쓰는 시점에 펼치고, 구성원과 활성도가 임계치를 넘는 그룹은 공유 기록에서 읽되 온라인 기기만 즉시 전달하는 혼합안이 적합하다.
오프라인 경로는 sync lag, 즉 최신 서버 이벤트와 기기 커서 사이의 차이, 페이지 크기, 스냅샷 전환율, 누락 번호 요청률과 동기화 완료 시간을 관측한다. 읽음 상태는 메시지마다 모든 구성원의 행을 만들지 않고 사용자·대화별 전진 커서 하나로 압축한다. 작은 그룹에서 “누가 읽었는가”가 꼭 필요할 때만 별도 조회용 목록을 제한적으로 만든다.
미디어 경로에서는 업로드 완료율, 해시 불일치, 검사·변환 작업의 대기 시간, 파생본 실패율, 저장 증가량과 CDN 적중률을 본다. egress는 클라우드 밖으로 나가는 데이터 전송량이며 비용과 직접 연결되므로 별도 관측한다. 텍스트 접수 목표와 미디어 준비 목표를 분리해야 동영상 적체가 대화를 멈추지 않는다.
어긋난 화면을 감지하고 같은 기록으로 되돌린다
장애 대응은 서버 오류 이름보다 사용자가 먼저 보는 현상에서 시작한다. 각 행은 감지, 즉시 완화, 복구, 마지막 정합성 확인 순서로 읽는다.
| 사용자에게 보이는 현상 | 감지 | 즉시 완화 | 복구 | 정합성 확인 |
|---|---|---|---|---|
| 보낸 말이 두 번 보임 | 같은 멱등 키·메시지 ID의 중복률 | 서버 고유 키와 기기 중복 제거 | 원장에서 전달 작업 재생 | 원장에 메시지가 한 행인지 확인 |
| 메시지 순서가 뒤집힘 | 기기에서 sequence 간격·역전 관측 | 번호 기준으로 잠시 모으고 빈 구간 조회 | 해당 대화 원장을 다시 읽어 정렬 | 같은 sequence에 다른 메시지가 없는지 검사 |
| PC에 새 메시지가 없거나 읽음이 오래됨 | 기기 sync lag와 읽음 커서 회귀 시도 | 부분 또는 전체 재동기화, max 갱신 |
원장·멤버십·읽음 커서를 다시 받음 | 로컬 스냅샷과 서버 기준 비교 |
| 알림은 왔는데 메시지가 없음 | 푸시 시각과 동기화 결과 불일치 | 알림을 힌트로만 취급하고 서버 조회 | 최신 대화 상태 반영 | 삭제·권한 변경에 맞춰 알림 제거 |
| 메시지는 있는데 알림이 안 옴 | 공급자 응답, 무효 토큰과 전달 지표 | 앱 안 읽음 배지와 재접속 동기화 유지 | 토큰 갱신·폐기 | 알림 실패를 메시지 실패로 처리하지 않았는지 확인 |
| 사진이 계속 처리 중 | 업로드 만료, 작업 대기 시간과 오류 | 재개 업로드, 텍스트 흐름과 격리 | 해시 확인 뒤 검사·변환 재작업 | 영구 실패면 재시도 가능 상태로 확정 |
| 새 기기에서 일부 내용을 읽을 수 없음 | 기기 목록 버전과 복호화 실패 | 새 기기 키 배포와 제한된 재시도 | 승인된 과거 기록 이전 또는 미제공 정책 적용 | 현재 허가된 기기 집합과 키 세대 비교 |
순서 담당 서버가 바뀌는 경우도 같은 순서를 따른다. 먼저 조건부 쓰기 충돌과 오래된 리더의 세대 번호를 감지한다. 이전 리더의 쓰기를 차단하고, 마지막 확정 sequence 다음에서 새 리더를 시작한다. 복구 뒤에는 중복 번호나 같은 번호에 서로 다른 메시지가 없는지 원장을 검사한다.
푸시는 메시지가 아니라 깨우기 신호다
FCM(Firebase Cloud Messaging)은 Google의 기기 푸시 전달 서비스다. 앱이 꺼져 있거나 연결이 없을 때 새 데이터가 있음을 알리는 데 필요하지만, FCM 공식 문서는 전달 순서를 보장하지 않는다고 밝힌다. Android에서 비축약 메시지가 100개 한도를 넘으면 저장 메시지를 버리고 앱에 전체 동기화가 필요하다는 신호를 줄 수도 있다.
축약 가능한 푸시는 이전 알림을 새 알림으로 바꿀 수 있다. 그래서 “이 메시지 본문을 반드시 표시하라”보다 “새 데이터가 있으니 서버와 동기화하라”는 힌트에 맞는다. 푸시가 먼저 도착해도 앱은 서버에서 확정 기록을 조회한다.
푸시에서 TTL(Time To Live)은 공급자가 전달을 시도할 최대 기간이다. FCM 수명 문서는 Android와 웹에서 0초부터 28일까지 설정할 수 있고, 0초는 즉시 전달하지 못하면 버린다는 뜻이라고 설명한다. 서버가 FCM 메시지 ID를 받았다는 사실도 기기 전달 완료를 뜻하지 않는다.
APNs(Apple Push Notification service)는 Apple 기기에 푸시를 전달하는 서비스다. Apple 공식 문서에 따르면 오프라인 알림을 저장할 수 있지만 즉시 전달하지 못한 같은 앱의 알림을 합칠 수도 있다. iOS에서도 알림을 대화 기록의 기준으로 쓸 수 없는 이유다.
푸시에는 최소한의 conversation_id, 불투명한 새 이벤트 힌트와 비민감 표시 정보만 담는다. 토큰은 기기별로 저장하고 공급자가 무효라고 답하면 폐기한다. 종단간 암호화 제품의 본문 미리보기는 기기에서 복호화하거나 사용자의 개인정보 설정에 따라 숨긴다.
암호화하면 포기하거나 기기로 옮겨야 할 일
모든 클라이언트와 서버, 서버와 푸시 공급자 연결에는 TLS(Transport Layer Security), 즉 전송 중 도청과 변조를 막는 암호화를 적용한다. 인증 토큰에는 짧은 만료와 기기 결속을 두고, 대화 참여 권한은 메시지를 받을 때뿐 아니라 미디어를 내려받을 때도 검사한다.
기기를 폐기하면 해당 연결, 푸시 토큰과 키 자료를 무효화한다. 로그에는 본문, 암호문 전체, 사전 서명 URL, 푸시 토큰과 전화번호를 남기지 않는다. 운영 지표에는 불투명한 식별자와 집계값을 사용한다. 업로드 권한에는 짧은 만료, 서버 발급 객체 키, 허용 크기·형식과 완료 후 해시 확인을 둔다.
종단간 암호화(E2EE, End-to-End Encryption)는 발신 기기와 허가된 수신 기기만 본문을 읽고 전달 서버는 평문을 알 수 없게 하는 방식이다. 멀티 디바이스에서는 사용자 한 명이 아니라 허가된 모든 기기가 암호화 대상이다. 상대의 모든 기기와 발신자의 다른 기기에 맞는 암호문 또는 키 자료가 필요하다.
큰 그룹에는 MLS(Messaging Layer Security, 메시징 계층 보안), 즉 비동기 그룹의 공통 키 상태를 안전하게 갱신하는 표준을 검토할 수 있다. 이 표준을 만든 IETF(Internet Engineering Task Force, 인터넷 기술 표준화 기구)는 인터넷 프로토콜의 공개 표준을 다루며, 여기서는 여러 기기의 구성원 변경 뒤 누가 새 메시지를 읽을 수 있는지 정하는 기준을 제공한다. epoch은 현재 구성원 집합에만 유효한 키 세대다. RFC 9420에 따르면 구성원 변경을 확정하면 새 epoch를 만들고, 제거된 구성원은 새 메시지를 읽지 못하게 한다. 새 구성원은 현재 키 상태에 들어오지만 가입 전 메시지는 기본적으로 읽지 못한다.
암호화 방식보다 먼저 네 가지 제품 결정을 내려야 한다.
- 새 기기가 과거 기록을 읽을 수 있는가. 가능하다면 기존 기기, 암호화 백업 또는 별도 복구 수단 중 누가 이전을 승인하는가.
- 서버 검색, 스팸 검사와 신고 검토가 필요한가. E2EE라면 기기 안에서 검색하고 사용자가 선택한 신고 자료만 제출하는 방식이 필요하다.
- 키를 잃었을 때 복구할 수 있는가. 서버가 복구 키를 단독 보유하면 종단간 약속은 약해진다.
- 그룹의 기기 추가·삭제와 메시지 순서를 어떻게 연결하는가. 메시지의
membership_version과sequence를 함께 기록해야 탈퇴 직전과 직후의 접근 권한을 판단할 수 있다.
대안은 정상 속도보다 실패했을 때의 모양으로 고른다
| 선택 지점 | 채택안 | 장점 | 비용·남은 한계 |
|---|---|---|---|
| 메시지 순서 | 대화별 단일 논리 순서와 조건부 증가 | 설명 가능한 확정 순서, 단순한 누락 복구 | 초대형 대화가 핫키가 됨 |
| 전달 | 최소 한 번 전달, message_id 중복 제거, 원장 동기화 |
장애 뒤 재생하기 현실적 | 모든 수신 처리에서 중복을 안전하게 버려야 함 |
| fan-out | 일반 대화는 쓸 때 펼치고 큰 그룹은 공유 기록과 온라인 즉시 전달을 혼합 | 평균 읽기 지연과 최악 쓰기 증폭을 함께 제한 | 두 경로를 운영하는 복잡도 증가 |
| 미디어 | 객체 저장소 직접 업로드, 비동기 검사·변환, media_id 참조 |
채팅 서버의 대역폭과 CPU(Central Processing Unit, 서버에서 계산을 수행하는 처리 장치) 사용량 보호 |
고아 객체, 처리 상태와 권한 만료 운영 필요 |
| 푸시 | 축약 가능한 동기화 힌트 | 유실·재정렬에 강하고 민감 정보 노출 감소 | 앱이 서버를 한 번 더 조회함 |
클라이언트 시각으로 정렬하면 쓰기는 단순하지만 기기 시계 차이와 네트워크 재정렬 때문에 확정 순서로 쓸 수 없다. 시간 순으로 정렬하기 쉬운 전역 ID는 분산 쓰기에 유리하지만 같은 시각의 충돌과 리더 전환에서 대화별 엄격한 순서를 따로 해결해야 한다.
“정확히 한 번 전달”을 네트워크와 기기 종료까지 약속하는 대안도 결국 수신 측 중복 제거와 저장 경계를 요구한다. 이 설계는 같은 전달이 반복될 수 있는 최소 한 번 전달을 허용하되, 사용자 화면에는 한 번만 반영되게 한다.
채팅 API를 통한 파일 업로드는 인증 경로가 단순하다. 그러나 큰 파일이 메시지 접수 서버의 메모리와 전송량을 차지한다. E2EE 상태에서 기기가 변환까지 맡으면 서버의 평문 노출은 줄지만 모바일 CPU, 배터리, 포맷 호환과 악성 파일 검사가 어려워진다.
어디까지 단순하게 시작할 수 있는가
- 초기 단계: 단일 지역에서 관계형
DB(Database, 구조화된 데이터를 저장하고 조회하는 시스템)의 대화별 조건부sequence, WebSocket 연결, 기기별 커서와 객체 저장소 직접 업로드로 시작한다. DB는 메시지와 순서 번호를 함께 확정해 접수 응답 뒤 기록이 사라지지 않게 하는 역할을 맡는다. 메시지 원장과 outbox는 같은 저장 작업에 둔다. - 수평 확장 단계:
conversation_id로 메시지와 이벤트 흐름을 나누고 연결 접점을 상태가 적은 형태로 늘린다. 멱등 키 충돌, 전달 재시도, sync lag와 핫키 비중을 측정한다. - 대형 그룹 단계: 구성원 수와 활성도가 임계치를 넘는 그룹은 공유 기록 방식으로 전환하고 전용 저장 구역에 격리한다. 상세 읽음 표시는 제품 요구에 따라 제한한다.
- 다지역 단계: 사용자의 연결은 가까운 지역에서 받되 대화마다 쓰기를 담당하는 주 지역을 정한다. 지역 장애 때 잠시 쓰기를 늦춰 단일 순서를 지키는 안과 임시 분기 뒤 합치는 안 가운데 선택해야 한다. 기본안은 짧은 지연을 감수하고 한 대화의 단일 순서를 지킨다.
- 강한 개인정보 단계: 기기 인증, 키 상태 확인과 안전한 기기 추가를 먼저 만든다. 그 뒤 1:1 E2EE와 그룹 키 방식을 검토한다. 백업, 신고, 검색과 미디어 처리 정책 없이 암호화만 먼저 약속하지 않는다.
다음 단계로 넘어가는 신호는 가입자 수 하나가 아니다. sequence 충돌, 상위 핫키 비중, fan-out 작업 대기 시간, sync lag, 재접속률, 미디어 처리 지연과 지역 간 복제 지연이 사용자 목표를 지속해서 위협할 때만 복잡도를 추가한다.
빠른 길보다 돌아올 기준이 먼저다
실시간 연결과 푸시는 메시지가 빨리 보이게 하는 길이다. 실패 뒤 돌아올 기준은 대화별 sequence가 붙은 원장과 기기별 동기화 위치다. 읽음은 앞으로만 움직이게 하고, 미디어는 채팅 접수와 분리해야 세 화면이 늦게 만나도 같은 상태로 모인다.
남은 한계는 초대형 그룹이다. 한 대화의 번호를 한곳에서 확정하는 선택은 복구를 단순하게 하지만, 쓰기가 한 대화에 집중되면 병목이 된다. 그때만 공유 기록과 전용 순서 처리로 옮기는 것이 이 설계의 경계다. 구현 전에는 아래 공식 문서에서 연결, 푸시와 멀티 디바이스 암호화의 최신 조건을 확인한다.
참고 링크
- IETF RFC 6455 — The WebSocket Protocol
- Firebase — Non-collapsible and collapsible messages
- Firebase — Set the lifespan of a message
- Apple — Setting up a remote notification server
- Apache Kafka — Introduction
- Signal — The Sesame Algorithm
- IETF RFC 9420 — The Messaging Layer Security Protocol
- Amazon S3 — Presigned URLs