Flutter 튜토리얼 31편: WebSocket 실시간 통신
요약
핵심 요지
- 문제 정의: HTTP는 클라이언트가 요청해야 응답을 받는다. 서버가 먼저 데이터를 보내려면 다른 방식이 필요하다.
- 핵심 주장: WebSocket은 연결을 유지한 채 서버와 클라이언트가 자유롭게 메시지를 주고받을 수 있다.
- 주요 근거: 채팅, 알림, 실시간 대시보드 등 즉각적인 업데이트가 필요한 기능에 적합하다.
- 실무 기준: 연결 관리, 재연결 로직, 메시지 파싱까지 실제 앱 수준으로 구현한다.
- 한계: 복잡한 실시간 기능에는 Firebase, Socket.IO 같은 고수준 솔루션을 고려해야 한다.
문서가 설명하는 범위
- HTTP와 WebSocket의 차이
- web_socket_channel 패키지 사용법
- 연결, 메시지 송수신, 연결 종료
- StreamBuilder로 실시간 UI 업데이트
- 간단한 채팅 앱 구현
- 연결 상태 관리와 재연결
읽는 시간: 15분 | 난이도: 중급
참고 자료
- Communicate with WebSockets - 공식 WebSocket 가이드
- web_socket_channel package - WebSocket 패키지
- Stream class - Dart Stream API
문제 상황
29편과 30편에서 HTTP로 서버와 통신하는 방법을 배웠습니다. 하지만 HTTP에는 한계가 있습니다.
HTTP의 한계
상황: 채팅 앱에서 새 메시지가 왔는지 확인하고 싶다
HTTP 방식:1. 클라이언트가 서버에 요청 → "새 메시지 있어?"2. 서버가 응답 → "없어" 또는 "있어, 여기"3. 1초 후 다시 요청 → "새 메시지 있어?"4. 계속 반복... (폴링)문제는 다음과 같습니다.
- 새 메시지가 없어도 계속 요청해야 한다 (비효율적).
- 요청 주기가 길면 메시지가 늦게 도착한다.
- 요청 주기가 짧으면 서버 부하가 커진다.
WebSocket은 이 문제를 해결합니다. 연결을 한 번 맺으면, 서버가 새 메시지가 있을 때 먼저 보내줍니다.
해결 방법
WebSocket은 양방향 통신을 제공합니다. 한 번 연결하면 서버와 클라이언트 모두 자유롭게 메시지를 보낼 수 있습니다.
챕터 1: WebSocket 기초 개념
Why
NOTEHTTP와 WebSocket의 차이를 이해해야 적절한 상황에 사용할 수 있습니다.
HTTP: 요청-응답 모델
- 클라이언트가 요청하면 서버가 응답
- 한 번의 요청에 한 번의 응답
- 연결이 바로 끊어짐
WebSocket: 양방향 스트림
- 연결을 유지한 채 메시지 교환
- 서버도 먼저 메시지를 보낼 수 있음
- 연결을 명시적으로 끊을 때까지 유지
What
NOTEWebSocket 사용 사례를 알아봅시다.
WebSocket이 적합한 경우:
- 채팅 앱 (새 메시지 실시간 수신)
- 실시간 알림 (푸시 알림 대체)
- 주식/코인 시세 (가격 실시간 업데이트)
- 멀티플레이어 게임 (플레이어 위치 동기화)
- 협업 도구 (문서 동시 편집)
HTTP가 여전히 적합한 경우:
- CRUD 작업 (데이터 생성, 조회, 수정, 삭제)
- 파일 업로드/다운로드
- 인증 (로그인, 토큰 발급)
- 검색, 필터링
How
TIPFlutter에서 WebSocket을 사용하려면
web_socket_channel패키지를 설치합니다.Terminal window flutter pub add web_socket_channel기본 사용법은 다음과 같습니다.
import 'package:web_socket_channel/web_socket_channel.dart';// 1. 연결final channel = WebSocketChannel.connect(Uri.parse('wss://echo.websocket.events'),);// 2. 메시지 받기channel.stream.listen((message) {print('받은 메시지: $message');});// 3. 메시지 보내기channel.sink.add('안녕하세요!');// 4. 연결 종료channel.sink.close();
wss://는 보안 WebSocket(WebSocket Secure)입니다. HTTPS처럼 암호화된 연결을 제공합니다.
Watch out
WARNINGws vs wss:
ws://는 암호화되지 않은 연결입니다. 프로덕션에서는 반드시wss://를 사용하세요.에코 서버:
wss://echo.websocket.events는 테스트용 서버입니다. 보낸 메시지를 그대로 돌려보내줍니다. 실제 앱에서는 본인의 서버를 사용해야 합니다.
챕터 2: 연결하고 메시지 받기
Why
NOTEWebSocket의 핵심은 Stream입니다. Stream은 시간에 따라 여러 값을 전달하는 비동기 시퀀스입니다.
- Future: 하나의 값을 나중에 전달
- Stream: 여러 값을 시간에 따라 전달
WebSocket 메시지는 Stream으로 들어옵니다. 새 메시지가 올 때마다 이벤트가 발생합니다.
What
NOTE
StreamBuilder로 실시간 메시지를 화면에 표시합니다.StreamBuilder(stream: channel.stream,builder: (context, snapshot) {// 연결 상태 확인if (snapshot.connectionState == ConnectionState.waiting) {return Text('연결 중...');}// 에러 확인if (snapshot.hasError) {return Text('에러: ${snapshot.error}');}// 데이터 표시if (snapshot.hasData) {return Text('받은 메시지: ${snapshot.data}');}return Text('메시지를 기다리는 중...');},)FutureBuilder vs StreamBuilder:
- FutureBuilder: 데이터가 한 번 오면 완료
- StreamBuilder: 데이터가 계속 올 때마다 업데이트
How
TIP전체 예제를 만들어봅시다.
import 'package:flutter/material.dart';import 'package:web_socket_channel/web_socket_channel.dart';class WebSocketDemo extends StatefulWidget {@override_WebSocketDemoState createState() => _WebSocketDemoState();}class _WebSocketDemoState extends State<WebSocketDemo> {final _controller = TextEditingController();late WebSocketChannel _channel;@overridevoid initState() {super.initState();_connectToServer();}void _connectToServer() {_channel = WebSocketChannel.connect(Uri.parse('wss://echo.websocket.events'),);}void _sendMessage() {if (_controller.text.isNotEmpty) {_channel.sink.add(_controller.text);_controller.clear();}}@overridevoid dispose() {_channel.sink.close();_controller.dispose();super.dispose();}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: Text('WebSocket 데모')),body: Padding(padding: EdgeInsets.all(16),child: Column(children: [// 메시지 입력Row(children: [Expanded(child: TextField(controller: _controller,decoration: InputDecoration(labelText: '메시지 입력',border: OutlineInputBorder(),),),),SizedBox(width: 8),IconButton(icon: Icon(Icons.send),onPressed: _sendMessage,),],),SizedBox(height: 24),// 받은 메시지 표시Expanded(child: StreamBuilder(stream: _channel.stream,builder: (context, snapshot) {if (snapshot.hasError) {return Center(child: Text('연결 오류: ${snapshot.error}'),);}if (snapshot.hasData) {return Center(child: Text('서버 응답: ${snapshot.data}',style: TextStyle(fontSize: 18),),);}return Center(child: Text('메시지를 보내보세요'),);},),),],),),);}}메시지를 보내면 에코 서버가 같은 메시지를 돌려보냅니다.
Watch out
WARNINGStream은 한 번만 listen할 수 있습니다:
// 에러! 같은 스트림을 두 번 사용channel.stream.listen(...); // 첫 번째 리스너StreamBuilder(stream: channel.stream, ...); // 두 번째 리스너 - 에러!여러 곳에서 사용하려면
broadcast스트림으로 변환하세요.final broadcastStream = channel.stream.asBroadcastStream();dispose에서 반드시 close: 연결을 닫지 않으면 리소스 누수가 발생합니다.
챕터 3: 채팅 앱 만들기
Why
NOTEWebSocket의 대표적인 사용 사례는 채팅입니다. 실제 채팅 앱처럼 메시지 목록을 관리하고 화면에 표시해봅시다.
핵심 기능:
- 메시지 목록 유지 (List)
- 새 메시지 실시간 추가
- 스크롤 자동 이동
What
NOTE채팅 메시지 모델을 정의합니다.
class ChatMessage {final String text;final bool isMe; // 내가 보낸 메시지인지final DateTime timestamp;ChatMessage({required this.text,required this.isMe,DateTime? timestamp,}) : timestamp = timestamp ?? DateTime.now();}메시지를 구분하면 UI에서 다르게 표시할 수 있습니다.
- 내 메시지: 오른쪽 정렬, 파란색 배경
- 상대 메시지: 왼쪽 정렬, 회색 배경
How
TIP완성된 채팅 화면입니다.
class ChatScreen extends StatefulWidget {@override_ChatScreenState createState() => _ChatScreenState();}class _ChatScreenState extends State<ChatScreen> {final _controller = TextEditingController();final _scrollController = ScrollController();final List<ChatMessage> _messages = [];late WebSocketChannel _channel;late StreamSubscription _subscription;@overridevoid initState() {super.initState();_connectToServer();}void _connectToServer() {_channel = WebSocketChannel.connect(Uri.parse('wss://echo.websocket.events'),);// 메시지 수신 리스너_subscription = _channel.stream.listen((message) {setState(() {_messages.add(ChatMessage(text: message,isMe: false, // 서버에서 온 메시지));});_scrollToBottom();},onError: (error) {print('WebSocket 에러: $error');},onDone: () {print('WebSocket 연결 종료');},);}void _sendMessage() {final text = _controller.text.trim();if (text.isEmpty) return;// 내 메시지 추가setState(() {_messages.add(ChatMessage(text: text,isMe: true,));});// 서버로 전송_channel.sink.add(text);_controller.clear();_scrollToBottom();}void _scrollToBottom() {WidgetsBinding.instance.addPostFrameCallback((_) {if (_scrollController.hasClients) {_scrollController.animateTo(_scrollController.position.maxScrollExtent,duration: Duration(milliseconds: 300),curve: Curves.easeOut,);}});}@overridevoid dispose() {_subscription.cancel();_channel.sink.close();_controller.dispose();_scrollController.dispose();super.dispose();}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: Text('채팅')),body: Column(children: [// 메시지 목록Expanded(child: ListView.builder(controller: _scrollController,padding: EdgeInsets.all(16),itemCount: _messages.length,itemBuilder: (context, index) {return ChatBubble(message: _messages[index]);},),),// 입력 영역Container(padding: EdgeInsets.all(8),decoration: BoxDecoration(color: Colors.white,boxShadow: [BoxShadow(color: Colors.black12,blurRadius: 4,),],),child: Row(children: [Expanded(child: TextField(controller: _controller,decoration: InputDecoration(hintText: '메시지 입력...',border: OutlineInputBorder(borderRadius: BorderRadius.circular(24),),contentPadding: EdgeInsets.symmetric(horizontal: 16,vertical: 8,),),onSubmitted: (_) => _sendMessage(),),),SizedBox(width: 8),CircleAvatar(child: IconButton(icon: Icon(Icons.send),onPressed: _sendMessage,),),],),),],),);}}채팅 버블 위젯입니다.
class ChatBubble extends StatelessWidget {final ChatMessage message;const ChatBubble({required this.message});@overrideWidget build(BuildContext context) {return Align(alignment: message.isMe? Alignment.centerRight: Alignment.centerLeft,child: Container(margin: EdgeInsets.only(bottom: 8,left: message.isMe ? 64 : 0,right: message.isMe ? 0 : 64,),padding: EdgeInsets.symmetric(horizontal: 16,vertical: 10,),decoration: BoxDecoration(color: message.isMe? Colors.blue: Colors.grey.shade200,borderRadius: BorderRadius.circular(16),),child: Text(message.text,style: TextStyle(color: message.isMe ? Colors.white : Colors.black,),),),);}}
Watch out
WARNING에코 서버 특성: 테스트 서버는 보낸 메시지를 그대로 돌려보냅니다. 그래서 내 메시지가 두 번 표시될 수 있습니다. 실제 채팅 서버에서는 다른 사용자의 메시지만 받습니다.
키보드와 스크롤: 키보드가 올라오면 메시지가 가려질 수 있습니다.
resizeToAvoidBottomInset: true(기본값)로 처리됩니다.
챕터 4: 연결 상태 관리
Why
NOTE실제 앱에서는 연결이 끊어질 수 있습니다.
- 네트워크 불안정
- 서버 재시작
- 앱이 백그라운드로 이동
연결 상태를 추적하고 자동 재연결하는 게 좋습니다.
What
NOTE연결 상태를 enum으로 정의합니다.
enum ConnectionStatus {connecting, // 연결 중connected, // 연결됨disconnected, // 연결 끊김error, // 에러 발생}상태에 따라 UI를 다르게 표시합니다.
- 연결 중: 로딩 표시
- 연결됨: 정상 UI
- 끊김: 재연결 버튼
How
TIP연결 상태 관리와 재연결 로직입니다.
class _ChatScreenState extends State<ChatScreen> {ConnectionStatus _status = ConnectionStatus.disconnected;WebSocketChannel? _channel;StreamSubscription? _subscription;Timer? _reconnectTimer;@overridevoid initState() {super.initState();_connect();}Future<void> _connect() async {if (_status == ConnectionStatus.connecting) return;setState(() {_status = ConnectionStatus.connecting;});try {_channel = WebSocketChannel.connect(Uri.parse('wss://echo.websocket.events'),);// 연결 성공을 기다림await _channel!.ready;setState(() {_status = ConnectionStatus.connected;});_subscription = _channel!.stream.listen(_onMessage,onError: _onError,onDone: _onDone,);} catch (e) {setState(() {_status = ConnectionStatus.error;});_scheduleReconnect();}}void _onMessage(dynamic message) {setState(() {_messages.add(ChatMessage(text: message, isMe: false));});}void _onError(dynamic error) {print('WebSocket 에러: $error');setState(() {_status = ConnectionStatus.error;});_scheduleReconnect();}void _onDone() {print('WebSocket 연결 종료');setState(() {_status = ConnectionStatus.disconnected;});_scheduleReconnect();}void _scheduleReconnect() {_reconnectTimer?.cancel();_reconnectTimer = Timer(Duration(seconds: 3), () {if (_status != ConnectionStatus.connected) {_connect();}});}void _disconnect() {_reconnectTimer?.cancel();_subscription?.cancel();_channel?.sink.close();setState(() {_status = ConnectionStatus.disconnected;});}@overridevoid dispose() {_disconnect();super.dispose();}// 상태 표시 위젯Widget _buildStatusIndicator() {Color color;String text;switch (_status) {case ConnectionStatus.connecting:color = Colors.orange;text = '연결 중...';break;case ConnectionStatus.connected:color = Colors.green;text = '연결됨';break;case ConnectionStatus.disconnected:color = Colors.grey;text = '연결 끊김';break;case ConnectionStatus.error:color = Colors.red;text = '오류';break;}return Container(padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),decoration: BoxDecoration(color: color.withOpacity(0.1),borderRadius: BorderRadius.circular(12),),child: Row(mainAxisSize: MainAxisSize.min,children: [Container(width: 8,height: 8,decoration: BoxDecoration(color: color,shape: BoxShape.circle,),),SizedBox(width: 6),Text(text, style: TextStyle(color: color)),],),);}}AppBar에 상태 표시기를 추가합니다.
AppBar(title: Text('채팅'),actions: [Padding(padding: EdgeInsets.only(right: 16),child: Center(child: _buildStatusIndicator()),),],)
Watch out
WARNING무한 재연결 방지: 재연결 횟수를 제한하거나 지수 백오프를 적용하세요.
int _reconnectAttempts = 0;static const maxAttempts = 5;void _scheduleReconnect() {if (_reconnectAttempts >= maxAttempts) {print('최대 재연결 시도 횟수 초과');return;}_reconnectAttempts++;final delay = Duration(seconds: _reconnectAttempts * 2); // 지수 백오프_reconnectTimer = Timer(delay, _connect);}백그라운드 처리: 앱이 백그라운드로 가면 연결을 끊고, 포그라운드로 돌아오면 재연결하는 게 좋습니다.
WidgetsBindingObserver로 앱 상태를 감지할 수 있습니다.
챕터 5: JSON 메시지 처리
Why
NOTE실제 서버는 단순 문자열이 아니라 JSON을 주고받습니다. 메시지 타입, 발신자 정보, 타임스탬프 등이 포함됩니다.
{"type": "message","sender": "user123","content": "안녕하세요!","timestamp": "2024-01-15T10:30:00Z"}
What
NOTEJSON 메시지를 파싱하고 직렬화합니다.
import 'dart:convert';// 수신 메시지 파싱void _onMessage(dynamic data) {try {final json = jsonDecode(data as String);final type = json['type'] as String;switch (type) {case 'message':_handleChatMessage(json);break;case 'user_joined':_handleUserJoined(json);break;case 'user_left':_handleUserLeft(json);break;default:print('알 수 없는 메시지 타입: $type');}} catch (e) {print('메시지 파싱 에러: $e');}}void _handleChatMessage(Map<String, dynamic> json) {setState(() {_messages.add(ChatMessage(text: json['content'] as String,sender: json['sender'] as String,isMe: json['sender'] == _myUserId,));});}메시지 전송도 JSON으로 합니다.
void _sendMessage(String text) {final message = {'type': 'message','content': text,'timestamp': DateTime.now().toIso8601String(),};_channel?.sink.add(jsonEncode(message));}
How
TIP메시지 모델을 확장합니다.
class ChatMessage {final String type;final String? sender;final String content;final DateTime timestamp;final bool isMe;ChatMessage({required this.type,this.sender,required this.content,required this.timestamp,required this.isMe,});factory ChatMessage.fromJson(Map<String, dynamic> json,String myUserId,) {return ChatMessage(type: json['type'] as String,sender: json['sender'] as String?,content: json['content'] as String,timestamp: DateTime.parse(json['timestamp'] as String),isMe: json['sender'] == myUserId,);}Map<String, dynamic> toJson() {return {'type': type,if (sender != null) 'sender': sender,'content': content,'timestamp': timestamp.toIso8601String(),};}}시스템 메시지도 처리합니다.
Widget _buildMessage(ChatMessage message) {if (message.type == 'system') {return Center(child: Container(margin: EdgeInsets.symmetric(vertical: 8),padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),decoration: BoxDecoration(color: Colors.grey.shade300,borderRadius: BorderRadius.circular(12),),child: Text(message.content,style: TextStyle(color: Colors.grey.shade700, fontSize: 12),),),);}return ChatBubble(message: message);}
Watch out
WARNING에러 처리: 서버가 잘못된 JSON을 보낼 수 있습니다. 항상 try-catch로 감싸세요.
타입 안전성:
as String으로 캐스팅할 때 null이면 에러납니다.// 위험final sender = json['sender'] as String;// 안전final sender = json['sender'] as String? ?? 'Unknown';
한계
이 튜토리얼에서 다룬 web_socket_channel은 기본적인 WebSocket 통신에 적합합니다.
더 복잡한 실시간 기능이 필요하면 다른 도구를 고려하세요.
web_socket_channel의 한계:
| 기능 | web_socket_channel | Socket.IO / Firebase |
|---|---|---|
| 기본 연결 | O | O |
| 자동 재연결 | X (직접 구현) | O |
| 룸/채널 개념 | X | O |
| 이벤트 기반 API | X | O |
| 오프라인 동기화 | X | O (Firebase) |
| 백엔드 SDK | X | O |
대안 솔루션:
- Socket.IO: 이벤트 기반, 자동 재연결, 폴백 지원
- Firebase Realtime Database: 실시간 동기화, 오프라인 지원
- Firebase Cloud Firestore: 더 복잡한 쿼리 지원
- Supabase Realtime: PostgreSQL 기반 실시간 업데이트
WebSocket은 실시간 통신의 기초입니다. 이 개념을 이해하면 고수준 라이브러리도 쉽게 사용할 수 있습니다.
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!