이 글의 위치: 기술 블로그 → Backend/Client · 채팅 백엔드 설계 시리즈의 클라이언트 편. 서버 쪽에서 dumb pipe 이중 쓰기와 멱등키·멀티테넌시를 봤다. 이번엔 그 백엔드에 실제로 붙은 Flutter 앱 Bibleana에서, 같은 설계가 클라이언트에서 어떻게 완성되는지를 본다.
Bibleana는 여러 기능을 묶은 개인 Flutter 앱이다. 다른 화면은 아직 목업이지만, 커뮤니티 채팅만은 OKE에 올린 Go chat-relay에 실제로 연동돼 있다. 그 연동 레이어가 이 앱에서 가장 단단한 코드다.
계층형 채팅 데이터 레이어
위젯은 ChatRepository 하나만 알면 된다. 그 아래 REST/WebSocket/세션은 위젯이 모른다.
ChatConfig (접속 설정)
→ ChatSession (게스트 인증)
→ ChatApiClient (REST: cms)
→ ChatSocket (WS: rgs)
→ ChatRepository(오케스트레이션 + DTO→UI 모델 매핑)
접속 오리진은 한 곳에서 관리한다. 빌드타임에 --dart-define으로 오버라이드할 수 있고, 기본값은 IP를 도메인처럼 쓰는 sslip.io 호스트다(인그레스 편에서 만든 그 오리진).
static const String baseUrl = String.fromEnvironment(
'CHAT_BASE_URL',
defaultValue: 'https://134-185-107-236.sslip.io',
);
static const String appKey = 'com.seongwon.myapp'; // 테넌트 식별(X-App-Key)
여기서 재밌는 트릭 하나 — WebSocket URI를 따로 설정하지 않고 baseUrl에서 scheme만 바꿔 유도한다. http→ws, https→wss.
final base = Uri.parse(ChatConfig.baseUrl);
final scheme = base.scheme == 'http' ? 'ws' : 'wss';
return Uri(scheme: scheme, host: base.host,
port: base.hasPort ? base.port : null,
path: '/api/rgs/ws', queryParameters: {'token': _token});
브라우저 WebSocket은 커스텀 헤더를 못 넣으므로, JWT를 쿼리스트링 token 으로 넘긴다. 이런 실무 제약이 코드 모양을 결정한다.
single-flight 게스트 인증
로그인 UI 없이 채팅을 붙이려면 익명 사용자에게 안정적 신원을 줘야 한다. 그리고 화면이 뜨자마자 여러 요청이 동시에 인증을 호출할 수 있는데, 이게 중복되면 안 된다. 그래서 single-flight — 진행 중인 인증이 있으면 그 Future를 공유한다.
Future<void> ensureAuthed() {
if (isAuthed) return Future.value();
return _authing ??= _authenticate().whenComplete(() => _authing = null);
}
deviceId는 Random.secure() + microsecondsSinceEpoch로 만들어 shared_preferences에 1회 영속화하고, 로그아웃(clear()) 때도 deviceId는 유지한다(신원 안정성). 서버는 이 deviceId 기반으로 POST /api/ums/auth/guest에 JWT를 발급한다.
핵심 — clientMsgId로 낙관적 UI와 에코를 잇는다
서버 편에서 봤듯 클라이언트는 이중 쓰기를 한다: REST로 영속화하고, WS로 릴레이한다. 그런데 WS 릴레이는 같은 방 전원에게 뿌려지므로 내가 보낸 메시지가 나에게 되돌아온다. 그냥 두면 화면에 같은 메시지가 두 번 뜬다.
해법은 clientMsgId 다. 이건 서버가 재시도 중복을 막는 멱등키와 정확히 같은 값이다 — 한 키가 서버(멱등 저장)와 클라이언트(에코 dedupe) 양쪽에서 일한다.
보낼 때 — REST와 WS에 같은 clientMsgId를 실어보내고, 화면에는 먼저 그 메시지를 낙관적으로 그린다(전송 확인을 기다리지 않는다).
// sendMessage: REST postMessage 와 WS socket.send 에 같은 clientMsgId
final clientMsgId = _newClientMsgId();
_api.postMessage(roomId, text, clientMsgId: clientMsgId); // 영속화
_socket.send(roomId, text, clientMsgId: clientMsgId); // 릴레이
되돌아올 때 — WS로 인바운드된 메시지의 id는 clientMsgId ?? 'srv_...' 로 매핑된다. 화면은 같은 id가 이미 있으면 새로 추가하지 않고 기존 낙관적 메시지를 서버 확정본으로 교체한다.
// _onIncoming: 같은 id가 있으면 교체, 없으면 추가
final idx = _messages.indexWhere((x) => x.id == m.id);
if (idx >= 0) {
next[idx] = m; // 낙관적 → 서버 확정본으로 승격
} else {
_messages = [..._messages, m]; // 남이 보낸 새 메시지
}
낙관적 UI(즉시 표시) + 멱등 키(중복 제거)의 교과서적 조합이다. 전송이 실패하면 SnackBar로 알리고 재시도하게 둔다.
히스토리 정렬 함정
작은 함정 하나. 서버(chat-management)는 timeline을 id DESC(최신순) 로 반환한다(페이지네이션이 그렇게 설계됨). 그런데 화면은 위→아래 시간순으로 그려야 하므로, 받아온 리스트를 sentAt 기준으로 다시 오름차순 정렬해야 한다.
list.sort((a, b) => a.sentAt.compareTo(b.sentAt));
정리
- 위젯은
ChatRepository만 알고, REST/WS/세션은 계층 뒤에 숨긴다. - baseUrl 하나에서 ws scheme을 유도하고, 브라우저 제약 때문에 토큰을 쿼리스트링으로 넘긴다.
- single-flight로 동시 인증 중복을 막고, deviceId를 영속화해 게스트 신원을 안정화한다.
- clientMsgId 한 값이 서버 멱등키이자 클라이언트 에코 dedupe 키다 — 이중 쓰기 설계가 클라이언트에서 완성되는 지점.
이 시리즈의 마지막은 이 모든 인프라 프로비저닝을 한 명령으로 묶은 이야기 — Provider 계층만 교체하는 스킬 설계로 마무리한다.
답글 남기기