목차
- 고수준 아키텍처
- 데이터 모델
- GraphQL API 표면
- 피어 채팅: 메시지 전송
- 피어 채팅: 메시지 수신
- 채널 채팅: 리더 선출과 fan-out
- 채널 시스템 메시지
- 채널 라이프사이클
- 피어 트랜스포트 계층(LAN → Wi-Fi Aware → BLE)
- 피어 상태 및 프레전스
- 캐싱 계층
- 파일 다운로드
- 설계 패턴 요약
고수준 아키텍처 {#high-level-architecture}
PlainApp 채팅은 서버리스입니다. 모든 기기는 임베디드 Ktor HTTP 서버를 실행하며, 기기들은 로컬 네트워크, Wi-Fi Aware(NAN), 또는 Bluetooth Low Energy를 통해 직접 통신합니다. 릴레이 서버도, 클라우드 받은편지함도, 전화번호 기반 신원도 없습니다. 기기는 자체 생성된 clientId로 식별되며, 페어링 중에 수행되는 Ed25519 + ECDH 핸드셰이크를 통해 인증됩니다.
두 종류의 대화가 존재합니다:
| 타입 | 상수 | 설명 |
|---|---|---|
PEER | ChatTargetType.PEER | 두 페어링된 기기 간의 1:1 직접 채팅. |
CHANNEL | ChatTargetType.CHANNEL | 한 기기가 소유하는 다자간 그룹 채팅; 멤버들이 서로에게 메시지를 fan-out. |
특수한 "local" 대상은 기기 자신의 스크래치패드(자기 자신에게 보내는 메모)입니다 —
이 대상으로 보내는 것은 와이어 상에서 no-op입니다.
컴포넌트 맵
아키텍처는 의도적으로 계층화되어 있습니다:
- UI / GraphQL 진입점은 트랜스포트나 DB에 직접 접근하지 않습니다.
- **
ChatManager**는 파사드입니다 — 모든 호출자(UI, GraphQL 리졸버, 피어 수신자)가 이것을 거칩니다. - **
ChatSender**는ChatTargetType에 따라 분기하여 피어 또는 채널 전송자에게 위임하는 디스패처입니다. - 트랜스포트 계층은 circuit breaking을 갖춘 플러거블 전략 체인이어서, 불안정한 Wi-Fi Aware 링크가 BLE로 갈 수 있는 메시지를 결코 차단하지 않습니다.
데이터 모델 {#data-model}
ChatTarget
라우팅의 가장 작은 단위는 ChatTarget입니다 — (toId, type) 쌍이며,
type은 PEER 또는 CHANNEL입니다. UI가 안정적 라우팅 키로 사용하는
encodedToId(peer:<id> 또는 channel:<id>)(예: TempData.activeToId,
수신자가 알림을 내보낼지 결정하기 위해), isLocal() 검사(toId == "local"),
그리고 저장된 문자열에서 대상을 재구성하는 parseId 컴패니언을 노출합니다.
데이터베이스 테이블
모든 영속화는 Room을 사용합니다. 채팅과 관련된 세 테이블이 중요합니다:
| 테이블 | 엔티티 | 목적 |
|---|---|---|
chats | DChat | 메시지당 한 행(텍스트 / 이미지 / 파일). |
chat_channels | DChatChannel | 그룹 채널당 한 행. |
peers | DPeer | 알려진 기기당 한 행(페어링 또는 채널 전용). |
주목할 만한 몇 가지:
- 신원은
clientId이지 MAC이 아닙니다. Android는 모든 연결마다 BLE MAC을 무작위화하므로, 데이터베이스는 안정적인 13문자 자체 생성 id를 사용합니다. 발견을 허용하기 위해 BLE로 브로드캐스트되는 것은 8바이트 SHA-256 접두사 (shortId)뿐입니다. status="channel"피어는 이 기기가 직접 페어링한 적이 없는 채널의 멤버입니다. 이들의key는 비어 있습니다 — 쌍방향 공유 키 대신 채널 키로 인증합니다.- **
owner="me"**는 새로 설치된 기기가clientId가 안정화되기 전에 소유자로 행동할 수 있게 하는 센티넬입니다;isOwnedByMe()는"me"와TempData.clientId모두를 받아들입니다.
GraphQL API 표면 {#graphql-api-surface}
PlainApp은 두 가지 GraphQL 스키마를 노출합니다:
- Web GraphQL (
addChatChannelSchema+addChatMessageSchema) — 로컬 Ktor 서버가 브라우저 UI와apitest/하네스에 서비스. ChaCha20 암호화 토큰으로 인증. - Peer GraphQL (
PeerGraphQLService.applyPeerSchema) — 암호화된 피어 트랜스포트를 통해 다른 기기를 위해/peer_graphql에 노출. Ed25519 서명 + ChaCha20 본문 암호화로 인증.
두 스키마는 동일한 비즈니스 로직 싱글톤(ChannelManager,
ChatMessageReceiver, …)을 공유하지만, 신뢰 모델이 다르기 때문에 다른
표면을 노출합니다: 웹 GraphQL은 로컬 UI를 신뢰하고, 피어 GraphQL은
암호학적으로 인증된 피어만 신뢰합니다.
Web GraphQL 표면(채팅)
쿼리: chatChannels(모든 채널 목록), chatItems(id)(대상의 메시지 — id는
"local", peer:<id>, 또는 channel:<id>), latestChatItems(모든 채팅의 미리보기).
채팅 뮤테이션: sendChatItem(toId, content), deleteChatItem(id),
deleteChatItems(query), retryChatItem(id).
채널 뮤테이션: createChatChannel(name), updateChatChannel(id, name),
deleteChatChannel(id), leaveChatChannel(id), addChatChannelMember(id, peerId), removeChatChannelMember(id, peerId), acceptChatChannelInvite(id),
declineChatChannelInvite(id).
Peer GraphQL 표면(트랜스포트)
/peer_graphql에 노출되며 Ed25519 서명 + ChaCha20 본문 암호화로 인증됩니다.
세 가지 뮤테이션만 트랜스포트 경계를 가로지릅니다: createChatItem(content)(들어오는
피어 메시지), channelSystemMessage(type, payload)(초대/탈퇴 같은 채널 라이프사이클
이벤트), startAware(피어에게 Wi-Fi Aware 서비스를 시작하라는 넛지로, 더 빠른
트랜스포트가 인계할 수 있게).
c-id HTTP 헤더는 발신자의 clientId를 전달합니다; c-cid 헤더는 요청이
채널 범위일 때 채널 id를 전달합니다(수신자가 복호화를 위해 쌍방향 피어 키 대신
채널 키를 선택하도록).
피어 채팅: 메시지 전송 {#peer-chat-sending-a-message}
사용자가 피어 대화에서 Send를 누를 때, 호출 체인은 다음과 같습니다:
각 홉에서 강제되는 핵심 불변 조건:
- **
ChatManager.createChatItem**은 항상 먼저 행을 삽입하고, 그 다음에 전송합니다. 이는 UI가 즉시 "보류 중" 버블을 보게 하고, 전달이 아직 일어나지 않았더라도 앱 크래시에도 메시지가 살아남음을 의미합니다. - **
PeerGraphQLClient.buildSignedRequest**는signature|timestamp|requestJson형태의 봉투를 만듭니다. 서명은"$timestamp$requestJson"에 대한 Ed25519로, 타임스탬프를 본문에 바인딩하여 새 타임스탬프로 재생될 수 없게 합니다. - **
PeerTransportRouter.send**는Lan → WifiAware → Ble순서로 트랜스포트를 순회합니다. 각 트랜스포트는TransportUnavailable을 throw하여 라우터가 다음 것을 시도하게 할 수 있습니다. - 수신 측에서, **
PeerChatParser.decrypt**는 GraphQL 뮤테이션이 실행되기도 전에 타임스탬프가±5분이내인지 확인하고 Ed25519 서명을 검증합니다. - **
ChatMessageReceiver.receive**는"$fromPeerId|$signature|$timestamp"로 키가 지정된seenSignatures집합을 유지하며, 중복 시ReplayedMessageException을 throw합니다 — 이는 트랜스포트가 동일한 페이로드를 두 번 전달할 수 있으므로(LAN + BLE) 필수적입니다.
PeerChatSender.send가 null이 아닌 오류 문자열을 반환하면, ChatSender는
**triggerPeerRediscovery(peerId)**를 호출하여, 피어가 현재 IP/포트를 다시
알릴 수 있도록 지시형 암호화 DISCOVER 브로드캐스트를 발생시킵니다.
피어 채팅: 메시지 수신 {#peer-chat-receiving-a-message}
인바운드 요청은 로컬 Ktor 서버의 /peer_graphql 라우트에 도착하며,
PeerGraphQLService가 처리합니다:
알림
emitNotificationIfNeeded는 마지막 단계입니다. TempData.activeToId == targetId일
때(즉, 사용자가 현재 해당 대화를 보고 있을 때) 또는 canShowNotifications()가
false일 때 알림을 억제합니다. 채널 알림은 발신자 이름이 접두사로 붙습니다.
채널 채팅: 리더 선출과 fan-out {#channel-chat-leader-election--fan-out}
채널은 다자간이지만 서버리스입니다. 모든 멤버가 같은 메시지를 N번 fan-out하는 것을 피하기 위해, 전송 측은 모든 가입 멤버에게 브로드캐스트할 임무를 지닌 단일 리더를 선출합니다.
리더 선출 알고리즘(DChatChannel.electLeader)
- 현재 온라인인 가입 멤버로 필터링(로컬 기기는 항상 온라인으로 간주).
- 소유자가 온라인 가입 멤버 중에 있으면 → 소유자가 리더.
- 그렇지 않으면, 리더는 **가장 작은
clientId**를 가진 온라인 가입 멤버 (결정적 타이브레이크, 조정 불필요). - 온라인 가입 멤버가 없으면
null반환.
전송 흐름
리더가 왜 필요한가?
5명 멤버 채널에서 모두가 서로에게 브로드캐스트한다고 상상해 보십시오: 단일 메시지가 20개의 네트워크 왕복과 각 멤버에게 도착하는 4개의 중복 사본을 생성할 것입니다. 하나의 리더를 선출함으로써, 오직 그 기기만 fan-out을 수행합니다 — 전송자는 스스로 fan-out을 수행하거나(리더인 경우), 리더에게 단일 사본을 릴레이하고 리더가 fan-out합니다.
리더가 오프라인이면, 전송자는 Result.NoLeader로 폴백하고, 피어 재발견을
트리거하여(리더의 IP를 찾기 위해), 상태를 지우고 사용자가 재시도하게 합니다.
채널 키 라우팅
채널 메시지는 쌍방향 피어 키가 아닌 채널의 ChaCha20 키로 암호화됩니다.
이것이 채널을 통해서만 다른 멤버를 만난(1:1로 페어링한 적 없는) 멤버가 메시지를
받을 수 있게 합니다 — 이들의 peers 행은 status="channel"이고 key=""입니다.
전송자는 c-cid HTTP 헤더를 채널 id로 설정하고; 수신자는 쌍방향 키 대신
ChannelCacher.getKeyBytes(channelId)를 조회합니다.
수신자별 재시도
각 sendToMember는 DMessageDeliveryResult를 반환합니다. 집계된
DMessageStatusData는 채팅 항목의 status_data JSON으로 영속화됩니다.
UI는 "Alice, Bob에게 전달됨; Carol은 실패"를 표시하고 사용자가 Carol에 대해
Retry를 누를 수 있게 합니다 — ChatManager.sendToChannelMembers는
재시도 부분집합에 대해 sendToRecipients를 재실행하고 새 결과를 기존 것과
병합하여, 재시도된 피어만 교체합니다.
채널 시스템 메시지 {#channel-system-messages}
채널 제어 평면 메시지(초대, 수락, 거절, 갱신, 추방, 탈퇴)는 피어 GraphQLchannelSystemMessage 뮤테이션을 통해 교환됩니다. 이들은 type 문자열로
타입이 지정된 JSON 페이로드입니다:
| 타입 | 방향 | 서명? | 목적 |
|---|---|---|---|
channel_invite | 소유자 → 초대받은 자 | 예 | 피어 초대; 채널 키 + 멤버 전달. |
channel_invite_accept | 초대받은 자 → 소유자 | 아니오 | 수락; 수락자의 공개 키 전달. |
channel_invite_decline | 초대받은 자 → 소유자 | 아니오 | 거절; 소유자가 멤버 제거. |
channel_update | 소유자 → 모든 멤버 | 예 | 멤버십/이름 변경 브로드캐스트. |
channel_kick | 소유자 → 추방된 피어 | 예 | 타겟팅 추방; 채널 삭제 시에도 브로드캐스트. |
channel_leave | 멤버 → 소유자 | 아니오 | 멤버가 시작한 탈퇴 통지. |
서명된 페이로드 포맷
세 가지 서명된 타입(invite, update, kick)은 정규 파이프 구분 문자열을
사용합니다: "$channelId|$version|$action|$target", 여기서 action은 invite,
update, kick 중 하나이고, target은 초대받은/추방된 피어 id(브로드캐스트
kick의 경우 비어 있음)입니다.
소유자가 자신의 Ed25519 키로 이 문자열에 서명합니다. 수신자는 서명을 확인하기
전에 channel.owner != fromId인 메시지를 거부하고, version이 로컬
버전 ≤인 ChannelUpdate 페이로드를 거부합니다(순서 어긋난 전달에 대한
구버전 가드).
지연 피어 하이드레이션
ChannelInvite와 ChannelUpdate는 memberPeers: List<MemberPeerInfo> 목록을
전달합니다 — 모든 멤버에 대한 경량 피어 정보(id, name, publicKey, deviceType, ip,
port). 수신자의 ensureChannelPeer는 이전에 본 적 없는 모든 멤버에 대해
status="channel"인 DPeer 행을 생성합니다. 이것은 fan-out 라우팅이 메시지를
보내기 위해 모든 멤버의 피어 레코드를 필요로 하므로 중요합니다.
채널 라이프사이클 {#channel-lifecycle}
피어 트랜스포트 계층(LAN → Wi-Fi Aware → BLE) {#peer-transport-layer-lan--wi-fi-aware--ble}
PeerTransportRouter는 circuit breaking을 갖춘 전략 체인입니다.
트랜스포트의 순서화된 목록은 다음과 같습니다:
LanTransport— 첫 번째 선택. HTTPS 위에 ChaCha20 암호화 인터셉터를 갖춘 OkHttp 사용.peer.ip가 비어 있으면 완전히 건너뜀(아직 발견하지 못한 서브넷 가로지르기 피어).WifiAwareTransport(Android 13+ 전용) — Wi-Fi Aware(NAN) 데이터 경로 사용. 피어의awareRunning플래그가 false이면 빠르게 건너뜀(BLE prewarmer 스캔으로 갱신). 피어의 IPv6는 호스트네임plain-aware-peer를 링크 로컬 주소로 매핑하는 커스텀 DNS로 해결.BleTransport— 페어링된 피어에 대한 보장된 폴백. GATT를 통해 청크된 RPC를 스트리밍. 느리지만 IP 연결 없이도 작동.
이 순서인 이유?
- LAN이 가장 빠름(단일 HTTPS 왕복, ~10ms 타임아웃).
- Wi-Fi Aware는 중간(데이터 경로 설정 ~5초, 이후 ~10ms 왕복)이며 서브넷
가로지르기 작동(예: 한 기기는 게스트 Wi-Fi, 다른 하나는 IoT Wi-Fi). 피어의
Aware 서비스가 실행 중이 아닐 때 빠르게 건너뛰도록 튜닝하여, 10초
buildLink타임아웃을 회피. - BLE가 가장 느리지만 IP 연결이 전혀 없어도 작동 — Wi-Fi가 없어도 메시지가 여전히 전달됨. 페어링된 피어에 대한 보장된 폴백으로 사용.
circuit breaker는 불안정한 트랜스포트(특히 네트워크 변동 중 Wi-Fi Aware)를 2회 실패 후 30초간 건너뛰게 하여, 반복된 10초 타임아웃을 기다리는 대신 폴백이 빠르게 일어나게 합니다.
Wi-Fi Aware 핸드셰이크
AwareSession은 데이터 경로를 열기 전에 두 메시지 핸드셰이크를 수행합니다:
MSG_HELLO(구독자 → 발행자): "나는 당신을 봅니다, 여기 내 피어 핸들이 있습니다."MSG_READY(발행자 → 구독자): "내 네트워크 스페시파이어를 등록했습니다, 이제requestNetwork할 수 있습니다."
이는 Android 프레임워크의 ~500ms 윈도우 내에 양쪽의
connectivityManager.requestNetwork(...) 호출을 동기화합니다. 구독자는
더 작은 clientId를 가진 쪽입니다(결정적 역할 분할 — 양쪽이 조정 없이 합의),
그리고 재시도 루프를 소유합니다.
피어 상태 및 프레전스 {#peer-status--presence}
프레전스는 장수 WebSocket 연결을 통해 추적됩니다. 각 쌍의 한쪽만 소켓을
엽니다 — 결정적 규칙 TempData.clientId < peer.id로 결정됩니다. 다른 쪽은
/peer_status에서 인바운드 연결을 수락합니다.
PeerCacher.onlineMap이 프레전스의 진실 공급원입니다. 이는
onlinePeerIds: StateFlow<Set<String>>로 노출되며, 채널 리더 선출
(electLeader(onlinePeerIds, myId))에 의해 소비됩니다.
캐싱 계층 {#caching-layer}
두 캐시가 데이터베이스 테이블을 메모리에 미러링하고 Compose가 직접 수집하는
StateFlow를 노출합니다:
copy-on-write인 이유?
Kotlin의 MutableStateFlow.distinctUntilChanged는 구조적 동등성을 사용합니다.
DPeer를 제자리에서 변이했다면, 파생된 pairedPeers 목록은 변이 전후에
동일한 DPeer 참조를 포함했을 것이고, distinctUntilChanged는 차이를
보지 못하고 방출을 억제했을 것입니다. 엔티티를 먼저 복사하고, 사본을 변이하고,
맵 항목을 새 PeerRuntime/ChannelRuntime으로 교체함으로써, 파생 목록은
새-참조의-새-목록을 얻고 흐름이 발생합니다.
파일 다운로드 {#file-downloads}
인바운드 파일/이미지 메시지는 경계가 있는 워커 풀에 의해 자동으로 다운로드됩니다.
각 다운로드는 사용 가능한 트랜스포트(PeerTransportRouter.downloadFile)를 통해
스트리밍되어 임시 파일에 쓰여지고, 이후 앱의 미디어 저장소로 임포트되어 채팅
항목의 uri 필드를 패치합니다.
트랜스포트 무관 스트리밍
DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable 추상화는
LAN과 Wi-Fi Aware가 라이브 HTTP 본문을 스트림하게 하고, BLE는 동일한
ByteReadChannel을 통해 청크된 RPC(16KiB 청크, GET /fs?id=…&offset=…&length=…
경유)를 스트림하게 합니다. onClose 콜백은 소비자가 응답을 일찍 닫을 때(예:
일시정지 시) BLE가 백그라운드 다운로드 코루틴을 취소하게 합니다.
설계 패턴 요약 {#design-patterns-recap}
| 패턴 | 위치 | 이유 |
|---|---|---|
| 파사드 | ChatManager | 단일 진입점; 호출자는 DB/트랜스포트에 직접 접근하지 않음. |
| 전략 + 책임 연쇄 | PeerTransportRouter + LanTransport/WifiAwareTransport/BleTransport | TransportUnavailable을 넘어가기 신호로 하는 플러거블 트랜스포트. |
| Circuit Breaker | PeerCircuitBreaker | 2회 실패 / 30초가 (피어, 트랜스포트) 구간을 열어 Wi-Fi Aware가 폴백을 차단하지 않게 함. |
| 상태 머신 | PeerStatusManager.PeerState, AwarePeerLink.LinkState | 소켓 라이프사이클과 NDP 링크 라이프사이클에 대한 명시적 전이. |
| 생산자/소비자 + 풀 | DownloadQueue (3 워커, Channel.BUFFERED) | 파일 다운로드를 위한 경계가 있는 동시성. |
| 옵저버 / 리액티브 | StateFlow 어디에나 | Compose가 직접 수집; 수동 갱신 없음. |
| 재생 보호 | ChatMessageReceiver.seenSignatures, PeerChatParser.MAX_TIMESTAMP_DIFF_MS | LAN+BLE 이중 전달로부터 중복 제거; 윈도우 밖 타임스탬프 거부. |
| 지수 백오프 | PeerStatusManager.scheduleReconnect | min(60초, 1초 × 2^min(n-1, 6)) — 64초에서 상한. |
| Copy-on-Write | PeerCacher.mutatePeer, ChannelCacher.mutateChannel | StateFlow.distinctUntilChanged가 모든 변이 시 발생하게 강제. |
| 서명 봉투 | PeerGraphQLClient.buildSignedRequest | signature|timestamp|body — 타임스탬프를 본문에 바인딩하여 재생 방지. |
| 결정적 역할 분할 | TempData.clientId < peer.id | WebSocket 클라이언트 대 서버, Wi-Fi Aware 구독자 대 발행자 결정. |
| 지연 하이드레이션 | invite/update 시 ensureChannelPeer | 보이지 않는 채널 멤버를 위해 peers 행을 생성하여 fan-out 라우팅이 작동하게 함. |
| 암호화된 신원 | LANDiscoverManager.discoverSpecificDevice | 지시형 DISCOVER가 대상 id를 피어 키로 암호화 — 대상만 인식. |
더 읽어보기
- Pairing Flow — 두 기기가 어떻게 신뢰를 설정하고 이 글의 모든 트랜스포트가 사용하는 공유 ChaCha20 키를 교환하는지.
apitest/groups/chat-messages.sh과apitest/groups/chat-channels.sh— 모든 GraphQL 뮤테이션을 종단 간으로 테스트하는 실행 가능 테스트 계획.