본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 11. · 12 Views
StreamNotifier로 채팅 메시지 관리 완벽 가이드
Riverpod의 StreamNotifier를 활용하여 WebSocket 기반 실시간 채팅 메시지를 관리하는 방법을 배웁니다. 연결 관리부터 메시지 송수신까지 실전 예제로 학습합니다.
목차
1. StreamNotifier 기본 구조
김개발 씨는 회사에서 실시간 채팅 기능을 개발하라는 업무를 받았습니다. 선배 개발자 박시니어 씨가 "Riverpod의 StreamNotifier를 사용하면 깔끔하게 구현할 수 있어요"라고 조언합니다.
그런데 StreamNotifier가 정확히 무엇이고 어떻게 사용하는 걸까요?
StreamNotifier는 Riverpod에서 제공하는 상태 관리 클래스로, 스트림 데이터를 관리하는 데 최적화되어 있습니다. 마치 실시간 뉴스 속보를 계속 전달해주는 방송국처럼, 지속적으로 발생하는 데이터를 효과적으로 처리합니다.
채팅 메시지, 주식 시세, 센서 데이터처럼 연속적으로 들어오는 정보를 관리할 때 아주 유용합니다.
다음 코드를 살펴봅시다.
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'chat_notifier.g.dart';
// 채팅 메시지 모델
class ChatMessage {
final String id;
final String text;
final DateTime timestamp;
ChatMessage({required this.id, required this.text, required this.timestamp});
}
// StreamNotifier를 상속받아 채팅 메시지 관리
@riverpod
class ChatNotifier extends _$ChatNotifier {
@override
Stream<List<ChatMessage>> build() async* {
// 초기 빈 리스트를 방출합니다
yield [];
}
}
김개발 씨는 입사 6개월 차 개발자입니다. 오늘 팀장님께 실시간 채팅 기능 개발 업무를 배정받았습니다.
"Flutter로 개발하는데, 서버에서 계속 메시지가 들어오잖아요? 어떻게 관리하는 게 좋을까요?" 막막한 마음에 선배 박시니어 씨에게 물어봤습니다.
박시니어 씨가 모니터를 가리키며 설명을 시작합니다. "채팅 메시지는 계속 들어오는 데이터죠.
이런 걸 스트림 데이터라고 해요. Riverpod의 StreamNotifier를 사용하면 이런 데이터를 아주 깔끔하게 관리할 수 있어요." 그렇다면 StreamNotifier란 정확히 무엇일까요?
쉽게 비유하자면, StreamNotifier는 마치 우체국의 우편물 분류 시스템과 같습니다. 우편물이 계속 도착하면 우체국에서는 이를 분류하고 정리해서 적절한 곳에 배달합니다.
이처럼 StreamNotifier도 계속해서 들어오는 데이터를 받아서 정리하고, 필요한 위젯에 전달하는 역할을 담당합니다. StreamNotifier가 없던 시절에는 어땠을까요?
개발자들은 StreamController를 직접 관리해야 했습니다. 스트림을 생성하고, 메모리 누수를 막기 위해 dispose도 챙겨야 했습니다.
더 큰 문제는 여러 위젯에서 같은 스트림을 사용할 때 발생했습니다. 각 위젯마다 별도의 스트림 구독을 관리하다 보면 코드가 복잡해지고 버그가 발생하기 쉬었습니다.
바로 이런 문제를 해결하기 위해 StreamNotifier가 등장했습니다. StreamNotifier를 사용하면 스트림 데이터의 생명주기 관리가 자동으로 이루어집니다.
또한 상태 캐싱을 통해 같은 데이터를 여러 위젯에서 효율적으로 공유할 수 있습니다. 무엇보다 Riverpod의 다른 기능들과 자연스럽게 통합된다는 큰 이점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 ChatMessage 클래스는 채팅 메시지의 구조를 정의합니다.
id, text, timestamp 같은 기본 정보를 담고 있습니다. 다음으로 ChatNotifier가 _$ChatNotifier를 상속받는데, 이는 riverpod_generator가 자동으로 생성하는 기본 클래스입니다.
마지막으로 build 메서드에서 초기 스트림을 반환합니다. yield 키워드를 사용해서 빈 리스트를 방출하는 것이 핵심입니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 메신저 앱을 개발한다고 가정해봅시다.
사용자가 대화방에 들어가면 서버로부터 새 메시지가 계속 도착합니다. StreamNotifier를 활용하면 이런 메시지들을 자동으로 수집하고, UI를 실시간으로 업데이트할 수 있습니다.
카카오톡, 슬랙 같은 대형 메신저 서비스들도 유사한 패턴을 사용하고 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 build 메서드 안에서 무거운 작업을 수행하는 것입니다. 이렇게 하면 위젯이 다시 빌드될 때마다 불필요한 연산이 발생할 수 있습니다.
따라서 초기화는 간단히 하고, 실제 데이터 처리는 별도 메서드로 분리해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, StreamNotifier가 스트림 관리를 자동으로 해주는 거군요!" StreamNotifier를 제대로 이해하면 실시간 데이터를 다루는 앱을 훨씬 깔끔하게 개발할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - build 메서드는 가볍게 유지하고, 실제 로직은 별도 메서드로 분리하세요
- async* 키워드로 비동기 스트림 생성기를 만들 수 있습니다
- yield 대신 yield*를 사용하면 다른 스트림을 그대로 전달할 수 있습니다
2. WebSocket 연결 설정
김개발 씨는 StreamNotifier의 기본 구조는 이해했지만, 실제로 서버와 어떻게 연결하는지 궁금해졌습니다. 박시니어 씨가 "실시간 통신은 보통 WebSocket을 사용하죠.
한번 연결하면 양방향 통신이 가능해요"라고 설명합니다. WebSocket 연결은 어떻게 설정할까요?
WebSocket은 서버와 클라이언트 간 실시간 양방향 통신을 가능하게 하는 프로토콜입니다. 마치 전화 통화처럼 한 번 연결하면 서로 자유롭게 메시지를 주고받을 수 있습니다.
HTTP 요청처럼 매번 새로 연결할 필요가 없어서 채팅, 게임, 실시간 알림 같은 기능에 최적화되어 있습니다.
다음 코드를 살펴봅시다.
import 'dart:async';
import 'package:web_socket_channel/web_socket_channel.dart';
@riverpod
class ChatNotifier extends _$ChatNotifier {
// WebSocket 채널을 저장할 변수
WebSocketChannel? _channel;
final List<ChatMessage> _messages = [];
@override
Stream<List<ChatMessage>> build() async* {
// WebSocket 연결을 초기화합니다
_initWebSocket();
// 초기 메시지 리스트를 방출합니다
yield _messages;
}
void _initWebSocket() {
// WebSocket 서버에 연결합니다
_channel = WebSocketChannel.connect(
Uri.parse('wss://your-chat-server.com/ws'),
);
}
}
김개발 씨가 코드를 작성하다가 의문이 생겼습니다. "선배님, 서버에서 메시지를 받으려면 어떻게 해야 하나요?
HTTP API를 계속 호출하면 되나요?" 박시니어 씨가 고개를 저으며 답합니다. "그건 비효율적이에요.
WebSocket을 사용해야죠." 그렇다면 WebSocket이란 무엇일까요? 쉽게 비유하자면, WebSocket은 마치 전화 통화와 같습니다.
일반 HTTP 요청은 편지를 주고받는 것과 비슷합니다. 질문을 보내면 답장이 올 때까지 기다려야 하고, 새로운 질문을 하려면 또 편지를 써야 합니다.
반면 WebSocket은 전화를 한 번 걸어두면 실시간으로 대화할 수 있습니다. 서로 언제든지 말을 걸 수 있죠.
WebSocket이 없던 시절에는 어땠을까요? 개발자들은 롱 폴링이나 폴링이라는 방식을 사용했습니다.
주기적으로 서버에 "새 메시지 있나요?"라고 물어보는 방식이었습니다. 서버 부하도 심했고, 실시간성도 떨어졌습니다.
더 큰 문제는 배터리 소모가 심했다는 점입니다. 모바일 앱에서는 치명적인 단점이었습니다.
바로 이런 문제를 해결하기 위해 WebSocket이 널리 사용되기 시작했습니다. WebSocket을 사용하면 단일 연결로 양방향 통신이 가능해집니다.
또한 낮은 레이턴시로 거의 실시간에 가까운 메시지 전달이 가능합니다. 무엇보다 불필요한 HTTP 헤더 오버헤드가 없어 네트워크 효율이 높다는 큰 이점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 web_socket_channel 패키지를 import합니다.
이는 Flutter에서 WebSocket을 쉽게 사용할 수 있게 해주는 공식 패키지입니다. 다음으로 _channel 변수를 선언해서 WebSocket 연결을 저장합니다.
_initWebSocket 메서드에서 실제 연결이 이루어지는데, WebSocketChannel.connect로 서버 URL을 지정합니다. 'wss://'는 보안이 적용된 WebSocket 프로토콜을 의미합니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 협업 도구를 개발한다고 가정해봅시다.
여러 사용자가 동시에 문서를 편집하는 기능이 필요합니다. WebSocket을 활용하면 한 사용자의 변경사항이 다른 모든 사용자에게 즉시 반영됩니다.
Google Docs나 Notion 같은 서비스들이 이런 방식으로 실시간 협업 기능을 구현하고 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 연결 해제를 제대로 처리하지 않는 것입니다. 이렇게 하면 메모리 누수가 발생하거나 불필요한 네트워크 연결이 계속 유지될 수 있습니다.
따라서 위젯이 dispose될 때 반드시 **_channel?.sink.close()**를 호출해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 노트북을 열고 코드를 작성하기 시작했습니다. "WebSocket으로 연결하면 계속 통신 상태를 유지하는 거군요!" WebSocket 연결을 제대로 설정하면 실시간 기능을 안정적으로 구현할 수 있습니다.
여러분도 오늘 배운 내용을 프로젝트에 적용해 보세요.
실전 팁
💡 - wss:// 프로토콜을 사용해서 보안 연결을 유지하세요
- 연결 실패 시 재시도 로직을 구현하는 것이 좋습니다
- dispose 시점에 반드시 채널을 닫아 리소스를 정리하세요
3. 메시지 수신 스트림
WebSocket 연결은 성공했지만, 김개발 씨는 서버에서 들어오는 메시지를 어떻게 받아야 할지 고민에 빠졌습니다. 박시니어 씨가 화면을 보더니 "WebSocket 채널의 stream을 listen하면 되는데, StreamNotifier와 연결하는 게 핵심이에요"라고 조언합니다.
메시지 수신은 어떻게 처리할까요?
WebSocket 채널의 stream은 서버로부터 도착하는 모든 메시지를 방출합니다. 마치 라디오 주파수를 맞추면 방송이 들리는 것처럼, 스트림을 구독하면 메시지가 계속 들어옵니다.
이 스트림을 StreamNotifier의 상태와 연결하면 새 메시지가 도착할 때마다 UI가 자동으로 업데이트됩니다.
다음 코드를 살펴봅시다.
@riverpod
class ChatNotifier extends _$ChatNotifier {
WebSocketChannel? _channel;
final List<ChatMessage> _messages = [];
StreamSubscription? _subscription;
@override
Stream<List<ChatMessage>> build() async* {
_initWebSocket();
yield _messages;
// WebSocket 메시지 스트림을 구독합니다
await for (final data in _channel!.stream) {
// 받은 메시지를 파싱합니다
final message = _parseMessage(data);
_messages.add(message);
// 새로운 메시지 리스트를 방출합니다
yield List.from(_messages);
}
}
ChatMessage _parseMessage(dynamic data) {
// JSON 데이터를 ChatMessage로 변환합니다
final json = jsonDecode(data as String);
return ChatMessage(
id: json['id'],
text: json['text'],
timestamp: DateTime.parse(json['timestamp']),
);
}
}
김개발 씨가 코드를 작성하다가 막혔습니다. "연결은 했는데, 메시지는 어떻게 받죠?" 모니터를 빤히 쳐다보던 중, 박시니어 씨가 의자를 끌고 옆에 앉습니다.
"채널의 stream 속성을 보세요. 거기서 메시지가 계속 나와요." 그렇다면 스트림 구독이란 정확히 무엇일까요?
쉽게 비유하자면, 스트림 구독은 마치 유튜브 채널 구독과 같습니다. 좋아하는 유튜버를 구독하면 새 영상이 올라올 때마다 알림이 옵니다.
이처럼 WebSocket 스트림을 구독하면 서버가 메시지를 보낼 때마다 우리 앱으로 데이터가 흘러들어옵니다. 구독을 해제하면 더 이상 알림을 받지 않는 것처럼, 스트림 구독을 취소하면 메시지를 받지 않습니다.
스트림 처리가 복잡했던 시절에는 어땠을까요? 개발자들은 콜백 함수를 중첩해서 사용해야 했습니다.
메시지를 받으면 콜백이 실행되고, 그 안에서 또 다른 비동기 작업을 하려면 콜백을 중첩했습니다. 이른바 콜백 지옥이라는 악명 높은 패턴이 생겼습니다.
코드를 읽기도 어렵고, 에러 처리도 복잡했습니다. 바로 이런 문제를 해결하기 위해 Dart에서는 async/await 문법을 스트림에도 적용할 수 있게 했습니다.
await for 구문을 사용하면 스트림에서 데이터가 올 때마다 자동으로 반복 실행됩니다. 또한 yield 키워드로 새로운 상태를 방출할 수 있어서 UI 업데이트가 자연스럽게 이루어집니다.
무엇보다 코드가 마치 동기식 코드처럼 읽기 쉽다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 await for 구문이 핵심입니다. 이것은 스트림에서 데이터가 올 때까지 기다렸다가, 데이터가 도착하면 반복문을 실행합니다.
다음으로 _parseMessage 메서드에서 JSON 문자열을 ChatMessage 객체로 변환합니다. 서버는 보통 JSON 형식으로 데이터를 보내기 때문입니다.
마지막으로 **yield List.from(_messages)**로 새로운 리스트를 방출합니다. 이렇게 하면 Riverpod가 상태 변경을 감지하고 UI를 업데이트합니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 주식 거래 앱을 개발한다고 가정해봅시다.
서버에서 주식 시세가 실시간으로 들어옵니다. 스트림을 구독해서 시세 데이터를 받아 파싱하고, 사용자 포트폴리오를 자동으로 업데이트할 수 있습니다.
삼성증권, 키움증권 같은 MTS 앱들이 모두 이런 방식으로 실시간 시세를 보여줍니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 파싱 에러를 처리하지 않는 것입니다. 서버에서 잘못된 형식의 데이터가 오거나 네트워크 문제로 데이터가 깨질 수 있습니다.
이렇게 하면 앱이 크래시될 수 있습니다. 따라서 try-catch로 에러를 잡고 적절히 처리해야 합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 듣고 코드를 작성한 김개발 씨는 앱을 실행해봤습니다.
"오! 메시지가 실시간으로 화면에 나타나네요!" 스트림 구독을 제대로 이해하면 실시간 데이터를 효과적으로 처리할 수 있습니다.
여러분도 오늘 배운 내용을 실전에 적용해 보세요.
실전 팁
💡 - await for 구문으로 스트림을 간결하게 처리할 수 있습니다
- JSON 파싱 시 반드시 try-catch로 에러를 처리하세요
- yield 대신 직접 state를 변경하면 안 됩니다
4. 메시지 전송 메서드
메시지는 잘 받아지는데, 김개발 씨는 이제 사용자가 입력한 메시지를 서버로 보내는 방법이 궁금해졌습니다. 박시니어 씨가 "채널의 sink를 사용하면 되는데, UI에서 호출할 수 있는 메서드를 만들어야 해요"라고 설명합니다.
메시지 전송은 어떻게 구현할까요?
WebSocket 채널의 sink는 서버로 데이터를 보내는 출구입니다. 마치 우체통에 편지를 넣으면 배달되는 것처럼, sink에 데이터를 넣으면 서버로 전송됩니다.
StreamNotifier에 메시지 전송 메서드를 추가하면 UI에서 버튼을 눌렀을 때 간편하게 메시지를 보낼 수 있습니다.
다음 코드를 살펴봅시다.
@riverpod
class ChatNotifier extends _$ChatNotifier {
WebSocketChannel? _channel;
final List<ChatMessage> _messages = [];
@override
Stream<List<ChatMessage>> build() async* {
_initWebSocket();
yield _messages;
await for (final data in _channel!.stream) {
final message = _parseMessage(data);
_messages.add(message);
yield List.from(_messages);
}
}
// 메시지를 서버로 전송하는 메서드
void sendMessage(String text) {
if (_channel == null) return;
// 메시지를 JSON으로 변환합니다
final message = {
'type': 'message',
'text': text,
'timestamp': DateTime.now().toIso8601String(),
};
// WebSocket으로 메시지를 전송합니다
_channel!.sink.add(jsonEncode(message));
}
}
김개발 씨가 테스트 앱을 실행해보니 메시지는 잘 받아지는데, 정작 보내는 건 안 됩니다. "선배님, 사용자가 메시지를 입력하면 어떻게 서버로 보내나요?" 박시니어 씨가 코드를 가리키며 답합니다.
"sink를 사용해야죠. stream이 들어오는 통로라면, sink는 나가는 통로예요." 그렇다면 sink란 정확히 무엇일까요?
쉽게 비유하자면, sink는 마치 하수구의 배수구와 같습니다. 싱크대에서 물이 들어오는 쪽이 수도꼭지라면, 나가는 쪽이 배수구입니다.
WebSocket도 마찬가지입니다. stream은 서버에서 우리 앱으로 데이터가 들어오는 통로이고, sink는 우리 앱에서 서버로 데이터가 나가는 통로입니다.
양방향 통신을 위해서는 둘 다 필요합니다. 메시지 전송이 복잡했던 시절에는 어땠을까요?
개발자들은 HTTP POST 요청을 직접 만들어서 보내야 했습니다. 헤더를 설정하고, 바디를 JSON으로 인코딩하고, 에러 처리까지 신경 써야 했습니다.
코드가 길어지고 복잡해졌습니다. 더 큰 문제는 요청-응답 방식이라 실시간성이 떨어진다는 점이었습니다.
바로 이런 문제를 해결하기 위해 WebSocket의 sink를 활용합니다. sink를 사용하면 간단한 메서드 호출 한 번으로 메시지를 전송할 수 있습니다.
또한 연결이 유지된 상태에서 보내기 때문에 빠르고 효율적입니다. 무엇보다 JSON 인코딩만 하면 되기 때문에 코드가 단순하다는 큰 이점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 sendMessage 메서드는 외부에서 호출할 수 있도록 public으로 선언합니다.
다음으로 _channel == null 체크로 연결이 끊어진 상태를 방어합니다. 메시지 데이터를 Map으로 만들고 jsonEncode로 JSON 문자열로 변환합니다.
마지막으로 _channel!.sink.add로 서버에 전송합니다. 느낌표는 null이 아님을 확신한다는 의미입니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 고객 상담 채팅 앱을 개발한다고 가정해봅시다.
고객이 질문을 입력하면 sendMessage를 호출해서 상담원에게 전달됩니다. 상담원의 답변은 stream을 통해 실시간으로 받습니다.
은행 앱의 챗봇, 쇼핑몰의 고객센터 같은 곳에서 이런 방식을 사용합니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 연결 상태를 확인하지 않고 메시지를 보내는 것입니다. 네트워크가 끊어진 상태에서 sink.add를 호출하면 에러가 발생합니다.
이렇게 하면 앱이 크래시될 수 있습니다. 따라서 메시지를 보내기 전에 반드시 연결 상태를 체크해야 합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 조언대로 코드를 수정한 김개발 씨는 테스트를 해봤습니다.
"와, 제가 보낸 메시지가 서버에 도착하고, 다시 받아지네요!" 메시지 전송 메서드를 제대로 구현하면 양방향 실시간 통신이 완성됩니다. 여러분도 오늘 배운 내용을 활용해 보세요.
실전 팁
💡 - 메시지 전송 전에 항상 연결 상태를 확인하세요
- 전송 실패 시 재시도 로직을 구현하면 사용자 경험이 좋아집니다
- 메시지에 고유 ID를 추가하면 중복 전송을 방지할 수 있습니다
5. 연결 상태 관리
메시지 송수신은 구현했지만, 김개발 씨는 네트워크가 불안정할 때가 걱정됩니다. 박시니어 씨가 "연결 상태를 추적하고, 끊어지면 자동으로 재연결하는 로직이 필요해요"라고 조언합니다.
안정적인 연결 관리는 어떻게 구현할까요?
연결 상태 관리는 WebSocket 연결의 현재 상태를 추적하고 문제 발생 시 적절히 대응하는 것입니다. 마치 휴대폰이 Wi-Fi 신호가 약해지면 자동으로 LTE로 전환하듯이, 연결이 끊어지면 자동으로 재연결을 시도해야 합니다.
연결 상태에 따라 UI를 업데이트하면 사용자에게 현재 상황을 명확히 알려줄 수 있습니다.
다음 코드를 살펴봅시다.
// 연결 상태를 나타내는 enum
enum ConnectionState {
disconnected,
connecting,
connected,
reconnecting,
}
@riverpod
class ChatNotifier extends _$ChatNotifier {
WebSocketChannel? _channel;
final List<ChatMessage> _messages = [];
ConnectionState _connectionState = ConnectionState.disconnected;
Timer? _reconnectTimer;
@override
Stream<List<ChatMessage>> build() async* {
_initWebSocket();
yield _messages;
await for (final data in _channel!.stream) {
_connectionState = ConnectionState.connected;
final message = _parseMessage(data);
_messages.add(message);
yield List.from(_messages);
}
// 스트림이 끝나면 재연결을 시도합니다
_handleDisconnection();
}
void _handleDisconnection() {
_connectionState = ConnectionState.reconnecting;
_reconnectTimer = Timer(Duration(seconds: 3), () {
_initWebSocket();
});
}
ConnectionState get connectionState => _connectionState;
}
김개발 씨가 테스트하던 중 Wi-Fi를 껐다 켰더니 앱이 먹통이 되었습니다. "선배님, 네트워크가 끊어지면 어떻게 해야 하나요?" 박시니어 씨가 고개를 끄덕입니다.
"좋은 질문이에요. 연결 상태를 관리하고 자동으로 재연결하는 게 중요해요." 그렇다면 연결 상태 관리란 정확히 무엇일까요?
쉽게 비유하자면, 연결 상태 관리는 마치 자동차의 계기판과 같습니다. 속도계, 연료계, 경고등이 있어서 운전자는 차의 상태를 한눈에 파악할 수 있습니다.
문제가 생기면 경고등이 켜지고, 운전자는 적절히 대응합니다. 앱도 마찬가지입니다.
ConnectionState로 현재 상태를 추적하고, 연결이 끊어지면 자동으로 복구를 시도해야 합니다. 연결 관리가 없던 시절에는 어땠을까요?
개발자들은 연결이 끊어지면 그냥 에러 메시지를 보여주고 끝이었습니다. 사용자는 앱을 완전히 종료하고 다시 실행해야 했습니다.
엘리베이터 안에서 잠깐 신호가 끊어지거나, 지하철에서 터널을 지나갈 때마다 앱을 재시작해야 했습니다. 사용자 경험이 매우 나빴습니다.
바로 이런 문제를 해결하기 위해 자동 재연결 로직이 필수가 되었습니다. 연결 상태를 enum으로 관리하면 코드가 명확해집니다.
또한 Timer를 활용한 재연결으로 일시적인 네트워크 문제를 자동으로 복구할 수 있습니다. 무엇보다 사용자에게 현재 상황을 UI로 표시할 수 있다는 큰 이점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 ConnectionState enum으로 네 가지 상태를 정의합니다.
disconnected는 끊김, connecting은 연결 중, connected는 정상 연결, reconnecting은 재연결 시도 중입니다. 다음으로 _handleDisconnection 메서드에서 재연결을 처리합니다.
Timer로 3초 후에 다시 연결을 시도하는데, 이는 서버가 일시적으로 바쁠 수 있기 때문입니다. 마지막으로 getter를 제공해서 UI에서 현재 상태를 읽을 수 있게 합니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 배달 앱을 개발한다고 가정해봅시다.
배달원이 오토바이를 타고 이동하면서 실시간으로 위치를 전송합니다. 터널을 지나가거나 신호가 약한 곳에서는 연결이 끊어질 수 있습니다.
자동 재연결 로직이 있으면 신호가 돌아오는 즉시 다시 연결되어 배달이 계속 추적됩니다. 배달의민족, 쿠팡이츠 같은 앱들이 이런 방식으로 안정성을 확보합니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 무한 재연결 시도입니다.
서버가 완전히 다운되었을 때도 계속 재연결을 시도하면 배터리가 빨리 소모되고 불필요한 네트워크 트래픽이 발생합니다. 따라서 재시도 횟수를 제한하거나 지수 백오프 전략을 사용해야 합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 조언대로 코드를 작성한 김개발 씨는 다시 테스트해봤습니다.
"오! Wi-Fi를 껐다 켰는데 자동으로 다시 연결되네요!" 연결 상태를 제대로 관리하면 안정적인 실시간 앱을 만들 수 있습니다.
여러분도 오늘 배운 내용을 적용해 보세요.
실전 팁
💡 - enum으로 상태를 명확히 정의하면 코드가 읽기 쉬워집니다
- 재연결 시 지수 백오프 전략을 사용하면 서버 부하를 줄일 수 있습니다
- UI에 연결 상태를 표시하면 사용자 경험이 크게 향상됩니다
6. 채팅 UI 구현
백엔드 로직은 완성했지만, 김개발 씨는 이제 실제 화면을 만들어야 합니다. 박시니어 씨가 "ConsumerWidget으로 StreamNotifier를 watch하면 자동으로 UI가 업데이트돼요"라고 설명합니다.
채팅 화면은 어떻게 구현할까요?
Riverpod의 ConsumerWidget은 Provider의 상태를 구독하고 변경사항을 자동으로 반영하는 위젯입니다. 마치 유튜브 구독자에게 알림이 가듯이, StreamNotifier의 상태가 변하면 UI가 자동으로 다시 그려집니다.
ref.watch로 Provider를 구독하기만 하면 실시간 채팅 UI가 완성됩니다.
다음 코드를 살펴봅시다.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ChatScreen extends ConsumerWidget {
final TextEditingController _controller = TextEditingController();
@override
Widget build(BuildContext context, WidgetRef ref) {
// StreamNotifier를 watch합니다
final messagesAsync = ref.watch(chatNotifierProvider);
final notifier = ref.read(chatNotifierProvider.notifier);
return Scaffold(
appBar: AppBar(title: Text('실시간 채팅')),
body: Column(
children: [
// 메시지 리스트를 표시합니다
Expanded(
child: messagesAsync.when(
data: (messages) => ListView.builder(
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
return ListTile(
title: Text(message.text),
subtitle: Text(message.timestamp.toString()),
);
},
),
loading: () => Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('에러: $err')),
),
),
// 메시지 입력창
Padding(
padding: EdgeInsets.all(8.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: InputDecoration(hintText: '메시지 입력...'),
),
),
IconButton(
icon: Icon(Icons.send),
onPressed: () {
notifier.sendMessage(_controller.text);
_controller.clear();
},
),
],
),
),
],
),
);
}
}
김개발 씨가 마지막 단계에 도달했습니다. "이제 화면만 만들면 되는데, 어떻게 하죠?" 박시니어 씨가 웃으며 답합니다.
"가장 쉬운 부분이에요. ConsumerWidget 하나면 끝이죠." 그렇다면 ConsumerWidget이란 정확히 무엇일까요?
쉽게 비유하자면, ConsumerWidget은 마치 뉴스 속보를 자동으로 표시하는 전광판과 같습니다. 뉴스가 업데이트되면 전광판 내용이 자동으로 바뀝니다.
별도로 새로고침 버튼을 누를 필요가 없습니다. ConsumerWidget도 마찬가지입니다.
ref.watch로 Provider를 구독하면 상태가 변할 때마다 위젯이 자동으로 다시 빌드됩니다. UI 업데이트가 수동이던 시절에는 어땠을까요?
개발자들은 setState를 직접 호출해서 화면을 갱신해야 했습니다. 데이터가 변경되는 모든 곳에서 setState를 빼먹지 않고 호출해야 했습니다.
코드가 여기저기 흩어지고, 빼먹으면 UI가 업데이트되지 않는 버그가 생겼습니다. 더 큰 문제는 여러 위젯에서 같은 상태를 공유할 때 동기화가 어려웠다는 점입니다.
바로 이런 문제를 해결하기 위해 Riverpod의 선언적 UI 패턴이 등장했습니다. ref.watch로 자동 구독하면 수동 setState가 필요 없습니다.
또한 AsyncValue.when으로 로딩, 데이터, 에러 상태를 깔끔하게 처리할 수 있습니다. 무엇보다 코드가 선언적이어서 "어떻게"가 아니라 "무엇을" 보여줄지만 정의하면 된다는 큰 이점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 ConsumerWidget을 상속받아서 ref 파라미터를 사용할 수 있게 합니다.
다음으로 ref.watch로 StreamNotifier를 구독합니다. 이렇게 하면 새 메시지가 올 때마다 자동으로 재빌드됩니다.
when 메서드로 세 가지 상태를 처리하는데, data는 정상 데이터, loading은 로딩 중, error는 에러 발생 시입니다. 마지막으로 sendMessage를 호출해서 사용자 입력을 전송합니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 팀 협업 도구를 개발한다고 가정해봅시다.
프로젝트 채널에서 팀원들이 실시간으로 메시지를 주고받습니다. ConsumerWidget으로 구현하면 누군가 메시지를 보내는 즉시 모든 팀원의 화면에 자동으로 표시됩니다.
Slack, Microsoft Teams 같은 협업 도구들이 모두 이런 방식으로 실시간 채팅을 구현합니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 불필요한 재빌드입니다. ref.watch를 너무 많이 사용하거나 최상위 위젯에서 사용하면 작은 변경에도 전체 화면이 다시 그려집니다.
이렇게 하면 성능이 떨어질 수 있습니다. 따라서 필요한 부분만 구독하고, 가능하면 위젯을 작게 나누는 것이 좋습니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 조언대로 코드를 완성한 김개발 씨는 앱을 실행해봤습니다.
"와! 제가 메시지를 보내면 바로 화면에 나타나고, 다른 사용자 메시지도 실시간으로 보이네요!" 팀장님이 지나가다가 김개발 씨의 화면을 보고 엄지를 치켜세웁니다.
"오, 잘 만들었네요! 이제 프로덕션에 배포할 수 있겠어요." ConsumerWidget으로 UI를 구현하면 간결하면서도 강력한 실시간 앱을 완성할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - ref.watch는 필요한 부분에만 사용해서 불필요한 재빌드를 방지하세요
- when 메서드로 로딩과 에러 상태를 깔끔하게 처리할 수 있습니다
- 위젯을 작게 나누면 성능이 향상되고 코드 재사용성도 높아집니다
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
Riverpod 3.0 쇼핑 앱 종합 프로젝트 완벽 가이드
Flutter와 Riverpod 3.0을 활용한 실무 수준의 쇼핑 앱 개발 과정을 단계별로 학습합니다. 상품 목록, 장바구니, 주문, 인증, 검색 기능까지 모든 핵심 기능을 구현하며 상태 관리의 실전 노하우를 익힙니다.
Riverpod 3.0 Retry 자동 재시도 완벽 가이드
Riverpod 3.0에 새로 추가된 Retry 기능을 활용하여 네트워크 오류나 일시적인 실패 상황에서 자동으로 재시도하는 방법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 예제와 함께 설명합니다.
Riverpod 3.0 requireValue로 Provider 결합하기
Riverpod 3.0에 새로 추가된 requireValue를 활용하여 여러 Provider의 데이터를 효율적으로 결합하는 방법을 배웁니다. 비동기 데이터를 마치 동기 데이터처럼 다루는 실전 패턴을 소개합니다.
Flutter 3.0 Offline 데이터 영속화 완벽 가이드
Flutter 3.0에서 새롭게 추가된 Offline 데이터 영속화 기능을 배웁니다. Storage 인터페이스부터 SharedPreferences 활용, 실전 예제까지 실무에서 바로 사용할 수 있는 패턴을 배워봅시다.
Riverpod 3.0 Mutation으로 폼 제출 완벽 가이드
Riverpod 3.0의 새로운 Mutation 기능으로 로그인과 회원가입 폼을 우아하게 처리하는 방법을 배웁니다. 로딩 상태, 에러 처리, 성공 처리까지 실무에서 바로 쓸 수 있는 패턴을 익혀보세요.