Flutter 튜토리얼 31편: WebSocket 실시간 통신

요약#

핵심 요지#

  • 문제 정의: HTTP는 클라이언트가 요청해야 응답을 받는다. 서버가 먼저 데이터를 보내려면 다른 방식이 필요하다.
  • 핵심 주장: WebSocket은 연결을 유지한 채 서버와 클라이언트가 자유롭게 메시지를 주고받을 수 있다.
  • 주요 근거: 채팅, 알림, 실시간 대시보드 등 즉각적인 업데이트가 필요한 기능에 적합하다.
  • 실무 기준: 연결 관리, 재연결 로직, 메시지 파싱까지 실제 앱 수준으로 구현한다.
  • 한계: 복잡한 실시간 기능에는 Firebase, Socket.IO 같은 고수준 솔루션을 고려해야 한다.

문서가 설명하는 범위#

  • HTTP와 WebSocket의 차이
  • web_socket_channel 패키지 사용법
  • 연결, 메시지 송수신, 연결 종료
  • StreamBuilder로 실시간 UI 업데이트
  • 간단한 채팅 앱 구현
  • 연결 상태 관리와 재연결

읽는 시간: 15분 | 난이도: 중급


참고 자료#


문제 상황#

29편과 30편에서 HTTP로 서버와 통신하는 방법을 배웠습니다. 하지만 HTTP에는 한계가 있습니다.

HTTP의 한계#

상황: 채팅 앱에서 새 메시지가 왔는지 확인하고 싶다
HTTP 방식:
1. 클라이언트가 서버에 요청 → "새 메시지 있어?"
2. 서버가 응답 → "없어" 또는 "있어, 여기"
3. 1초 후 다시 요청 → "새 메시지 있어?"
4. 계속 반복... (폴링)

문제는 다음과 같습니다.

  • 새 메시지가 없어도 계속 요청해야 한다 (비효율적).
  • 요청 주기가 길면 메시지가 늦게 도착한다.
  • 요청 주기가 짧으면 서버 부하가 커진다.

WebSocket은 이 문제를 해결합니다. 연결을 한 번 맺으면, 서버가 새 메시지가 있을 때 먼저 보내줍니다.


해결 방법#

WebSocket은 양방향 통신을 제공합니다. 한 번 연결하면 서버와 클라이언트 모두 자유롭게 메시지를 보낼 수 있습니다.

graph LR subgraph HTTP C1[클라이언트] -->|요청| S1[서버] S1 -->|응답| C1 end subgraph WebSocket C2[클라이언트] <-->|양방향| S2[서버] end

챕터 1: WebSocket 기초 개념#

Why#

NOTE

HTTP와 WebSocket의 차이를 이해해야 적절한 상황에 사용할 수 있습니다.

HTTP: 요청-응답 모델

  • 클라이언트가 요청하면 서버가 응답
  • 한 번의 요청에 한 번의 응답
  • 연결이 바로 끊어짐

WebSocket: 양방향 스트림

  • 연결을 유지한 채 메시지 교환
  • 서버도 먼저 메시지를 보낼 수 있음
  • 연결을 명시적으로 끊을 때까지 유지

What#

NOTE

WebSocket 사용 사례를 알아봅시다.

WebSocket이 적합한 경우:

  • 채팅 앱 (새 메시지 실시간 수신)
  • 실시간 알림 (푸시 알림 대체)
  • 주식/코인 시세 (가격 실시간 업데이트)
  • 멀티플레이어 게임 (플레이어 위치 동기화)
  • 협업 도구 (문서 동시 편집)

HTTP가 여전히 적합한 경우:

  • CRUD 작업 (데이터 생성, 조회, 수정, 삭제)
  • 파일 업로드/다운로드
  • 인증 (로그인, 토큰 발급)
  • 검색, 필터링

How#

TIP

Flutter에서 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#

WARNING

ws vs wss: ws://는 암호화되지 않은 연결입니다. 프로덕션에서는 반드시 wss://를 사용하세요.

에코 서버: wss://echo.websocket.events는 테스트용 서버입니다. 보낸 메시지를 그대로 돌려보내줍니다. 실제 앱에서는 본인의 서버를 사용해야 합니다.


챕터 2: 연결하고 메시지 받기#

Why#

NOTE

WebSocket의 핵심은 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;
@override
void 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();
}
}
@override
void dispose() {
_channel.sink.close();
_controller.dispose();
super.dispose();
}
@override
Widget 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#

WARNING

Stream은 한 번만 listen할 수 있습니다:

// 에러! 같은 스트림을 두 번 사용
channel.stream.listen(...); // 첫 번째 리스너
StreamBuilder(stream: channel.stream, ...); // 두 번째 리스너 - 에러!

여러 곳에서 사용하려면 broadcast 스트림으로 변환하세요.

final broadcastStream = channel.stream.asBroadcastStream();

dispose에서 반드시 close: 연결을 닫지 않으면 리소스 누수가 발생합니다.


챕터 3: 채팅 앱 만들기#

Why#

NOTE

WebSocket의 대표적인 사용 사례는 채팅입니다. 실제 채팅 앱처럼 메시지 목록을 관리하고 화면에 표시해봅시다.

핵심 기능:

  • 메시지 목록 유지 (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;
@override
void 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,
);
}
});
}
@override
void dispose() {
_subscription.cancel();
_channel.sink.close();
_controller.dispose();
_scrollController.dispose();
super.dispose();
}
@override
Widget 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});
@override
Widget 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;
@override
void 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;
});
}
@override
void 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#

NOTE

JSON 메시지를 파싱하고 직렬화합니다.

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_channelSocket.IO / Firebase
기본 연결OO
자동 재연결X (직접 구현)O
룸/채널 개념XO
이벤트 기반 APIXO
오프라인 동기화XO (Firebase)
백엔드 SDKXO

대안 솔루션:

  • Socket.IO: 이벤트 기반, 자동 재연결, 폴백 지원
  • Firebase Realtime Database: 실시간 동기화, 오프라인 지원
  • Firebase Cloud Firestore: 더 복잡한 쿼리 지원
  • Supabase Realtime: PostgreSQL 기반 실시간 업데이트

WebSocket은 실시간 통신의 기초입니다. 이 개념을 이해하면 고수준 라이브러리도 쉽게 사용할 수 있습니다.


공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 31편: WebSocket 실시간 통신
https://moodturnpost.net/posts/flutter/flutter-websocket-realtime/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차